537 lines
12 KiB
Lua
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
|