neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

tohtml.lua (44642B)


      1 --- @brief
      2 ---<pre>help
      3 ---:[range]TOhtml {file}                                                *:TOhtml*
      4 ---Converts the buffer shown in the current window to HTML, opens the generated
      5 ---HTML in a new split window, and saves its contents to {file}. If {file} is not
      6 ---given, a temporary file (created by |tempname()|) is used.
      7 ---</pre>
      8 
      9 -- The HTML conversion script is different from Vim's one. If you want to use
     10 -- Vim's TOhtml converter, download it from the vim GitHub repo.
     11 -- Here are the Vim files related to this functionality:
     12 -- - https://github.com/vim/vim/blob/master/runtime/syntax/2html.vim
     13 -- - https://github.com/vim/vim/blob/master/runtime/autoload/tohtml.vim
     14 -- - https://github.com/vim/vim/blob/master/runtime/plugin/tohtml.vim
     15 --
     16 -- Main differences between this and the vim version:
     17 -- - No "ignore some visual thing" settings (just set the right Vim option)
     18 -- - No support for legacy web engines
     19 -- - No support for legacy encoding (supports only UTF-8)
     20 -- - No interactive webpage
     21 -- - No specifying the internal HTML (no XHTML, no use_css=false)
     22 -- - No multiwindow diffs
     23 -- - No ranges
     24 --
     25 -- Remarks:
     26 -- - Not all visuals are supported, so it may differ.
     27 
     28 --- @class (private) vim.tohtml.state.global
     29 --- @field background string
     30 --- @field foreground string
     31 --- @field title string|false
     32 --- @field font string
     33 --- @field highlights_name table<integer,string>
     34 --- @field conf vim.tohtml.opt
     35 
     36 --- @class (private) vim.tohtml.state:vim.tohtml.state.global
     37 --- @field style vim.tohtml.styletable
     38 --- @field tabstop string|false
     39 --- @field opt vim.wo
     40 --- @field winid integer
     41 --- @field bufnr integer
     42 --- @field width integer
     43 --- @field start integer
     44 --- @field end_ integer
     45 
     46 --- @class (private) vim.tohtml.styletable
     47 --- @field [integer] vim.tohtml.line (integer: (1-index, exclusive))
     48 
     49 --- @class (private) vim.tohtml.line
     50 --- @field virt_lines {[integer]:[string,integer][]}
     51 --- @field pre_text [string, integer?][]
     52 --- @field hide? boolean
     53 --- @field [integer] vim.tohtml.cell? (integer: (1-index, exclusive))
     54 
     55 --- @class (private) vim.tohtml.cell
     56 --- @field [1] integer[] start
     57 --- @field [2] integer[] close
     58 --- @field [3] any[][] virt_text
     59 --- @field [4] any[][] overlay_text
     60 
     61 --- @type string[]
     62 local notifications = {}
     63 
     64 ---@param msg string
     65 local function notify(msg)
     66  if #notifications == 0 then
     67    vim.schedule(function()
     68      if #notifications > 1 then
     69        vim.notify(('TOhtml: %s (+ %d more warnings)'):format(notifications[1], #notifications - 1))
     70      elseif #notifications == 1 then
     71        vim.notify('TOhtml: ' .. notifications[1])
     72      end
     73      notifications = {}
     74    end)
     75  end
     76  table.insert(notifications, msg)
     77 end
     78 
     79 local HIDE_ID = -1
     80 -- stylua: ignore start
     81 local cterm_8_to_hex={
     82  [0] = "#808080", "#ff6060", "#00ff00", "#ffff00",
     83  "#8080ff", "#ff40ff", "#00ffff", "#ffffff",
     84 }
     85 local cterm_16_to_hex={
     86  [0] = "#000000", "#c00000", "#008000", "#804000",
     87  "#0000c0", "#c000c0", "#008080", "#c0c0c0",
     88  "#808080", "#ff6060", "#00ff00", "#ffff00",
     89  "#8080ff", "#ff40ff", "#00ffff", "#ffffff",
     90 }
     91 local cterm_88_to_hex={
     92  [0] = "#000000", "#c00000", "#008000", "#804000",
     93  "#0000c0", "#c000c0", "#008080", "#c0c0c0",
     94  "#808080", "#ff6060", "#00ff00", "#ffff00",
     95  "#8080ff", "#ff40ff", "#00ffff", "#ffffff",
     96  "#000000", "#00008b", "#0000cd", "#0000ff",
     97  "#008b00", "#008b8b", "#008bcd", "#008bff",
     98  "#00cd00", "#00cd8b", "#00cdcd", "#00cdff",
     99  "#00ff00", "#00ff8b", "#00ffcd", "#00ffff",
    100  "#8b0000", "#8b008b", "#8b00cd", "#8b00ff",
    101  "#8b8b00", "#8b8b8b", "#8b8bcd", "#8b8bff",
    102  "#8bcd00", "#8bcd8b", "#8bcdcd", "#8bcdff",
    103  "#8bff00", "#8bff8b", "#8bffcd", "#8bffff",
    104  "#cd0000", "#cd008b", "#cd00cd", "#cd00ff",
    105  "#cd8b00", "#cd8b8b", "#cd8bcd", "#cd8bff",
    106  "#cdcd00", "#cdcd8b", "#cdcdcd", "#cdcdff",
    107  "#cdff00", "#cdff8b", "#cdffcd", "#cdffff",
    108  "#ff0000", "#ff008b", "#ff00cd", "#ff00ff",
    109  "#ff8b00", "#ff8b8b", "#ff8bcd", "#ff8bff",
    110  "#ffcd00", "#ffcd8b", "#ffcdcd", "#ffcdff",
    111  "#ffff00", "#ffff8b", "#ffffcd", "#ffffff",
    112  "#2e2e2e", "#5c5c5c", "#737373", "#8b8b8b",
    113  "#a2a2a2", "#b9b9b9", "#d0d0d0", "#e7e7e7",
    114 }
    115 local cterm_256_to_hex={
    116  [0] = "#000000", "#c00000", "#008000", "#804000",
    117  "#0000c0", "#c000c0", "#008080", "#c0c0c0",
    118  "#808080", "#ff6060", "#00ff00", "#ffff00",
    119  "#8080ff", "#ff40ff", "#00ffff", "#ffffff",
    120  "#000000", "#00005f", "#000087", "#0000af",
    121  "#0000d7", "#0000ff", "#005f00", "#005f5f",
    122  "#005f87", "#005faf", "#005fd7", "#005fff",
    123  "#008700", "#00875f", "#008787", "#0087af",
    124  "#0087d7", "#0087ff", "#00af00", "#00af5f",
    125  "#00af87", "#00afaf", "#00afd7", "#00afff",
    126  "#00d700", "#00d75f", "#00d787", "#00d7af",
    127  "#00d7d7", "#00d7ff", "#00ff00", "#00ff5f",
    128  "#00ff87", "#00ffaf", "#00ffd7", "#00ffff",
    129  "#5f0000", "#5f005f", "#5f0087", "#5f00af",
    130  "#5f00d7", "#5f00ff", "#5f5f00", "#5f5f5f",
    131  "#5f5f87", "#5f5faf", "#5f5fd7", "#5f5fff",
    132  "#5f8700", "#5f875f", "#5f8787", "#5f87af",
    133  "#5f87d7", "#5f87ff", "#5faf00", "#5faf5f",
    134  "#5faf87", "#5fafaf", "#5fafd7", "#5fafff",
    135  "#5fd700", "#5fd75f", "#5fd787", "#5fd7af",
    136  "#5fd7d7", "#5fd7ff", "#5fff00", "#5fff5f",
    137  "#5fff87", "#5fffaf", "#5fffd7", "#5fffff",
    138  "#870000", "#87005f", "#870087", "#8700af",
    139  "#8700d7", "#8700ff", "#875f00", "#875f5f",
    140  "#875f87", "#875faf", "#875fd7", "#875fff",
    141  "#878700", "#87875f", "#878787", "#8787af",
    142  "#8787d7", "#8787ff", "#87af00", "#87af5f",
    143  "#87af87", "#87afaf", "#87afd7", "#87afff",
    144  "#87d700", "#87d75f", "#87d787", "#87d7af",
    145  "#87d7d7", "#87d7ff", "#87ff00", "#87ff5f",
    146  "#87ff87", "#87ffaf", "#87ffd7", "#87ffff",
    147  "#af0000", "#af005f", "#af0087", "#af00af",
    148  "#af00d7", "#af00ff", "#af5f00", "#af5f5f",
    149  "#af5f87", "#af5faf", "#af5fd7", "#af5fff",
    150  "#af8700", "#af875f", "#af8787", "#af87af",
    151  "#af87d7", "#af87ff", "#afaf00", "#afaf5f",
    152  "#afaf87", "#afafaf", "#afafd7", "#afafff",
    153  "#afd700", "#afd75f", "#afd787", "#afd7af",
    154  "#afd7d7", "#afd7ff", "#afff00", "#afff5f",
    155  "#afff87", "#afffaf", "#afffd7", "#afffff",
    156  "#d70000", "#d7005f", "#d70087", "#d700af",
    157  "#d700d7", "#d700ff", "#d75f00", "#d75f5f",
    158  "#d75f87", "#d75faf", "#d75fd7", "#d75fff",
    159  "#d78700", "#d7875f", "#d78787", "#d787af",
    160  "#d787d7", "#d787ff", "#d7af00", "#d7af5f",
    161  "#d7af87", "#d7afaf", "#d7afd7", "#d7afff",
    162  "#d7d700", "#d7d75f", "#d7d787", "#d7d7af",
    163  "#d7d7d7", "#d7d7ff", "#d7ff00", "#d7ff5f",
    164  "#d7ff87", "#d7ffaf", "#d7ffd7", "#d7ffff",
    165  "#ff0000", "#ff005f", "#ff0087", "#ff00af",
    166  "#ff00d7", "#ff00ff", "#ff5f00", "#ff5f5f",
    167  "#ff5f87", "#ff5faf", "#ff5fd7", "#ff5fff",
    168  "#ff8700", "#ff875f", "#ff8787", "#ff87af",
    169  "#ff87d7", "#ff87ff", "#ffaf00", "#ffaf5f",
    170  "#ffaf87", "#ffafaf", "#ffafd7", "#ffafff",
    171  "#ffd700", "#ffd75f", "#ffd787", "#ffd7af",
    172  "#ffd7d7", "#ffd7ff", "#ffff00", "#ffff5f",
    173  "#ffff87", "#ffffaf", "#ffffd7", "#ffffff",
    174  "#080808", "#121212", "#1c1c1c", "#262626",
    175  "#303030", "#3a3a3a", "#444444", "#4e4e4e",
    176  "#585858", "#626262", "#6c6c6c", "#767676",
    177  "#808080", "#8a8a8a", "#949494", "#9e9e9e",
    178  "#a8a8a8", "#b2b2b2", "#bcbcbc", "#c6c6c6",
    179  "#d0d0d0", "#dadada", "#e4e4e4", "#eeeeee",
    180 }
    181 -- stylua: ignore end
    182 
    183 --- @type table<integer,string>
    184 local cterm_color_cache = {}
    185 --- @type string?
    186 local background_color_cache = nil
    187 --- @type string?
    188 local foreground_color_cache = nil
    189 
    190 local len = vim.api.nvim_strwidth
    191 
    192 --- @see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
    193 --- @param color "background"|"foreground"|integer
    194 --- @return string?
    195 local function try_query_terminal_color(color)
    196  local parameter = 4
    197  if color == 'foreground' then
    198    parameter = 10
    199  elseif color == 'background' then
    200    parameter = 11
    201  end
    202  --- @type string?
    203  local hex = nil
    204  local au = vim.api.nvim_create_autocmd('TermResponse', {
    205    once = true,
    206    callback = function(args)
    207      hex = '#'
    208        .. table.concat({
    209          args.data.sequence:match('\027%]%d+;%d*;?rgb:(%w%w)%w%w/(%w%w)%w%w/(%w%w)%w%w'),
    210        })
    211    end,
    212  })
    213  if type(color) == 'number' then
    214    vim.api.nvim_ui_send(('\027]%s;%s;?\027\\'):format(parameter, color))
    215  else
    216    vim.api.nvim_ui_send(('\027]%s;?\027\\'):format(parameter))
    217  end
    218  vim.wait(100, function()
    219    return hex and true or false
    220  end)
    221  pcall(vim.api.nvim_del_autocmd, au)
    222  return hex
    223 end
    224 
    225 --- @param colorstr string
    226 --- @return string
    227 local function cterm_to_hex(colorstr)
    228  if colorstr:sub(1, 1) == '#' then
    229    return colorstr
    230  end
    231  assert(colorstr ~= '')
    232  local color = tonumber(colorstr) --[[@as integer]]
    233  assert(color and 0 <= color and color <= 255)
    234  if cterm_color_cache[color] then
    235    return cterm_color_cache[color]
    236  end
    237  local hex = try_query_terminal_color(color)
    238  if hex then
    239    cterm_color_cache[color] = hex
    240  else
    241    notify("Couldn't get terminal colors, using fallback")
    242    local t_Co = tonumber(vim.api.nvim_eval('&t_Co'))
    243    if t_Co <= 8 then
    244      cterm_color_cache = cterm_8_to_hex
    245    elseif t_Co == 88 then
    246      cterm_color_cache = cterm_88_to_hex
    247    elseif t_Co == 256 then
    248      cterm_color_cache = cterm_256_to_hex
    249    else
    250      cterm_color_cache = cterm_16_to_hex
    251    end
    252  end
    253  return cterm_color_cache[color]
    254 end
    255 
    256 --- @return string
    257 local function get_background_color()
    258  local bg = vim.fn.synIDattr(vim.fn.hlID('Normal'), 'bg#')
    259  if bg ~= '' then
    260    return cterm_to_hex(bg)
    261  end
    262  if background_color_cache then
    263    return background_color_cache
    264  end
    265  local hex = try_query_terminal_color('background')
    266  if not hex or not hex:match('#%x%x%x%x%x%x') then
    267    notify("Couldn't get terminal background colors, using fallback")
    268    hex = vim.o.background == 'light' and '#ffffff' or '#000000'
    269  end
    270  background_color_cache = hex
    271  return hex
    272 end
    273 
    274 --- @return string
    275 local function get_foreground_color()
    276  local fg = vim.fn.synIDattr(vim.fn.hlID('Normal'), 'fg#')
    277  if fg ~= '' then
    278    return cterm_to_hex(fg)
    279  end
    280  if foreground_color_cache then
    281    return foreground_color_cache
    282  end
    283  local hex = try_query_terminal_color('foreground')
    284  if not hex or not hex:match('#%x%x%x%x%x%x') then
    285    notify("Couldn't get terminal foreground colors, using fallback")
    286    hex = vim.o.background == 'light' and '#000000' or '#ffffff'
    287  end
    288  foreground_color_cache = hex
    289  return hex
    290 end
    291 
    292 --- @param style_line vim.tohtml.line
    293 --- @param col integer (1-index)
    294 --- @param field integer
    295 --- @param val any
    296 local function _style_line_insert(style_line, col, field, val)
    297  if style_line[col] == nil then
    298    style_line[col] = { {}, {}, {}, {} }
    299  end
    300  table.insert(style_line[col][field], val)
    301 end
    302 
    303 --- @param style_line vim.tohtml.line
    304 --- @param col integer (1-index)
    305 --- @param val any[]
    306 local function style_line_insert_overlay_char(style_line, col, val)
    307  _style_line_insert(style_line, col, 4, val)
    308 end
    309 
    310 --- @param style_line vim.tohtml.line
    311 --- @param col integer (1-index)
    312 --- @param val any[]
    313 local function style_line_insert_virt_text(style_line, col, val)
    314  _style_line_insert(style_line, col, 3, val)
    315 end
    316 
    317 --- @param state vim.tohtml.state
    318 --- @param hl string|integer|string[]|integer[]?
    319 --- @return nil|integer
    320 local function register_hl(state, hl)
    321  if type(hl) == 'table' then
    322    hl = hl[#hl] --- @type string|integer
    323  end
    324  if type(hl) == 'nil' then
    325    return
    326  elseif type(hl) == 'string' then
    327    hl = vim.fn.hlID(hl)
    328    assert(hl ~= 0)
    329  end
    330  hl = vim.fn.synIDtrans(hl)
    331  if not state.highlights_name[hl] then
    332    local name = vim.fn.synIDattr(hl, 'name')
    333    assert(name ~= '')
    334    state.highlights_name[hl] = name
    335  end
    336  return hl
    337 end
    338 
    339 --- @param state vim.tohtml.state
    340 --- @param start_row integer (1-index)
    341 --- @param start_col integer (1-index)
    342 --- @param end_row integer (1-index)
    343 --- @param end_col integer (1-index)
    344 --- @param conceal_text string
    345 --- @param hl_group string|integer?
    346 local function styletable_insert_conceal(
    347  state,
    348  start_row,
    349  start_col,
    350  end_row,
    351  end_col,
    352  conceal_text,
    353  hl_group
    354 )
    355  assert(state.opt.conceallevel > 0)
    356  local styletable = state.style
    357  if start_col == end_col and start_row == end_row then
    358    return
    359  end
    360  if state.opt.conceallevel == 1 and conceal_text == '' then
    361    conceal_text = vim.opt_local.listchars:get().conceal or ' '
    362  end
    363  local hlid = register_hl(state, hl_group)
    364  if vim.wo[state.winid].conceallevel ~= 3 then
    365    _style_line_insert(styletable[start_row], start_col, 3, { conceal_text, hlid })
    366  end
    367  _style_line_insert(styletable[start_row], start_col, 1, HIDE_ID)
    368  _style_line_insert(styletable[end_row], end_col, 2, HIDE_ID)
    369 end
    370 
    371 --- @param state vim.tohtml.state
    372 --- @param start_row integer (1-index)
    373 --- @param start_col integer (1-index)
    374 --- @param end_row integer (1-index)
    375 --- @param end_col integer (1-index)
    376 --- @param hl_group string|integer|nil
    377 local function styletable_insert_range(state, start_row, start_col, end_row, end_col, hl_group)
    378  if start_col == end_col and start_row == end_row or not hl_group then
    379    return
    380  end
    381  local styletable = state.style
    382  _style_line_insert(styletable[start_row], start_col, 1, hl_group)
    383  _style_line_insert(styletable[end_row], end_col, 2, hl_group)
    384 end
    385 
    386 --- @param bufnr integer
    387 --- @return vim.tohtml.styletable
    388 local function generate_styletable(bufnr)
    389  --- @type vim.tohtml.styletable
    390  local styletable = {}
    391  for row = 1, vim.api.nvim_buf_line_count(bufnr) + 1 do
    392    styletable[row] = { virt_lines = {}, pre_text = {} }
    393  end
    394  return styletable
    395 end
    396 
    397 --- @param state vim.tohtml.state
    398 local function styletable_syntax(state)
    399  for row = state.start, state.end_ do
    400    local prev_id = 0
    401    local prev_col --- @type integer?
    402    for col = 1, #vim.fn.getline(row) + 1 do
    403      local hlid = vim.fn.synID(row, col, 1)
    404      hlid = hlid == 0 and 0 or assert(register_hl(state, hlid))
    405      if hlid ~= prev_id then
    406        if prev_id ~= 0 then
    407          styletable_insert_range(state, row, assert(prev_col), row, col, prev_id)
    408        end
    409        prev_col = col
    410        prev_id = hlid
    411      end
    412    end
    413  end
    414 end
    415 
    416 --- @param state vim.tohtml.state
    417 local function styletable_diff(state)
    418  local styletable = state.style
    419  for row = state.start, state.end_ do
    420    local style_line = styletable[row]
    421    local filler = vim.fn.diff_filler(row)
    422    if filler ~= 0 then
    423      local fill = (vim.opt_local.fillchars:get().diff or '-')
    424      table.insert(
    425        style_line.virt_lines,
    426        { { fill:rep(state.width), register_hl(state, 'DiffDelete') } }
    427      )
    428    end
    429    if row == state.end_ + 1 then
    430      break
    431    end
    432    local prev_id = 0
    433    local prev_col --- @type integer?
    434    for col = 1, #vim.fn.getline(row) do
    435      local hlid = vim.fn.diff_hlID(row, col)
    436      hlid = hlid == 0 and 0 or assert(register_hl(state, hlid))
    437      if hlid ~= prev_id then
    438        if prev_id ~= 0 then
    439          styletable_insert_range(state, row, assert(prev_col), row, col, prev_id)
    440        end
    441        prev_col = col
    442        prev_id = hlid
    443      end
    444    end
    445    if prev_id ~= 0 then
    446      styletable_insert_range(state, row, assert(prev_col), row, #vim.fn.getline(row) + 1, prev_id)
    447    end
    448  end
    449 end
    450 
    451 --- @param state vim.tohtml.state
    452 local function styletable_treesitter(state)
    453  local bufnr = state.bufnr
    454  local buf_highlighter = vim.treesitter.highlighter.active[bufnr]
    455  if not buf_highlighter then
    456    return
    457  end
    458  buf_highlighter.tree:parse(true)
    459  buf_highlighter.tree:for_each_tree(function(tstree, tree)
    460    --- @cast tree vim.treesitter.LanguageTree
    461    if not tstree then
    462      return
    463    end
    464    local root = tstree:root()
    465    local q = buf_highlighter:get_query(tree:lang())
    466    --- @type vim.treesitter.Query?
    467    local query = q:query()
    468    if not query then
    469      return
    470    end
    471    for capture, node, metadata in
    472      query:iter_captures(root, buf_highlighter.bufnr, state.start - 1, state.end_)
    473    do
    474      local srow, scol, erow, ecol = node:range()
    475      --- @diagnostic disable-next-line: invisible
    476      local c = q._query.captures[capture]
    477      if c ~= nil then
    478        local hlid = register_hl(state, '@' .. c .. '.' .. tree:lang())
    479        if metadata.conceal and state.opt.conceallevel ~= 0 then
    480          styletable_insert_conceal(state, srow + 1, scol + 1, erow + 1, ecol + 1, metadata.conceal)
    481        end
    482        styletable_insert_range(state, srow + 1, scol + 1, erow + 1, ecol + 1, hlid)
    483      end
    484    end
    485  end)
    486 end
    487 
    488 --- @param state vim.tohtml.state
    489 --- @param extmark [integer, integer, integer, vim.api.keyset.extmark_details]
    490 --- @param namespaces table<integer,string>
    491 local function _styletable_extmarks_highlight(state, extmark, namespaces)
    492  if not extmark[4].hl_group then
    493    return
    494  end
    495  ---TODO(altermo) LSP semantic tokens (and some other extmarks) are only
    496  ---generated in visible lines, and not in the whole buffer.
    497  if (namespaces[extmark[4].ns_id] or ''):find('nvim.lsp.semantic_tokens') then
    498    notify('lsp semantic tokens are not supported, HTML may be incorrect')
    499    return
    500  end
    501  local srow, scol, erow, ecol =
    502    extmark[2], extmark[3], extmark[4].end_row or extmark[2], extmark[4].end_col or extmark[3]
    503  if scol == ecol and srow == erow then
    504    return
    505  end
    506  local hlid = register_hl(state, extmark[4].hl_group)
    507  styletable_insert_range(state, srow + 1, scol + 1, erow + 1, ecol + 1, hlid)
    508 end
    509 
    510 --- @param state vim.tohtml.state
    511 --- @param extmark [integer, integer, integer, vim.api.keyset.set_extmark|any]
    512 --- @param namespaces table<integer,string>
    513 local function _styletable_extmarks_virt_text(state, extmark, namespaces)
    514  if not extmark[4].virt_text then
    515    return
    516  end
    517  ---TODO(altermo) LSP semantic tokens (and some other extmarks) are only
    518  ---generated in visible lines, and not in the whole buffer.
    519  if (namespaces[extmark[4].ns_id] or ''):find('nvim.lsp.inlayhint') then
    520    notify('lsp inlay hints are not supported, HTML may be incorrect')
    521    return
    522  end
    523  local styletable = state.style
    524  --- @type integer,integer
    525  local row, col = extmark[2], extmark[3]
    526  if
    527    row < vim.api.nvim_buf_line_count(state.bufnr)
    528    and (
    529      extmark[4].virt_text_pos == 'inline'
    530      or extmark[4].virt_text_pos == 'eol'
    531      or extmark[4].virt_text_pos == 'overlay'
    532    )
    533  then
    534    if extmark[4].virt_text_pos == 'eol' then
    535      style_line_insert_virt_text(styletable[row + 1], #vim.fn.getline(row + 1) + 1, { ' ' })
    536    end
    537    local virt_text_len = 0
    538    for _, i in
    539      ipairs(extmark[4].virt_text --[[@as (string[][])]])
    540    do
    541      local hlid = register_hl(state, i[2])
    542      if extmark[4].virt_text_pos == 'eol' then
    543        style_line_insert_virt_text(
    544          styletable[row + 1],
    545          #vim.fn.getline(row + 1) + 1,
    546          { i[1], hlid }
    547        )
    548      else
    549        style_line_insert_virt_text(styletable[row + 1], col + 1, { i[1], hlid })
    550      end
    551      virt_text_len = virt_text_len + len(assert(i[1]))
    552    end
    553    if extmark[4].virt_text_pos == 'overlay' then
    554      styletable_insert_range(state, row + 1, col + 1, row + 1, col + virt_text_len + 1, HIDE_ID)
    555    end
    556  end
    557  local not_supported = {
    558    virt_text_pos = 'right_align',
    559    hl_mode = 'blend',
    560    hl_group = 'combine',
    561  }
    562  for opt, val in pairs(not_supported) do
    563    if extmark[4][opt] == val then
    564      notify(('extmark.%s="%s" is not supported, HTML may be incorrect'):format(opt, val))
    565    end
    566  end
    567 end
    568 
    569 --- @param state vim.tohtml.state
    570 --- @param extmark [integer, integer, integer, vim.api.keyset.set_extmark|any]
    571 local function _styletable_extmarks_virt_lines(state, extmark)
    572  ---TODO(altermo) if the fold start is equal to virt_line start then the fold hides the virt_line
    573  if not extmark[4].virt_lines then
    574    return
    575  end
    576  --- @type integer
    577  local row = extmark[2] + (extmark[4].virt_lines_above and 1 or 2)
    578  for _, line in
    579    ipairs(extmark[4].virt_lines --[[@as (string[][][])]])
    580  do
    581    local virt_line = {}
    582    for _, i in ipairs(line) do
    583      local hlid = register_hl(state, i[2])
    584      table.insert(virt_line, { i[1], hlid })
    585    end
    586    table.insert(state.style[row].virt_lines, virt_line)
    587  end
    588 end
    589 
    590 --- @param state vim.tohtml.state
    591 --- @param extmark [integer, integer, integer, vim.api.keyset.extmark_details]
    592 local function _styletable_extmarks_conceal(state, extmark)
    593  if not extmark[4].conceal or state.opt.conceallevel == 0 then
    594    return
    595  end
    596  local srow, scol, erow, ecol =
    597    extmark[2], extmark[3], extmark[4].end_row or extmark[2], extmark[4].end_col or extmark[3]
    598  styletable_insert_conceal(
    599    state,
    600    srow + 1,
    601    scol + 1,
    602    erow + 1,
    603    ecol + 1,
    604    extmark[4].conceal,
    605    extmark[4].hl_group or 'Conceal'
    606  )
    607 end
    608 
    609 --- @param state vim.tohtml.state
    610 local function styletable_extmarks(state)
    611  --TODO(altermo) extmarks may have col/row which is outside of the buffer, which could cause an error
    612  local bufnr = state.bufnr
    613  local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, -1, 0, -1, { details = true })
    614  --- @cast extmarks [integer,integer,integer,vim.api.keyset.extmark_details][]
    615 
    616  local namespaces = {} --- @type table<integer, string>
    617  for ns, ns_id in pairs(vim.api.nvim_get_namespaces()) do
    618    namespaces[ns_id] = ns
    619  end
    620  for _, v in ipairs(extmarks) do
    621    _styletable_extmarks_highlight(state, v, namespaces)
    622  end
    623  for _, v in ipairs(extmarks) do
    624    _styletable_extmarks_conceal(state, v)
    625  end
    626  for _, v in ipairs(extmarks) do
    627    _styletable_extmarks_virt_text(state, v, namespaces)
    628  end
    629  for _, v in ipairs(extmarks) do
    630    _styletable_extmarks_virt_lines(state, v)
    631  end
    632 end
    633 
    634 --- @param state vim.tohtml.state
    635 local function styletable_folds(state)
    636  local styletable = state.style
    637  local has_folded = false
    638  for row = state.start, state.end_ do
    639    if vim.fn.foldclosed(row) > 0 then
    640      has_folded = true
    641      styletable[row].hide = true
    642    end
    643    if vim.fn.foldclosed(row) == row then
    644      local hlid = register_hl(state, 'Folded')
    645      ---TODO(altermo): Is there a way to get highlighted foldtext?
    646      local foldtext = vim.fn.foldtextresult(row)
    647      foldtext = foldtext .. (vim.opt.fillchars:get().fold or '·'):rep(state.width - #foldtext)
    648      table.insert(styletable[row].virt_lines, { { foldtext, hlid } })
    649    end
    650  end
    651  if has_folded and type(({ pcall(vim.api.nvim_eval, vim.o.foldtext) })[2]) == 'table' then
    652    notify('foldtext returning a table with highlights is not supported, HTML may be incorrect')
    653  end
    654 end
    655 
    656 --- @param state vim.tohtml.state
    657 local function styletable_conceal(state)
    658  local bufnr = state.bufnr
    659  vim._with({ buf = bufnr }, function()
    660    for row = state.start, state.end_ do
    661      --- @type table<integer,[integer,integer,string]>
    662      local conceals = {}
    663      local line_len_exclusive = #vim.fn.getline(row) + 1
    664      for col = 1, line_len_exclusive do
    665        --- @type integer,string,integer
    666        local is_concealed, conceal, hlid = unpack(vim.fn.synconcealed(row, col) --[[@as table]])
    667        if is_concealed ~= 0 then
    668          if not conceals[hlid] then
    669            conceals[hlid] = { col, math.min(col + 1, line_len_exclusive), conceal }
    670          else
    671            conceals[hlid][2] = math.min(col + 1, line_len_exclusive)
    672          end
    673        end
    674      end
    675      for _, v in pairs(conceals) do
    676        styletable_insert_conceal(state, row, v[1], row, v[2], v[3], 'Conceal')
    677      end
    678    end
    679  end)
    680 end
    681 
    682 --- @param state vim.tohtml.state
    683 local function styletable_match(state)
    684  for _, match in ipairs(vim.fn.getmatches(state.winid)) do
    685    local hlid = register_hl(state, match.group)
    686    local function range(srow, scol, erow, ecol)
    687      if match.group == 'Conceal' and state.opt.conceallevel ~= 0 then
    688        styletable_insert_conceal(state, srow, scol, erow, ecol, match.conceal or '', hlid)
    689      else
    690        styletable_insert_range(state, srow, scol, erow, ecol, hlid)
    691      end
    692    end
    693    if match.pos1 then
    694      for key, v in
    695        pairs(match --[[@as table<string,[integer,integer,integer]>]])
    696      do
    697        if key:match('^pos(%d+)$') then
    698          if #v == 1 then
    699            range(v[1], 1, v[1], #vim.fn.getline(v[1]) + 1)
    700          else
    701            range(v[1], v[2], v[1], v[3] + v[2])
    702          end
    703        end
    704      end
    705    else
    706      for _, v in
    707        ipairs(vim.fn.matchbufline(state.bufnr, assert(match.pattern), 1, '$') --[[@as (table[])]])
    708      do
    709        range(v.lnum, v.byteidx + 1, v.lnum, v.byteidx + 1 + #v.text)
    710      end
    711    end
    712  end
    713 end
    714 
    715 --- Requires state.conf.number_lines to be set to true
    716 --- @param state vim.tohtml.state
    717 local function styletable_statuscolumn(state)
    718  if not state.conf.number_lines then
    719    return
    720  end
    721  local statuscolumn = state.opt.statuscolumn
    722 
    723  if statuscolumn == '' then
    724    if state.opt.relativenumber then
    725      if state.opt.number then
    726        statuscolumn = '%C%s%{%v:lnum!=line(".")?"%=".v:relnum." ":v:lnum%}'
    727      else
    728        statuscolumn = '%C%s%{%"%=".v:relnum." "%}'
    729      end
    730    else
    731      statuscolumn = '%C%s%{%"%=".v:lnum." "%}'
    732    end
    733  end
    734  local minwidth = 0
    735 
    736  local signcolumn = state.opt.signcolumn
    737  if state.opt.number or state.opt.relativenumber then
    738    minwidth = minwidth + state.opt.numberwidth
    739    if signcolumn == 'number' then
    740      signcolumn = 'no'
    741    end
    742  end
    743  if signcolumn == 'number' then
    744    signcolumn = 'auto'
    745  end
    746  if signcolumn ~= 'no' then
    747    local max = tonumber(signcolumn:match('^%w-:(%d)')) --[[@as integer?]]
    748      or 1
    749    if signcolumn:match('^auto') then
    750      --- @type table<integer,integer>
    751      local signcount = {}
    752      for _, extmark in
    753        ipairs(vim.api.nvim_buf_get_extmarks(state.bufnr, -1, 0, -1, { details = true }))
    754      do
    755        --- @cast extmark [integer, integer, integer, vim.api.keyset.extmark_details]
    756        if extmark[4].sign_text then
    757          signcount[extmark[2]] = (signcount[extmark[2]] or 0) + 1
    758        end
    759      end
    760      local maxsigns = 0
    761      for _, v in pairs(signcount) do
    762        if v > maxsigns then
    763          maxsigns = v
    764        end
    765      end
    766      minwidth = minwidth + math.min(maxsigns, max) * 2
    767    else
    768      minwidth = minwidth + max * 2
    769    end
    770  end
    771 
    772  local foldcolumn = state.opt.foldcolumn
    773  if foldcolumn ~= '0' then
    774    if foldcolumn:match('^auto') then
    775      local max = tonumber(foldcolumn:match('^%w-:(%d)')) --[[@as integer?]]
    776        or 1
    777      local maxfold = 0
    778      vim._with({ buf = state.bufnr }, function()
    779        for row = state.start, state.end_ do
    780          local foldlevel = vim.fn.foldlevel(row)
    781          if foldlevel > maxfold then
    782            maxfold = foldlevel
    783          end
    784        end
    785      end)
    786      minwidth = minwidth + math.min(maxfold, max)
    787    else
    788      minwidth = minwidth + tonumber(foldcolumn) --[[@as integer]]
    789    end
    790  end
    791 
    792  --- @type table<integer,vim.api.keyset.eval_statusline_ret>
    793  local statuses = {}
    794  for row = state.start, state.end_ do
    795    local status = vim.api.nvim_eval_statusline(
    796      statuscolumn,
    797      { winid = state.winid, use_statuscol_lnum = row, highlights = true }
    798    )
    799    local width = len(status.str)
    800    if width > minwidth then
    801      minwidth = width
    802    end
    803    table.insert(statuses, status)
    804  end
    805  for row, status in pairs(statuses) do
    806    local str = status.str
    807    local hls = status.highlights
    808    for k, v in ipairs(hls) do
    809      local hlsk1 = hls[k + 1]
    810      local text = str:sub(v.start + 1, hlsk1 and hlsk1.start or nil)
    811      if k == #hls then
    812        text = text .. (' '):rep(minwidth - len(str))
    813      end
    814      if text ~= '' then
    815        local hlid = register_hl(state, v.group)
    816        local virt_text = { text, hlid }
    817        table.insert(state.style[row].pre_text, virt_text)
    818      end
    819    end
    820  end
    821 end
    822 
    823 --- @param state vim.tohtml.state
    824 local function styletable_listchars(state)
    825  if not state.opt.list then
    826    return
    827  end
    828  --- @return string
    829  local function utf8_sub(str, i, j)
    830    return vim.fn.strcharpart(str, i - 1, j and j - i + 1 or nil)
    831  end
    832  --- @type table<string,string>
    833  local listchars = vim.opt_local.listchars:get()
    834  local ids = setmetatable({}, {
    835    __index = function(t, k)
    836      rawset(t, k, register_hl(state, k))
    837      return rawget(t, k)
    838    end,
    839  })
    840 
    841  if listchars.eol then
    842    for row = state.start, state.end_ do
    843      local style_line = state.style[row]
    844      style_line_insert_overlay_char(
    845        style_line,
    846        #vim.fn.getline(row) + 1,
    847        { listchars.eol, ids.NonText }
    848      )
    849    end
    850  end
    851 
    852  if listchars.tab and state.tabstop then
    853    for _, match in
    854      ipairs(vim.fn.matchbufline(state.bufnr, '\t', 1, '$') --[[@as (table[])]])
    855    do
    856      local vcol = vim.fn.virtcol({ match.lnum, match.byteidx }, false, state.winid) --[[@as integer]]
    857      local tablen = #state.tabstop - (vcol % #state.tabstop)
    858      local text --- @type string
    859      if len(listchars.tab) == 3 then
    860        if tablen == 1 then
    861          text = utf8_sub(listchars.tab, 3, 3)
    862        else
    863          text = utf8_sub(listchars.tab, 1, 1)
    864            .. utf8_sub(listchars.tab, 2, 2):rep(tablen - 2)
    865            .. utf8_sub(listchars.tab, 3, 3)
    866        end
    867      else
    868        text = utf8_sub(listchars.tab, 1, 1) .. utf8_sub(listchars.tab, 2, 2):rep(tablen - 1)
    869      end
    870      style_line_insert_overlay_char(
    871        state.style[match.lnum],
    872        match.byteidx + 1,
    873        { text, ids.Whitespace }
    874      )
    875    end
    876  end
    877 
    878  if listchars.space then
    879    for _, match in
    880      ipairs(vim.fn.matchbufline(state.bufnr, ' ', 1, '$') --[[@as (table[])]])
    881    do
    882      style_line_insert_overlay_char(
    883        state.style[match.lnum],
    884        match.byteidx + 1,
    885        { listchars.space, ids.Whitespace }
    886      )
    887    end
    888  end
    889 
    890  if listchars.multispace then
    891    for _, match in
    892      ipairs(vim.fn.matchbufline(state.bufnr, [[  \+]], 1, '$') --[[@as (table[])]])
    893    do
    894      local text = utf8_sub(listchars.multispace:rep(len(match.text)), 1, len(match.text))
    895      for i = 1, len(text) do
    896        style_line_insert_overlay_char(
    897          state.style[match.lnum],
    898          match.byteidx + i,
    899          { utf8_sub(text, i, i), ids.Whitespace }
    900        )
    901      end
    902    end
    903  end
    904 
    905  if listchars.lead or listchars.leadmultispace then
    906    for _, match in
    907      ipairs(vim.fn.matchbufline(state.bufnr, [[^ \+]], 1, '$') --[[@as (table[])]])
    908    do
    909      local text = ''
    910      if len(match.text) == 1 or not listchars.leadmultispace then
    911        if listchars.lead then
    912          text = listchars.lead:rep(len(match.text))
    913        end
    914      elseif listchars.leadmultispace then
    915        text = utf8_sub(listchars.leadmultispace:rep(len(match.text)), 1, len(match.text))
    916      end
    917      for i = 1, len(text) do
    918        style_line_insert_overlay_char(
    919          state.style[match.lnum],
    920          match.byteidx + i,
    921          { utf8_sub(text, i, i), ids.Whitespace }
    922        )
    923      end
    924    end
    925  end
    926 
    927  if listchars.trail then
    928    for _, match in
    929      ipairs(vim.fn.matchbufline(state.bufnr, [[ \+$]], 1, '$') --[[@as (table[])]])
    930    do
    931      local text = listchars.trail:rep(len(match.text))
    932      for i = 1, len(text) do
    933        style_line_insert_overlay_char(
    934          state.style[match.lnum],
    935          match.byteidx + i,
    936          { utf8_sub(text, i, i), ids.Whitespace }
    937        )
    938      end
    939    end
    940  end
    941 
    942  if listchars.nbsp then
    943    for _, match in
    944      ipairs(
    945        vim.fn.matchbufline(state.bufnr, '\226\128\175\\|\194\160', 1, '$') --[[@as (table[])]]
    946      )
    947    do
    948      style_line_insert_overlay_char(
    949        state.style[match.lnum],
    950        match.byteidx + 1,
    951        { listchars.nbsp, ids.Whitespace }
    952      )
    953      for i = 2, #match.text do
    954        style_line_insert_overlay_char(
    955          state.style[match.lnum],
    956          match.byteidx + i,
    957          { '', ids.Whitespace }
    958        )
    959      end
    960    end
    961  end
    962 end
    963 
    964 --- @param name string
    965 --- @return string
    966 local function highlight_name_to_class_name(name)
    967  return (name:gsub('%.', '-'):gsub('@', '-'))
    968 end
    969 
    970 --- @param name string
    971 --- @return string
    972 local function name_to_tag(name)
    973  return '<span class="' .. highlight_name_to_class_name(name) .. '">'
    974 end
    975 
    976 --- @param _ string
    977 --- @return string
    978 local function name_to_closetag(_)
    979  return '</span>'
    980 end
    981 
    982 --- @param str string
    983 --- @param tabstop string|false?
    984 --- @return string
    985 local function html_escape(str, tabstop)
    986  str = str:gsub('&', '&amp;'):gsub('<', '&lt;'):gsub('>', '&gt;'):gsub('"', '&quot;')
    987  if tabstop then
    988    --- @type string
    989    str = str:gsub('\t', tabstop)
    990  end
    991  return str
    992 end
    993 
    994 --- @param out string[]
    995 --- @param state vim.tohtml.state.global
    996 local function extend_style(out, state)
    997  table.insert(out, '<style>')
    998  table.insert(out, ('* {font-family: %s}'):format(state.font))
    999  table.insert(
   1000    out,
   1001    ('body {background-color: %s; color: %s}'):format(state.background, state.foreground)
   1002  )
   1003  for hlid, name in pairs(state.highlights_name) do
   1004    --TODO(altermo) use local namespace (instead of global 0)
   1005    local fg = vim.fn.synIDattr(hlid, 'fg#')
   1006    local bg = vim.fn.synIDattr(hlid, 'bg#')
   1007    local sp = vim.fn.synIDattr(hlid, 'sp#')
   1008    local decor_line = {}
   1009    if vim.fn.synIDattr(hlid, 'underline') ~= '' then
   1010      table.insert(decor_line, 'underline')
   1011    end
   1012    if vim.fn.synIDattr(hlid, 'strikethrough') ~= '' then
   1013      table.insert(decor_line, 'line-through')
   1014    end
   1015    if vim.fn.synIDattr(hlid, 'undercurl') ~= '' then
   1016      table.insert(decor_line, 'underline')
   1017    end
   1018    local c = {
   1019      color = fg ~= '' and cterm_to_hex(fg) or nil,
   1020      ['background-color'] = bg ~= '' and cterm_to_hex(bg) or nil,
   1021      ['font-style'] = vim.fn.synIDattr(hlid, 'italic') ~= '' and 'italic' or nil,
   1022      ['font-weight'] = vim.fn.synIDattr(hlid, 'bold') ~= '' and 'bold' or nil,
   1023      ['text-decoration-line'] = not vim.tbl_isempty(decor_line) and table.concat(decor_line, ' ')
   1024        or nil,
   1025      -- TODO(ribru17): fallback to displayed text color if sp not set
   1026      ['text-decoration-color'] = sp ~= '' and cterm_to_hex(sp) or nil,
   1027      --TODO(altermo) if strikethrough and undercurl then the strikethrough becomes wavy
   1028      ['text-decoration-style'] = vim.fn.synIDattr(hlid, 'undercurl') ~= '' and 'wavy' or nil,
   1029    }
   1030    local attrs = {}
   1031    for attr, val in pairs(c) do
   1032      table.insert(attrs, attr .. ': ' .. val)
   1033    end
   1034    table.insert(
   1035      out,
   1036      '.' .. highlight_name_to_class_name(name) .. ' {' .. table.concat(attrs, '; ') .. '}'
   1037    )
   1038  end
   1039  table.insert(out, '</style>')
   1040 end
   1041 
   1042 --- @param out string[]
   1043 --- @param state vim.tohtml.state.global
   1044 local function extend_head(out, state)
   1045  table.insert(out, '<head>')
   1046  table.insert(out, '<meta charset="UTF-8">')
   1047  if state.title ~= false then
   1048    table.insert(out, ('<title>%s</title>'):format(state.title))
   1049  end
   1050  local colorscheme = vim.api.nvim_exec2('colorscheme', { output = true }).output
   1051  table.insert(
   1052    out,
   1053    ('<meta name="colorscheme" content="%s"></meta>'):format(html_escape(colorscheme))
   1054  )
   1055  extend_style(out, state)
   1056  table.insert(out, '</head>')
   1057 end
   1058 
   1059 --- @param out string[]
   1060 --- @param state vim.tohtml.state
   1061 --- @param row integer
   1062 local function _extend_virt_lines(out, state, row)
   1063  local style_line = state.style[row]
   1064  for _, virt_line in ipairs(style_line.virt_lines) do
   1065    local virt_s = ''
   1066    for _, v in ipairs(virt_line) do
   1067      if v[2] then
   1068        virt_s = virt_s .. (name_to_tag(state.highlights_name[v[2]]))
   1069      end
   1070      virt_s = virt_s .. v[1]
   1071      if v[2] then
   1072        --- @type string
   1073        virt_s = virt_s .. (name_to_closetag(state.highlights_name[v[2]]))
   1074      end
   1075    end
   1076    table.insert(out, virt_s)
   1077  end
   1078 end
   1079 
   1080 --- @param state vim.tohtml.state
   1081 --- @param row integer
   1082 --- @return string
   1083 local function _pre_text_to_html(state, row)
   1084  local style_line = state.style[row]
   1085  local s = ''
   1086  for _, pre_text in ipairs(style_line.pre_text) do
   1087    if pre_text[2] then
   1088      s = s .. (name_to_tag(state.highlights_name[pre_text[2]]))
   1089    end
   1090    s = s .. (html_escape(pre_text[1], state.tabstop))
   1091    if pre_text[2] then
   1092      --- @type string
   1093      s = s .. (name_to_closetag(state.highlights_name[pre_text[2]]))
   1094    end
   1095  end
   1096  return s
   1097 end
   1098 
   1099 --- @param state vim.tohtml.state
   1100 --- @param char table
   1101 --- @return string
   1102 local function _char_to_html(state, char)
   1103  local s = ''
   1104  if char[2] then
   1105    s = s .. name_to_tag(state.highlights_name[char[2]])
   1106  end
   1107  s = s .. html_escape(char[1], state.tabstop)
   1108  if char[2] then
   1109    s = s .. name_to_closetag(state.highlights_name[char[2]])
   1110  end
   1111  return s
   1112 end
   1113 
   1114 --- @param state vim.tohtml.state
   1115 --- @param cell vim.tohtml.cell
   1116 --- @return string
   1117 local function _virt_text_to_html(state, cell)
   1118  local s = ''
   1119  for _, v in ipairs(cell[3]) do
   1120    if v[2] then
   1121      s = s .. (name_to_tag(state.highlights_name[v[2]]))
   1122    end
   1123    --- @type string
   1124    s = s .. html_escape(v[1], state.tabstop)
   1125    if v[2] then
   1126      s = s .. name_to_closetag(state.highlights_name[v[2]])
   1127    end
   1128  end
   1129  return s
   1130 end
   1131 
   1132 --- @param out string[]
   1133 --- @param state vim.tohtml.state
   1134 local function extend_pre(out, state)
   1135  local styletable = state.style
   1136  table.insert(out, '<pre>')
   1137  local out_start = #out
   1138  local hide_count = 0
   1139  --- @type integer[]
   1140  local stack = {}
   1141 
   1142  local before = ''
   1143  local after = ''
   1144  --- @param row integer
   1145  local function loop(row)
   1146    local inside = row <= state.end_ and row >= state.start
   1147    local style_line = styletable[row]
   1148    if style_line.hide and (styletable[row - 1] or {}).hide then
   1149      return
   1150    end
   1151    if inside then
   1152      _extend_virt_lines(out, state, row)
   1153    end
   1154    --Possible improvement (altermo):
   1155    --Instead of looping over all the buffer characters per line,
   1156    --why not loop over all the style_line cells,
   1157    --and then calculating the amount of text.
   1158    if style_line.hide then
   1159      return
   1160    end
   1161    local line = vim.api.nvim_buf_get_lines(state.bufnr, row - 1, row, false)[1] or ''
   1162    local s = ''
   1163    if inside then
   1164      s = s .. _pre_text_to_html(state, row)
   1165    end
   1166    local true_line_len = #line + 1
   1167    for k in
   1168      pairs(style_line --[[@as table<string,any>]])
   1169    do
   1170      if type(k) == 'number' and k > true_line_len then
   1171        true_line_len = k --[[@as integer]]
   1172      end
   1173    end
   1174    for col = 1, true_line_len do
   1175      local cell = style_line[col]
   1176      --- @type table?
   1177      local char
   1178      if cell then
   1179        for i = #cell[2], 1, -1 do
   1180          local hlid = cell[2][i]
   1181          if hlid < 0 then
   1182            if hlid == HIDE_ID then
   1183              hide_count = hide_count - 1
   1184            end
   1185          else
   1186            --- @type integer?
   1187            local index
   1188            for idx = #stack, 1, -1 do
   1189              s = s .. (name_to_closetag(state.highlights_name[stack[idx]]))
   1190              if stack[idx] == hlid then
   1191                index = idx
   1192                break
   1193              end
   1194            end
   1195            assert(index, 'a coles tag which has no corresponding open tag')
   1196            for idx = index + 1, #stack do
   1197              s = s .. (name_to_tag(state.highlights_name[stack[idx]]))
   1198            end
   1199            table.remove(stack, index)
   1200          end
   1201        end
   1202 
   1203        for _, hlid in ipairs(cell[1]) do
   1204          if hlid < 0 then
   1205            if hlid == HIDE_ID then
   1206              hide_count = hide_count + 1
   1207            end
   1208          else
   1209            table.insert(stack, hlid)
   1210            s = s .. (name_to_tag(state.highlights_name[hlid]))
   1211          end
   1212        end
   1213 
   1214        if cell[3] and inside then
   1215          s = s .. _virt_text_to_html(state, cell)
   1216        end
   1217 
   1218        char = cell[4][#cell[4]]
   1219      end
   1220 
   1221      if col == true_line_len and not char then
   1222        break
   1223      end
   1224 
   1225      if hide_count == 0 and inside then
   1226        s = s
   1227          .. _char_to_html(
   1228            state,
   1229            char
   1230              or { vim.api.nvim_buf_get_text(state.bufnr, row - 1, col - 1, row - 1, col, {})[1] }
   1231          )
   1232      end
   1233    end
   1234    if row > state.end_ + 1 then
   1235      after = after .. s
   1236    elseif row < state.start then
   1237      before = s .. before
   1238    else
   1239      table.insert(out, s)
   1240    end
   1241  end
   1242 
   1243  for row = 1, vim.api.nvim_buf_line_count(state.bufnr) + 1 do
   1244    loop(row)
   1245  end
   1246  out[out_start] = out[out_start] .. before
   1247  out[#out] = out[#out] .. after
   1248  assert(#stack == 0, 'an open HTML tag was never closed')
   1249  table.insert(out, '</pre>')
   1250 end
   1251 
   1252 --- @param out string[]
   1253 --- @param fn fun()
   1254 local function extend_body(out, fn)
   1255  table.insert(out, '<body style="display: flex">')
   1256  fn()
   1257  table.insert(out, '</body>')
   1258 end
   1259 
   1260 --- @param out string[]
   1261 --- @param fn fun()
   1262 local function extend_html(out, fn)
   1263  table.insert(out, '<!DOCTYPE html>')
   1264  table.insert(out, '<html>')
   1265  fn()
   1266  table.insert(out, '</html>')
   1267 end
   1268 
   1269 --- @param winid integer
   1270 --- @param global_state vim.tohtml.state.global
   1271 --- @return vim.tohtml.state
   1272 local function global_state_to_state(winid, global_state)
   1273  local bufnr = vim.api.nvim_win_get_buf(winid)
   1274  local opt = global_state.conf
   1275  local width = opt.width or vim.bo[bufnr].textwidth
   1276  if not width or width < 1 then
   1277    width = vim.api.nvim_win_get_width(winid)
   1278  end
   1279  local range = opt.range or { 1, vim.api.nvim_buf_line_count(bufnr) }
   1280  local state = setmetatable({
   1281    winid = winid == 0 and vim.api.nvim_get_current_win() or winid,
   1282    opt = vim.wo[winid],
   1283    style = generate_styletable(bufnr),
   1284    bufnr = bufnr,
   1285    tabstop = (' '):rep(vim.bo[bufnr].tabstop),
   1286    width = width,
   1287    start = range[1],
   1288    end_ = range[2],
   1289  }, { __index = global_state })
   1290  return state --[[@as vim.tohtml.state]]
   1291 end
   1292 
   1293 --- @param opt vim.tohtml.opt
   1294 --- @param title? string
   1295 --- @return vim.tohtml.state.global
   1296 local function opt_to_global_state(opt, title)
   1297  local fonts = {}
   1298  if opt.font then
   1299    fonts = type(opt.font) == 'string' and { opt.font } or opt.font --[[@as (string[])]]
   1300    for i, v in pairs(fonts) do
   1301      fonts[i] = ('"%s"'):format(v)
   1302    end
   1303  elseif vim.o.guifont:match('^[^:]+') then
   1304    -- Example:
   1305    -- Input: "Font,Escape\,comma, Ignore space after comma"
   1306    -- Output: { "Font","Escape,comma","Ignore space after comma" }
   1307    local prev = ''
   1308    for name in vim.gsplit(assert(vim.o.guifont:match('^[^:]+')), ',', { trimempty = true }) do
   1309      if vim.endswith(name, '\\') then
   1310        prev = prev .. vim.trim(name:sub(1, -2) .. ',')
   1311      elseif vim.trim(name) ~= '' then
   1312        table.insert(fonts, ('"%s%s"'):format(prev, vim.trim(name)))
   1313        prev = ''
   1314      end
   1315    end
   1316  end
   1317  -- Generic family names (monospace here) must not be quoted
   1318  -- because the browser recognizes them as font families.
   1319  table.insert(fonts, 'monospace')
   1320  --- @type vim.tohtml.state.global
   1321  local state = {
   1322    background = get_background_color(),
   1323    foreground = get_foreground_color(),
   1324    title = opt.title or title or false,
   1325    font = table.concat(fonts, ','),
   1326    highlights_name = {},
   1327    conf = opt,
   1328  }
   1329  return state
   1330 end
   1331 
   1332 --- @type fun(state: vim.tohtml.state)[]
   1333 local styletable_funcs = {
   1334  styletable_syntax,
   1335  styletable_diff,
   1336  styletable_treesitter,
   1337  styletable_match,
   1338  styletable_extmarks,
   1339  styletable_conceal,
   1340  styletable_listchars,
   1341  styletable_folds,
   1342  styletable_statuscolumn,
   1343 }
   1344 
   1345 --- @param state vim.tohtml.state
   1346 local function state_generate_style(state)
   1347  vim._with({ win = state.winid }, function()
   1348    for _, fn in ipairs(styletable_funcs) do
   1349      --- @type string?
   1350      local cond
   1351      if type(fn) == 'table' then
   1352        cond = fn[2] --[[@as string]]
   1353        --- @type function
   1354        fn = fn[1]
   1355      end
   1356      if not cond or cond(state) then
   1357        fn(state)
   1358      end
   1359    end
   1360  end)
   1361 end
   1362 
   1363 --- @param winid integer
   1364 --- @param opt? vim.tohtml.opt
   1365 --- @return string[]
   1366 local function win_to_html(winid, opt)
   1367  opt = opt or {}
   1368  local title = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(winid))
   1369 
   1370  local global_state = opt_to_global_state(opt, title)
   1371  local state = global_state_to_state(winid, global_state)
   1372  state_generate_style(state)
   1373 
   1374  local html = {}
   1375  table.insert(html, '<!-- vim: set nomodeline: -->')
   1376  extend_html(html, function()
   1377    extend_head(html, global_state)
   1378    extend_body(html, function()
   1379      extend_pre(html, state)
   1380    end)
   1381  end)
   1382  return html
   1383 end
   1384 
   1385 local M = {}
   1386 
   1387 --- @class vim.tohtml.opt
   1388 --- @inlinedoc
   1389 ---
   1390 --- Title tag to set in the generated HTML code.
   1391 --- (default: buffer name)
   1392 --- @field title? string|false
   1393 ---
   1394 --- Show line numbers.
   1395 --- (default: `false`)
   1396 --- @field number_lines? boolean
   1397 ---
   1398 --- Fonts to use.
   1399 --- (default: `guifont`)
   1400 --- @field font? string[]|string
   1401 ---
   1402 --- Width used for items which are either right aligned or repeat a character
   1403 --- infinitely.
   1404 --- (default: 'textwidth' if non-zero or window width otherwise)
   1405 --- @field width? integer
   1406 ---
   1407 --- Range of rows to use.
   1408 --- (default: entire buffer)
   1409 --- @field range? integer[]
   1410 
   1411 --- Converts the buffer shown in the window {winid} to HTML and returns the output as a list of string.
   1412 --- @param winid? integer Window to convert (defaults to current window)
   1413 --- @param opt? vim.tohtml.opt Optional parameters.
   1414 --- @return string[]
   1415 function M.tohtml(winid, opt)
   1416  return win_to_html(winid or 0, opt)
   1417 end
   1418 
   1419 return M