blogs/scripts/pandoc-filters/semantic-blocks.lua
Neeldhara Misra b03d27cc3a
Some checks are pending
Build / build (push) Waiting to run
Make callout headings explicit
2026-06-26 01:44:41 +05:30

537 lines
12 KiB
Lua

local env_types = {
theorem = true,
lemma = true,
proposition = true,
corollary = true,
definition = true,
example = true,
remark = true,
proof = true,
}
traverse = "topdown"
local callout_labels = {
aside = "Aside",
caution = "Caution",
note = "Note",
tip = "Tip",
warning = "Warning",
}
local callout_envs = {
aside = "blogasidebox",
caution = "blogcautionbox",
note = "blognotebox",
tip = "blogtipbox",
warning = "blogwarningbox",
}
local callout_markers = {
{ marker = "📝", key = "note" },
{ marker = "💡", key = "tip" },
{ marker = "", key = "tip" },
{ marker = "⚠️", key = "warning" },
{ marker = "", key = "warning" },
}
local chess_macros = {
[""] = "\\BlogChessWhite{\\symking}",
[""] = "\\BlogChessWhite{\\symqueen}",
[""] = "\\BlogChessWhite{\\symrook}",
[""] = "\\BlogChessWhite{\\symbishop}",
[""] = "\\BlogChessWhite{\\symknight}",
[""] = "\\BlogChessWhite{\\sympawn}",
[""] = "\\BlogChessBlack{\\symking}",
[""] = "\\BlogChessBlack{\\symqueen}",
[""] = "\\BlogChessBlack{\\symrook}",
[""] = "\\BlogChessBlack{\\symbishop}",
[""] = "\\BlogChessBlack{\\symknight}",
[""] = "\\BlogChessBlack{\\sympawn}",
}
local card_macros = {
[""] = "\\BlogCardBlack{\\ensuremath{\\spadesuit}}",
[""] = "\\BlogCardBlack{\\ensuremath{\\spadesuit}}",
[""] = "\\BlogCardBlack{\\ensuremath{\\clubsuit}}",
[""] = "\\BlogCardBlack{\\ensuremath{\\clubsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\heartsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\heartsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\diamondsuit}}",
[""] = "\\BlogCardRed{\\ensuremath{\\diamondsuit}}",
}
local function has_class(el, class_name)
for _, value in ipairs(el.classes) do
if value == class_name then
return true
end
end
return false
end
local function first_env_class(el)
for _, value in ipairs(el.classes) do
if env_types[value] then
return value
end
end
return nil
end
local function normalize_label(value)
value = tostring(value or ""):lower()
value = value:gsub("^%s+", ""):gsub("%s+$", "")
value = value:gsub("^%[!", ""):gsub("%]$", "")
value = value:gsub(":$", "")
return value
end
local function inline_text(inline)
if not inline then
return ""
end
return pandoc.utils.stringify(inline)
end
local function first_inline_text(block)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return ""
end
return inline_text(block.content[1])
end
local function paragraph_text(block)
if not block or block.t ~= "Para" then
return ""
end
return pandoc.utils.stringify(block)
end
local function latex_escape(value)
value = tostring(value or "")
value = value:gsub("\\", "\\textbackslash{}")
value = value:gsub("([{}%%$&#_])", "\\%1")
value = value:gsub("%^", "\\textasciicircum{}")
value = value:gsub("~", "\\textasciitilde{}")
return value
end
local function codepoint_at(value, index)
return utf8.codepoint(value, index, index)
end
local function codepoint_len(value, index)
local byte = string.byte(value, index)
if not byte then
return 0
elseif byte < 0x80 then
return 1
elseif byte < 0xE0 then
return 2
elseif byte < 0xF0 then
return 3
end
return 4
end
local function char_at(value, index)
local len = codepoint_len(value, index)
return value:sub(index, index + len - 1), index + len
end
local function is_variation_selector(cp)
return cp == 0xFE0E or cp == 0xFE0F
end
local function is_skin_tone(cp)
return cp >= 0x1F3FB and cp <= 0x1F3FF
end
local function is_emoji_base(cp)
return (cp >= 0x1F000 and cp <= 0x1FAFF)
or (cp >= 0x2600 and cp <= 0x27BF)
or cp == 0x00A9
or cp == 0x00AE
or cp == 0x203C
or cp == 0x2049
or cp == 0x2122
or cp == 0x2139
or cp == 0x3030
or cp == 0x303D
or cp == 0x3297
or cp == 0x3299
end
local function emoji_cluster(value, index)
local start = index
local current, next_index = char_at(value, index)
if current == "" then
return "", index
end
index = next_index
while index <= #value do
local cp = codepoint_at(value, index)
if is_variation_selector(cp) or is_skin_tone(cp) then
local _, ni = char_at(value, index)
index = ni
elseif cp == 0x200D then
local _, ni = char_at(value, index)
index = ni
if index <= #value then
local _, after_joined = char_at(value, index)
index = after_joined
end
else
break
end
end
return value:sub(start, index - 1), index
end
local function symbol_macro_for_cluster(cluster)
local cp = utf8.codepoint(cluster, 1, 1)
if not cp then
return nil
end
if chess_macros[cluster] then
return chess_macros[cluster]
end
if card_macros[cluster] then
return card_macros[cluster]
end
if cp >= 0x1F0A0 and cp <= 0x1F0FF then
return "\\BlogEmojiText{" .. cluster .. "}"
end
if is_emoji_base(cp) then
return "\\BlogEmojiText{" .. cluster .. "}"
end
return nil
end
local function symbol_str_to_latex(el)
if not FORMAT:match("latex") then
return nil
end
local text = el.text
local pieces = pandoc.List()
local plain = {}
local changed = false
local index = 1
local function flush_plain()
if #plain > 0 then
pieces:insert(pandoc.Str(table.concat(plain)))
plain = {}
end
end
while index <= #text do
local cp = codepoint_at(text, index)
if cp and (chess_macros[utf8.char(cp)] or card_macros[utf8.char(cp)] or is_emoji_base(cp)) then
local cluster, next_index = emoji_cluster(text, index)
local macro = symbol_macro_for_cluster(cluster)
if macro then
flush_plain()
pieces:insert(pandoc.RawInline("latex", macro))
changed = true
index = next_index
else
local char, ni = char_at(text, index)
plain[#plain + 1] = char
index = ni
end
else
local char, ni = char_at(text, index)
plain[#plain + 1] = char
index = ni
end
end
flush_plain()
if changed then
return pieces
end
return nil
end
local function internal_link_to_cref(el)
if FORMAT:match("latex") and el.target:sub(1, 1) == "#" then
local label = el.target:sub(2)
if label ~= "" then
return pandoc.RawInline("latex", "\\cref{" .. label .. "}")
end
end
return nil
end
local function latexize_doc(doc)
return doc:walk({
Link = internal_link_to_cref,
Str = symbol_str_to_latex,
})
end
local function markdown_to_latex(markdown)
local doc = pandoc.read(
markdown or "",
"markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html"
)
doc = latexize_doc(doc)
return pandoc.write(doc, "latex")
end
local function blocks_to_latex(blocks)
local doc = latexize_doc(pandoc.Pandoc(blocks))
return pandoc.write(doc, "latex")
end
local function label_line(label)
if label and label ~= "" then
return "\\label{" .. label .. "}\n"
end
return ""
end
local function interactive_to_latex(el)
local label = el.identifier
local caption = el.attributes.caption or el.attributes.title or ""
local fallback = el.attributes.pdf or el.attributes.fallback or el.text
local fallback_latex = markdown_to_latex(fallback)
local caption_line = ""
if caption ~= "" then
caption_line = "\\caption{" .. latex_escape(caption) .. "}\n"
end
return pandoc.RawBlock(
"latex",
table.concat({
"\\begin{figure}[htbp]\n",
"\\centering\n",
"\\fbox{\\begin{minipage}{0.86\\linewidth}\n",
fallback_latex,
"\n\\end{minipage}}\n",
caption_line,
label_line(label),
"\\end{figure}",
})
)
end
local function remove_first_inline(block)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return
end
table.remove(block.content, 1)
while #block.content > 0 and block.content[1].t == "Space" do
table.remove(block.content, 1)
end
if #block.content > 0 and block.content[1].t == "Str" then
block.content[1].text = block.content[1].text:gsub("^:%s*", "")
if block.content[1].text == "" then
table.remove(block.content, 1)
end
end
end
local function strip_text_prefix(block, pattern)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return
end
local first = block.content[1]
if first.t ~= "Str" then
return
end
first.text = first.text:gsub(pattern, "")
if first.text == "" then
remove_first_inline(block)
end
end
local function cleanup_inline_start(block)
if not block or block.t ~= "Para" or not block.content then
return
end
while #block.content > 0 do
local first = block.content[1]
if first.t == "Space" or first.t == "SoftBreak" or first.t == "LineBreak" then
table.remove(block.content, 1)
elseif first.t == "Str" then
first.text = first.text:gsub("^%s+", "")
if first.text == "" then
table.remove(block.content, 1)
else
return
end
else
return
end
end
end
local function strip_bracket_callout(block)
if not block or block.t ~= "Para" or not block.content then
return
end
while #block.content > 0 do
local first = block.content[1]
local text = inline_text(first)
local close_index = text:find("%]")
if close_index then
local remaining = text:sub(close_index + 1):gsub("^%s+", "")
if first.t == "Str" and remaining ~= "" then
first.text = remaining
elseif remaining ~= "" then
block.content[1] = pandoc.Str(remaining)
else
table.remove(block.content, 1)
end
break
end
table.remove(block.content, 1)
end
cleanup_inline_start(block)
end
local function parse_bracket_callout(text)
local key, title = text:match("^%s*%[!([%a-]+)%s*[:|]%s*([^%]]+)%]")
if key then
return normalize_label(key), title:gsub("^%s+", ""):gsub("%s+$", "")
end
key = text:match("^%s*%[!([%a-]+)%]")
if key then
return normalize_label(key), ""
end
return nil, nil
end
local function detect_callout(block)
local text = first_inline_text(block)
if text == "" then
return nil
end
if block.content[1].t == "Strong" then
local key = normalize_label(text)
if callout_labels[key] then
return { key = key, title = "", remover = "strong" }
end
end
local key, title = parse_bracket_callout(paragraph_text(block))
if key and callout_labels[key] then
return { key = key, title = title or "", remover = "bracket" }
end
for _, item in ipairs(callout_markers) do
if text:sub(1, #item.marker) == item.marker then
return { key = item.key, title = "", remover = "marker", marker = item.marker }
end
end
return nil
end
local function callout_to_latex(el)
if not FORMAT:match("latex") or not el.content or #el.content == 0 then
return nil
end
local blocks = pandoc.List(el.content)
local callout = detect_callout(blocks[1])
if not callout then
return nil
end
if callout.remover == "strong" then
remove_first_inline(blocks[1])
elseif callout.remover == "bracket" then
strip_bracket_callout(blocks[1])
elseif callout.remover == "marker" then
strip_text_prefix(blocks[1], "^" .. callout.marker .. "%s*")
end
if blocks[1] and blocks[1].t == "Para" and #blocks[1].content == 0 then
table.remove(blocks, 1)
end
local env = callout_envs[callout.key] or "blognotebox"
return pandoc.RawBlock(
"latex",
"\\begin{" .. env .. "}{" .. latex_escape(callout.title or "") .. "}\n"
.. blocks_to_latex(blocks)
.. "\n\\end{" .. env .. "}"
)
end
local function env_to_latex(el)
local env_type = el.attributes.type or first_env_class(el)
if not env_type or not env_types[env_type] then
return nil
end
local title = el.attributes.title or el.attributes.name or ""
local begin_env = "\\begin{" .. env_type .. "}"
if title ~= "" then
begin_env = begin_env .. "[" .. latex_escape(title) .. "]"
end
return pandoc.RawBlock(
"latex",
table.concat({
begin_env,
"\n",
label_line(el.identifier),
markdown_to_latex(el.text),
"\n\\end{",
env_type,
"}",
})
)
end
function CodeBlock(el)
if FORMAT:match("latex") and has_class(el, "interactive") then
return interactive_to_latex(el)
end
if FORMAT:match("latex") and (has_class(el, "env") or has_class(el, "latex-env") or first_env_class(el)) then
return env_to_latex(el)
end
return nil
end
function BlockQuote(el)
return callout_to_latex(el)
end
function Link(el)
return internal_link_to_cref(el)
end
function Str(el)
return symbol_str_to_latex(el)
end