Skip to content

Instantly share code, notes, and snippets.

@Be1zebub
Created June 5, 2024 17:38
Show Gist options
  • Save Be1zebub/254d716369878dc665640d4008035f7d to your computer and use it in GitHub Desktop.
Save Be1zebub/254d716369878dc665640d4008035f7d to your computer and use it in GitHub Desktop.
-- unfinished markdown parser & renderer
local markdown = {}
do
local markdown_rules = {
{
"%*%*%*(.-)%*%*%*", "bolditalic", 3
},
{
"%*%*(.-)%*%*", "bold", 2
},
{
"%*(.-)%*", "italic"
},
{
"__(.-)__", "underline"
},
{
"```(.-)```", "codeblock", 3
},
{
"`(.-)`", "codeblock"
},
{
"~~(.-)~~", "strikeout"
},
{
":([%w%p]+):", "emote"
}
}
local markdown_line_rules = {
{
"^###%s*(.-)$", "header3"
},
{
"^##%s*(.-)$", "header2"
},
{
"^#%s*(.-)$", "header1"
}
}
local black = Color(0, 0, 0)
local function Hex2Color(hex)
if type(hex) == "string" then
hex = tonumber(hex:gsub("^[#0]x?", ""), 16)
end
if hex == nil then return black end
return Color(
bit.rshift(bit.band(hex, 0xFF0000), 16),
bit.rshift(bit.band(hex, 0xFF00), 8),
bit.band(hex, 0xFF)
)
end
local urlColor = Color(20, 20, 238)
local function RawMatch(raw, out)
--[[
do
local start, ending, name = raw:find("%[(.-)%]")
if name and #name > 0 then
local _, ending2, url = raw:find("%((.-)%)", ending + 1)
if url and #url > 0 then
if start > 1 then
out[#out + 1] = {
type = "raw",
body = raw:sub(1, start - 1)
}
end
out[#out + 1] = {
type = "url",
body = name,
url = url,
color = urlColor
}
if #raw > ending + 1 then
out[#out + 1] = {
type = "raw",
body = raw:sub(ending2 + 1)
}
end
return true
end
end
end
do
local start, ending, color = raw:find("<(.-)>")
if color and #color > 0 then
local _, ending2, text = raw:find("(.-)</>", ending + 1)
if text and #text > 0 then
if start > 1 then
out[#out + 1] = {
type = "raw",
body = raw:sub(1, start - 1)
}
end
out[#out + 1] = {
type = "colored",
body = text,
color = Hex2Color(color)
}
if #raw > ending + 1 then
out[#out + 1] = {
type = "raw",
body = raw:sub(ending2 + 1)
}
end
return true
end
end
end
return false
]]--
--[[
local name, url = raw:match("%[(.-)%]%((.-)%)")
if name then
return {
type = "url",
body = name,
url = url,
color = urlColor
}
end
local color, text = raw:match("<(.-)>(.-)</>")
if color then
return {
type = "colored",
body = text,
color = Hex2Color(color)
}
end
]]--
return false
end
function markdown.parse(text)
text = text:gsub("\r\n", "\n") -- win > unix newlines, nobody care about archaism
local parsed = {}
do
local function ParseLine(line, lineStart, i)
-- if i then
-- parsed[#parsed + 1] = {
-- type = "newline",
-- start = i
-- }
-- end
if #line > 0 then
for _, rule in ipairs(markdown_line_rules) do
local start, ending, body = line:find(rule[1])
if start and ending > start and body and #body > 0 then
parsed[#parsed + 1] = {
type = rule[2],
body = body,
start = lineStart + start - 1,
ending = lineStart + ending,
newline = true
}
-- text = text:sub(1, lineStart - 1) .. text:sub(lineStart + #line + 1)
break
end
end
end
end
local pos = 1
while true do -- iterate & parse lines
local i, j = string.find(text, "\n", pos, true)
if i then
ParseLine(
text:sub(pos, i - 1), pos, i
)
pos = j + 1
else
stop = true
ParseLine(
text:sub(pos), pos, i
)
break
end
end
end
--[[
do
local pos = 1
while true do -- iterate & parse lines
local i, j = string.find(text, "\n", pos, true)
if i then
parsed[#parsed + 1] = {
type = "newline",
start = i
}
pos = j + 1
else
break
end
end
end
]]--
-- table.remove(parsed, 1)
local done = {}
for _, rule in ipairs(markdown_rules) do
local pos = 1
while true do
local start, ending, body = text:find(rule[1], pos)
if start == nil or ending <= start then break end
pos = ending + 1
if done[start] then continue end
if body and #body > 0 then
for t = 0, (rule[3] or 1) - 1 do
done[start + t] = true
end
local perenos, addend = "", 0
if text[ending + 1] == "\n" then
perenos, addend = "\n", 1
end
parsed[#parsed + 1] = {
type = rule[2],
body = body .. perenos,
start = start,
ending = ending + addend
}
end
if pos >= #text then break end
end
end
table.sort(parsed, function(a, b)
return a.start < b.start
end)
local out, pos = {}, 1
for _, info in ipairs(parsed) do
-- if info.type == "newline" then
-- out[#out + 1] = {type = "newline"}
-- continue
-- end
if info.start - 1 > pos then
local raw = text:sub(pos, info.start - 1)
if #raw > 0 then
out[#out + 1] = {
type = "raw",
body = raw,
}
end
end
out[#out + 1] = {
type = info.type,
body = info.body,
newline = info.newline,
}
pos = info.ending + 1
end
if pos < #text then
local raw = text:sub(pos, #text)
out[#out + 1] = {
type = "raw",
body = raw,
}
end
--[[ todo: match [hyperlink](url) and <hex-color>text</>
local i, info = 1, out[1]
while info do
-- local emoji = body:match(":[%w%p]+:")
-- local name, url = body:match("%[(.-)%]%((.-)%)")
-- local color, text = body:match("<(.-)>(.-)</>")
if info.body:match("%[(.-)%]%((.-)%)") == nil then
i = i + 1
info = out[i]
continue
end
for name, url in info.body:gmatch("%[(.-)%]%((.-)%)") do
local start = info.body:find(name)
local ending = info.body:find(url)
local pre = info.body:sub(1, start - 1)
if #pre > 0 then
out[#out + 1] = pre
end
info.body = info.body:sub(ending + 1)
out[#out + 1] = {
type = "url",
body = emote
}
end
if #info.body > 0 then
out[#out + 1] = info.body
end
i = i + 1
info = out[i]
end
]]--
i, info = 1, out[1]
while info do
if info.body:find("\n", 1, true) then
table.remove(out, i)
local pos = 1
--print("`".. info.body .."`\n")
while true do
local start = info.body:find("\n", pos, true)
if start == nil then break end
local prev = info.body:sub(pos, start - 1)
if #prev > 0 then
local data = table.Copy(info)
data.body = prev
table.insert(out, i, data)
i = i + 1
end
table.insert(out, i, {
type = "newline"
})
i = i + 1
pos = start + 1
end
local after = info.body:sub(pos)
if #after > 0 then
local data = table.Copy(info)
data.body = after
table.insert(out, i, data)
i = i + 1
end
else
i = i + 1
end
info = out[i]
end
PrintTable(out)
return out
end
end
markdown.fonts = {}
do
local fonts = {
bolditalic = {
size = 16,
weight = 600,
italic = true
},
bold = {
size = 16,
weight = 600
},
italic = {
size = 16,
weight = 400,
italic = true
},
underline = {
size = 16,
weight = 400,
underline = true
},
header3 = {
size = 20,
weight = 500
},
header2 = {
size = 24,
weight = 500
},
header1 = {
size = 28,
weight = 500
},
strikeout = {
size = 16,
-- strikeout = true,
-- rotary = true
},
basic = {
size = 16,
weight = 400
}
}
function markdown.getFont(font, nodeType)
nodeType = fonts[nodeType] and nodeType or "basic"
local data = fonts[nodeType]
data.font = font
data.extended = true
local name = string.format("markdown-%s-%s", font, nodeType)
if markdown.fonts[name] == nil then
surface.CreateFont(name, data)
end
return name
end
end
local white = Color(225, 225, 225)
function markdown.prepare(text, font, x, y, maxW)
local data = {}
surface.SetFont(markdown.getFont(font))
local baseX, baseY = x, y
local w, h = surface.GetTextSize("W")
for _, node in ipairs(markdown.parse(text)) do
if node.type == "emote" then
-- PrintTable(node)
continue
end
if node.type == "newline" then
x, y = baseX, y + h
continue
end
local fontName = markdown.getFont(font, node.type)
surface.SetFont(fontName)
w, h = surface.GetTextSize(node.body)
if x + w <= maxW then -- it fits
data[#data + 1] = {
font = fontName,
text = node.body,
color = node.color or white,
type = node.type,
x = x,
y = y,
w = w,
h = h
}
if node.newline then
x, y = baseX, y + h
else
x = x + w
end
else
-- todo: зачем рендерить каждое слово в отдельности? нужно пытаться рендерить на одно слово меньше каждый раз, пока текст не войдёт.
node.body:gsub("(%s?[%S]+)", function(word) -- split render by words then
w, h = surface.GetTextSize(word)
if x + w > maxW then -- still doesnt enough space?
x, y = baseX, y + h
word = word:gsub("^%s+", "") -- nobody needs spaces at the beginning
w, h = surface.GetTextSize(word)
if x + w > maxW then -- stiil doesnt enough?
-- todo: зачем рендерить каждый символ в отдельности? нужно пытаться рендерить на один символ меньше каждый раз, пока текст не войдёт.
word:gsub("[%z\x01-\x7F\xC2-\xF4][\x80-\xBF]*", function(char) -- split render by characters then (utf8 char pattern)
w, h = surface.GetTextSize(char)
if x + w >= maxW then
x, y = baseX, y + h
end
data[#data + 1] = {
font = fontName,
text = char,
color = node.color or white,
type = node.type,
x = x,
y = y,
w = w,
h = h
}
x = x + w
end)
return
end
end
data[#data + 1] = {
font = fontName,
text = word,
color = node.color or white,
type = node.type,
x = x,
y = y,
w = w,
h = h
}
x = x + w
end)
end
end
-- PrintTable(data)
return {
text = text,
font = font,
x = baseX,
y = baseY,
w = maxW,
data = data
}
end
local function DrawMultineText(text, x, y)
local baseX = x
local sizeX, lineHeight = surface.GetTextSize("\n")
lineHeight = lineHeight * 0.5
for str in text:gmatch("[^\n]*") do
if #str > 0 then
surface.SetTextPos(x, y)
surface.DrawText(str)
else
x, y = baseX, y + lineHeight
end
end
end
function markdown.draw(info)
for _, node in ipairs(info.data) do
-- if node.type == "emote" then continue end
if node.type == "codeblock" then
surface.SetDrawColor(57, 60, 67)
surface.DrawRect(node.x, node.y, node.w, node.h)
end
surface.SetFont(node.font)
surface.SetTextColor(node.color.r, node.color.g, node.color.b)
surface.SetTextPos(node.x, node.y)
--surface.DrawText(node.text)
DrawMultineText(node.text, node.x, node.y)
if node.type == "strikeout" then
surface.SetDrawColor(node.color.r, node.color.g, node.color.b)
surface.DrawRect(node.x, node.y + node.h * 0.5, node.w, 1)
end
end
end
local testText = [[Hello **bold world!** I use *italic text*.
Raw text
# Header 1
## Header 2
### Header 3
URL: [Google](https://google.com/)
***Bold + italic***
~~Strike out~~
__Underline__
`codeblock`
another raw
```multi-line
codeblock```
![#4287f5](Color test 1)
![#b24cd4](Color test 2)
:joy:]]
local function test()
local test = markdown.prepare(testText, "Roboto", 32, 32, 256)
PrintTable(test)
hook.Add("HUDPaint", "markdown test", function()
markdown.draw(test)
end)
end
test()
concommand.Add("markdown_test", function()
markdown_test = true
test()
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment