299 lines
11 KiB
Lua
299 lines
11 KiB
Lua
---@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<string, CustomCalloutDefinition>
|
|
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<string, string>
|
|
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,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"rgb(33, 37, 41)\" class=\"bi bi-chevron-down\" viewBox=\"0 0 16 16\"><path fill-rule=\"evenodd\" d=\"M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z\"/></svg>');"
|
|
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,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"%s\" class=\"bi bi-exclamation-triangle\" viewBox=\"0 0 16 16\"><path d=\"M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.146.146 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0 0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566z\"/></svg>');\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', '<style>\n' .. customCSS .. '</style>')
|
|
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
|
|
} |