---@meta ---@class CustomCalloutDefinition ---@field type string The type/identifier of the callout ---@field title string|pandoc.Inlines|nil The title of the callout ---@field icon boolean|nil Whether to show an icon ---@field appearance string|nil The appearance style ('default', 'minimal', 'simple') ---@field collapse boolean|string|nil Whether the callout is collapsible ---@field icon_symbol string|nil Custom icon symbol or font awesome icon ---@field color string|nil The color of the callout ---@field background_color string|nil The background color of the callout ---@class CustomCalloutsMap -- Global variable to store custom callout definitions ---@type table local customCallouts = {} local fa = require("fa") ---Converts a valid CSS color string or hexadecimal to RGBA format ---@param color string The color in hex (#RRGGBB) or named format ---@param alpha number The alpha value between 0 and 1 ---@return string rgba The color in rgba() or rgb(from color) format local function colorToRgba(color, alpha) if color:sub(1,1) == "#" then local r = tonumber(color:sub(2,3), 16) local g = tonumber(color:sub(4,5), 16) local b = tonumber(color:sub(6,7), 16) return string.format("rgba(%d, %d, %d, %.2f)", r, g, b, alpha) else -- For named colors, we use the functional notation of rgba() return string.format("rgb(from %s r g b / %.0f%%)", color, alpha * 100) end end ---CSS named color to hex lookup (without #, uppercase) ---@type table local cssNamedColors = { -- CSS Level 1 black = "000000", silver = "C0C0C0", gray = "808080", white = "FFFFFF", maroon = "800000", red = "FF0000", purple = "800080", fuchsia = "FF00FF", green = "008000", lime = "00FF00", olive = "808000", yellow = "FFFF00", navy = "000080", blue = "0000FF", teal = "008080", aqua = "00FFFF", -- Extended common colors orange = "FFA500", pink = "FFC0CB", brown = "A52A2A", cyan = "00FFFF", grey = "808080", crimson = "DC143C", coral = "FF7F50", gold = "FFD700", indigo = "4B0082", violet = "EE82EE", steelblue = "4682B4", dodgerblue = "1E90FF", forestgreen = "228B22", tomato = "FF6347", darkorange = "FF8C00", firebrick = "B22222", slategray = "708090", darkred = "8B0000", } ---Converts a color string to a 6-digit uppercase hex value (without #) ---@param color string The color in hex (#RRGGBB) or named CSS format ---@return string|nil hex The 6-digit hex string, or nil if conversion fails local function colorToHex(color) if color:sub(1, 1) == "#" then return color:sub(2):upper() end return cssNamedColors[color:lower()] end ---Adds HTML dependency for bundled FontAwesome 7 Free Solid font local function ensureFontAwesomeDeps() quarto.doc.add_html_dependency({ name = "fontawesome-7-free", version = "7.2.0", stylesheets = {"assets/css/fontawesome.css"}, resources = { { name = "fa-solid-900.woff2", path = "assets/webfonts/fa-solid-900.woff2" } } }) end ---Checks if a string represents a Font Awesome icon ---@param icon string|nil The icon string to check ---@return boolean is_fa True if the string starts with "fa-" local function isFontAwesomeIcon(icon) return icon ~= nil and icon:sub(1, 3) == "fa-" end ---Generates CSS for all defined custom callouts ---@param isRevealJS boolean Whether the output format is RevealJS ---@return string css The generated CSS rules local function generateCustomCSS(isRevealJS) local css = "" -- RevealJS uses `.reveal div.callout...` selectors with higher specificity, -- so we need to match that prefix. It also uses `.callout-title` instead -- of `.callout-header` for the header element. local prefix = isRevealJS and ".reveal " or "" local headerClass = isRevealJS and ".callout-title" or ".callout-header" -- Translate YAML callout information for custom callouts for type, callout in pairs(customCallouts) do if callout.color then local color = pandoc.utils.stringify(callout.color) -- Base color css = css .. string.format("%sdiv.callout-%s.callout {\n", prefix, type) css = css .. string.format(" border-left-color: %s;\n", color) css = css .. "}\n" -- Header background css = css .. string.format("%sdiv.callout-%s.callout-style-default %s {\n", prefix, type, headerClass) css = css .. string.format(" background-color: %s;\n", colorToRgba(color, 0.13)) css = css .. "}\n" -- Collapse Icon (not supported in RevealJS) if not isRevealJS then css = css .. string.format("div.callout-%s .callout-toggle::before {", type) css = css .. " background-image: url('data:image/svg+xml,');" css = css .. "}\n" end -- Icon Styling css = css .. string.format("%sdiv.callout-%s.callout-style-default .callout-icon::before, %sdiv.callout-%s.callout-titled .callout-icon::before {\n", prefix, type, prefix, type) if callout.icon_symbol then local icon_symbol_str = pandoc.utils.stringify(callout.icon_symbol) if isFontAwesomeIcon(icon_symbol_str) then -- Font Awesome icon css = css .. " font-family: 'Font Awesome 7 Free';\n" css = css .. " font-weight: 900;\n" css = css .. " font-style: normal;\n" css = css .. string.format(" content: '%s' !important;\n", fa.fa_unicode(icon_symbol_str)) else -- Custom icon symbol css = css .. string.format(" content: '%s';\n", icon_symbol_str) end css = css .. " background-image: none;\n" else -- The fallback case local escapedColor = color:gsub("#", "%%23") -- Escape # in hex colors css = css .. string.format(" background-image: url('data:image/svg+xml,');\n", escapedColor) end css = css .. "}\n" end end return css end ---Generates LaTeX \definecolor commands for PDF output ---@return string latex The LaTeX color definitions local function generatePdfStyles() local latex = "" for type, callout in pairs(customCallouts) do if callout.color then local hex = colorToHex(pandoc.utils.stringify(callout.color)) if hex then latex = latex .. string.format( "\\definecolor{quarto-callout-%s-color}{HTML}{%s}\n", type, hex ) latex = latex .. string.format( "\\definecolor{quarto-callout-%s-color-frame}{HTML}{%s}\n", type, hex ) end end end return latex end ---Generates Typst color definitions for Typst output ---@return string typst The Typst style definitions local function generateTypstStyles() local typst = "" for type, callout in pairs(customCallouts) do if callout.color then local hex = colorToHex(pandoc.utils.stringify(callout.color)) if hex then typst = typst .. string.format( '#let quarto-callout-%s-color = rgb("#%s")\n', type, hex ) typst = typst .. string.format( '#let quarto-callout-%s-color-frame = rgb("#%s")\n', type, hex ) end end end return typst end ---Parses custom callout definitions from document metadata ---@param meta pandoc.Meta The document metadata local function parseCustomCallouts(meta) if not meta['custom-callout'] then return end for k, v in pairs(meta['custom-callout']) do if type(v) == "table" then customCallouts[k] = { type = tostring(k), title = v.title or k:gsub("^%l", string.upper), icon = v.icon == 'true' or nil, appearance = v.appearance or nil, collapse = v.collapse or nil, icon_symbol = v['icon-symbol'] or nil, color = v.color or nil, background_color = v['background-color'] or nil } end end -- Detect format and inject appropriate styles local isRevealJS = quarto.doc.is_format("revealjs") if quarto.doc.is_format("html") or isRevealJS then local customCSS = generateCustomCSS(isRevealJS) if customCSS ~= "" then quarto.doc.include_text('in-header', '') end -- Load FontAwesome font dependency (HTML only) for _, callout in pairs(customCallouts) do if callout.icon_symbol and isFontAwesomeIcon(pandoc.utils.stringify(callout.icon_symbol)) then ensureFontAwesomeDeps() break end end elseif quarto.doc.is_format("pdf") then local pdfStyles = generatePdfStyles() if pdfStyles ~= "" then quarto.doc.include_text('in-header', pdfStyles) end elseif quarto.doc.is_format("typst") then local typstStyles = generateTypstStyles() if typstStyles ~= "" then quarto.doc.include_text('in-header', typstStyles) end end end ---Converts a div to a custom callout if it matches a defined custom callout ---@param div pandoc.Div The div to potentially convert ---@return pandoc.Div|quarto.Callout converted The converted callout or original div local function convertToCustomCallout(div) -- Check if the div has classes for _, class in ipairs(div.classes) do -- Check if the class matches a custom callout local callout = customCallouts[class] if callout then -- Use the default title if not provided local title = callout.title -- Check to see if the title is specified in the div content if div.content[1] ~= nil and div.content[1].t == "Header" then title = div.content[1] div.content:remove(1) end -- Create a new Callout with the custom callout parameters local calloutParams = { type = callout.type, content = div.content, title = div.attributes.title or title, icon = div.attributes.icon or callout.icon, appearance = div.attributes.appearance or callout.appearance, collapse = div.attributes.collapse or callout.collapse } return quarto.Callout(calloutParams) end end return div end ---Walks the Pandoc document and processes divs to ---convert to custom callouts ---@class pandoc.Doc ---@field blocks pandoc.Blocks ---@param doc pandoc.Doc The Pandoc document ---@return pandoc.Doc doc The processed document local function customCalloutFilter(doc) -- Walk the AST and process divs doc.blocks = doc.blocks:walk({ Div = convertToCustomCallout }) -- Return the modified document return doc end -- Return the Pandoc filter return { ---@type fun(meta: pandoc.Meta) Meta = parseCustomCallouts, ---@type fun(doc: pandoc.Doc): pandoc.Doc Pandoc = customCalloutFilter }