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 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 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, label = callout_labels[key], remover = "strong" } end end local bracket = text:match("^%[!([%a-]+)%]") if bracket then local key = normalize_label(bracket) if callout_labels[key] then return { key = key, label = callout_labels[key], remover = "bracket" } end end for _, item in ipairs(callout_markers) do if text:sub(1, #item.marker) == item.marker then return { key = item.key, label = callout_labels[item.key], 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_text_prefix(blocks[1], "^%[![%a-]+%]%s*") 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 .. "}{}\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