0byt3m1n1
Path:
C:
/
Program Files
/
RStudio
/
resources
/
app
/
bin
/
quarto
/
share
/
filters
/
[
Home
]
File: main.lua
-- mainstateinit.lua -- initializes global state for the main.lua script -- -- Copyright (C) 2022 by RStudio, PBC -- global state quarto_global_state = { usingBookmark = false, usingTikz = false, results = { resourceFiles = pandoc.List({}), inputTraits = {} }, file = nil, appendix = false, fileSectionIds = {}, emulatedNodeHandlers = {}, reader_options = {} } crossref = { using_theorems = false, startAppendix = nil, -- initialize autolabels table autolabels = pandoc.List(), -- store a subfloat index to be able to lookup by id later. subfloats = {}, -- kinds are "float", "block", "inline", "anchor" categories = { all = { { caption_location = "bottom", kind = "float", name = "Figure", prefix = "Figure", latex_env = "figure", ref_type = "fig", }, { caption_location = "top", kind = "float", name = "Table", prefix = "Table", latex_env = "table", ref_type = "tbl", }, { caption_location = "top", kind = "float", name = "Listing", prefix = "Listing", latex_env = "codelisting", ref_type = "lst", }, -- callouts { kind = "Block", name = "Note", prefix = "Note", ref_type = "nte", }, { kind = "Block", name = "Warning", prefix = "Warning", ref_type = "wrn", }, { kind = "Block", name = "Caution", prefix = "Caution", ref_type = "cau", }, { kind = "Block", name = "Tip", prefix = "Tip", ref_type = "tip", }, { kind = "Block", name = "Important", prefix = "Important", ref_type = "imp", -- this will look weird but we decided to do it for consistency with the original callout types }, -- proof envs { kind = "Block", name = "Proof", prefix = "Proof", ref_type = "prf", }, { kind = "Block", name = "Remark", prefix = "Remark", ref_type = "rem", }, { kind = "Block", name = "Solution", prefix = "Solution", ref_type = "sol", }, } -- eventually we'll have other block kinds here, specifically theorem envs -- eventually we'll have inline kinds here -- with equation refs -- eventually we'll have anchor kinds here -- with section/chapter/slide refs, etc } } -- set up crossref category indices function setup_crossref_category_indices() crossref.categories.by_ref_type = {} crossref.categories.by_name = {} for _, category in ipairs(crossref.categories.all) do crossref.categories.by_ref_type[category.ref_type] = category crossref.categories.by_name[category.name] = category end end function add_crossref_category(category) table.insert(crossref.categories.all, category) setup_crossref_category_indices() end setup_crossref_category_indices() -- customnodes.lua -- support for custom nodes in quarto's emulated ast -- -- Copyright (C) 2023 Posit Software, PBC local handlers = {} local custom_node_data = pandoc.List({}) local n_custom_nodes = 0 local profiler = require('profiler') function scaffold(node) local pt = pandoc.utils.type(node) if pt == "Blocks" then return pandoc.Div(node, {"", {"quarto-scaffold"}}) elseif pt == "Inlines" then return pandoc.Span(node, {"", {"quarto-scaffold"}}) else return node end end function is_custom_node(node, name) if node.attributes and node.attributes.__quarto_custom == "true" then if name == nil or name == node.attributes.__quarto_custom_type then return node end end return false end function ensure_custom(node) if pandoc.utils.type(node) == "Block" or pandoc.utils.type(node) == "Inline" then local result = _quarto.ast.resolve_custom_data(node) return result or node -- it'll never be nil or false, but the lua analyzer doesn't know that end return node end -- use this instead of node.t == "Div" so that custom nodes -- are not considered Divs function is_regular_node(node, name) if type(node) ~= "userdata" then return false end if is_custom_node(node) then return false end if name ~= nil and node.t ~= name then return false end return node end function run_emulated_filter(doc, filter) if doc == nil then return nil end local state = quarto_global_state.extended_ast_handlers local needs_custom = false local sz = 0 for k, v in pairs(filter) do sz = sz + 1 if (k == "Custom" or k == "CustomInline" or k == "CustomBlock" or state.handlers.by_ast_name[k] ~= nil or -- we need custom handling to _avoid_ custom nodes as well. k == "Div" or k == "Span") then needs_custom = true end end local function checked_walk(node, filter_param) if node.walk == nil then if #node == 0 then -- empty node return node else -- luacov: disable internal_error() -- luacov: enable end end return node:walk(filter_param) end -- performance: if filter is empty, do nothing if sz == 0 then return doc elseif sz == 1 then local result local t if filter.Pandoc then -- performance: if filter is only Pandoc, call that directly instead of walking. result = filter.Pandoc(doc) or doc elseif filter.Meta then -- performance: if filter is only Meta, call that directly instead of walking. t = pandoc.utils.type(doc) if t == "Pandoc" then local result_meta = filter.Meta(doc.meta) or doc.meta result = doc result.meta = result_meta else goto regular end else goto regular end if in_filter then profiler.category = "" end return result end ::regular:: -- if user passed a table corresponding to the custom node instead -- of the custom node, then first we will get the actual node if doc.__quarto_custom_node ~= nil then doc = doc.__quarto_custom_node needs_custom = true end local is_custom = is_custom_node(doc) if not needs_custom or (not is_custom and filter._is_wrapped) then if doc.walk == nil then if #doc == 0 then -- empty doc return doc else -- luacov: disable internal_error() -- luacov: enable end end local result, recurse = checked_walk(doc, filter) if in_filter then profiler.category = "" end return result, recurse end -- assert: needs_custom and (is_custom or not filter._is_wrapped) local wrapped_filter = { _is_wrapped = true } for k, v in pairs(filter) do wrapped_filter[k] = v end local function process_custom_preamble(custom_data, t, kind, custom_node) if custom_data == nil then return nil end local node_type = { Block = "CustomBlock", Inline = "CustomInline" } local filter_fn = filter[t] or filter[node_type[kind]] or filter.Custom if filter_fn ~= nil then local result, recurse = filter_fn(custom_data, custom_node) if result == nil then return nil, recurse end -- do the user a kindness and unwrap the result if it's a custom node if type(result) == "table" and result.__quarto_custom_node ~= nil then return result.__quarto_custom_node, recurse end return result, recurse end end function wrapped_filter.Div(node) if is_custom_node(node) then local custom_data, t, kind = _quarto.ast.resolve_custom_data(node) -- here, if the node is actually an inline, -- it's ok, because Pandoc will wrap it in a Plain return process_custom_preamble(custom_data, t, kind, node) end if node.attributes.__quarto_custom_scaffold == "true" then return nil end if node.identifier == _quarto.ast.vault._uuid then return nil end if filter.Div ~= nil then return filter.Div(node) end return nil end function wrapped_filter.Span(node) if is_custom_node(node) then local custom_data, t, kind = _quarto.ast.resolve_custom_data(node) -- only follow through if node matches the expected kind if kind == "Inline" then return process_custom_preamble(custom_data, t, kind, node) end -- luacov: disable fatal("Custom node of type " .. t .. " is not an inline, but found in an inline context") return nil -- luacov: enable end if node.attributes.__quarto_custom_scaffold == "true" then return nil end if filter.Span ~= nil then return filter.Span(node) end return nil end if is_custom then local custom_data, t, kind = _quarto.ast.resolve_custom_data(doc) local result, recurse = process_custom_preamble(custom_data, t, kind, doc) if in_filter then profiler.category = "" end if result ~= nil then doc = result end if recurse == false then return doc, recurse end end return checked_walk(doc, wrapped_filter) end function create_custom_node_scaffold(t, context) local result if context == "Block" then result = pandoc.Div({}) elseif context == "Inline" then result = pandoc.Span({}) else -- luacov: disable fatal("Invalid context for custom node: " .. context) -- luacov: enable end n_custom_nodes = n_custom_nodes + 1 local id = tostring(n_custom_nodes) result.attributes.__quarto_custom = "true" result.attributes.__quarto_custom_type = t result.attributes.__quarto_custom_context = context result.attributes.__quarto_custom_id = id return result end function create_emulated_node(t, tbl, context, forwarder) local result = create_custom_node_scaffold(t, context) tbl.t = t -- set t always to custom ast type local id = result.attributes.__quarto_custom_id custom_node_data[id] = _quarto.ast.create_proxy_accessor(result, tbl, forwarder) return result, custom_node_data[id] end _quarto.ast = { vault = { _uuid = "3ade8a4a-fb1d-4a6c-8409-ac45482d5fc9", _added = {}, _removed = {}, add = function(id, contents) _quarto.ast.vault._added[id] = contents end, remove = function(id) _quarto.ast.vault._removed[id] = true end, locate = function(doc) if doc == nil then doc = _quarto.ast._current_doc end -- attempt a fast lookup first if #doc.blocks > 0 and doc.blocks[#doc.blocks].identifier == _quarto.ast.vault._uuid then return doc.blocks[#doc.blocks] else -- otherwise search for it for _, block in ipairs(doc.blocks) do if block.identifier == _quarto.ast.vault._uuid then return block end end end return nil end, }, custom_node_data = custom_node_data, create_custom_node_scaffold = create_custom_node_scaffold, grow_scaffold = function(node, size) local n = #node.content local ctor = pandoc[node.t or pandoc.utils.type(node)] for _ = n + 1, size do local scaffold = ctor({}) scaffold.attributes.__quarto_custom_scaffold = "true" node.content:insert(scaffold) end end, create_proxy_metatable = function(forwarder, node_accessor) node_accessor = node_accessor or function(table) return table["__quarto_custom_node"] end return { __index = function(table, key) local index = forwarder(key) if index == nil then return rawget(table, key) end local node = node_accessor(table) local content = node.content if index > #content then return nil end local result = content[index] if result == nil then return nil end local t = result.t -- if not (t == "Div" or t == "Span") then -- warn("Custom node content is not a Div or Span, but a " .. t) -- return nil -- end local content = result.content if content == nil then return nil end local n = #content if n == 0 then return nil elseif n ~= 1 then return content else return content[1] end end, __newindex = function(table, key, value) local index = forwarder(key) if index == nil then rawset(table, key, value) return end local node = node_accessor(table) local t = pandoc.utils.type(value) -- FIXME this is broken; that can only be "Block", "Inline", etc if t == "Div" or t == "Span" then local custom_data, t, kind = _quarto.ast.resolve_custom_data(value) if custom_data ~= nil then value = custom_data end end if index > #node.content then _quarto.ast.grow_scaffold(node, index) end local pt = pandoc.utils.type(value) if pt == "Block" or pt == "Inline" then node.content[index].content = {value} else node.content[index].content = value end end } end, create_proxy_accessor = function(div_or_span, custom_data, forwarder) if forwarder == nil then return custom_data end local proxy = { __quarto_custom_node = div_or_span } setmetatable(proxy, _quarto.ast.create_proxy_metatable(function(key) return forwarder[key] end)) for k, v in pairs(custom_data) do proxy[k] = v end return proxy end, resolve_custom_data = function(div_or_span) if (div_or_span == nil or div_or_span.attributes == nil or div_or_span.attributes.__quarto_custom ~= "true") then return end local t = div_or_span.attributes.__quarto_custom_type local n = div_or_span.attributes.__quarto_custom_id local kind = div_or_span.attributes.__quarto_custom_context local handler = _quarto.ast.resolve_handler(t) -- luacov: disable if handler == nil then fatal("Internal Error: handler not found for custom node " .. t) end -- luacov: enable local custom_data = _quarto.ast.custom_node_data[n] custom_data["__quarto_custom_node"] = div_or_span return custom_data, t, kind end, add_handler = function(handler) local state = quarto_global_state.extended_ast_handlers if type(handler.constructor) == "nil" then -- luacov: disable quarto.utils.dump(handler) fatal("Internal Error: extended ast handler must have a constructor") -- luacov: enable elseif type(handler.class_name) == "nil" then -- luacov: disable quarto.utils.dump(handler) fatal("handler must define class_name") -- luacov: enable elseif type(handler.class_name) == "string" then state.handlers[handler.kind][handler.class_name] = handler elseif type(handler.class_name) == "table" then for _, name in ipairs(handler.class_name) do state.handlers[handler.kind][name] = handler end else -- luacov: disable quarto.utils.dump(handler) fatal("ERROR: class_name must be a string or an array of strings") -- luacov: enable end local forwarder = { } if tisarray(handler.slots) then for i, slot in ipairs(handler.slots) do forwarder[slot] = i end else forwarder = handler.slots end quarto[handler.ast_name] = function(params) local tbl, need_emulation = handler.constructor(params) if need_emulation ~= false then return create_emulated_node(handler.ast_name, tbl, handler.kind, forwarder) else tbl.t = handler.ast_name -- set t always to custom ast type custom_node_data[tbl.__quarto_custom_node.attributes.__quarto_custom_id] = tbl return tbl.__quarto_custom_node, tbl end end -- we also register them under the ast_name so that we can render it back state.handlers.by_ast_name[handler.ast_name] = handler end, add_renderer = function(name, condition, renderer) if renderer == nil then -- luacov: disable fatal("Internal Error in add_renderer: renderer for " .. name .. " is nil") -- luacov: enable end local handler = _quarto.ast.resolve_handler(name) if handler == nil then -- luacov: disable fatal("Internal Error in add_renderer: handler not found for custom node " .. name) -- luacov: enable end if handler.renderers == nil then handler.renderers = { } end -- we insert renderers at the beginning of the list so that they have -- a chance to gtrigger before the default ones table.insert(handler.renderers, 1, { condition = condition, render = renderer }) end, -- find handler by name in given table, or in the by_ast_name table if no table -- is specified. resolve_handler = function(name, key) local state = quarto_global_state.extended_ast_handlers local handlers = state.handlers[key or 'by_ast_name'] if handlers ~= nil then return handlers[name] end -- TODO: should we just fail here? We seem to be failing downstream of every nil -- result anyway. -- luacov: disable return nil -- luacov: enable end, walk = run_emulated_filter, writer_walk = function(doc, filter) local old_custom_walk = filter.Custom local function custom_walk(node) local handler = quarto._quarto.ast.resolve_handler(node.t) if handler == nil then -- luacov: disable fatal("Internal Error: handler not found for custom node " .. node.t) -- luacov: enable end if handler.render == nil then -- luacov: disable fatal("Internal Error: handler for custom node " .. node.t .. " does not have a render function") -- luacov: enable end return handler.render(node) end if filter.Custom == nil then filter.Custom = custom_walk end local result = run_emulated_filter(doc, filter) filter.Custom = old_custom_walk return result end } quarto._quarto = _quarto function construct_extended_ast_handler_state() local state = { handlers = { Inline = {}, -- Inline handlers by class name Block = {}, -- Block handlers by class name by_ast_name = {}, -- All handlers by Ast name }, } if quarto_global_state ~= nil then quarto_global_state.extended_ast_handlers = state end -- we currently don't have any handlers at startup, -- so we disable coverage for this block -- luacov: disable for _, handler in ipairs(handlers) do _quarto.ast.add_handler(handler) end -- luacov: enable end construct_extended_ast_handler_state() -- emulatedfilter.lua -- creates lua filter loaders to support emulated AST -- -- Copyright (C) 2022 by RStudio, PBC local function plain_loader(handlers) local function wrapFilter(handler) local wrappedFilter = {} wrappedFilter.scriptFile = handler.scriptFile for k, v in pairs(handler) do wrappedFilter[k] = v.handle end return wrappedFilter end return map_or_call(wrapFilter, handlers) end make_wrapped_user_filters = function(filterListName) local filters = {} for _, v in ipairs(param("quarto-filters")[filterListName]) do if (type(v) == "string" and string.match(v, ".lua$") == nil) then v = { path = v, type = "json" } end local wrapped = makeWrappedFilter(v, plain_loader) if tisarray(wrapped) then for _, innerWrapped in ipairs(wrapped) do table.insert(filters, innerWrapped) end else table.insert(filters, wrapped) end end return filters end inject_user_filters_at_entry_points = function(filter_list) local function find_index_of_entry_point(entry_point) for i, filter in ipairs(filter_list) do if filter.name == entry_point then return i end end return nil end local entry_point_counts = {} for _, v in ipairs(param("quarto-filters").entryPoints) do local entry_point = v["at"] -- FIXME entry_point or entryPoint if entry_point_counts[entry_point] == nil then entry_point_counts[entry_point] = 0 end entry_point_counts[entry_point] = entry_point_counts[entry_point] + 1 local wrapped = makeWrappedFilter(v, plain_loader) local is_many_filters = tisarray(wrapped) local index = find_index_of_entry_point(entry_point) if index == nil then warn("filter entry point " .. entry_point .. " not found in filter list") warn("Will use pre-quarto entry point instead") index = find_index_of_entry_point("pre-quarto") if index == nil then internal_error() return end end local filter = { name = entry_point .. "-user-" .. tostring(entry_point_counts[entry_point]), } if is_many_filters then filter.filters = wrapped else filter.filter = wrapped end table.insert(filter_list, index, filter) end end -- parse.lua -- convert custom div inputs to custom nodes -- -- Copyright (C) 2022 by RStudio, PBC local function parse(node, kind) for _, class in ipairs(node.attr.classes) do local tag = pandoc.utils.stringify(class) local handler = _quarto.ast.resolve_handler(tag, kind) if handler ~= nil then return handler.parse(node) end end return node end local function parse_inline(node) return parse(node, 'Inline') end local function parse_block(node) return parse(node, 'Block') end function parse_extended_nodes() return { Div = parse_block, Span = parse_inline, } end -- render.lua -- convert custom nodes to their final representation -- -- Copyright (C) 2022 by RStudio, PBC function render_extended_nodes() local function has_custom_nodes(node) local has_custom_nodes = false _quarto.ast.walk(node, { Custom = function() has_custom_nodes = true end }) return has_custom_nodes end local filter local function render_custom(node) local function postprocess_render(render_result) -- we need to recurse in case custom nodes render to other custom nodes if is_custom_node(render_result) then -- recurse directly return render_custom(render_result) elseif has_custom_nodes(render_result) then -- recurse via the filter return _quarto.ast.walk(render_result, filter) else return render_result end end if type(node) == "userdata" then node = _quarto.ast.resolve_custom_data(node) end local handler = _quarto.ast.resolve_handler(node.t) if handler == nil then -- luacov: disable fatal("Internal Error: handler not found for custom node " .. node.t) -- luacov: enable end if handler.renderers then for _, renderer in ipairs(handler.renderers) do if renderer.condition(node) then return scaffold(postprocess_render(scaffold(renderer.render(node)))) end end -- luacov: disable fatal("Internal Error: renderers table was exhausted without a match for custom node " .. node.t) -- luacov: enable elseif handler.render ~= nil then return scaffold(postprocess_render(scaffold(handler.render(node)))) else -- luacov: disable fatal("Internal Error: handler for custom node " .. node.t .. " does not have a render function or renderers table") -- luacov: enable end end filter = { Custom = render_custom } return filter end -- runemulation.lua -- run filters in pandoc emulation mode -- -- Copyright (C) 2022 by RStudio, PBC local profiler = require('profiler') -- locate or create the quarto vault, -- inserting the just-added nodes if needed, and mutating doc local ensure_vault = function(doc) local vault = _quarto.ast.vault.locate(doc) -- create if missing if vault == nil then vault = pandoc.Div({}, pandoc.Attr(_quarto.ast.vault._uuid, {"hidden"}, {})) doc.blocks:insert(vault) end for k, v in pairs(_quarto.ast.vault._added) do local div = pandoc.Div(quarto.utils.as_blocks(v), pandoc.Attr(k, {}, {})) vault.content:insert(div) end vault.content = _quarto.ast.walk(vault.content, { Div = function(div) if _quarto.ast.vault._removed[div.identifier] then return {} end end }) or pandoc.Blocks({}) -- to satisfy the Lua analyzer _quarto.ast.vault._added = {} _quarto.ast.vault._removed = {} end local function remove_vault(doc) -- attempt a fast lookup first if #doc.blocks > 0 and doc.blocks[#doc.blocks].identifier == _quarto.ast.vault._uuid then doc.blocks:remove(#doc.blocks) else -- otherwise search for it for i, block in ipairs(doc.blocks) do if block.identifier == _quarto.ast.vault._uuid then doc.blocks:remove(i) break end end end end local function run_emulated_filter_chain(doc, filters, afterFilterPass, profiling) init_trace(doc) for i, v in ipairs(filters) do local function callback() if v.flags then if type(v.flags) ~= "table" then -- luacov: disable fatal("filter " .. v.name .. " has invalid flags") -- luacov: enable end local can_skip = true for _, index in ipairs(v.flags) do if flags[index] == true then can_skip = false end end if can_skip then return end end -- We don't seem to need coverage for profiling -- luacov: disable if profiling then profiler.category = v.name end -- luacov: enable if v.print_ast then print(pandoc.write(doc, "native")) else _quarto.ast._current_doc = doc doc = run_emulated_filter(doc, v.filter) ensure_vault(doc) add_trace(doc, v.name) -- luacov: disable if profiling then profiler.category = "" end if os.getenv("QUARTO_FLUSH_TRACE") then end_trace() end -- luacov: enable end end if v.filter and v.filter.scriptFile then _quarto.withScriptFile(v.filter.scriptFile, callback) else callback() end if afterFilterPass then afterFilterPass() end end end_trace() remove_vault(doc) return doc end local function emulate_pandoc_filter(filters, afterFilterPass) local cached_paths local profiler -- luacov: disable local function get_paths(tmpdir) if cached_paths then return cached_paths end os.execute("quarto --paths > " .. tmpdir .. "paths.txt") local paths_file = io.open(tmpdir .. "paths.txt", "r") if paths_file == nil then error("couldn't open paths file") end cached_paths = paths_file:read("l") paths_file:close() return cached_paths end -- luacov: enable return { traverse = 'topdown', Pandoc = function(doc) local profiling = option("profiler", false) if not profiling then return run_emulated_filter_chain(doc, filters, afterFilterPass), false end -- luacov: disable if profiler == nil then profiler = require('profiler') end pandoc.system.with_temporary_directory("temp", function(tmpdir) profiler.start(tmpdir .. "/prof.txt") doc = run_emulated_filter_chain(doc, filters, afterFilterPass, profiling) profiler.stop() -- os.execute("cp " .. tmpdir .. "/prof.txt /tmp/prof.out") local ts_source = get_paths(tmpdir) .. "/../../../tools/profiler/convert-to-perfetto.ts" os.execute("quarto run " .. ts_source .. " " .. tmpdir .. "/prof.txt > " .. profiling) return nil end) return doc, false -- luacov: enable end } end function run_as_extended_ast(specTable) local function coalesce_filters(filterList) local finalResult = {} for i, v in ipairs(filterList) do if v.filter ~= nil or v.print_ast then -- v.filter._filter_name = v.name table.insert(finalResult, v) elseif v.filters ~= nil then for j, innerV in pairs(v.filters) do innerV._filter_name = string.format("%s-%s", v.name, j) table.insert(finalResult, { filter = innerV, name = innerV._filter_name, flags = v.flags }) end else -- luacov: disable warn("filter " .. v.name .. " didn't declare filter or filters.") -- luacov: enable end end return finalResult end specTable.filters = coalesce_filters(specTable.filters) local pandocFilterList = {} if specTable.pre then for _, v in ipairs(specTable.pre) do table.insert(pandocFilterList, v) end end table.insert(pandocFilterList, emulate_pandoc_filter( specTable.filters, specTable.afterFilterPass )) if specTable.post then for _, v in ipairs(specTable.post) do table.insert(pandocFilterList, v) end end return pandocFilterList end -- traceexecution.lua -- produce a json file from filter chain execution -- -- Copyright (C) 2022 by RStudio, PBC local data = {} -- don't test coverage for filter tracing -- TODO but maybe we should? -- -- luacov: disable if os.getenv("QUARTO_TRACE_FILTERS") then function init_trace(doc) table.insert(data, { state = "__start", doc = quarto.json.decode(pandoc.write(doc, "json")) }) end function add_trace(doc, filter_name) local function safe_json(value) local t = type(value) if t == "table" then local result = {} for k,v in pairs(value) do result[k] = safe_json(v) end return result elseif t == "userdata" then return nil -- skip pandoc values entirely else return value end end doc = _quarto.ast.walk(doc, { Custom = function(custom) local div = custom.__quarto_custom_node local custom_table = quarto.json.encode(safe_json(custom)) div.attributes["__quarto_custom_table"] = custom_table return div end }) if doc == nil then fatal("Unable to encode document as json") end table.insert(data, { state = filter_name, doc = quarto.json.decode(pandoc.write(doc, "json")) }) end function end_trace() local tracefile = os.getenv("QUARTO_TRACE_FILTERS") if tracefile == "true" then tracefile = "quarto-filter-trace.json" end local file = io.open(tracefile, "w") if file == nil then fatal("Unable to open quarto-filter-trace.json for writing") end file:write(quarto.json.encode({ data = data })) file:close() end else function init_trace(doc) end function add_trace(doc, filter_name) end function end_trace() end end -- luacov: enable -- wrappedwriter.lua -- support for creating better custom writers -- -- Copyright (C) 2022 by RStudio, PBC function wrapped_writer() return filterIf(function() return param("custom-writer") end, makeWrappedFilter(param("custom-writer"), function(handler) local resultingStrs = {} local contentHandler = function(el) return el.content end local bottomUpWalkers = { Pandoc = function(doc) local result = {} if doc.blocks then for _, block in ipairs(doc.blocks) do table.insert(result, block) end end -- TODO I think we shouldn't walk meta, but I'm not positive. -- if doc.meta then -- table.insert(result, doc.meta) -- end return result end, BlockQuote = contentHandler, BulletList = contentHandler, DefinitionList = contentHandler, Div = contentHandler, Header = contentHandler, LineBlock = contentHandler, OrderedList = contentHandler, Para = contentHandler, Plain = contentHandler, Cite = function(element) local result = {} for _, block in ipairs(element.content) do table.insert(result, block) end for _, block in ipairs(element.citations) do table.insert(result, block) end return result end, Emph = contentHandler, Figure = function(element) local result = {} for _, block in ipairs(element.content) do table.insert(result, block) end table.insert(result.caption) return result end, Image = function(element) return element.caption end, Link = contentHandler, Note = contentHandler, Quoted = contentHandler, SmallCaps = contentHandler, Span = contentHandler, Strikeout = contentHandler, Strong = contentHandler, Subscript = contentHandler, Superscript = contentHandler, Underline = contentHandler, -- default simple behavior Str = function(s) return { s.text } end, Space = function() return { " " } end, LineBreak = function() return { "\n" } end, SoftBreak = function() return { "\n" } end, Inlines = function(inlines) return inlines end, Blocks = function(blocks) return blocks end, RawInline = function(inline) local tbl, t = _quarto.ast.resolve_custom_data(inline) if tbl == nil then return {} end local handler = _quarto.ast.resolve_handler(t) if handler == nil then return {} end local result = pandoc.List({}) for _, v in ipairs(handler.inner_content(tbl)) do result:extend(v) end return result end } local function handleBottomUpResult(v) if type(v) == "string" then table.insert(resultingStrs, v) elseif type(v) == "userdata" then bottomUp(v) elseif tisarray(v) then for _, inner in ipairs(v) do bottomUp(v) end end end local bottomUp bottomUp = function(node) if type(node) == "string" then table.insert(resultingStrs, node) return nil end local t if type(node) == "userdata" then local tbl tbl, t = _quarto.ast.resolve_custom_data(node) if tbl ~= nil then local astHandler = _quarto.ast.resolve_handler(t) if astHandler == nil then -- luacov: disable fatal("Internal error: no handler for " .. t) -- luacov: enable end local nodeHandler = astHandler and handler[astHandler.ast_name] and handler[astHandler.ast_name].handle if nodeHandler == nil then local inner = astHandler.inner_content(tbl) for _, v in pairs(inner) do bottomUp(v) end else handleBottomUpResult(nodeHandler(tbl, bottomUp, node)) end else local nodeHandler t = node.t or pandoc.utils.type(node) nodeHandler = handler[t] and handler[t].handle if nodeHandler == nil then -- no handler, just walk the internals in some default order if bottomUpWalkers[t] then for _, v in ipairs(bottomUpWalkers[t](node)) do bottomUp(v) end else for _, v in pairs(node) do bottomUp(v) end end else handleBottomUpResult(nodeHandler(node, bottomUp)) end end else -- allow t = type(node) local nodeHandler = handler[t] if nodeHandler ~= nil then handleBottomUpResult(nodeHandler(node, bottomUp)) end if tisarray(node) then for _, v in ipairs(node) do bottomUp(v) end end -- do nothing if no handler for builtin type end return nil end local wrappedFilter = { Pandoc = function(doc) local strs if handler.Writer then strs = handler.Writer.handle(doc) else bottomUp(doc) strs = table.concat(resultingStrs, "") end return pandoc.Pandoc(pandoc.Blocks(pandoc.RawBlock("markdown", strs .. "\n"))) end } return wrappedFilter end)) end ---@diagnostic disable: undefined-field --[[ base64 -- v1.5.3 public domain Lua base64 encoder/decoder no warranty implied; use at your own risk Needs bit32.extract function. If not present it's implemented using BitOp or Lua 5.3 native bit operators. For Lua 5.1 fallbacks to pure Lua implementation inspired by Rici Lake's post: http://ricilake.blogspot.co.uk/2007/10/iterating-bits-in-lua.html author: Ilya Kolbin (iskolbin@gmail.com) url: github.com/iskolbin/lbase64 COMPATIBILITY Lua 5.1+, LuaJIT LICENSE See end of file for license information. --]] local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode if not extract then if _G.bit then -- LuaJIT local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band extract = function( v, from, width ) return band( shr( v, from ), shl( 1, width ) - 1 ) end elseif _G._VERSION == "Lua 5.1" then extract = function( v, from, width ) local w = 0 local flag = 2^from for i = 0, width-1 do local flag2 = flag + flag if v % flag2 >= flag then w = w + 2^i end flag = flag2 end return w end else -- Lua 5.3+ extract = load[[return function( v, from, width ) return ( v >> from ) & ((1 << width) - 1) end]]() end end function base64_makeencoder( s62, s63, spad ) local encoder = {} for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J', 'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y', 'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n', 'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2', '3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do encoder[b64code] = char:byte() end return encoder end function base64_makedecoder( s62, s63, spad ) local decoder = {} for b64code, charcode in pairs( base64_makeencoder( s62, s63, spad )) do decoder[charcode] = b64code end return decoder end local DEFAULT_ENCODER = base64_makeencoder() local DEFAULT_DECODER = base64_makedecoder() local char, concat = string.char, table.concat function base64_encode( str, encoder, usecaching ) encoder = encoder or DEFAULT_ENCODER local t, k, n = {}, 1, #str local lastn = n % 3 local cache = {} for i = 1, n-lastn, 3 do local a, b, c = str:byte( i, i+2 ) local v = a*0x10000 + b*0x100 + c local s if usecaching then s = cache[v] if not s then s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) cache[v] = s end else s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) end t[k] = s k = k + 1 end if lastn == 2 then local a, b = str:byte( n-1, n ) local v = a*0x10000 + b*0x100 t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64]) elseif lastn == 1 then local v = str:byte( n )*0x10000 t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64]) end return concat( t ) end function base64_decode( b64, decoder, usecaching ) decoder = decoder or DEFAULT_DECODER local pattern = '[^%w%+%/%=]' if decoder then local s62, s63 for charcode, b64code in pairs( decoder ) do if b64code == 62 then s62 = charcode elseif b64code == 63 then s63 = charcode end end pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) ) end b64 = b64:gsub( pattern, '' ) local cache = usecaching and {} local t, k = {}, 1 local n = #b64 local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0 for i = 1, padding > 0 and n-4 or n, 4 do local a, b, c, d = b64:byte( i, i+3 ) local s if usecaching then local v0 = a*0x1000000 + b*0x10000 + c*0x100 + d s = cache[v0] if not s then local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d] s = char( extract(v,16,8), extract(v,8,8), extract(v,0,8)) cache[v0] = s end else local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d] s = char( extract(v,16,8), extract(v,8,8), extract(v,0,8)) end t[k] = s k = k + 1 end if padding == 1 then local a, b, c = b64:byte( n-3, n-1 ) local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 t[k] = char( extract(v,16,8), extract(v,8,8)) elseif padding == 2 then local a, b = b64:byte( n-3, n-2 ) local v = decoder[a]*0x40000 + decoder[b]*0x1000 t[k] = char( extract(v,16,8)) end return concat( t ) end --[[ ------------------------------------------------------------------------------ This software is available under 2 licenses -- choose whichever you prefer. ------------------------------------------------------------------------------ ALTERNATIVE A - MIT License Copyright (c) 2018 Ilya Kolbin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------ ALTERNATIVE B - Public Domain (www.unlicense.org) This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------ --]] -- citation.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- read and replace the citation field -- with reshaped data that has been -- restructured into the standard has -- format local constants = require("modules/constants") local function processTypedId(el) if pandoc.utils.type(el) == "Inlines" then return { value = el } else return el end end local function normalizeTypedId(els) if pandoc.utils.type(els) == "List" then -- this is a list of ids local normalizedEls = {} for i,v in ipairs(els) do local normalized = processTypedId(v) tappend(normalizedEls, {normalized}) end return normalizedEls elseif pandoc.utils.type(els) == "Inlines" then -- this is a simple id (a string) return { processTypedId(els )} else -- this is a single id, but is already a typed id return { processTypedId(els) } end end function processCitationMeta(meta) if meta then local citationMeta = meta[constants.kCitation] if citationMeta and type(citationMeta) == "object" then local containerIds = citationMeta[constants.kContainerId] if containerIds ~= nil then meta[constants.kCitation][constants.kContainerId] = normalizeTypedId(containerIds) end local articleIds = citationMeta[constants.kArticleId] if articleIds ~= nil then meta[constants.kCitation][constants.kArticleId] = normalizeTypedId(articleIds) end if citationMeta[constants.kPage] and citationMeta[constants.kPageFirst] == nil and citationMeta[constants.kPageLast] == nil then local pagerange = split(pandoc.utils.stringify(citationMeta[constants.kPage]), '-') meta[constants.kCitation][constants.kPageFirst] = pandoc.Inlines(pagerange[1]) if pagerange[2] then meta[constants.kCitation][constants.kPageLast] = pandoc.Inlines(pagerange[2]) end end end return meta end end -- colors.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- These colors are used as background colors with an opacity of 0.75 kColorUnknown = "909090" kColorNote = "0758E5" kColorImportant = "CC1914" kColorWarning = "EB9113" kColorTip = "00A047" kColorCaution = "FC5300" -- these colors are used with no-opacity kColorUnknownFrame = "acacac" kColorNoteFrame = "4582ec" kColorImportantFrame = "d9534f" kColorWarningFrame = "f0ad4e" kColorTipFrame = "02b875" kColorCautionFrame = "fd7e14" kBackgroundColorUnknown = "e6e6e6" kBackgroundColorNote = "dae6fb" kBackgroundColorImportant = "f7dddc" kBackgroundColorWarning = "fcefdc" kBackgroundColorTip = "ccf1e3" kBackgroundColorCaution = "ffe5d0" function latexXColor(color) -- remove any hash at the front color = pandoc.utils.stringify(color) color = color:gsub("#","") local hexCount = 0 for match in color:gmatch "%x%x" do hexCount = hexCount + 1 end if hexCount == 3 then -- this is a hex color return "{HTML}{" .. color .. "}" else -- otherwise treat it as a named color -- and hope for the best return '{named}{' .. color .. '}' end end -- converts a hex string to a RGB function hextoRgb(hex) -- remove any leading # hex = hex:gsub("#","") -- convert to return { red = tonumber("0x"..hex:sub(1,2)), green = tonumber("0x"..hex:sub(3,4)), blue = tonumber("0x"..hex:sub(5,6)) } end -- collate.lua -- Copyright (C) 2023 Posit Software, PBC -- improved formatting for dumping tables function collate(lst, predicate) local result = pandoc.List({}) local current_block = pandoc.List({}) for _, block in ipairs(lst) do if #current_block == 0 then current_block = pandoc.List({ block }) else if predicate(block, current_block[#current_block]) then current_block:insert(block) else if #current_block > 0 then result:insert(current_block) end current_block = pandoc.List({ block }) end end end if #current_block > 0 then result:insert(current_block) end return result end -- crossref.lua -- Copyright (C) 2023 Posit Software, PBC -- -- common crossref functions/data function add_crossref(label, type, title) if pandoc.utils.type(title) ~= "Blocks" then title = quarto.utils.as_blocks(title) end local order = indexNextOrder(type) indexAddEntry(label, nil, order, title) return order end -- debug.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- improved formatting for dumping tables function tdump (tbl, indent, refs) if not refs then refs = {} end if not indent then indent = 0 end local address = string.format("%p", tbl) if refs[address] ~= nil then print(string.rep(" ", indent) .. "(circular reference to " .. address .. ")") return end if tbl.t and type(t) == "string" then print(string.rep(" ", indent) .. tbl.t) end local empty = true for k, v in pairs(tbl) do empty = false formatting = string.rep(" ", indent) .. k .. ": " v = asLua(v) if type(v) == "table" then print(formatting .. "table: " .. address) refs[address] = true tdump(v, indent+1, refs) elseif type(v) == 'boolean' then print(formatting .. tostring(v)) elseif (v ~= nil) then print(formatting .. tostring(v)) else print(formatting .. 'nil') end end if empty then print(string.rep(" ", indent) .. "<empty table>") end end function asLua(o) if type(o) ~= 'userdata' then return o end if rawequal(o, PANDOC_READER_OPTIONS) then return { abbreviations = o.abbreviations, columns = o.columns, default_image_extension = o.default_image_extension, extensions = o.extensions, indented_code_classes = o.indented_code_classes, standalone = o.standalone, strip_comments = o.strip_comments, tab_stop = o.tab_stop, track_changes = o.track_changes, } elseif rawequal(o, PANDOC_WRITER_OPTIONS) then return { cite_method = o.cite_method, columns = o.columns, dpi = o.dpi, email_obfuscation = o.email_obfuscation, epub_chapter_level = o.epub_chapter_level, epub_fonts = o.epub_fonts, epub_metadata = o.epub_metadata, epub_subdirectory = o.epub_subdirectory, extensions = o.extensions, highlight_style = o.highlight_style, html_math_method = o.html_math_method, html_q_tags = o.html_q_tags, identifier_prefix = o.identifier_prefix, incremental = o.incremental, listings = o.listings, number_offset = o.number_offset, number_sections = o.number_sections, prefer_ascii = o.prefer_ascii, reference_doc = o.reference_doc, reference_links = o.reference_links, reference_location = o.reference_location, section_divs = o.section_divs, setext_headers = o.setext_headers, slide_level = o.slide_level, tab_stop = o.tab_stop, table_of_contents = o.table_of_contents, template = o.template, toc_depth = o.toc_depth, top_level_division = o.top_level_division, variables = o.variables, wrap_text = o.wrap_text } end v = tostring(o) if string.find(v, "^pandoc CommonState") then return { input_files = o.input_files, output_file = o.output_file, log = o.log, request_headers = o.request_headers, resource_path = o.resource_path, source_url = o.source_url, user_data_dir = o.user_data_dir, trace = o.trace, verbosity = o.verbosity } elseif string.find(v, "^pandoc LogMessage") then return v end return o end -- dump an object to stdout local function dump(o) o = asLua(o) if type(o) == 'table' then tdump(o) else print(tostring(o) .. "\n") end end -- debug.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- luacov: disable function fail_and_ask_for_bug_report(message) fail(message .. "\nThis is a quarto bug. Please consider filing a bug report at https://github.com/quarto-dev/quarto-cli/issues", 5) end function fail(message, level) local file = currentFile() if file then fatal("An error occurred while processing '" .. file .. "':\n" .. message, level or 4) else fatal("An error occurred:\n" .. message, level or 4) end end function internal_error() fail("This is an internal error. Please file a bug report at https://github.com/quarto-dev/quarto-cli/", 5) end function currentFile() -- if we're in a multifile contatenated render, return which file we're rendering local fileState = currentFileMetadataState() if fileState ~= nil and fileState.file ~= nil and fileState.file.bookItemFile ~= nil then return fileState.file.bookItemFile elseif fileState ~= nil and fileState.include_directory ~= nil then return fileState.include_directory else -- if we're not in a concatenated scenario, file name doesn't really matter since the invocation is only -- targeting a single file return nil end end -- luacov: enable -- figures.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- constants for figure attributes kFigAlign = "fig-align" kFigEnv = "fig-env" kFigAlt = "fig-alt" kFigPos = "fig-pos" kFigCap = "fig-cap" kFigScap = "fig-scap" kResizeWidth = "resize.width" kResizeHeight = "resize.height" function isFigAttribute(name) return string.find(name, "^fig%-") end function figAlignAttributeDefault(el, default) local align = attribute(el, kFigAlign, default) if align == "default" then align = default end return validatedAlign(align, default) end function figAlignAttribute(el) local default = pandoc.utils.stringify( param(kFigAlign, pandoc.Str("default")) ) local align = attribute(el, kFigAlign, default) if align == "default" then align = default end return validatedAlign(align, "center") end -- is this a Div containing a figure function isFigureDiv(el, captionRequired) if is_regular_node(el, "Div") and hasFigureRef(el) then if captionRequired == nil then captionRequired = true end if not captionRequired then return true end return el.attributes[kFigCap] ~= nil or refCaptionFromDiv(el) ~= nil else return discoverLinkedFigureDiv(el) ~= nil end end local singleton_list = function(el) return #el.content == 1 end function discoverFigure(el, captionRequired) if captionRequired == nil then captionRequired = true end local function check_caption(image) return #image.caption > 0 or not captionRequired end return quarto.utils.match( "Para", singleton_list, 1, "Image", check_caption)(el) or nil end function discoverLinkedFigure(el, captionRequired) local function check_caption(image) return #image.caption > 0 or not captionRequired end return quarto.utils.match( "Para", singleton_list, 1, "Link", singleton_list, 1, "Image", check_caption)(el) or nil end function discoverLinkedFigureDiv(el, captionRequired) if is_regular_node(el, "Div") and hasFigureRef(el) and #el.content == 2 and el.content[1].t == "Para" and el.content[2].t == "Para" then return discoverLinkedFigure(el.content[1], captionRequired) end return nil end local anonymousCount = 0 function anonymousFigId() anonymousCount = anonymousCount + 1 return "fig-anonymous-" .. tostring(anonymousCount) end function isAnonymousFigId(identifier) return string.find(identifier, "^fig%-anonymous-") end function isReferenceableFig(figEl) return figEl.attr.identifier ~= "" and not isAnonymousFigId(figEl.attr.identifier) end function latexIsTikzImage(image) return _quarto.format.isLatexOutput() and string.find(image.src, "%.tex$") end function latexFigureInline(image) -- if this is a tex file (e.g. created w/ tikz) then use \\input if latexIsTikzImage(image) then -- be sure to inject \usepackage{tikz} quarto_global_state.usingTikz = true -- base input local input = "\\input{" .. image.src .. "}" -- apply resize.width and/or resize.height if specified local rw = attribute(image, kResizeWidth, attribute(image, "width", "!")) local rh = attribute(image, kResizeHeight, attribute(image, "height", "!")) -- convert % to linewidth rw = asLatexSize(rw) rh = asLatexSize(rh) if rw ~= "!" or rh ~= "!" then input = "\\resizebox{" .. rw .. "}{" .. rh .. "}{" .. input .. "}" end -- return inline return pandoc.RawInline("latex", input) else return image end end -- file-metadata.lua -- Copyright (C) 2020-2022 Posit Software, PBC fileMetadataState = { file = nil, appendix = false, include_directory = nil, } function file_metadata() return { RawInline = parseFileMetadata, RawBlock = parseFileMetadata } end function parseFileMetadata(el) if _quarto.format.isRawHtml(el) then local rawMetadata = string.match(el.text, "^<!%-%- quarto%-file%-metadata: ([^ ]+) %-%->$") if rawMetadata then local decoded = base64_decode(rawMetadata) local file = quarto.json.decode(decoded) fileMetadataState.file = file -- flip into appendix mode as appropriate if file.bookItemType == "appendix" then fileMetadataState.appendix = true end -- set and unset file directory for includes if file.include_directory ~= nil then fileMetadataState.include_directory = file.include_directory end if file.clear_include_directory ~= nil then fileMetadataState.include_directory = nil end end end return el end function currentFileMetadataState() return fileMetadataState end function resetFileMetadata() fileMetadataState = { file = nil, appendix = false, include_directory = nil, } end -- floats.lua -- Copyright (C) 2023 Posit Software, PBC -- constants for float attributes local kFloatAlignSuffix = "-align" -- local kEnvSuffix = "-env" -- local kAltSuffix = "-alt" -- local kPosSuffix = "-pos" -- local kCapSuffix = "-cap" -- local kScapSuffix = "-scap" -- local kResizeWidth = "resize.width" -- local kResizeHeight = "resize.height" function align_attribute(float) local prefix = refType(float.identifier) local attr_key = prefix .. kFloatAlignSuffix local default = pandoc.utils.stringify( param(attr_key, pandoc.Str("default")) ) local align = attribute(float, attr_key, default) if align == "default" then align = default end return validatedAlign(align, "center") end -- format.lua -- Copyright (C) 2020-2022 Posit Software, PBC function round(num, numDecimalPlaces) local mult = 10^(numDecimalPlaces or 0) return math.floor(num * mult + 0.5) / mult end -- latex.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- generates a set of options for a tColorBox function tColorOptions(options) local optionStr = "" local prepend = false for k, v in pairs(options) do if (prepend) then optionStr = optionStr .. ', ' end if v ~= "" then optionStr = optionStr .. k .. '=' .. v else optionStr = optionStr .. k end prepend = true end return optionStr end -- layout.lua -- Copyright (C) 2020-2022 Posit Software, PBC kLayoutAlign = "layout-align" kLayoutVAlign = "layout-valign" kLayoutNcol = "layout-ncol" kLayoutNrow = "layout-nrow" kLayout = "layout" function layout_align_attribute(el_with_attr, default) return validatedAlign(el_with_attr.attributes[kLayoutAlign], default) end -- now unused. Remove? -- luacov: disable function layout_valign_attribute(el_with_attr, default) return validatedVAlign(el_with_attr.attributes[kLayoutVAlign] or default) end -- luacov: enable function attr_has_layout_attributes(attr) local attribs = tkeys(attr.attributes) return attribs:includes(kLayoutNrow) or attribs:includes(kLayoutNcol) or attribs:includes(kLayout) end function hasLayoutAttributes(el) return attr_has_layout_attributes(el.attr) end function isLayoutAttribute(key) return key == kLayoutNrow or key == kLayoutNcol or key == kLayout end -- locate an image in a layout cell function figureImageFromLayoutCell(cellDivEl) for _,block in ipairs(cellDivEl.content) do local fig = discoverFigure(block, false) if not fig then fig = discoverLinkedFigure(block, false) end if not fig then fig = discoverLinkedFigureDiv(block, false) end if fig then return fig end end return nil end -- we often wrap a table in a div, unwrap it function tableFromLayoutCell(cell) local tbl cell:walk({ Table = function(t) tbl = t end }) return tbl end -- resolve alignment for layout cell (default to center or left depending -- on the content in the cell) function layoutCellAlignment(cell, align) if not align then local image = figureImageFromLayoutCell(cell) local tbl = tableFromLayoutCell(cell) if image or tbl then return "center" else return "left" end else return align end end function sizeToPercent(size) if size then local percent = string.match(size, "^([%d%.]+)%%$") if percent then return tonumber(percent) end end return nil end function asLatexSize(size, macro) -- default to linewidth if not macro then macro = "linewidth" end -- see if this is a percent, if it is the conver local percentSize = sizeToPercent(size) if percentSize then if percentSize == 100 then return "\\" .. macro else return string.format("%2.2f", percentSize/100) .. "\\" .. macro end else return size end end -- list.lua -- Copyright (C) 2020-2022 Posit Software, PBC function filter(list, test) local result = {} for index, value in ipairs(list) do if test(value, index) then result[#result + 1] = value end end return result end -- log.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- TODO -- could write to named filed (e.g. <docname>.filter.log) and client could read warnings and delete (also delete before run) -- always append b/c multiple filters -- luacov: disable local function caller_info(offset) offset = offset or 3 local caller = debug.getinfo(offset, "lS") return caller.source:sub(2,-1) .. ":" .. tostring(caller.currentline) end function info(message) io.stderr:write(message .. "\n") end function warn(message, offset) io.stderr:write(lunacolors.yellow("WARNING (" .. caller_info(offset) .. ") " .. message .. "\n")) end function error(message, offset) io.stderr:write(lunacolors.red("ERROR (" .. caller_info(offset) .. ") " .. message .. "\n")) end function fatal(message, offset) io.stderr:write(lunacolors.red("FATAL (" .. caller_info(offset) .. ") " ..message .. "\n")) -- TODO write stack trace into log, and then exit. crash_with_stack_trace() end -- luacov: enable -- lunacolors.lua -- -- Copyright (c) 2021, Hilbis -- https://github.com/Rosettea/Lunacolors lunacolors = {} function init(name, codes) lunacolors[name] = function(text) return ansi(codes[1], codes[2], text) end end function ansi(open, close, text) if text == nil then return '\27[' .. open .. 'm' end return '\27[' .. open .. 'm' .. text .. '\27[' .. close .. 'm' end -- Define colors -- Modifiers init('reset', {0, 0}) init('bold', {1, 22}) init('dim', {2, 22}) init('italic', {3, 23}) init('underline', {4, 24}) init('invert', {7, 27}) init('hidden', {8, 28}) init('strikethrough', {9, 29}) -- Colors init('black', {30, 39}) init('red', {31, 39}) init('green', {32, 39}) init('yellow', {33, 39}) init('blue', {34, 39}) init('magenta', {35, 39}) init('cyan', {36, 39}) init('white', {37, 39}) -- Background colors init('blackBg', {40, 49}) init('redBg', {41, 49}) init('greenBg', {42, 49}) init('yellowBg', {43, 49}) init('blueBg', {44, 49}) init('magentaBg', {45, 49}) init('cyanBg', {46, 49}) init('whiteBg', {47, 49}) -- Bright colors init('brightBlack', {90, 39}) init('brightRed', {91, 39}) init('brightGreen', {92, 39}) init('brightYellow', {93, 39}) init('brightBlue', {94, 39}) init('brightMagenta', {95, 39}) init('brightCyan', {96, 39}) init('brightWhite', {97, 39}) -- Bright background init('brightBlackBg', {100, 49}) init('brightRedBg', {101, 49}) init('brightGreenBg', {102, 49}) init('brightYellowBg', {103, 49}) init('brightBlueBg', {104, 49}) init('brightMagentaBg', {105, 49}) init('brightCyanBg', {106, 49}) init('brightWhiteBg', {107, 49}) lunacolors.version = '0.1.0' lunacolors.format = function(text) local colors = { reset = {'{reset}', ansi(0)}, bold = {'{bold}', ansi(1)}, dim = {'{dim}', ansi(2)}, italic = {'{italic}', ansi(3)}, underline = {'{underline}', ansi(4)}, invert = {'{invert}', ansi(7)}, bold_off = {'{bold-off}', ansi(22)}, underline_off = {'{underline-off}', ansi(24)}, black = {'{black}', ansi(30)}, red = {'{red}', ansi(31)}, green = {'{green}', ansi(32)}, yellow = {'{yellow}', ansi(33)}, blue = {'{blue}', ansi(34)}, magenta = {'{magenta}', ansi(35)}, cyan = {'{cyan}', ansi(36)}, white = {'{white}', ansi(37)}, red_bg = {'{red-bg}', ansi(41)}, green_bg = {'{green-bg}', ansi(42)}, yellow_bg = {'{green-bg}', ansi(43)}, blue_bg = {'{blue-bg}', ansi(44)}, magenta_bg = {'{magenta-bg}', ansi(45)}, cyan_bg = {'{cyan-bg}', ansi(46)}, white_bg = {'{white-bg}', ansi(47)}, gray = {'{gray}', ansi(90)}, bright_red = {'{bright-red}', ansi(91)}, bright_green = {'{bright-green}', ansi(92)}, bright_yellow = {'{bright-yellow}', ansi(93)}, bright_blue = {'{bright-blue}', ansi(94)}, bright_magenta = {'{bright-magenta}', ansi(95)}, bright_cyan = {'{bright-cyan}', ansi(96)} } for k, v in pairs(colors) do text = text:gsub(v[1], v[2]) end return text .. colors['reset'][2] end -- map-or-call.lua -- Copyright (C) 2020 by RStudio, PBC function map_or_call(fun, arrayOrValue) if tisarray(arrayOrValue) then -- array local result = {} for i, v in pairs(arrayOrValue) do table.insert(result, fun(v)) end return result else -- value return fun(arrayOrValue) end end -- meta.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- constants kHeaderIncludes = "header-includes" kIncludeBefore = "include-before" kIncludeAfter = "include-after" function ensureIncludes(meta, includes) if not meta[includes] then meta[includes] = pandoc.List({}) elseif pandoc.utils.type(meta[includes]) == "Inlines" or pandoc.utils.type(meta[includes]) == "Blocks" then meta[includes] = pandoc.List({meta[includes]}) end end function removeEmptyIncludes(meta, includes) if meta[includes] and pandoc.utils.type(meta[includes]) == "List" and #meta[includes] == 0 then meta[includes] = nil end end function removeAllEmptyIncludes(meta) removeEmptyIncludes(meta, kHeaderIncludes) removeEmptyIncludes(meta, kIncludeBefore) removeEmptyIncludes(meta, kIncludeAfter) end -- add a header include as a raw block function addInclude(meta, format, includes, include) if _quarto.format.isHtmlOutput() then blockFormat = "html" else blockFormat = format end meta[includes]:insert(pandoc.Blocks({ pandoc.RawBlock(blockFormat, include) })) end -- conditionally include a package function usePackage(pkg) return "\\@ifpackageloaded{" .. pkg .. "}{}{\\usepackage{" .. pkg .. "}}" end function usePackageWithOption(pkg, option) return "\\@ifpackageloaded{" .. pkg .. "}{}{\\usepackage[" .. option .. "]{" .. pkg .. "}}" end function metaInjectLatex(meta, func) if _quarto.format.isLatexOutput() then local function inject(tex) addInclude(meta, "tex", kHeaderIncludes, tex) end inject("\\makeatletter") func(inject) inject("\\makeatother") end end function metaInjectLatexBefore(meta, func) metaInjectRawLatex(meta, kIncludeBefore, func) end function metaInjectLatexAfter(meta, func) metaInjectRawLatex(meta, kIncludeAfter, func) end function metaInjectRawLatex(meta, include, func) if _quarto.format.isLatexOutput() then local function inject(tex) addInclude(meta, "tex", include, tex) end func(inject) end end function metaInjectHtml(meta, func) if _quarto.format.isHtmlOutput() then local function inject(html) addInclude(meta, "html", kHeaderIncludes, html) end func(inject) end end function readMetaOptions(meta) local options = {} for key,value in pairs(meta) do if type(value) == "table" and value.clone ~= nil then options[key] = value:clone() else options[key] = value end end return options end -- options.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- initialize options from 'crossref' metadata value function readFilterOptions(meta, filter) local options = {} if type(meta[filter]) == "table" then options = readMetaOptions(meta[filter]) end return options end -- get option value function readOption(options, name, default) local value = options[name] if value == nil then value = default end if type(value) == "table" and value.clone ~= nil then return value:clone() else return value; end end -- pandoc.lua -- Copyright (C) 2020-2022 Posit Software, PBC local readqmd = require("readqmd") function hasBootstrap() local hasBootstrap = param("has-bootstrap", false) return hasBootstrap end -- read attribute w/ default function attribute(el, name, default) return el.attributes[name] or default end function removeClass(classes, remove) return classes:filter(function(clz) return clz ~= remove end) end function combineFilters(filters) -- the final list of filters local filterList = {} for _, filter in ipairs(filters) do for key,func in pairs(filter) do -- ensure that there is a list for this key if filterList[key] == nil then filterList[key] = pandoc.List() end -- add the current function to the list filterList[key]:insert(func) end end local combinedFilters = {} for key,fns in pairs(filterList) do -- if there is only one function for this key -- just use it if #fns == 1 then combinedFilters[key] = fns[1] else -- otherwise combine them into a single function combinedFilters[key] = function(x) -- capture the current value local current = x -- iterate through functions for this key for _, fn in ipairs(fns) do local result = fn(current) if result ~= nil then if (pandoc.utils.type(result) ~= pandoc.utils.type(current) or result.t ~= current.t) then -- luacov: disable quarto.log.info("combineFilters: expected " .. (current.t or pandoc.utils.type(current)) .. " got " .. (result.t or pandoc.utils.type(result))) quarto.log.info("Exiting filter early. (This is a potential bug in Quarto.)") return result -- luacov: enable end -- if there is a result from this function -- update the current value with the result current = result end end -- return result from calling the functions return current end end end return combinedFilters end function inlinesToString(inlines) local pt = pandoc.utils.type(inlines) if pt ~= "Inlines" then -- luacov: disable fail("inlinesToString: expected Inlines, got " .. pt) return "" -- luacov: enable end return pandoc.utils.stringify(pandoc.Span(inlines)) end -- lua string to pandoc inlines function stringToInlines(str) if str then return pandoc.Inlines({pandoc.Str(str)}) else return pandoc.Inlines({}) end end -- FIXME we should no longer be using this. -- lua string with markdown to pandoc inlines function markdownToInlines(str) if str then local doc = pandoc.read(str) if #doc.blocks == 0 then return pandoc.List({}) else return doc.blocks[1].content end else return pandoc.List() end end function stripTrailingSpace(inlines) -- we always convert to pandoc.List to ensure a uniform -- return type (and its associated methods) if #inlines > 0 then if inlines[#inlines].t == "Space" then return pandoc.List(tslice(inlines, 1, #inlines - 1)) else return pandoc.List(inlines) end else return pandoc.List(inlines) end end -- non-breaking space function nbspString() return pandoc.Str '\u{a0}' end -- the first heading in a div is sometimes the caption function resolveHeadingCaption(div) local capEl = div.content[1] if capEl ~= nil and is_regular_node(capEl, "Header") then div.content:remove(1) return capEl.content else return nil end end local kBlockTypes = { "BlockQuote", "BulletList", "CodeBlock ", "DefinitionList", "Div", "Header", "HorizontalRule", "LineBlock", "OrderedList", "Para", "Plain", "RawBlock", "Table" } function isBlockEl(el) return tcontains(kBlockTypes, el.t) end function isInlineEl(el) return not isBlockEl(el) end function compileTemplate(template, meta) local f = io.open(pandoc.utils.stringify(template), "r") if f then local contents = f:read("*all") f:close() -- compile the title block template local compiledTemplate = pandoc.template.compile(contents) local template_opts = pandoc.WriterOptions {template = compiledTemplate} -- render the current document and read it to generate an AST for the -- title block local metaDoc = pandoc.Pandoc(pandoc.Blocks({}), meta) local rendered = pandoc.write(metaDoc, 'gfm', template_opts) -- read the rendered document local renderedDoc = pandoc.read(rendered, 'gfm') return renderedDoc.blocks else -- luacov: disable fail('Error compiling template: ' .. template) -- luacov: enable end end function merge_attrs(attr, ...) local result = pandoc.Attr(attr.identifier, attr.classes, attr.attributes) for _, a in ipairs({...}) do if a ~= nil then result.identifier = result.identifier or a.identifier result.classes:extend(a.classes) for k, v in pairs(a.attributes) do result.attributes[k] = v end end end return result end -- used to convert metatable, attributetable, etc -- to plain tables that can be serialized to JSON function as_plain_table(value) local result = {} for k, v in pairs(value) do result[k] = v end return result end function string_to_quarto_ast_blocks(text, opts) local doc = readqmd.readqmd(text, opts or quarto_global_state.reader_options) -- run the whole normalization pipeline here to get extended AST nodes, etc. for _, filter in ipairs(quarto_ast_pipeline()) do doc = doc:walk(filter.filter) end -- compute flags so we don't skip filters that depend on them doc:walk(compute_flags()) return doc.blocks end function string_to_quarto_ast_inlines(text, sep) return pandoc.utils.blocks_to_inlines(string_to_quarto_ast_blocks(text), sep) end -- paths.lua -- Copyright (C) 2022 Posit Software, PBC function resourceRef(ref, dir) -- if the ref starts with / then just strip if off if string.find(ref, "^/") then -- check for protocol relative url if string.find(ref, "^//") == nil then return pandoc.text.sub(ref, 2, #ref) else return ref end -- if it's a relative ref then prepend the resource dir elseif isRelativeRef(ref) then if dir == '.' then return ref else return dir .. "/" .. ref end else -- otherwise just return it return ref end end function fixIncludePath(ref, dir) -- if it's a relative ref then prepend the resource dir if isRelativeRef(ref) then if dir ~= "." then return dir .. "/" .. ref else return ref end else -- otherwise just return it return ref end end function isRelativeRef(ref) return ref:find("^/") == nil and ref:find("^%a+://") == nil and ref:find("^data:") == nil and ref:find("^#") == nil end function handlePaths(el, path, replacer) el.text = handleHtmlRefs(el.text, path, "img", "src", replacer) el.text = handleHtmlRefs(el.text, path, "img", "data-src", replacer) el.text = handleHtmlRefs(el.text, path, "link", "href", replacer) el.text = handleHtmlRefs(el.text, path, "script", "src", replacer) el.text = handleHtmlRefs(el.text, path, "source", "src", replacer) el.text = handleHtmlRefs(el.text, path, "embed", "src", replacer) el.text = handleCssRefs(el.text, path, "@import%s+", replacer) el.text = handleCssRefs(el.text, path, "url%(", replacer) end function handleHtmlRefs(text, resourceDir, tag, attrib, replacer) return text:gsub("(<" .. tag .. " [^>]*" .. attrib .. "%s*=%s*)\"([^\"]+)\"", function(preface, value) return preface .. "\"" .. replacer(value, resourceDir) .. "\"" end) end function handleCssRefs(text, resourceDir, prefix, replacer) return text:gsub("(" .. prefix .. ")\"([^\"]+)\"", function(preface, value) return preface .. "\"" .. replacer(value, resourceDir) .. "\"" end) end -- ref parent attribute (e.g. fig:parent or tbl:parent) kRefParent = "ref-parent" -- does this element have a figure label? function hasFigureRef(el) return isFigureRef(el.identifier) end function isFigureRef(identifier) if identifier == nil then return nil end local ref = refType(identifier) return crossref.categories.by_ref_type[ref] ~= nil end -- does this element have a table label? function hasTableRef(el) return isTableRef(el.identifier) end function isTableRef(identifier) return (identifier ~= nil) and string.find(identifier, "^tbl%-") end -- does this element support sub-references function hasFigureOrTableRef(el) return hasFigureRef(el) or hasTableRef(el) end function hasRefParent(el) return el.attributes[kRefParent] ~= nil end function refType(id) local match = string.match(id, "^(%a+)%-") if match then return pandoc.text.lower(match) else return nil end end function refCaptionFromDiv(el) local last = el.content[#el.content] if last and last.t == "Para" and #el.content > 1 then return last else return nil end end function noCaption() return pandoc.Strong( { pandoc.Str("?(caption)") }) end function emptyCaption() return pandoc.Str("") end -- string.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- tests whether a string ends with another string function endsWith(str, ending) return ending == "" or str:sub(-#ending) == ending end function startsWith(str, starting) return starting == "" or str:sub(1, #starting) == starting end -- trim a string function trim(s) return (string.gsub(s, "^%s*(.-)%s*$", "%1")) end -- splits a string on a separator function split(str, sep, allow_empty) local fields = {} sep = sep or " " local pattern if allow_empty == true then pattern = string.format("([^%s]*)", patternEscape(sep)) else pattern = string.format("([^%s]+)", patternEscape(sep)) end local _ignored = string.gsub(str, pattern, function(c) fields[#fields + 1] = c end) return fields end -- escape string by converting using Pandoc function stringEscape(str, format) local doc = pandoc.Pandoc({pandoc.Para(str)}) return pandoc.write(doc, format) end -- The character `%´ works as an escape for those magic characters. -- So, '%.' matches a dot; '%%' matches the character `%´ itself. -- You can use the escape `%´ not only for the magic characters, -- but also for all other non-alphanumeric characters. When in doubt, -- play safe and put an escape. -- ( from http://www.lua.org/pil/20.2.html ) function patternEscape(str) return str:gsub("([^%w])", "%%%1") end function html_escape(s, in_attribute) return s:gsub("[<>&\"']", function(x) if x == '<' then return '<' elseif x == '>' then return '>' elseif x == '&' then return '&' elseif in_attribute and x == '"' then return '"' elseif in_attribute and x == "'" then return ''' else return x end end) end -- Escape '%' in string by replacing by '%%' -- This is especially useful in Lua patterns to escape a '%' function percentEscape(str) return str:gsub("%%", "%%%%") end -- table.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- append values to table function tappend(t, values) for i,value in pairs(values) do table.insert(t, value) end end -- prepend values to table function tprepend(t, values) for i=1, #values do table.insert(t, 1, values[#values + 1 - i]) end end -- slice elements out of a table function tslice(t, first, last, step) local sliced = {} for i = first or 1, last or #t, step or 1 do sliced[#sliced+1] = t[i] end return sliced end -- is the table a simple array? -- see: https://web.archive.org/web/20140227143701/http://ericjmritz.name/2014/02/26/lua-is_array/ function tisarray(t) if type(t) ~= "table" then return false end local i = 0 for _ in pairs(t) do i = i + 1 if t[i] == nil then return false end end return true end -- map elements of a table function tmap(tbl, f) local t = {} for k,v in pairs(tbl) do t[k] = f(v) end return t end -- does the table contain a value function tcontains(t,value) if t and type(t)=="table" and value then for _, v in ipairs (t) do if v == value then return true end end return false end return false end -- clear a table function tclear(t) for k,v in pairs(t) do t[k] = nil end end -- get keys from table function tkeys(t) local keyset=pandoc.List({}) local n=0 for k,v in pairs(t) do n=n+1 keyset[n]=k end return keyset end -- sorted pairs. order function takes (t, a,) function spairs(t, order) -- collect the keys local keys = {} for k in pairs(t) do keys[#keys+1] = k end -- if order function given, sort by it by passing the table and keys a, b, -- otherwise just sort the keys if order then table.sort(keys, function(a,b) return order(t, a, b) end) else table.sort(keys) end -- return the iterator function local i = 0 return function() i = i + 1 if keys[i] then return keys[i], t[keys[i]] end end end -- tables.lua -- Copyright (C) 2021-2022 Posit Software, PBC local patterns = require("modules/patterns") function anonymousTblId() return "tbl-anonymous-" .. tostring(math.random(10000000)) end function isAnonymousTblId(identifier) return string.find(identifier, "^tbl%-anonymous-") end function isReferenceableTbl(tblEl) return tblEl.attr.identifier ~= "" and not isAnonymousTblId(tblEl.attr.identifier) end function parseTableCaption(caption) -- string trailing space caption = stripTrailingSpace(caption) -- does the caption end with "}" local lastInline = caption[#caption] if lastInline ~= nil and lastInline.t == "Str" then if endsWith(trim(lastInline.text), "}") then -- find index of first inline that starts with "{" local beginIndex = nil for i = 1,#caption do if caption[i].t == "Str" and startsWith(caption[i].text, "{") then beginIndex = i break end end if beginIndex ~= nil then local attrText = trim(inlinesToString(pandoc.Inlines(tslice(caption, beginIndex, #caption)))) attrText = attrText:gsub("“", "'"):gsub("”", "'") local elWithAttr = pandoc.read("## " .. attrText).blocks[1] if elWithAttr.attr ~= nil then if not startsWith(attrText, "{#") then elWithAttr.attr.identifier = "" end if beginIndex > 1 then return stripTrailingSpace(tslice(caption, 1, beginIndex - 1)), elWithAttr.attr else return pandoc.List({}), elWithAttr.attr end end end end end -- no attributes return caption, pandoc.Attr("") end function createTableCaption(caption, attr) -- convert attr to inlines local attrInlines = pandoc.List() if attr.identifier ~= nil and attr.identifier ~= "" then attrInlines:insert(pandoc.Str("#" .. attr.identifier)) end if #attr.classes > 0 then for i = 1,#attr.classes do if #attrInlines > 0 then attrInlines:insert(pandoc.Space()) end attrInlines:insert(pandoc.Str("." .. attr.classes[i])) end end if #attr.attributes > 0 then for k,v in pairs(attr.attributes) do if #attrInlines > 0 then attrInlines:insert(pandoc.Space()) end attrInlines:insert(pandoc.Str(k .. "='" .. v .. "'")) end end if #attrInlines > 0 then attrInlines:insert(1, pandoc.Space()) attrInlines[2] = pandoc.Str("{" .. attrInlines[2].text) attrInlines[#attrInlines] = pandoc.Str(attrInlines[#attrInlines].text .. "}") local tableCaption = caption:clone() tappend(tableCaption, attrInlines) return tableCaption else return caption end end function countTables(div) local tables = 0 _quarto.ast.walk(div, { Table = function(table) tables = tables + 1 end, RawBlock = function(raw) if hasTable(raw) then tables = tables + 1 end end }) return tables end function hasGtHtmlTable(raw) if _quarto.format.isRawHtml(raw) and _quarto.format.isHtmlOutput() then return raw.text:match(patterns.html_gt_table) else return false end end function hasPagedHtmlTable(raw) if _quarto.format.isRawHtml(raw) and _quarto.format.isHtmlOutput() then return raw.text:match(patterns.html_paged_table) else return false end end function hasRawHtmlTable(raw) if _quarto.format.isRawHtml(raw) and _quarto.format.isHtmlOutput() then return raw.text:match(patterns.html_table) else return false end end function hasRawLatexTable(raw) if _quarto.format.isRawLatex(raw) and _quarto.format.isLatexOutput() then for i,pattern in ipairs(_quarto.patterns.latexTablePatterns) do if raw.text:match(pattern) then return true end end return false else return false end end local tableCheckers = { hasRawHtmlTable, hasRawLatexTable, hasPagedHtmlTable, } function hasTable(raw) for i, checker in ipairs(tableCheckers) do local val = checker(raw) if val then return true end end return false end -- theorems.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- url.lua -- Copyright (C) 2020-2022 Posit Software, PBC function urldecode(url) if url == nil then return end url = url:gsub("+", " ") url = url:gsub("%%(%x%x)", function(x) return string.char(tonumber(x, 16)) end) return url end function fullyUrlDecode(url) -- decode the url until it is fully decoded (not a single pass, -- but repeated until it decodes no further) result = urldecode(url) if result == url then return result else return fullyUrlDecode(result) end end -- validate.lua -- Copyright (C) 2020-2022 Posit Software, PBC kAlignments = pandoc.List({ "center", "left", "right" }) kVAlignments = pandoc.List({"top", "center", "bottom"}) function validatedAlign(align, default) return validateInList(align, kAlignments, "alignment", default) end function validatedVAlign(vAlign) return validateInList(vAlign, kVAlignments, "vertical alignment", "top") end function validateInList(value, list, attribute, default) if value == "default" then return default elseif value and not list:includes(value) then -- luacov: disable warn("Invalid " .. attribute .. " attribute value: " .. value) return default -- luacov: enable else return value end end -- wrapped-filter.lua -- creates wrapped pandoc filters -- Copyright (C) 2022 by RStudio, PBC local function shortcodeMetatable(scriptFile) return { -- https://www.lua.org/manual/5.3/manual.html#6.1 assert = assert, collectgarbage = collectgarbage, dofile = dofile, error = error, getmetatable = getmetatable, ipairs = ipairs, load = load, loadfile = loadfile, next = next, pairs = pairs, pcall = pcall, print = print, rawequal = rawequal, rawget = rawget, rawlen = rawlen, rawset = rawset, select = select, setmetatable = setmetatable, tonumber = tonumber, tostring = tostring, type = type, _VERSION = _VERSION, xpcall = xpcall, coroutine = coroutine, require = require, package = package, string = string, utf8 = utf8, table = table, math = math, io = io, file = file, os = os, debug = debug, -- https://pandoc.org/lua-filters.html FORMAT = FORMAT, PANDOC_READER_OPTIONS = PANDOC_READER_OPTIONS, PANDOC_WRITER_OPTIONS = PANDOC_WRITER_OPTIONS, PANDOC_VERSION = PANDOC_VERSION, PANDOC_API_VERSION = PANDOC_API_VERSION, PANDOC_SCRIPT_FILE = scriptFile, PANDOC_STATE = PANDOC_STATE, pandoc = pandoc, lpeg = lpeg, re = re, -- quarto global environment json = json, -- quarto functions quarto = quarto, -- global environment _G = _G } end local function safeguard_for_meta(customnode) if customnode == nil then return nil end local result = {} for k,v in pairs(customnode) do local t = type(v) local pt = pandoc.utils.type(v) if pt == "Attr" then local converted_attrs = {} for i, attr in ipairs(v.attributes) do table.insert(converted_attrs, { attr[1], attr[2] }) end result[k] = { identifier = v.identifier, classes = v.classes, attributes = converted_attrs } elseif t == "userdata" then result[k] = v -- assume other pandoc objects are ok elseif t == "table" then result[k] = safeguard_for_meta(v) end end return result end function makeWrappedJsonFilter(scriptFile, filterHandler) local handlers = { Pandoc = { file = scriptFile, handle = function(doc) local json = pandoc.write(doc, "json") path = quarto.utils.resolve_path_relative_to_document(scriptFile) local custom_node_map = {} local has_custom_nodes = false doc = doc:walk({ -- FIXME: This is broken with new AST. Needs to go through Custom node instead. RawInline = function(raw) local custom_node, t, kind = _quarto.ast.resolve_custom_data(raw) if custom_node ~= nil then has_custom_nodes = true custom_node = safeguard_for_meta(custom_node) table.insert(custom_node_map, { id = raw.text, tbl = custom_node, t = t, kind = kind }) end end, Meta = function(meta) if has_custom_nodes then meta["quarto-custom-nodes"] = pandoc.MetaList(custom_node_map) end return meta end }) local success, result = pcall(pandoc.utils.run_json_filter, doc, path) if not success then local pandoc_error = tostring(result) local filename = pandoc.path.filename(path) local message = { "Could not run " .. path .. " as a JSON filter.", "Please make sure the file exists and is executable.", "\nDid you intend '" .. filename .. "' as a Lua filter in an extension?", "If so, make sure you've spelled the name of the extension correctly.", "\nThe original Pandoc error follows below.", pandoc_error } fail(table.concat(message, "\n")) return nil end if has_custom_nodes then doc:walk({ Meta = function(meta) _quarto.ast.reset_custom_tbl(meta["quarto-custom-nodes"]) end }) end return result end } } if filterHandler ~= nil then return filterHandler(handlers) else local result = {} for k,v in pairs(handlers) do result[k] = v.handle end return result end end function makeWrappedLuaFilter(scriptFile, filterHandler) return _quarto.withScriptFile(scriptFile, function() local env = setmetatable({}, {__index = shortcodeMetatable(scriptFile)}) local chunk, err = loadfile(scriptFile, "bt", env) local handlers = {} local function makeSingleHandler(handlerTable) local result = {} setmetatable(result, { __index = { scriptFile = scriptFile } }) for k,v in pairs(handlerTable) do result[k] = { file = scriptFile, handle = v, } end return result end if not err and chunk then local result = chunk() if result then if quarto.utils.table.isarray(result) then for i, handlerTable in ipairs(result) do table.insert(handlers, makeSingleHandler(handlerTable)) end else handlers = makeSingleHandler(result) end else handlers = makeSingleHandler(env) end if filterHandler ~= nil then return filterHandler(handlers) else result = {} for k,v in pairs(handlers) do result[k] = v.handle end return result end else error(err) os.exit(1) end end) end function makeWrappedFilter(scriptFile, filterHandler) if type(scriptFile) == "userdata" then scriptFile = pandoc.utils.stringify(scriptFile) end if type(scriptFile) == "string" then return makeWrappedLuaFilter(scriptFile, filterHandler) elseif type(scriptFile) == "table" then local path = scriptFile.path local type = scriptFile.type if type == "json" then return makeWrappedJsonFilter(path, filterHandler) else return makeWrappedLuaFilter(path, filterHandler) end end end function filterIf(condition, filter) return { Pandoc = function(doc) if condition() then return _quarto.ast.walk(doc, filter) -- doc:walk(filter) end end } end function filterSeq(filters) return { Pandoc = function(doc) local result -- TODO handle timing and tracing uniformly through our new filter infra for _, filter in ipairs(filters) do if filter.filter ~= nil then filter = filter.filter end local r = run_emulated_filter(doc, filter, true) if r ~= nil then doc = r result = r end end return result end } end -- configurefilters.lua -- Determine which filter chains will be active function configure_filters() -- return { -- Meta = function(meta) quarto_global_state.active_filters = param("active-filters") -- end -- } end -- includes.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") function read_includes(meta) -- return { -- Meta = function(meta) -- ensure all includes are meta lists ensureIncludes(meta, constants.kHeaderIncludes) ensureIncludes(meta, constants.kIncludeBefore) ensureIncludes(meta, constants.kIncludeAfter) -- read file includes readIncludeFiles(meta, constants.kIncludeInHeader, constants.kHeaderIncludes) readIncludeFiles(meta, constants.kIncludeBeforeBody, constants.kIncludeBefore) readIncludeFiles(meta, constants.kIncludeAfterBody, constants.kIncludeAfter) -- read text based includes readIncludeStrings(meta, constants.kHeaderIncludes) readIncludeStrings(meta, constants.kIncludeBefore) readIncludeStrings(meta, constants.kIncludeAfter) return meta -- end -- } end function readIncludeStrings(meta, includes) local strs = param(includes, {}) for _,str in ipairs(strs) do if pandoc.utils.type(str) == "Blocks" then meta[includes]:insert(str) else if type(str) == "table" then str = inlinesToString(str) end addInclude(meta, FORMAT, includes, str) end end end function readIncludeFiles(meta, includes, target) -- process include files local files = param(includes, {}) for _,file in ipairs(files) do local status, err = pcall(function () -- read file contents local f = io.open(pandoc.utils.stringify(file), "rb") if f == nil then fail("Error resolving " .. target .. "- unable to open file " .. file) end local contents = f:read("*all") f:close() -- write as as raw include addInclude(meta, FORMAT, target, contents) end) end end -- resourceRefs.lua -- Copyright (C) 2020-2022 Posit Software, PBC local function handle_raw_element_resource_ref(el) if _quarto.format.isRawHtml(el) then local file = currentFileMetadataState().file if file ~= nil and file.resourceDir ~= nil then handlePaths(el, file.resourceDir, resourceRef) return el end end end function resourceRefs() return { Image = function(el) local file = currentFileMetadataState().file if file ~= nil and file.resourceDir ~= nil then el.src = resourceRef(el.src, file.resourceDir) end return el end, RawInline = handle_raw_element_resource_ref, RawBlock = handle_raw_element_resource_ref, } end knitr_fixup = function() return { -- https://github.com/quarto-dev/quarto-cli/issues/4869 -- knitr:::eng_sql is badly design and escape our cell-output wrapping in R -- so we need to fix it up here by detecting the <div> which will be seen as DIV because -- we use +native_divs by default Div = function(e) if e.classes:includes("knitsql-table") then return pandoc.Div(e.content, { class = "cell-output-display" }) end return e end } end -- render-asciidoc.lua -- Copyright (C) 2020-2022 Posit Software, PBC local kAsciidocNativeCites = require("modules/constants").kAsciidocNativeCites function render_asciidoc() -- This only applies to asciidoc output if not quarto.doc.isFormat("asciidoc") then return {} end local hasMath = false return { Meta = function(meta) if hasMath then meta['asciidoc-stem'] = 'latexmath' end -- We construct the title with cross ref information into the metadata -- if we see such a title, we need to move the identifier up outside the title local titleInlines = meta['title'] if titleInlines ~= nil and #titleInlines == 1 and titleInlines[1].t == 'Span' then ---@diagnostic disable-line ---@type pandoc.Span local span = titleInlines[1] local identifier = span.identifier -- if there is an identifier in the title, we should take over and emit -- the proper asciidoc if identifier ~= nil then -- this is a chapter title, tear out the id and make it ourselves local titleContents = pandoc.write(pandoc.Pandoc({span.content}), "asciidoc") meta['title'] = pandoc.RawInline("asciidoc", titleContents) meta['title-prefix'] = pandoc.RawInline("asciidoc", "[[" .. identifier .. "]]") end end return meta end, Math = function(el) hasMath = true; end, Cite = function(el) -- If quarto is going to be processing the cites, go ahead and convert -- them to a native cite if param(kAsciidocNativeCites) then local citesStr = table.concat(el.citations:map(function (cite) return '<<' .. cite.id .. '>>' end)) return pandoc.RawInline("asciidoc", citesStr); end end, Callout = function(el) -- callout -> admonition types pass through local admonitionType = el.type:upper() local admonitionPre local admonitionPost = "====\n\n" if el.title then -- A titled admonition local admonitionTitle = pandoc.write(pandoc.Pandoc({el.title}), "asciidoc") admonitionPre = "[" .. admonitionType .. "]\n." .. admonitionTitle .. "====\n" else -- A titleless admonition admonitionPre = "[" .. admonitionType .. "]\n====\n" end if el.content.t == "Para" then el.content.content:insert(1, pandoc.RawInline("asciidoc", admonitionPre)) el.content.content:insert(pandoc.RawInline("asciidoc", "\n" .. admonitionPost)) elseif pandoc.utils.type(el.content) == "Blocks" then el.content:insert(1, pandoc.RawBlock("asciidoc", admonitionPre)) el.content:insert(pandoc.RawBlock("asciidoc", admonitionPost)) end return el.content end, Inlines = function(el) -- Walk inlines and see if there is an inline code followed directly by a note. -- If there is, place a space there (because otherwise asciidoc may be very confused) for i, v in ipairs(el) do if v.t == "Code" then if el[i+1] and el[i+1].t == "Note" then local noteEl = el[i+1] -- if the note contains a code inline, we need to add a space local hasCode = false pandoc.walk_inline(noteEl, { Code = function(_el) hasCode = true end }) -- insert a space if hasCode then table.insert(el, i+1, pandoc.RawInline("asciidoc", "{empty}")) end end end end return el end } end -- book.lua -- Copyright (C) 2020-2022 Posit Software, PBC local authors = require 'modules/authors' local license = require 'modules/license' --- Removes notes and links local function clean (inlines) -- this is in post, so it's after render, so we don't need to worry about -- custom ast nodes return inlines:walk { Note = function (_) return {} end, Link = function (link) return link.content end, } end --- Creates an Inlines singleton containing the raw LaTeX. local function l(text) return pandoc.Inlines{pandoc.RawInline('latex', text)} end -- inject metadata function quartoBook() return { Header = function(el) if (quarto.doc.is_format("pdf") and param("single-file-book", false)) then -- Works around https://github.com/jgm/pandoc/issues/1632 -- See https://github.com/quarto-dev/quarto-cli/issues/2412 if el.level <= 2 and el.classes:includes 'unnumbered' then local title = clean(el.content) local secmark = el.level == 1 and l'\\markboth{' .. title .. l'}{' .. title .. l'}' or l'\\markright{' .. title .. l'}' -- subsection, keep left mark unchanged return {el, secmark} end end end, CodeBlock = function(el) -- If this is a title block cell, we should render it -- using the template if el.attr.classes:includes('quarto-title-block') then -- read the contents of the code cell -- this should just be some metadata local renderedDoc = pandoc.read(el.text, 'markdown') -- render the title block using the metdata and -- and the template local template = el.attr.attributes['template'] -- process any author information local processedMeta = authors.processAuthorMeta(renderedDoc.meta) or renderedDoc.meta -- process license information for the book processedMeta = license.processLicenseMeta(processedMeta) -- read the title block template local renderedBlocks = compileTemplate(template, processedMeta) if #renderedBlocks ~= 0 then local emptyLine = pandoc.LineBreak() renderedBlocks:insert(emptyLine) end return renderedBlocks end end } end -- cites.lua -- Copyright (C) 2020-2022 Posit Software, PBC local discoveredCites = pandoc.List() local constants = require("modules/constants") function indexCites() return { Div = function(el) local refsIndentifier = param(constants.kRefsIndentifier) if el.attr.identifier == 'refs' and refsIndentifier then tappend(el.content, {pandoc.Plain(refsIndentifier)}) return el; end end, Cite = function(el) for i,v in ipairs(el.citations) do discoveredCites:insert(v.id) end end } end function writeCites() return { Pandoc = function(el) -- the file to write to local citesFilePath = param("cites-index-file") if citesFilePath and quarto.project.directory then -- open the file local citesRaw = _quarto.file.read(citesFilePath) local documentCites = {} if citesRaw then documentCites = quarto.json.decode(citesRaw) end -- write the cites local inputFile = quarto.doc.input_file local relativeFilePath = pandoc.path.make_relative(inputFile, quarto.project.directory) documentCites[relativeFilePath] = discoveredCites -- write the file local json = quarto.json.encode(documentCites) local file = io.open(citesFilePath, "w") if file ~= nil then file:write(json .. "\n") file:close() else fail('Error opening book citations file at ' .. citesFilePath) end end end } end -- delink.lua -- Copyright (C) 2021-2022 Posit Software, PBC local kDelinkClass = require("modules/constants").kDelinkClass function delink() return { -- Removes links from any divs marked with 'delink' class Div = function(div) if _quarto.format.isHtmlOutput() and div.attr.classes:includes(kDelinkClass) then -- remove the delink class for i, clz in ipairs(div.attr.classes) do if clz == kDelinkClass then div.attr.classes:remove(i) end end -- find links and transform them to spans -- this is in post, so it's after render, so we don't need to worry about -- custom ast nodes return pandoc.walk_block(div, { Link = function(link) return pandoc.Span(link.content) end }) end end } end -- docx.lua -- Copyright (C) 2023 Posit Software, PBC -- -- renders AST nodes to docx local constants = require("modules/constants") local function calloutDocxDefault(node, type, hasIcon) local title = quarto.utils.as_inlines(node.title) local color = htmlColorForType(type) local backgroundColor = htmlBackgroundColorForType(type) local tablePrefix = [[ <w:tbl> <w:tblPr> <w:tblStyle w:val="Table" /> <w:tblLook w:firstRow="0" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="0" w:val="0000" /> <w:tblBorders> <w:left w:val="single" w:sz="24" w:space="0" w:color="$color"/> <w:right w:val="single" w:sz="4" w:space="0" w:color="$color"/> <w:top w:val="single" w:sz="4" w:space="0" w:color="$color"/> <w:bottom w:val="single" w:sz="4" w:space="0" w:color="$color"/> </w:tblBorders> <w:tblCellMar> <w:left w:w="144" w:type="dxa" /> <w:right w:w="144" w:type="dxa" /> </w:tblCellMar> <w:tblInd w:w="164" w:type="dxa" /> <w:tblW w:type="pct" w:w="100%"/> </w:tblPr> <w:tr> <w:trPr> <w:cantSplit/> </w:trPr> <w:tc> <w:tcPr> <w:shd w:color="auto" w:fill="$background" w:val="clear"/> <w:tcMar> <w:top w:w="92" w:type="dxa" /> <w:bottom w:w="92" w:type="dxa" /> </w:tcMar> </w:tcPr> ]] local calloutContents = pandoc.List({ pandoc.RawBlock("openxml", tablePrefix:gsub('$background', backgroundColor):gsub('$color', color)), }) -- Create a title if there isn't already one -- if title == nil then -- title = pandoc.List({pandoc.Str(displayName(type))}) -- end if pandoc.utils.stringify(title) == "" then title = quarto.utils.as_inlines(pandoc.Plain(displayName(node.type))) end -- add the image to the title, if needed local calloutImage = docxCalloutImage(type); if hasIcon and calloutImage ~= nil then -- Create a paragraph with the icon, spaces, and text local image_title = pandoc.List({ pandoc.RawInline("openxml", '<w:pPr>\n<w:spacing w:before="0" w:after="0" />\n<w:textAlignment w:val="center"/>\n</w:pPr>'), calloutImage, pandoc.Space(), pandoc.Space()}) tappend(image_title, title) calloutContents:insert(pandoc.Para(image_title)) else local titleRaw = openXmlPara(pandoc.Para(title), 'w:before="16" w:after="16"') calloutContents:insert(titleRaw) end -- end the title row and start the body row local tableMiddle = [[ </w:tc> </w:tr> <w:tr> <w:trPr> <w:cantSplit/> </w:trPr> <w:tc> <w:tcPr> <w:tcMar> <w:top w:w="108" w:type="dxa" /> <w:bottom w:w="108" w:type="dxa" /> </w:tcMar> </w:tcPr> ]] calloutContents:insert(pandoc.Div(pandoc.RawBlock("openxml", tableMiddle))) -- the main contents of the callout local contents = quarto.utils.as_blocks(node.content) -- ensure there are no nested callouts if contents:find_if(function(el) return is_regular_node(el, "Div") and el.attr.classes:find_if(isDocxCallout) ~= nil end) ~= nil then fail("Found a nested callout in the document. Please fix this issue and try again.") end -- remove padding from existing content and add it removeParagraphPadding(contents) tappend(calloutContents, contents) -- close the table local suffix = pandoc.List({pandoc.RawBlock("openxml", [[ </w:tc> </w:tr> </w:tbl> ]])}) tappend(calloutContents, suffix) -- return the callout local callout = pandoc.Div(calloutContents, pandoc.Attr("", {"docx-callout"})) return callout end local function calloutDocxSimple(node, type, hasIcon) local color = htmlColorForType(type) local title = quarto.utils.as_inlines(node.title) local tablePrefix = [[ <w:tbl> <w:tblPr> <w:tblStyle w:val="Table" /> <w:tblLook w:firstRow="0" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="0" w:val="0000" /> <w:tblBorders> <w:left w:val="single" w:sz="24" w:space="0" w:color="$color"/> </w:tblBorders> <w:tblCellMar> <w:left w:w="0" w:type="dxa" /> <w:right w:w="0" w:type="dxa" /> </w:tblCellMar> <w:tblInd w:w="164" w:type="dxa" /> </w:tblPr> <w:tr> <w:trPr> <w:cantSplit/> </w:trPr> <w:tc> ]] local prefix = pandoc.List({ pandoc.RawBlock("openxml", tablePrefix:gsub('$color', color)), }) local calloutImage = docxCalloutImage(type) if hasIcon and calloutImage ~= nil then local imagePara = pandoc.Para({ pandoc.RawInline("openxml", '<w:pPr>\n<w:spacing w:before="0" w:after="8" />\n<w:jc w:val="center" />\n</w:pPr>'), calloutImage}) prefix:insert(pandoc.RawBlock("openxml", '<w:tcPr><w:tcMar><w:left w:w="144" w:type="dxa" /><w:right w:w="144" w:type="dxa" /></w:tcMar></w:tcPr>')) prefix:insert(imagePara) prefix:insert(pandoc.RawBlock("openxml", "</w:tc>\n<w:tc>")) else prefix:insert(pandoc.RawBlock("openxml", '<w:tcPr><w:tcMar><w:left w:w="144" w:type="dxa" /></w:tcMar></w:tcPr>')) end local suffix = pandoc.List({pandoc.RawBlock("openxml", [[ </w:tc> </w:tr> </w:tbl> ]])}) local calloutContents = pandoc.List({}) tappend(calloutContents, prefix) -- deal with the title, if present if title ~= nil then local titlePara = pandoc.Para(pandoc.Strong(title)) calloutContents:insert(openXmlPara(titlePara, 'w:before="16" w:after="64"')) end -- convert to open xml paragraph local contents = pandoc.List({}) -- use as pandoc.List() for find_if contents:extend(quarto.utils.as_blocks(node.content)) removeParagraphPadding(contents) -- ensure there are no nested callouts if contents:find_if(function(el) return is_regular_node(el, "Div") and el.attr.classes:find_if(isDocxCallout) ~= nil end) ~= nil then fail("Found a nested callout in the document. Please fix this issue and try again.") end tappend(calloutContents, contents) tappend(calloutContents, suffix) local callout = pandoc.Div(calloutContents, pandoc.Attr("", {"docx-callout"})) return callout end function calloutDocx(node) node = decorate_callout_title_with_crossref(node) local type = node.type local appearance = node.appearance local hasIcon = node.icon if appearance == constants.kCalloutAppearanceDefault then return calloutDocxDefault(node, type, hasIcon) else return calloutDocxSimple(node, type, hasIcon) end end -- fig-cleanup.lua -- Copyright (C) 2021-2022 Posit Software, PBC local function stripFigAnonymous(el) if isAnonymousFigId(el.attr.identifier) then el.attr.identifier = "" return el end end function figCleanup() return { Div = stripFigAnonymous, Image = stripFigAnonymous } end -- foldcode.lua -- Copyright (C) 2021-2022 Posit Software, PBC -- slightly fancy code here to make two operations work in a single pass function render_folded_block(block) local make_code_fold_html = function(fold, summary) local div = pandoc.Div({}, pandoc.Attr("", { "quarto-scaffold" })) quarto_global_state.codeFoldingCss = _quarto.format.isHtmlOutput() local open = "" if fold == "show" then open = " open" end local style = "" local clz = 'code-fold' if block.attr.classes:includes("hidden") then clz = clz .. " hidden" end style = ' class="' .. clz .. '"' local beginPara = pandoc.Plain({ pandoc.RawInline("html", "<details" .. open .. style .. ">\n<summary>"), }) if not isEmpty(summary) then tappend(beginPara.content, markdownToInlines(summary)) end beginPara.content:insert(pandoc.RawInline("html", "</summary>")) div.content:insert(beginPara) div.content:insert(block) div.content:insert(pandoc.RawBlock("html", "</details>")) return div end if (not block.attr.classes:includes("cell-code") or (not (_quarto.format.isHtmlOutput() or _quarto.format.isMarkdownWithHtmlOutput()))) then return block, false end local fold = foldAttribute(block) local summary = summaryAttribute(block) if fold ~= nil or summary ~= nil then block.attr.attributes["code-fold"] = nil block.attr.attributes["code-summary"] = nil if fold ~= "none" then return make_code_fold_html(fold, summary), true else return block, false end else return block, false end end function fold_code_and_lift_codeblocks() return { traverse = "topdown", FloatRefTarget = function(float, float_node) -- we need to not lift code blocks from listing floats if float.type == "Listing" then return nil end local blocks = pandoc.Blocks({}) local prev_annotated_code_block_scaffold = nil local prev_annotated_code_block = nil -- ok to lift codeblocks float.content = _quarto.ast.walk(float.content, { traverse = "topdown", DecoratedCodeBlock = function(block) -- defer the folding of code blocks to the DecoratedCodeBlock renderer -- so that we can handle filename better return nil, false end, CodeBlock = function(block) local folded_block, did_fold = render_folded_block(block) local need_to_lift = did_fold or block.classes:includes("code-annotation-code") if need_to_lift then folded_block = make_scaffold(pandoc.Div, { folded_block } ) end if block.classes:includes("code-annotation-code") then prev_annotated_code_block_scaffold = folded_block prev_annotated_code_block = block else prev_annotated_code_block_scaffold = nil end if need_to_lift then blocks:insert(folded_block) return {} else return nil end end, Div = function(div) if not div.classes:includes("cell-annotation") then return nil end local need_to_move_dl = false _quarto.ast.walk(div, { Span = function(span) if (prev_annotated_code_block and prev_annotated_code_block.identifier == span.attributes["data-code-cell"]) then need_to_move_dl = true end end, }) if need_to_move_dl then assert(prev_annotated_code_block_scaffold) print(prev_annotated_code_block_scaffold) prev_annotated_code_block_scaffold.content:insert(div) return {} end end, }) if #blocks > 0 then blocks:insert(float_node) return blocks, false end end, DecoratedCodeBlock = function(block) -- defer the folding of code blocks to the DecoratedCodeBlock renderer -- so that we can handle filename better return nil, false end, CodeBlock = function(block) return render_folded_block(block), false end } end function isEmpty(str) return str == nil or string.len(trim(str)) == 0 end function foldAttribute(el) local default = param("code-fold") if default then default = pandoc.utils.stringify(default) else default = "none" end local fold = attribute(el, "code-fold", default) if fold == true or fold == "true" or fold == "1" then return "hide" elseif fold == nil or fold == false or fold == "false" or fold == "0" then return "none" else return tostring(fold) end end function summaryAttribute(el) local default = param("code-summary") if default then default = pandoc.utils.stringify(default) else default = "Code" end return attribute(el, "code-summary", default) end -- gfm.lua -- Copyright (C) 2023 Posit Software, PBC function render_gfm_fixups() if not _quarto.format.isGithubMarkdownOutput() then return {} end return { Figure = function(fig) local link = quarto.utils.match("[1]/Plain/[1]/{Link}/[1]/Image")(fig) -- render these "figure" ourselves, because: -- -- - GitHub markdown doesn't like the repeated captions we end up with -- - stripping the image alt text doesn't work as of 2023-12-14 -- - Pandoc emits <figure> elements that are ugly and unnecessary -- -- See #6118. if link then return pandoc.Para({link[1]}) end local img = quarto.utils.match("[1]/Plain/[1]/Image")(fig) if img then return pandoc.Para({img}) end end } end -- ipynb.lua -- Copyright (C) 2021-2022 Posit Software, PBC local produceSourceNotebook = param('produce-source-notebook', false) function render_ipynb_fixups() if not _quarto.format.isIpynbOutput() then return {} end return { Image = function(imgEl) -- remove image classes/attributes (as this causes Pandoc to write raw html, which in turn -- prevents correct handling of attachments in some environments including VS Code) -- If we are in source mode, we should produce a markdown image with all the additional attributes and data -- but we can't let Pandoc do that (or it will produce an HTML image), so we do this -- little hack local imgAttr = imgEl.attr imgEl.attr = pandoc.Attr() -- the content that will be output local outputList = pandoc.List({imgEl}) -- if we're producing a source notebook, try to preserve image attributes -- this is important for things like mermaid and graphviz if produceSourceNotebook and (imgAttr.identifier ~= "" or #imgAttr.classes > 0 or #imgAttr.attributes > 0) then -- process identifier local idStr = '' if imgAttr.identifier ~= "" then idStr = '#' .. imgAttr.identifier end -- process classes local clzStr = '' if imgAttr.classes and #imgAttr.classes > 0 then local clzTbl = {} for i, v in ipairs(imgAttr.classes) do clzTbl[i] = '.' .. v end clzStr = ' ' .. table.concat(clzTbl, ' ') end -- process atrributes local attrStr = '' if imgAttr.attributes then local attrTbl = {} for k, v in pairs(imgAttr.attributes) do table.insert(attrTbl, k .. '=' .. '"' .. v .. '"') end attrStr = ' ' .. table.concat(attrTbl, ' ') end -- return an markdown identifier directly adjacent to the image (tricking pandoc ;-) ) outputList:insert(pandoc.RawInline("markdown", '{' .. idStr .. clzStr .. attrStr .. '}')) end return outputList end, } end function ipynb() if FORMAT == "ipynb" then return { { Div = function(el) if el.attr.classes:includes('cell') then el.attr.classes:insert('code') end el.attr.classes = fixupCellOutputClasses( el.attr.classes, 'cell-output-stdout', { 'stream', 'stdout' } ) el.attr.classes = fixupCellOutputClasses( el.attr.classes, 'cell-output-stderr', { 'stream', 'stderr' } ) -- if we are in source notebook mode, we need to omit identifiers that appear on images -- and instead allow the cell yaml to declare things like ids if produceSourceNotebook and el.attr.classes:includes('cell-output-display') then -- First, we need to collapse tables that are surrounded by -- raw blocks (which specifically addresses tables that are parsed and -- surrounded by rawblocks to contain unparseable content) -- This will catch GT tables and render HTML and markdown versions. if #el.content == 3 and el.content[1].t == "RawBlock" and el.content[2].t == "Table" and el.content[3].t == "RawBlock" then if el.content[1].format == "html" and el.content[3].format == "html" then local tbl = pandoc.Pandoc(el.content[2]) local htmlRenderedTbl = pandoc.write(tbl, "html") local htmlRawBlock = pandoc.RawBlock("html", el.content[1].text .. htmlRenderedTbl .. el.content[3].text) local mdRenderedTbl = pandoc.write(tbl, "markdown") local mdRawBlock = pandoc.RawBlock("markdown", mdRenderedTbl) el.content = pandoc.Blocks({htmlRawBlock, mdRawBlock}) end end el = _quarto.ast.walk(el, { Image = function(imgEl) imgEl.attr = pandoc.Attr() return imgEl end, Table = function(tbl) local rendered = pandoc.write(pandoc.Pandoc(tbl), "markdown") return pandoc.RawBlock("markdown", rendered) end, }) elseif produceSourceNotebook then el = _quarto.ast.walk(el, { FloatRefTarget = function(float) float.in_code_cell_output = true return float end, }) end el.attr.classes = fixupCellOutputClasses( el.attr.classes, 'cell-output-display', { 'display_data' } ) el.attr.classes = removeClass(el.attr.classes, 'cell-output') return el end, }, { Pandoc = function(doc) -- pandoc doesn'tx handle front matter title/author/date when creating ipynb -- so do that manually here. note that when we make authors more -- sophisticated we'll need to update this code -- read the title block template local titleBlockTemplate = param('ipynb-title-block') -- render the title block template local renderedBlocks = compileTemplate(titleBlockTemplate, doc.meta) -- prepend the blocks to the notebook tprepend(doc.blocks, renderedBlocks) return doc end, CodeBlock = function(el) if (el.attr.classes:includes('cell-code')) then el.attr.classes = removeClass(el.attr.classes, 'cell-code') end return el end, -- remove image classes/attributes (as this causes Pandoc to write raw html, which in turn -- prevents correct handling of attachments in some environments including VS Code) Image = function(el) end, -- note that this also catches raw blocks inside display_data -- but pandoc seems to ignore the .cell .raw envelope in this -- case and correctly produce text/html cell output RawBlock = function(el) local rawDiv = pandoc.Div( { el }, pandoc.Attr("", { "cell", "raw" }) ) return rawDiv end } } else return {} end end function fixupCellOutputClasses(classes, cellOutputClass, outputClasses) if classes:includes(cellOutputClass) then classes = removeClass(classes, cellOutputClass) classes:insert("output") tappend(classes, outputClasses) end return classes end function readMetadataInlines(meta, key) val = meta[key] if type(val) == "boolean" then return { pandoc.Str( tostring(val) ) } elseif type(val) == "string" then return stringToInlines(val) elseif pandoc.utils.type(val) == "Inlines" then return val else return nil end end -- latex.lua -- Copyright (C) 2023 Posit Software, PBC -- -- renders AST nodes to LaTeX local constants = require("modules/constants") local callout_counters = {} local function ensure_callout_counter(ref) if callout_counters[ref] ~= nil then return end -- \newcounter{quartocalloutnotno} -- \newcommand{\quartocalloutnot}[1]{\refstepcounter{calloutnoteno}\label{#1}} callout_counters[ref] = true local crossref_info = crossref.categories.by_ref_type[ref] local counter_name = 'quartocallout' .. crossref_info.ref_type .. 'no' local counter_command_name = 'quartocallout' .. crossref_info.ref_type local newcounter = '\\newcounter{quartocallout' .. ref .. 'no}' local newcommand = '\\newcommand{\\' .. counter_command_name .. '}[1]{\\refstepcounter{' .. counter_name .. '}\\label{#1}}' quarto.doc.include_text('in-header', newcounter) quarto.doc.include_text('in-header', newcommand) end function latexCalloutBoxDefault(title, callout_type, icon, callout) -- callout dimensions local leftBorderWidth = '.75mm' local borderWidth = '.15mm' local borderRadius = '.35mm' local leftPad = '2mm' local color = latexColorForType(callout_type) local frameColor = latexFrameColorForType(callout_type) local iconForType = iconForType(callout_type) local calloutContents = pandoc.List({}); if is_valid_ref_type(refType(callout.attr.identifier)) then local ref = refType(callout.attr.identifier) local crossref_info = crossref.categories.by_ref_type[ref] -- ensure that front matter includes the correct new counter types ensure_callout_counter(ref) local delim = "" if title:len() > 0 then delim = pandoc.utils.stringify(titleDelim()) end title = crossref_info.prefix .. " \\ref*{" .. callout.attr.identifier .. "}" .. delim .. " " .. title calloutContents:insert(pandoc.RawInline('latex', '\\quartocallout' .. crossref_info.ref_type .. '{' .. callout.attr.identifier .. '} ')) end -- generate options local options = { breakable = "", colframe = frameColor, colbacktitle = color ..'!10!white', coltitle = 'black', colback = 'white', opacityback = 0, opacitybacktitle = 0.6, left = leftPad, leftrule = leftBorderWidth, toprule = borderWidth, bottomrule = borderWidth, rightrule = borderWidth, arc = borderRadius, title = '{' .. title .. '}', titlerule = '0mm', toptitle = '1mm', bottomtitle = '1mm', } if icon ~= false and iconForType ~= nil then options.title = '\\textcolor{' .. color .. '}{\\' .. iconForType .. '}\\hspace{0.5em}' .. options.title end -- the core latex for the box local beginInlines = { pandoc.RawInline('latex', '\\begin{tcolorbox}[enhanced jigsaw, ' .. tColorOptions(options) .. ']\n') } local endInlines = { pandoc.RawInline('latex', '\n\\end{tcolorbox}') } -- Add the titles and contents -- the inlines return { contents = calloutContents, beginInlines = beginInlines, endInlines = endInlines } end -- create the tcolorBox function latexCalloutBoxSimple(title, type, icon, callout) -- callout dimensions local leftBorderWidth = '.75mm' local borderWidth = '.15mm' local borderRadius = '.35mm' local leftPad = '2mm' local color = latexColorForType(type) local colorFrame = latexFrameColorForType(type) if title == nil then title = "" else title = pandoc.write(pandoc.Pandoc(title), 'latex') end -- generate options local options = { breakable = "", colframe = colorFrame, colback = 'white', opacityback = 0, left = leftPad, leftrule = leftBorderWidth, toprule = borderWidth, bottomrule = borderWidth, rightrule = borderWidth, arc = borderRadius, } -- Add the titles and contents local calloutContents = pandoc.List({}); if is_valid_ref_type(refType(callout.attr.identifier)) then local ref = refType(callout.attr.identifier) local crossref_info = crossref.categories.by_ref_type[ref] -- ensure that front matter includes the correct new counter types ensure_callout_counter(ref) local delim = "" if title:len() > 0 then delim = pandoc.utils.stringify(titleDelim()) end title = crossref_info.prefix .. " \\ref*{" .. callout.attr.identifier .. "}" .. delim .. " " .. title calloutContents:insert(pandoc.RawInline('latex', '\\quartocallout' .. crossref_info.ref_type .. '{' .. callout.attr.identifier .. '} ')) end -- the core latex for the box local beginInlines = { pandoc.RawInline('latex', '\\begin{tcolorbox}[enhanced jigsaw, ' .. tColorOptions(options) .. ']\n') } local endInlines = { pandoc.RawInline('latex', '\n\\end{tcolorbox}') } -- generate the icon and use a minipage to position it local iconForCat = iconForType(type) if icon ~= false and iconForCat ~= nil then local iconName = '\\' .. iconForCat local iconColSize = '5.5mm' -- add an icon to the begin local iconTex = '\\begin{minipage}[t]{' .. iconColSize .. '}\n\\textcolor{' .. color .. '}{' .. iconName .. '}\n\\end{minipage}%\n\\begin{minipage}[t]{\\textwidth - ' .. iconColSize .. '}\n' tappend(beginInlines, {pandoc.RawInline('latex', iconTex)}) -- close the icon tprepend(endInlines, {pandoc.RawInline('latex', '\\end{minipage}%')}); end if title:len() > 0 then -- TODO use a better spacing rule title = '\\vspace{-3mm}\\textbf{' .. title .. '}\\vspace{3mm}' calloutContents:insert(pandoc.RawInline('latex', title)) end -- the inlines return { contents = calloutContents, beginInlines = beginInlines, endInlines = endInlines } end function render_latex() if not _quarto.format.isLatexOutput() then return {} end function beginColumnComment() return pandoc.RawBlock("latex", "% quarto-tables-in-margin-AB1927C9:begin") end function endColumnComment() return pandoc.RawBlock("latex", "% quarto-tables-in-margin-AB1927C9:end") end function handle_table_columns(table) local useMargin = table.classes:find_if(isStarEnv) if useMargin then return { beginColumnComment(), table, endColumnComment() } end if table.classes:includes("render-as-tabular") then return latexTabular(table) end end -- renders the outermost element with .column-margin inside -- as a marginnote environment, but don't nest marginnote environments -- This works because it's a topdown traversal local function handle_column_classes(el) local function strip(content, class) local function strip_class(inner_el) if inner_el.classes == nil then return nil end inner_el.classes = inner_el.classes:filter(function(clz) return clz ~= class end) return inner_el end return _quarto.ast.walk(content, { Block = strip_class, Inline = strip_class }) end if el.classes:includes("column-margin") then noteHasColumns() local is_block = pandoc.utils.type(el) == "Block" el.content = strip(el.content, "column-margin") tprepend(el.content, {latexBeginSidenote(is_block)}) tappend(el.content, {latexEndSidenote(el, is_block)}) return el, false else local f = el.classes:find_if(isStarEnv) if f ~= nil then noteHasColumns() el.content = strip(el.content, f) tprepend(el.content, {pandoc.RawBlock("latex", "\\begin{figure*}[H]")}) tappend(el.content, {pandoc.RawBlock("latex", "\\end{figure*}")}) return el, false end end end local function handle_panel_layout(panel) panel.rows = _quarto.ast.walk(panel.rows, { FloatRefTarget = function(float) if float.attributes["ref-parent"] == nil then -- we're about to mess up here, force a [H] position local ref = refType(float.identifier) if ref == nil then -- don't know what to do with this -- give up return nil end float.attributes[ref .. "-pos"] = "H" return float end end, Figure = function(figure) if figure.identifier ~= nil then local ref = refType(figure.identifier) or "fig" figure.attributes[ref .. "-pos"] = "H" end return figure end }) end return { traverse = "topdown", Div = handle_column_classes, Span = handle_column_classes, Table = handle_table_columns, PanelLayout = handle_panel_layout, -- Pandoc emits longtable environments by default; -- longtable environments increment the _table_ counter (!!) -- https://mirror2.sandyriver.net/pub/ctan/macros/latex/required/tools/longtable.pdf -- (page 13, definition of \LT@array) -- -- This causes double counting in our table environments. Our solution -- is to decrement the counter manually after each longtable environment. -- -- This hack causes some warning during the compilation of the latex document, -- but the alternative is worse. FloatRefTarget = function(float) -- don't look inside floats, they get their own rendering. if float.type == "Table" then -- we have a separate fixup for longtables in our floatreftarget renderer -- in the case of subfloat tables... float.content = _quarto.ast.walk(quarto.utils.as_blocks(float.content), { traverse = "topdown", FloatRefTarget = function(float) return nil, false end, }) end float.content = _quarto.ast.walk(quarto.utils.as_blocks(float.content), { PanelLayout = function(panel) panel.attributes["fig-pos"] = "H" return panel end }) return float, false end, Image = function(img) if img.classes:includes("column-margin") then return handle_column_classes(pandoc.Span(img, img.attr)) end local align = attribute(img, kFigAlign, nil) or attribute(img, kLayoutAlign, nil) if align == nil then return nil end img.attributes[kFigAlign] = nil -- \\centering doesn't work consistently here... return pandoc.Inlines({ pandoc.RawInline('latex', '\\begin{center}\n'), img, pandoc.RawInline('latex', '\n\\end{center}\n') }) end, Callout = function(node) -- read and clear attributes local lua_type = type local title = node.title local type = node.type local calloutAppearance = node.appearance local icon = node.icon -- Discover notes in the callout and pull the contents out -- replacing with a footnote mark. This is required because -- if the footnote stays in the callout, the footnote text -- will not appear at the bottom of the document but will instead -- appear in the callout itself (at the bottom) -- -- Also note whether the footnotes contain codeblocks, which -- require special handling local hasVerbatimInNotes = false local noteContents = {} local lifted_contents = pandoc.Blocks({}) local nodeContent = _quarto.ast.walk(node.content, { traverse = "topdown", FloatRefTarget = function(float, float_node) if float.identifier ~= nil then local ref = refType(float.identifier) if ref ~= nil then float.attributes[ref .. "-pos"] = "H" return float end end end, Note = function(el) tappend(noteContents, {el.content}) el.content:walk({ CodeBlock = function(el) hasVerbatimInNotes = true end }) return pandoc.RawInline('latex', '\\footnotemark{}') end }) -- generate the callout box local callout if calloutAppearance == constants.kCalloutAppearanceDefault then if title == nil then title = displayName(type) else title = pandoc.write(pandoc.Pandoc(title), 'latex') end callout = latexCalloutBoxDefault(title, type, icon, node) else callout = latexCalloutBoxSimple(title, type, icon, node) end local beginEnvironment = callout.beginInlines local endEnvironment = callout.endInlines local calloutContents = callout.contents if calloutContents == nil then calloutContents = pandoc.Blocks({}) end if lua_type(nodeContent) == "table" then tappend(calloutContents, nodeContent) else table.insert(calloutContents, nodeContent) end if calloutContents[1] ~= nil and calloutContents[1].t == "Para" and calloutContents[#calloutContents].t == "Para" then tprepend(calloutContents, { pandoc.Plain(beginEnvironment) }) tappend(calloutContents, { pandoc.Plain(endEnvironment) }) else tprepend(calloutContents, { pandoc.Para(beginEnvironment) }) tappend(calloutContents, { pandoc.Para(endEnvironment) }) end -- For any footnote content that was pulled out, append a footnotetext -- that include the contents for _i, v in ipairs(noteContents) do -- If there are paragraphs, just attach to them when possible if v[1].t == "Para" then table.insert(v[1].content, 1, pandoc.RawInline('latex', '\\footnotetext{')) else v:insert(1, pandoc.RawInline('latex', '\\footnotetext{')) end if v[#v].t == "Para" then table.insert(v[#v].content, pandoc.RawInline('latex', '}')) else v:extend({pandoc.RawInline('latex', '}')}) end tappend(calloutContents, v) end calloutContents:extend(lifted_contents) -- Enable fancyvrb if verbatim appears in the footnotes if hasVerbatimInNotes then quarto.doc.use_latex_package('fancyvrb') quarto.doc.include_text('in-header', '\\VerbatimFootnotes') end return pandoc.Div(calloutContents) end, Note = function(n) if marginReferences() then -- This is to support multiple paragraphs in footnotes in margin as sidenotes CTAN has some issue (quarto-dev/quarto-cli#7534) n.content = pandoc.Para(pandoc.utils.blocks_to_inlines(n.content, {pandoc.RawInline('latex', '\n\\endgraf\n')})) return n end end } end function render_latex_fixups() if not _quarto.format.isLatexOutput() then return {} end return { RawBlock = function(raw) if _quarto.format.isRawLatex(raw) then if (raw.text:match(_quarto.patterns.latexLongtablePattern) and not raw.text:match(_quarto.patterns.latexCaptionPattern)) then raw.text = raw.text:gsub( _quarto.patterns.latexLongtablePattern, "\\begin{longtable*}%2\\end{longtable*}", 1) return raw end end end } end -- latex.lua -- Copyright (C) 2023 Posit Software, PBC -- -- renders AST nodes to Typst function typst_function_call(name, params) local result = pandoc.Blocks({}) result:insert(pandoc.RawInline("typst", "#" .. name .. "(")) local function add(v) if type(v) == "userdata" or type(v) == "table" then result:extend(quarto.utils.as_blocks(v) or {}) else result:extend(quarto.utils.as_blocks({pandoc.utils.stringify(v)}) or {}) end end -- needs to be array of pairs because order matters for typst for i, pair in ipairs(params) do local k = pair[1] local v = pair[2] if v ~= nil then result:insert(pandoc.RawInline("typst", k .. ": ")) add(v) result:insert(pandoc.RawInline("typst", ", ")) else add(k) end end result:insert(pandoc.RawInline("typst", ")")) return pandoc.Div(result) end function as_typst_content(content) local result = pandoc.Blocks({}) result:insert(pandoc.RawInline("typst", "[\n")) result:extend(quarto.utils.as_blocks(content) or {}) result:insert(pandoc.RawInline("typst", "]\n")) return result end function render_typst() if not _quarto.format.isTypstOutput() then return {} end local number_depth return { { Meta = function(m) m["toc-depth"] = PANDOC_WRITER_OPTIONS["toc_depth"] if m["number-depth"] then number_depth = tonumber(pandoc.utils.stringify(m["number-depth"])) print(number_depth) end return m end }, { Div = function(div) if div.classes:includes("block") then div.classes = div.classes:filter(function(c) return c ~= "block" end) local preamble = pandoc.Blocks({}) local postamble = pandoc.Blocks({}) preamble:insert(pandoc.RawBlock("typst", "#block(")) for k, v in pairs(div.attributes) do -- FIXME: proper escaping of k and v preamble:insert(pandoc.RawBlock("typst", k .. ":" .. v .. ",\n")) end preamble:insert(pandoc.RawBlock("typst", "[\n")) postamble:insert(pandoc.RawBlock("typst", "])\n\n")) local result = pandoc.Blocks({}) result:extend(preamble) result:extend(div.content) result:extend(postamble) return result end end, Header = function(el) if number_depth and el.level > number_depth then el.classes:insert("unnumbered") end if not el.classes:includes("unnumbered") and not el.classes:includes("unlisted") then return nil end local params = pandoc.List({ {"level", el.level}, }) if el.classes:includes("unnumbered") then params:insert({"numbering", "none"}) end if el.classes:includes("unlisted") then params:insert({"outlined", false}) end params:insert({as_typst_content(el.content)}) return typst_function_call("heading", params) end, } } end function render_typst_fixups() local function is_ratio_or_relative(value) if value == nil then return nil end if value:find("%%") then return true end end if not _quarto.format.isTypstOutput() then return {} end return { traverse = "topdown", Image = function(image) -- if the width or height are "ratio" or "relative", in typst parlance, -- then we currently need to hide it from Pandoc 3.1.9 until -- https://github.com/jgm/pandoc/issues/9104 is properly fixed if is_ratio_or_relative(image.attributes["width"]) or is_ratio_or_relative(image.attributes["height"]) then local width = image.attributes["width"] local height = image.attributes["height"] image.attributes["width"] = nil image.attributes["height"] = nil local attr_str = "" if width ~= nil then attr_str = attr_str .. "width: " .. width .. "," end if height ~= nil then attr_str = attr_str .. "height: " .. height .. "," end local escaped_src = image.src:gsub("\\", "\\\\"):gsub("\"", "\\\"") return pandoc.RawInline("typst", "#box(" .. attr_str .. "image(\"" .. escaped_src .. "\"))") end end, Para = function(para) if #para.content ~= 1 then return nil end local img = quarto.utils.match("[1]/Image")(para) if not img then return nil end local align = img.attributes["fig-align"] if align == nil then return nil end img.attributes["fig-align"] = nil return pandoc.Inlines({ pandoc.RawInline("typst", "#align(" .. align .. ")["), img, pandoc.RawInline("typst", "]"), }) end, } end --[[ A Pandoc 2 Lua filter converting Pandoc native divs to LaTeX environments Author: Romain Lesur, Christophe Dervieux, and Yihui Xie License: Public domain Ported from: https://github.com/rstudio/rmarkdown/blob/80f14b2c6e63dcb8463df526354f4cd4fc72fd04/inst/rmarkdown/lua/latex-div.lua --]] function latexDiv() return { Div = function (divEl) -- look for 'latex' or 'data-latex' and at least 1 class local options = attribute(divEl, 'latex', attribute(divEl, 'data-latex')) if not options or #divEl.attr.classes == 0 then return nil end -- if the output format is not latex, remove the attr and return if not _quarto.format.isLatexOutput() then divEl.attributes['latex'] = nil divEl.attributes['data-latex'] = nil return divEl end -- if it's "1" or "true" then just set it to empty string if options == "1" or pandoc.text.lower(options) == "true" then options = "" end -- environment begin/end local env = divEl.classes[1] local beginEnv = '\\begin' .. '{' .. env .. '}' .. options local endEnv = '\n\\end{' .. env .. '}' -- if the first and last div blocks are paragraphs then we can -- bring the environment begin/end closer to the content if divEl.content[1].t == "Para" and divEl.content[#divEl.content].t == "Para" then table.insert(divEl.content[1].content, 1, pandoc.RawInline('tex', beginEnv .. "\n")) table.insert(divEl.content[#divEl.content].content, pandoc.RawInline('tex', "\n" .. endEnv)) else table.insert(divEl.content, 1, pandoc.RawBlock('tex', beginEnv)) table.insert(divEl.content, pandoc.RawBlock('tex', endEnv)) end return divEl end } end -- meta.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- inject metadata function quartoPostMetaInject() return { Meta = function(meta) metaInjectLatex(meta, function(inject) if quarto_global_state.usingTikz then inject(usePackage("tikz")) end end) -- don't emit unnecessary metadata meta["quarto-filters"] = nil return meta end } end -- ojs.lua -- Copyright (C) 2020-2022 Posit Software, PBC function ojs() local uid = 0 local cells = pandoc.List() local function uniqueId() uid = uid + 1 return "ojs-element-id-" .. uid end local function ojsInline(src) local id = uniqueId() cells:insert({ src = src, id = id, inline = true }) return pandoc.Span('', { id = id }) end local function isInterpolationOpen(str) if str.t ~= "Str" then return false end return str.text:find("${") end local function isInterpolationClose(str) if str.t ~= "Str" then return false end return str.text:find("}") end local function findArgIf(lst, fun, start) if start == nil then start = 1 end local sz = #lst for i=start, sz do if fun(lst[i]) then return i end end return nil end local function escapeSingle(str) local sub, _ = string.gsub(str, "'", "\\\\'") return sub end local function escapeDouble(str) local sub, _ = string.gsub(str, '"', '\\\\"') return sub end local stringifyTokens local stringifyTokenInto stringifyTokens = function(sequence) local result = pandoc.List() for i = 1, #sequence do stringifyTokenInto(sequence[i], result) end return table.concat(result, "") end stringifyTokenInto = function(token, sequence) local function unknown() fail_and_ask_for_bug_report("Don't know how to handle token " .. token.t) end if token.t == 'Cite' then unknown() elseif token.t == 'Code' then sequence:insert('`') sequence:insert(token.text) sequence:insert('`') elseif token.t == 'Emph' then sequence:insert('*') sequence:insert(token.text) sequence:insert('*') elseif token.t == 'Image' then unknown() elseif token.t == 'LineBreak' then sequence:insert("\n") elseif token.t == 'Link' then unknown() elseif token.t == 'Math' then unknown() elseif token.t == 'Note' then unknown() elseif token.t == 'Quoted' then if token.quotetype == 'SingleQuote' then sequence:insert("'") local innerContent = stringifyTokens(token.content) sequence:insert(escapeSingle(innerContent)) sequence:insert("'") else sequence:insert('"') local innerContent = stringifyTokens(token.content) sequence:insert(escapeDouble(innerContent)) sequence:insert('"') end elseif token.t == 'RawInline' then sequence:insert(token.text) elseif token.t == 'SmallCaps' then unknown() elseif token.t == 'SoftBreak' then sequence:insert("\n") elseif token.t == 'Space' then sequence:insert(" ") elseif token.t == 'Span' then stringifyTokenInto(token.content, sequence) elseif token.t == 'Str' then sequence:insert(token.text) elseif token.t == 'Strikeout' then unknown() elseif token.t == 'Strong' then sequence:insert('**') sequence:insert(token.text) sequence:insert('**') elseif token.t == 'Superscript' then unknown() elseif token.t == 'Underline' then sequence:insert('_') sequence:insert(token.text) sequence:insert('_') else unknown() end end local function escape_quotes(str) local sub, _ = string.gsub(str, '\\', '\\\\') sub, _ = string.gsub(sub, '"', '\\"') sub, _ = string.gsub(sub, "'", "\\'") sub, _ = string.gsub(sub, '`', '\\\\`') return sub end local function inlines_rec(inlines) -- FIXME I haven't tested this for nested interpolations local i = findArgIf(inlines, isInterpolationOpen) while i do if i then local j = findArgIf(inlines, isInterpolationClose, i) if j then local is, ie = inlines[i].text:find("${") local js, je = inlines[j].text:find("}") local beforeFirst = inlines[i].text:sub(1, is - 1) local firstChunk = inlines[i].text:sub(ie + 1, -1) local lastChunk = inlines[j].text:sub(1, js - 1) local afterLast = inlines[j].text:sub(je + 1, -1) local slice = {pandoc.Str(firstChunk)} local slice_i = 2 for k=i+1, j-1 do slice[slice_i] = inlines[i+1] slice_i = slice_i + 1 inlines:remove(i+1) end slice[slice_i] = pandoc.Str(lastChunk) inlines:remove(i+1) inlines[i] = pandoc.Span({ pandoc.Str(beforeFirst), ojsInline(stringifyTokens(slice)), pandoc.Str(afterLast) }) end -- recurse i = findArgIf(inlines, isInterpolationOpen, i+1) end end return inlines end if (param("ojs", false)) then return { Inlines = function (inlines) return inlines_rec(inlines) end, Pandoc = function(doc) if uid > 0 then local div = pandoc.Div({}, pandoc.Attr("", {"ojs-auto-generated", "hidden"}, {})) div.content:insert(pandoc.RawBlock("html", "<script type='ojs-module-contents'>")) local contents = pandoc.List({}) contents:insert('{"contents":[') for i, v in ipairs(cells) do if i > 1 then contents:insert(",") end contents:insert( (' {"methodName":"interpret","inline":"true","source":"htl.html`<span>${' .. escape_quotes(v.src) .. '}</span>`", "cellName":"' .. v.id .. '"}')) end contents:insert(']}') div.content:insert(pandoc.RawBlock("html", quarto.base64.encode(table.concat(contents, "")))) div.content:insert(pandoc.RawBlock("html", "</script>")) doc.blocks:insert(div) end return doc end, Str = function(el) local b, e, s = el.text:find("${(.+)}") if s then return pandoc.Span({ pandoc.Str(string.sub(el.text, 1, b - 1)), ojsInline(s), pandoc.Str(string.sub(el.text, e + 1, -1)) }) end end } else return {} end end -- jats.lua -- Copyright (C) 2021-2022 Posit Software, PBC local normalizeAuthors = require 'modules/authors' local normalizeLicense = require 'modules/license' local constants = require("modules/constants") local function isCell(el) return el.classes:includes("cell") end local function jatsMeta(meta) -- inspect the meta and set flags that will aide the rendering of -- the JATS template by providing some synthesize properties -- to prevent empty container XML elements -- are there author notes? local authors = meta[normalizeAuthors.constants.author.output_key] if authors ~= nil then -- has author notes local hasNotes = authors:find_if(function(author) local hasAttr = author[normalizeAuthors.constants.author.attributes] ~= nil and next(author[normalizeAuthors.constants.author.attributes]) local hasNote = author[normalizeAuthors.constants.author.note] and next(author[normalizeAuthors.constants.author.note]) return hasAttr or hasNote end) -- has permissions local hasCopyright = meta[normalizeLicense.constants.copyright] ~= nil local hasLicense = meta[normalizeLicense.constants.license] ~= nil local hasPermissions = hasCopyright or hasLicense if meta[constants.kQuartoInternal] == nil then meta[constants.kQuartoInternal] = {} end meta[constants.kQuartoInternal][constants.kHasAuthorNotes] = hasNotes; meta[constants.kQuartoInternal][constants.kHasPermissions] = hasPermissions; -- normalize keywords into tags if they're present and tags aren't if meta[constants.kTags] == nil and meta[constants.kKeywords] ~= nil and meta[constants.kKeywords].t == "Table" then meta[constants.kKeywords] = meta[constants.kTags] end return meta end end function unrollDiv(div, fnSkip) -- unroll blocks contained in divs local blocks = pandoc.List() for _, childBlock in ipairs(div.content) do if is_regular_node(childBlock, "Div") then if fnSkip and not fnSkip(div) then blocks:insert(childBlock) else tappend(blocks, childBlock.content) end else blocks:insert(childBlock) end end return blocks end function jatsCallout(node) local contents = resolveCalloutContents(node, true) local boxedStart = '<boxed-text>' if node.id and node.id ~= "" then boxedStart = "<boxed-text id='" .. node.id .. "'>" end contents:insert(1, pandoc.RawBlock('jats', boxedStart)) contents:insert(pandoc.RawBlock('jats', '</boxed-text>')) return contents end function jats() if _quarto.format.isJatsOutput() then return { Meta = jatsMeta, -- clear out divs Div = function(div) if isTableDiv(div) then local tbl = div.content[1] if tbl.t == "Table" then tbl.identifier = div.identifier end return tbl else -- otherwise, if this is a div, we can unroll its contents -- TODO can we replace this by a single return div.content? return unrollDiv(div, is_custom_node) end end, Callout = jatsCallout, } else return {} end end function jatsSubarticle() if _quarto.format.isJatsOutput() then local isCodeCell = function(el) return not el.classes:includes('markdown') end local isCodeCellOutput = function(el) return el.classes:includes("cell-output") end local ensureValidIdentifier = function(identifier) -- Identifiers may not start with a digit, so add a prefix -- if necessary to ensure that they're valid if identifier:find('^%d.*') then return "cell-" .. identifier else return identifier end end local cellId = function(identifier) if identifier == nil or identifier == "" then return "" else return ' id="' .. ensureValidIdentifier(identifier) .. '"' end end local function renderCell(el, type) local renderedCell = pandoc.List() renderedCell:insert(pandoc.RawBlock('jats', '<sec' .. cellId(el.identifier) .. ' specific-use="' .. type .. '">')) for _i, v in ipairs(el.content) do renderedCell:insert(v) end renderedCell:insert(pandoc.RawBlock('jats', '</sec>')) return renderedCell end local function renderCellOutput(el, type) local renderedCell = pandoc.List() renderedCell:insert(pandoc.RawBlock('jats', '<sec' .. cellId(el.identifier) .. ' specific-use="' .. type .. '">')) for _i, v in ipairs(el.content) do renderedCell:insert(v) end renderedCell:insert(pandoc.RawBlock('jats', '</sec>')) return renderedCell end local unidentifiedCodeCellCount = 0 return { Meta = jatsMeta, Div = function(div) -- this is a notebook cell, handle it if isCell(div) then if isCodeCell(div) then -- if this is an executable notebook cell, walk the contents and add identifiers -- to the outputs if div.identifier == nil or div.identifier == "" then unidentifiedCodeCellCount = unidentifiedCodeCellCount + 1 div.identifier = 'nb-code-cell-' .. tostring(unidentifiedCodeCellCount) end local parentId = div.identifier -- JATS requires that sections that contain other sections must -- have the section after elements like code -- so this moves the sections to the bottom of the element local outputEls = pandoc.List() local otherEls = pandoc.List() for i, v in ipairs(div.content) do if is_regular_node(v, "Div") and isCodeCellOutput(v) then outputEls:extend({v}) else otherEls:extend({v}) end end local orderedContents = pandoc.List() orderedContents:extend(otherEls) orderedContents:extend(outputEls) div.content = orderedContents local count = 0 div = _quarto.ast.walk(div, { Div = function(childEl) if (isCodeCellOutput(childEl)) then childEl.identifier = parentId .. '-output-' .. count count = count + 1 return renderCellOutput(childEl, constants.kNoteBookOutput) end end }) -- render the cell return renderCell(div, constants.kNoteBookCode) else if #div.content == 0 then -- eat empty markdown cells return {} else -- the is a valid markdown cell, let it through return renderCell(div, constants.kNoteBookContent) end end elseif isCodeCellOutput(div) then -- do nothing else -- Forward the identifier from a table div onto the table itself and -- discard the div if isTableDiv(div) then local tbl = div.content[1] tbl.identifier = div.identifier return tbl else -- otherwise, if this is a div, we can unroll its contents return unrollDiv(div, function(el) return isCodeCellOutput(el) or isCell(el) end) end end end, } else return {} end end -- responsive.lua -- Copyright (C) 2021-2022 Posit Software, PBC function responsive() return { -- make images responsive (unless they have an explicit height attribute) Image = function(image) if _quarto.format.isHtmlOutput() and param('fig-responsive', false) then if not image.attr.attributes["height"] and not image.attr.attributes["data-no-responsive"] then image.attr.classes:insert("img-fluid") return image end end end } end function responsive_table() return { -- make simple HTML tables responsive (if they contain a .responsive(-*) class) Table = function(tbl) if _quarto.format.isHtmlOutput() == false then return tbl end local table_responsive_nm = { ["responsive" ] = "table-responsive" , ["responsive-sm" ] = "table-responsive-sm" , ["responsive-md" ] = "table-responsive-md" , ["responsive-lg" ] = "table-responsive-lg" , ["responsive-xl" ] = "table-responsive-xl" , ["responsive-xxl"] = "table-responsive-xxl", ["table-responsive" ] = "table-responsive" , ["table-responsive-sm" ] = "table-responsive-sm" , ["table-responsive-md" ] = "table-responsive-md" , ["table-responsive-lg" ] = "table-responsive-lg" , ["table-responsive-xl" ] = "table-responsive-xl" , ["table-responsive-xxl"] = "table-responsive-xxl" } local found, found_key for _, v in ipairs(tbl.classes) do if table_responsive_nm[v] then found = table_responsive_nm[v] found_key = v break end end if not found then return tbl end tbl.classes = tbl.classes:filter(function(class) return class ~= found_key end) return pandoc.Div(tbl, pandoc.Attr("", { found })) end } end -- reveal.lua -- Copyright (C) 2021-2022 Posit Software, PBC local kShowNotes = require("modules/constants").kShowNotes function reveal() if _quarto.format.isRevealJsOutput() then return combineFilters{ { Meta = function(meta) if meta[kShowNotes] ~= nil and pandoc.utils.type(meta[kShowNotes]) == "Inlines" then meta[kShowNotes]:insert(1, '"') meta[kShowNotes]:insert('"') return meta end end, Div = applyPosition, Span = applyPosition, Image = applyPosition }, { Div = fencedDivFix } } else return {} end end function applyPosition(el) if el.attr.classes:includes("absolute") then -- translate position attributes into style local style = el.attr.attributes['style'] if style == nil then style = '' end local attrs = pandoc.List({ "top", "left", "bottom", "right", "width", "height" }) for _, attr in ipairs(attrs) do local value = el.attr.attributes[attr] if value ~= nil then style = style .. attr .. ': ' .. asCssSize(value) .. '; ' el.attr.attributes[attr] = nil end end el.attr.attributes['style'] = style return el end end function asCssSize(size) local number = tonumber(size) if number ~= nil then return tostring(number) .. "px" else return size end end function fencedDivFix(el) -- to solve https://github.com/quarto-dev/quarto-cli/issues/976 -- until Pandoc may deal with it https://github.com/jgm/pandoc/issues/8098 if el.content[1] and el.content[1].t == "Header" and el.attr.classes:includes("fragment") then level = PANDOC_WRITER_OPTIONS.slide_level if level and el.content[1].level > level then -- This will prevent Pandoc to create a <section> el.content:insert(1, pandoc.RawBlock("html", "<!-- -->")) end end return el end -- tikz.lua -- Copyright (C) 2021-2022 Posit Software, PBC function tikz() if _quarto.format.isLatexOutput() then return { Image = function(image) if latexIsTikzImage(image) then return latexFigureInline(image) end end } else return {} end end -- svg.lua -- Copyright (C) 2021 by RStudio, PBC local function convert_svg(path) local stem = pandoc.path.split_extension(path) local output = stem .. '.pdf' local status, results = pcall(pandoc.pipe, "rsvg-convert", {"-f", "pdf", "-a", "-o", output, path}, "") if status then return output else if results['command'] == nil then -- command not found fail("Could not convert a SVG to a PDF for output. Please ensure that rsvg-convert is available on the path.") else fail("Could not convert a SVG to a PDF for output. An error occurred while attempting to run rsvg-convert.\nError code " .. tostring(results['error_code']) ) end end end local mimeImgExts = { ["image/jpeg"]="jpg", ["image/gif"]="gif", ["image/vnd.microsoft.icon"]="ico", ["image/avif"]="avif", ["image/bmp"]="bmp", ["image/png"]="png", ["image/svg+xml"]="svg", ["image/tiff"]="tif", ["image/webp"]="webp", } -- A cache of image urls that we've resolved into the mediabag -- keyed by {url: mediabagpath} local resolvedUrls = {} -- windows has a max path length of 260 characters -- but we'll be conservative since we're sometimes appending a number local windows_safe_filename = function(filename) -- pull the first 200 characters without the extension local stem, ext = pandoc.path.split_extension(filename) local safeStem = stem:sub(1, 20) local result = safeStem .. ext if #ext > 40 then -- if the extension is too long, truncate it result = safeStem .. ext:sub(1, 40) end return result end -- replace invalid tex characters with underscores local tex_safe_filename = function(filename) -- return filename return filename:gsub("[ <>()|:&;#?*'\\/]", '-') end function pdfImages() return { -- convert SVG images to PDF when rendering PDFS Image = function(image) if quarto.doc.is_format("pdf") then if _quarto.file.exists(image.src) then -- If the src is pointing to a local file that is an svg, process it local ext = select(2, pandoc.path.split_extension(image.src)) if ext == '.svg' then local convertedPath = convert_svg(image.src) if convertedPath then local contents = _quarto.file.read(convertedPath) local relativePath = pandoc.path.make_relative(convertedPath, '.') -- add to media bag and remove the converted file pandoc.mediabag.insert(relativePath, 'application/pdf', contents) _quarto.file.remove(relativePath) image.src = relativePath return image end end else -- See if the path points to an SVG in the media bag -- (been generated by a filter, for example) local mt, contents = pandoc.mediabag.lookup(image.src) if mt == 'image/svg+xml' then local result = pandoc.system.with_temporary_directory('svg-convert', function (tmpdir) -- write the media bag contents to a temp file local filename = image.src local tempPath = pandoc.path.join({tmpdir, filename}) local file = _quarto.file.write(tempPath, contents) if file then -- convert to svg local convertedPath = convert_svg(tempPath) if convertedPath then -- compute the correct relative path to the newly created file local mbPath = pandoc.path.make_relative(convertedPath, tmpdir, false) local mbContents = _quarto.file.read(convertedPath) -- place the new file in the mediabag, remove the old pandoc.mediabag.insert(mbPath, 'application/pdf', mbContents) pandoc.mediabag.delete(filename) -- update the path image.src = mbPath return image end end return nil end) return result elseif mt == nil then -- This file doesn't exist and isn't in the media bag -- see if it need to be fetched if resolvedUrls[image.src] then image.src = resolvedUrls[image.src] return image else local relativePath = image.src:match("https?://[%w%$%-%_%.%+%!%*%'%(%)%:%%]+/(.+)") if relativePath or param("has-resource-path", false) then local imgMt, imgContents = pandoc.mediabag.fetch(image.src) local decodedSrc = fullyUrlDecode(image.src) if decodedSrc == nil then decodedSrc = "unknown" end local function filenameFromMimeType(filename, imgMt) -- Use the mime type to compute an extension when possible -- This will allow pandoc to properly know the type, even when -- the path to the image is a difficult to parse URI local mimeExt = mimeImgExts[imgMt] if mimeExt then local stem, _ext = pandoc.path.split_extension(filename) return stem .. '.' .. mimeExt else return filename end end -- compute the filename for this file local basefilename = pandoc.path.filename(decodedSrc) local safefilename = windows_safe_filename(tex_safe_filename(basefilename)) local filename = filenameFromMimeType(safefilename, imgMt) if imgMt ~= nil then local existingMt = pandoc.mediabag.lookup(filename) local counter = 1 while (existingMt) do local stem, ext = pandoc.path.split_extension(filename) filename = stem .. counter .. ext existingMt = pandoc.mediabag.lookup(filename) counter = counter + 1 end resolvedUrls[image.src] = filename pandoc.mediabag.insert(filename, imgMt, imgContents) image.src = filename return image end end end end end end end } end -- cellcleanup.lua -- Copyright (C) 2020-2023 Posit Software, PBC function cell_cleanup() return { Div = function(div) if (#div.classes == 1 and div.classes[1] == "cell" and #div.content == 0) then return {} end end } end -- bibliography.lua -- Copyright (C) 2020-2022 Posit Software, PBC function bibliography() return { Div = function(el) local citeMethod = param('cite-method', 'citeproc') if _quarto.format.isLatexOutput() and el.attr.identifier == "refs" and citeMethod ~= 'citeproc' then return pandoc.RawBlock("latex", '%bib-loc-124C8010') end end } end -- code.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") local function toLines(s) if s:sub(-1)~="\n" then s=s.."\n" end return s:gmatch("(.-)\n") end function removeCodeOptions() return { CodeBlock = function(codeEl) local lang = codeEl.attr.classes[1] local commentChars = constants.kLangCommentChars[lang] if commentChars then local pattern = '^' .. patternEscape(commentChars[1]) .. "|%s*%S+%s*:.+" if #commentChars == 2 then pattern = pattern .. patternEscape(commentChars[2]) end pattern = pattern .. '$' local outLines = {} for line in toLines(codeEl.text) do local matches = string.find(line, pattern) if not matches then tappend(outLines, {line}) end end local outputText = "" for _, output in ipairs(outLines) do outputText = outputText .. output .. '\n' end codeEl.text = outputText return codeEl end end } end -- html.lua -- Copyright (C) 2023 Posit Software, PBC function render_html_fixups() if not _quarto.format.isHtmlOutput() then return {} end local function needs_forward_align(source) return attribute(source, kFigAlign, nil) or source.classes:find_if(function(c) return c:match("quarto%-figure.*") end) end local function forward_align(source, target) local align = attribute(source, kFigAlign, nil) if align ~= nil then target.classes:insert("quarto-figure") target.classes:insert("quarto-figure-" .. align) end for i, c in ipairs(source.classes) do if c:match("quarto%-figure.*") then target.classes:insert(c) end end end return { Table = function(tbl) -- this requires bootstrap CSS if quarto.doc.crossref.cap_location(tbl) == "top" then tbl.classes:insert("caption-top") return tbl end end, Figure = function(fig) if #fig.content ~= 1 then return nil end local img = quarto.utils.match("Figure/[1]/Plain/[1]/Image")(fig) or quarto.utils.match("Figure/[1]/Plain/[1]/Link/[1]/Image")(fig) if not img then return nil end if not needs_forward_align(img) then return nil end forward_align(img, fig) return fig end, Image = function(el) -- FIXME we're not validating here, but we can't use figAlignAttribute because -- it picks up the default value from the document metadata, which is not -- what we want here. local align = attribute(el, kFigAlign, nil) if align ~= nil then el.attributes[kFigAlign] = nil el.classes:insert("quarto-figure") el.classes:insert("quarto-figure-" .. align) end local alt_text = attribute(el, kFigAlt, nil) if alt_text ~= nil then el.attributes["alt"] = alt_text el.attributes[kFigAlt] = nil end return el end, Para = function(para) if #para.content ~= 1 then return nil end local img = quarto.utils.match("Para/[1]/Image")(para) or quarto.utils.match("Para/[1]/Link/[1]/Image")(para) if not img then return nil end if not needs_forward_align(img) then return nil end local el = pandoc.Div({ pandoc.RawBlock("html", "<figure>"), para, pandoc.RawBlock("html", "</figure>") }) forward_align(img, el) return el end, Div = function(div) -- this narrow fix prevents a 1.3 regression with knitr: -- https://github.com/quarto-dev/quarto-cli/issues/7516 -- -- if we have a cell-output-display with a para with an image, we want to -- wrap the paragraph in a <figure> rawblock so that our CSS works compatibly with the -- CSS we use for FloatRefTargets local lst = quarto.utils.match(".cell-output-display/:child/{Para}/:child/{Image}")(div) -- ideally, we'd do the lightbox processing after this fixup, but ordering matters, -- so we need to check for a potential output of the lightbox filter here if not lst or #lst == 0 then lst = quarto.utils.match(".cell-output-display/:child/{Para}/:child/Link/:child/{Image}")(div) end if not lst or #lst == 0 then return end local para = lst[1] local img = lst[2] -- we still need to find the correct index in the parent content for i, node in ipairs(div.content) do if node == para then local el = pandoc.Div({ pandoc.RawBlock("html", "<figure>"), para, pandoc.RawBlock("html", "</figure>") }) div.content[i] = el -- the image here might have been changed by the filter above already, -- but I don't trust this order to be consistent, so here we check -- for both attribute and class forward_align(img, el) return div end end end } end -- dashboard.lua -- Copyright (C) 2020-2022 Posit Software, PBC local dashboard = require 'modules/dashboard' local constants = { kSectionClass = "section", kHiddenClass = "hidden", kIgnoreWhenOrganizingClz = {"section", "hidden"}, } local dashboardState = { previousCardTarget = nil, pendingCardToolbar = nil, pendingCardSidebar = nil, cardToolbarTargets = { }, cardSidebarTargets = { }, protectedBlocks = pandoc.List({}) } local function setPendingCardToolbar(el) dashboardState.pendingCardToolbar = el end local function setPendingCardSidebar(el) dashboardState.pendingCardSidebar = el end local function popPendingCardToolbar() local pendingToolbar = dashboardState.pendingCardToolbar dashboardState.pendingCardToolbar = nil return pendingToolbar end local function popPendingSidebar() local pendingSidebar = dashboardState.pendingCardSidebar dashboardState.pendingCardSidebar = nil return pendingSidebar end function noteTargetForCardToolbar(toolbar, id) dashboard.card_toolbar.markProcessed(toolbar) dashboardState.cardToolbarTargets[id] = dashboardState.cardToolbarTargets[id] or pandoc.List() dashboardState.cardToolbarTargets[id]:insert(toolbar) end function popCardTargetsForId(id) local cardTargets = dashboardState.cardToolbarTargets[id] dashboardState.cardToolbarTargets[id] = nil return cardTargets end function noteTargetForCardSidebar(sidebar, id) dashboard.card_sidebar.markProcessed(sidebar) dashboardState.cardSidebarTargets[id] = dashboardState.cardSidebarTargets[id] or pandoc.List() dashboardState.cardSidebarTargets[id]:insert(sidebar) end function popCardSidebarTargetsForId(id) local cardTargets = dashboardState.cardSidebarTargets[id] dashboardState.cardSidebarTargets[id] = nil return cardTargets end function render_dashboard() -- only do this for dashboad output if not _quarto.format.isDashboardOutput() then return {} end -- Track the orientation that is used to perform heading based layout -- the basic idea is to alternate the orientation at new heading levels local lastLevel = 0 -- This happens in 2 passes: -- The first pass will resolve cards, valueboxes, etc... -- The second pass will layout the dashboard return { { traverse = 'topdown', Pandoc = function(el) -- Note that HTML dependencies can get mangled (I think) in pandoc -- in particular when things are gathered into sections. So.... -- This pops them out and just hangs onto them and then we inject them -- afterwards to ensure they remain in place at the bottom of the document -- -- __________________ -- \ \ -- \ \ -- \ \ -- \ \ -- /------------------------------------- -- //---------------// / | -- // // / __| -- // // / / || -- // // / / || -- //_______________// o o / / || ___/-\___ -- ------------------------------------/ ------- | |---------| -- | DO NOT PLAY | | HOUSEHOLD | | | | | | | -- | ON OR AROUND| |WASTE ONLY | | | | | | | -- |-------------- ------------| | | | | | | -- | | | | | | | | -- ------------------------------------------------- |_______| local preserveInlinePattern = "preserve[0-9a-fA-F]" local blocks = pandoc.Blocks({}) local collecting = false; for i, v in ipairs(el.blocks) do if v.t == "Para" then -- Any paragraphs that are composed only of HTML preserve strings -- and soft breaks will protected local isHtmlPreserve = true for j, w in ipairs(v.content) do if w.t ~= "Str" and w.t ~= "SoftBreak" then isHtmlPreserve = false break elseif w.t == "Str" and not w.text:match(preserveInlinePattern) then isHtmlPreserve = false break end end if isHtmlPreserve then dashboardState.protectedBlocks:insert(v) else blocks:insert(v) end elseif is_regular_node(v, "RawBlock") then -- Raw Blocks that are html_preserve comments or between -- those comments will be protected (contiguously) if v.text == "<!--html_preserve-->" then collecting = true; dashboardState.protectedBlocks:insert(v) elseif v.text == "<!--/html_preserve-->" then collecting = false; dashboardState.protectedBlocks:insert(v) elseif collecting then dashboardState.protectedBlocks:insert(v) else blocks:insert(v) end else -- stop collecting collecting = false blocks:insert(v) end end el.blocks = blocks return el end, PanelLayout = function(el) local options, userClasses = dashboard.card.readOptions(el) return dashboard.card.makeCard({ el }, userClasses, options), false end, Div = function(el) if el.attributes["output"] == "asis" then return nil elseif dashboard.card_sidebar.isCardSidebar(el) then -- Convert any card sidebars into their standard representation -- note that these will be process downstream to do things like -- convert them into a card, or merge them into other card header/footers -- per the user's request local options = dashboard.card_sidebar.readOptions(el) local cardSidebar = dashboard.card_sidebar.makeCardSidebar(el.content, options) local targetId = dashboard.card_sidebar.targetId(cardSidebar) if targetId ~= nil then noteTargetForCardSidebar(cardSidebar, targetId) return pandoc.Null(), false else return cardSidebar, false end elseif dashboard.card_toolbar.isCardToolbar(el) then -- Convert any card toolbars into their standard representation -- note that these will be process downstream to do things like -- convert them into a card, or merge them into other card header/footers -- per the user's request local options = dashboard.card_toolbar.readOptions(el) local cardToolbar = dashboard.card_toolbar.makeCardToolbar(el.content, options) local targetId = dashboard.card_toolbar.targetId(cardToolbar) if targetId ~= nil then noteTargetForCardToolbar(cardToolbar, targetId) return pandoc.Null(), false else return cardToolbar, false end elseif dashboard.card.isCard(el) then -- see if the card is already in the correct structure (a single header and body) -- exit early, not processing if it is already processed in this way if dashboard.card.isLiteralCard(el) then return nil end local contents = el.content local options, userClasses = dashboard.card.readOptions(el) return dashboard.card.makeCard(contents, userClasses, options), false elseif dashboard.valuebox.isValueBox(el) then return dashboard.valuebox.makeValueBox(el), false elseif el.classes:includes(dashboard.utils.constants.cell) and el.classes:includes("markdown") then -- See if this is explicitely a markdown cell (being preserved by a notebook) -- If so, provide some special handling which pops any markdown cell first header -- out and then treats the rest of the cell as a card -- First, if the user provided only a single element which is a card, just treat that -- as the user providing the card envelope (place the contents into a card whose -- options are determined by the card element that the user is providing) if #el.content == 1 and dashboard.card.isCard(el.content[1]) then local options, userClasses = dashboard.card.readOptions(el.content[1]) return dashboard.card.makeCard(el.content[1].content, userClasses, options) else -- Otherwise, look more closely at the markdown contents and figure out -- how to best handle local options, userClasses = dashboard.card.readOptions(el) if options[dashboard.card.optionKeys.fill] == nil then options[dashboard.card.optionKeys.fill] = false end local results = pandoc.List() local cardContent = el.content if #el.content > 0 and el.content[1].t == "Header" then results:insert(el.content[1]) cardContent = tslice(cardContent, 2) end local card = dashboard.card.makeCard(cardContent, userClasses, options) if card ~= nil then results:insert(card) end if #results > 0 then return pandoc.Blocks(results) end end elseif el.classes:includes(dashboard.utils.constants.cell) then -- Process a standard code cell. In particular, we should be -- looking to try to determine the visibility and processing behavior -- for the cell -- See if this cell has bslib output already local isHidden = false local isMarkdownOutput = false local bslibRawOutputs = pandoc.List() el = _quarto.ast.walk(el, { Div = function(childDiv) if childDiv.classes:includes(dashboard.utils.constants.cell_output_display) then -- Note whether we see any markdown cells if childDiv.classes:includes("cell-output-markdown") then isMarkdownOutput = true end if #childDiv.content == 1 and childDiv.content[1].t == "RawBlock" and childDiv.content[1].format == "html" then if childDiv.content[1].text:match('bslib-') ~= nil then -- capture any raw blocks that we see bslibRawOutputs:insert(childDiv.content[1]) -- Don't emit these within the cell outputs return pandoc.Null() end end end -- Note whether there are hidden elements in the cell isHidden = isHidden or childDiv.classes:includes(constants.kHiddenClass) end }) -- If the element is marked hidden or the element -- has bslib output (e.g. it is code that is outputing bslib components) -- give it special treatment if #bslibRawOutputs > 0 then -- If bslib outputs were detected, we need to elevate those rawblocks and -- just allow them to pass through the system unharmed along side -- the cell and any of its other output local result = pandoc.Blocks(bslibRawOutputs) if el ~= nil and #el.content > 0 then local options, userClasses = dashboard.card.readOptions(el) local card = dashboard.card.makeCard(el.content, userClasses, options) if card ~= nil then result:insert(card) end end return result elseif isHidden then if el ~= nil then el.classes:insert(constants.kHiddenClass) end return el else -- Look for markdown explictly being output local options, userClasses = dashboard.card.readOptions(el) -- if not explicitly set, mark markdown cells as flow if isMarkdownOutput and options[dashboard.card.optionKeys.fill] == nil then options[dashboard.card.optionKeys.fill] = false end -- Try to read the title from any programmatic output -- in case it is showing up that way local cardContent = el.content if #cardContent > 1 and is_regular_node(cardContent[1], "Div") and not is_custom_node(cardContent[1]) then if cardContent[1].classes:includes('cell-output-stdout') then -- See if the content is a CodeBlock local codeBlockEl = cardContent[1].content[1] if codeBlockEl.t == "CodeBlock" then local titlePrefix = "title=" local prefixLen = pandoc.text.len(titlePrefix) local strValue = codeBlockEl.text if pandoc.text.len(strValue) > prefixLen and strValue:match('^title=') then options['title'] = trim(pandoc.text.sub(codeBlockEl.text, prefixLen + 1)) cardContent = tslice(cardContent, 2) end end end end return dashboard.card.makeCard(cardContent, userClasses, options), false end end end, }, { traverse = 'topdown', Pandoc = function(el) -- Look for global fill setting local options = dashboard.layout.makeOptions(dashboard.document.scrolling) -- Make sections based upon the headings and use that for the -- document structure -- el is a 'Pandoc' object which has blocks which is Blocks, not a list, I can't explain this warning el.blocks = pandoc.structure.make_sections(el.blocks, {}) -- Now that the document has been re-organized, gather any -- loose elements that appear before the first section and cleave them -- out for use later -- Once we've visited a card or section, any subsequent content that appears loose is -- no longer considered above the fold local nonSectionEls = pandoc.List() local sectionEls = pandoc.List() local visitedSectionOrCard = false for _i, v in ipairs(el.blocks) do if v.classes ~= nil and (v.classes:includes(constants.kSectionClass) or dashboard.card.isCard(v)) then sectionEls:insert(v) visitedSectionOrCard = true else if visitedSectionOrCard then sectionEls:insert(v) else nonSectionEls:insert(v) end end end -- Sort out whether we're snagging loose content above -- sections (e.g. if there is a section) local layoutEls = nonSectionEls local finalEls = pandoc.List() if #sectionEls > 0 then layoutEls = sectionEls finalEls = nonSectionEls end -- ensure that root level elements are containers local organizer = dashboard.layoutContainer.organizer(layoutEls, pandoc.List(constants.kIgnoreWhenOrganizingClz)) local layoutContentEls = organizer.ensureInLayoutContainers() -- force the global orientation to columns if there is a sidebar present local inferredOrientation = dashboard.suggestOrientation(el) if inferredOrientation ~= nil then dashboard.layout.setOrientation(inferredOrientation) end -- Layout the proper elements with a specific orientation local cardsWithLayoutEl = dashboard.layout.orientContents(layoutContentEls, dashboard.layout.currentOrientation(), options) finalEls:insert(cardsWithLayoutEl) -- return the newly restructured document el.blocks = finalEls return el end, Div = function(el) if el.classes:includes(constants.kSectionClass) then -- Allow arbitrary nesting of sections / heading levels to perform layouts local header = el.content[1] if header.t == "Header" then local level = header.level local contents = tslice(el.content, 2) -- The first time we see a level, we should emit the rows and -- flip the orientation if dashboard.sidebar.isSidebar(header) then -- resolve headers inside the sidebar local resolved = dashboard.escapeLeafNodeContents(pandoc.Blocks(contents)) el.content = pandoc.List({}) if resolved ~= nil then el.content:extend(resolved) end return el, false elseif dashboard.toolbar.isToolbar(header) then local resolved = dashboard.escapeLeafNodeContents(pandoc.Blocks(contents)) el.content = pandoc.List({}) if resolved ~= nil then el.content:extend(resolved) end return el, false elseif level == 1 then -- A level 1 header marked as a sidebar is global, just let it -- flow through and the sidebar collector will ingest it and convert it into -- a sidebar (which contains the other pages as its content) if dashboard.sidebar.isSidebar(header) then local options = dashboard.sidebar.readOptions(header) return dashboard.sidebar.pageSidebarPlaceholder(contents, options) elseif dashboard.toolbar.isToolbar(header) then local options = dashboard.toolbar.readOptions(header) return dashboard.toolbar.pageToolbarPlaceholder(contents, options) else lastLevel = level -- Make sure everything is in a card local organizer = dashboard.layoutContainer.organizer(contents, pandoc.List(constants.kIgnoreWhenOrganizingClz)) local layoutContentEls = organizer.ensureInLayoutContainers() -- Convert this to a page local options = dashboard.page.readOptions(header) local page = dashboard.page.makePage(el.identifier, header, layoutContentEls, options) return page end else -- Make sure everything is in a card local organizer = dashboard.layoutContainer.organizer(contents, pandoc.List(constants.kIgnoreWhenOrganizingClz)) local layoutContentEls = organizer.ensureInLayoutContainers() -- see if this heading is marked as a tabset if dashboard.tabset.isTabset(header) then -- Process the component local options, userClasses = dashboard.tabset.readOptions(header) -- don't pass an explicit title - any title will come from the card options return dashboard.tabset.makeTabset(nil, contents, userClasses, options) else -- Process the layout -- TODO: extend to other component types for completeness if dashboard.card.hasCardDecoration(header) then -- sections may not have component decorations, throw error fatal("Headings may not be cards - please remove the `card` class from the offending heading: '" .. pandoc.utils.stringify(header) .. "'") end -- Compute the options local options = dashboard.layout.readOptions(header) local toOrientation = dashboard.layout.currentOrientation() if level ~= lastLevel then -- Note the new level lastLevel = level -- force the global orientation to columns if there is a sidebar present local inferredOrientation = dashboard.suggestOrientation(el) if inferredOrientation ~= nil then toOrientation = dashboard.layout.setOrientation(inferredOrientation) else toOrientation = dashboard.layout.rotatedOrientation() end end return dashboard.layout.orientContents(layoutContentEls, toOrientation, options) end end end end end, }, { traverse = 'topdown', Blocks = function(blocks) -- Track the last card and any pending card toolbars to be joined -- to cards local result = pandoc:Blocks() for _i, v in ipairs(blocks) do if is_regular_node(v, "Div") then if dashboard.card.isCard(v) then -- If there is a pending card toolbar, then insert it into -- this card (note that a pending card toolbar will only -- be present if the card is to be inserted into the below -- container) local pendingToolbar = popPendingCardToolbar() if pendingToolbar ~= nil then dashboard.card_toolbar.addToTarget(pendingToolbar, v, dashboard.card.addToHeader, dashboard.card.addToFooter) end -- if thers is a pending card sidebar, inject that local pendingSidebar = popPendingSidebar() if pendingSidebar ~= nil then dashboard.card_sidebar.addToTarget(pendingSidebar, v, dashboard.card.addSidebar) end -- inject any specifically target card toolbars or sidebars local possibleTargetIds = dashboard.utils.idsWithinEl(v) if possibleTargetIds ~= nil then for _j, targetId in ipairs(possibleTargetIds) do local toolbarsForTarget = popCardTargetsForId(targetId) if toolbarsForTarget ~= nil then for _j,toolbar in ipairs(toolbarsForTarget) do dashboard.card_toolbar.addToTarget(toolbar, v, dashboard.card.addToHeader, dashboard.card.addToFooter) end end local sidebarsForTarget = popCardSidebarTargetsForId(targetId) if sidebarsForTarget ~= nil then for _k,sidebar in ipairs(sidebarsForTarget) do dashboard.card_sidebar.addToTarget(sidebar, v, dashboard.card.addSidebar) end end end end result:insert(v) dashboardState.previousCardTarget = v elseif (dashboard.tabset.isTabset(v)) then -- If there is a pending card toolbar, then insert it into -- this tabset (note that a pending card toolbar will only -- be present if the card is to be inserted into the below -- container) local pendingToolbar = popPendingCardToolbar() if pendingToolbar ~= nil then dashboard.card_toolbar.addToTarget(pendingToolbar, v, dashboard.tabset.addToHeader, dashboard.tabset.addToFooter) end -- if thers is a pending card sidebar, inject that local pendingSidebar = popPendingSidebar() if pendingSidebar ~= nil then dashboard.card_sidebar.addToTarget(pendingSidebar, v, dashboard.tabset.addSidebar) end -- inject an specifically target card toolbars local possibleTargetIds = dashboard.utils.idsWithinEl(v) if possibleTargetIds ~= nil then for _j, targetId in ipairs(possibleTargetIds) do local toolbarsForTarget = popCardTargetsForId(targetId) if toolbarsForTarget ~= nil then for _j,toolbar in ipairs(toolbarsForTarget) do dashboard.card_toolbar.addToTarget(toolbar, v, dashboard.tabset.addToHeader, dashboard.tabset.addToFooter) end end local sidebarsForTarget = popCardSidebarTargetsForId(targetId) if sidebarsForTarget ~= nil then for _k,sidebar in ipairs(sidebarsForTarget) do dashboard.card_sidebar.addToTarget(sidebar, v, dashboard.tabset.addSidebar) end end end end result:insert(v) dashboardState.previousCardTarget = v elseif dashboard.card_toolbar.isCardToolbar(v) and dashboard.card_toolbar.isUnprocessed(v) then -- If this is an unprocessed card toolbar, mark it processed and handle it appropriately dashboard.card_toolbar.markProcessed(v) if dashboard.card_toolbar.targetPrevious(v) then -- This is for a the card/tabset that appears above if dashboardState.previousCardTarget == nil then fatal("A card toolbar specified to insert into previous card or tabset, but there was no previous card or tabset.") elseif dashboard.card.isCard(dashboardState.previousCardTarget) then dashboard.card_toolbar.addToTarget(v, dashboardState.previousCardTarget, dashboard.card.addToHeader, dashboard.card.addToFooter) elseif dashboard.tabset.isTabset(dashboardState.previousCardTarget) then dashboard.card_toolbar.addToTarget(v, dashboardState.previousCardTarget, dashboard.tabset.addToHeader, dashboard.tabset.addToFooter) else fatal("Unexpected element " .. dashboardState.previousCardTarget.t .. "appearing as the target for a card toolbar.") end elseif dashboard.card_toolbar.targetNext(v) then -- This card toolbar belongs in the next card, hang onto it -- don't inject it setPendingCardToolbar(v) else -- Free floating card toolbar, place it in a card local userClasses, cardOptions = dashboard.card.readOptions(v) cardOptions[dashboard.card.optionKeys.expandable] = false cardOptions[dashboard.card.optionKeys.fill] = false result:insert(dashboard.card.makeCard({v}, userClasses, cardOptions)) end elseif dashboard.card_sidebar.isCardSidebar(v) and dashboard.card_sidebar.isUnprocessed(v) then -- If this is an unprocessed card sidebar, mark it processed and handle it appropriately dashboard.card_sidebar.markProcessed(v) if dashboard.card_sidebar.targetPrevious(v) then -- This is for a the card/tabset that appears above if dashboardState.previousCardTarget == nil then fatal("A card sidebar specified to insert into previous card or tabset, but there was no previous card or tabset.") elseif dashboard.card.isCard(dashboardState.previousCardTarget) then dashboard.card_sidebar.addToTarget(v, dashboardState.previousCardTarget, dashboard.card.addSidebar) elseif dashboard.tabset.isTabset(dashboardState.previousCardTarget) then dashboard.card_sidebar.addToTarget(v, dashboardState.previousCardTarget, dashboard.tabset.addSidebar) else fatal("Unexpected element " .. dashboardState.previousCardTarget.t .. "appearing as the target for a card sidebar.") end elseif dashboard.card_sidebar.targetNext(v) then -- This card toolbar belongs in the next card, hang onto it -- don't inject it setPendingCardSidebar(v) else -- Free floating card sidebar, place it in a card local userClasses, cardOptions = dashboard.card.readOptions(v) cardOptions[dashboard.card.optionKeys.expandable] = false cardOptions[dashboard.card.optionKeys.fill] = false result:insert(dashboard.card.makeCard({v}, userClasses, cardOptions)) end else result:insert(v) end else result:insert(v) end end return result end, Div = function(el) if dashboard.layout.isRowOrColumnContainer(el) and #el.content == 0 then -- don't emit completely empty layout containers return pandoc.Null() elseif dashboard.layout.isColumnContainer(el) then local sidebar = nil local sidebarBottom = false local sidebarContent = pandoc.List({}) for i, v in ipairs(el.content) do if dashboard.sidebar.isSidebar(v) then sidebar = v else sidebarContent:insert(v) if i == 1 then sidebarBottom = true end end end if sidebar then local options = dashboard.sidebar.readOptions(sidebar) if sidebarBottom then dashboard.sidebar.setPositionEnd(options) end return dashboard.sidebar.makeSidebar(sidebar.content, sidebarContent, options) end elseif dashboard.layout.isRowContainer(el) then local toolbar = nil local toolbarContent = pandoc.List({}) local toolbarBottom = false for i, v in ipairs(el.content) do if dashboard.toolbar.isToolbar(v) then toolbar = v else toolbarContent:insert(v) if i == 1 then -- if we see content before the toolbar -- then it should be placed at the bottom toolbarBottom = true end end end if toolbar then local options = dashboard.toolbar.readOptions(sidebar) if toolbarBottom then dashboard.toolbar.setPositionEnd(options) end return dashboard.toolbar.makeToolbar(toolbar.content, toolbarContent, options) end end end, }, { Pandoc = function(doc) -- If there is still a pending card toolbar, that means that the user -- placed inputs at the end of the document with no cards or tabsets following local pendingToolbar = popPendingCardToolbar() if pendingToolbar ~= nil then fatal("The card toolbar was unable to placed within the next card or tabset as there was no next card or tabset.") end local pendingSidebar = popPendingSidebar() if pendingSidebar ~= nil then fatal("The card sidebar was unable to placed within the next card or tabset as there was no next card or tabset.") end -- If there are ids that haven't been resolved, that means that the user targeted ids with -- inputs and those ids were never found, so the card toolbar was never placed. local missingIds = pandoc.List() for k,v in pairs(dashboardState.cardToolbarTargets) do missingIds:insert(k) end for l, v in pairs(dashboardState.cardSidebarTargets) do missingIds:insert(l) end if #missingIds > 0 then fatal("A card toolbar or sidebar failed to be placed within a card or tabset using an id. The following id(s) could not be found in the document:\n" .. table.concat(missingIds, ", ")) end if #dashboardState.protectedBlocks > 0 then doc.blocks:extend(dashboardState.protectedBlocks) return doc end end } } end --[[ Extension for generating email components needed for Posit Connect 1. Extracts the subject line of the email from a div with the class `subject` 2. Takes a div from a Quarto HTML document that has the class `email`, places that in a specially-crafted HTML-email template 3. Takes all references to images (i.e, image tags) and replaces them with CID (Content-ID) tags. When embedding an image in an HTML email, rather than linking to the image file on a server, the image is encoded and included directly in the message. 4. Identifies all associated images (e.g., PNGs) in the email portion of the document (as some may exist outside of the email context/div and creates Base64 encoded strings; we must also include mime type information 5. Generates a JSON file (.output_metadata.json) which contains specific email message components that Posit Connect expects for its own email generation code 6. Produces a local `index.html` file that contains the HTML email for previewing purposes (this can be disabled by setting `email-preview: false` in the YAML header) --]] -- Get the file extension of any file residing on disk function get_file_extension(file_path) local pattern = "%.([^%.]+)$" local ext = file_path:match(pattern) return ext end -- Determine whether a Lua table is completely empty function is_empty_table(table) return next(table) == nil end -- Determine whether a file exists at a specific path function file_exists(path) local file = io.open(path, "r") if file then file:close() return true end return false end -- Trim surrounding whitespace for a string and truncate to a maximum length function str_trunc_trim(str, max_length) local str_trimmed = str:match("^%s*(.-)%s*$") local str_formatted = string.sub(str_trimmed, 1, max_length) return str_formatted end -- Determine whether a single string is truthy or falsy function str_truthy_falsy(str) local truthy_terms = {"true", "yes"} local falsy_terms = {"false", "no"} for _, term in ipairs(truthy_terms) do if string.match(str, term) then return true end end for _, term in ipairs(falsy_terms) do if string.match(str, term) then return false end end return false end local html_email_template_1 = [[ <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <!-- utf-8 works for most cases --> <meta name="viewport" content="width=device-width"> <!-- Forcing initial-scale shouldn't be necessary --> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <!-- Use the latest (edge) version of IE rendering engine --> <meta name="x-apple-disable-message-reformatting"> <!-- Disable auto-scale in iOS 10 Mail entirely --> <meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no"> <!-- Tell iOS not to automatically link certain text strings. --> <meta name="color-scheme" content="light"> <meta name="supported-color-schemes" content="light"> <!-- What it does: Makes background images in 72ppi Outlook render at correct size. --> <!--[if gte mso 9]> <xml> <o:OfficeDocumentSettings> <o:AllowPNG/> <o:PixelsPerInch>96</o:PixelsPerInch> </o:OfficeDocumentSettings> </xml> <![endif]--> <style> body { font-family: Helvetica, sans-serif; font-size: 14px; } .content { background-color: white; } .content .message-block { margin-bottom: 24px; } .header .message-block, .footer message-block { margin-bottom: 12px; } img { max-width: 100%; } @media only screen and (max-width: 767px) { .container { width: 100%; } .articles, .articles tr, .articles td { display: block; width: 100%; } .article { margin-bottom: 24px; } } </style> </head> <body style="background-color:#f6f6f6;font-family:Helvetica, sans-serif;color:#222;margin:0;padding:0;"> <table width="85%" align="center" class="container" style="max-width:1000px;"> <tr> <td style="padding:24px;"> <div class="header" style="font-family:Helvetica, sans-serif;color:#999999;font-size:12px;font-weight:normal;margin:0 0 24px 0;text-align:center;"> </div> <table width="100%" class="content" style="background-color:white;"> <tr> ]] local html_email_template_2 = [[ </tr> </table> ]] local html_email_template_3 = [[ <div class="footer" style="font-family:Helvetica, sans-serif;color:#999999;font-size:12px;font-weight:normal;margin:24px 0 0 0;"> ]] local html_email_template_4 = [[ <p>If HTML documents are attached, they may not render correctly when viewed in some email clients. For a better experience, download HTML documents to disk before opening in a web browser.</p> </div> </td> </tr> </table> </body> </html> ]] -- Function to generate an HTML email message body using HTML email -- template fragments, the rendered and extracted email component from -- the document (`email_html`), and data specific to Connect custom emails function generate_html_email_from_template( email_html, connect_date_time, connect_report_rendering_url, connect_report_url, connect_report_subscription_url ) -- Use the Connect email template components along with the `email_html` -- fragment to generate the email message body as HTML if connect_report_rendering_url == nil or connect_report_url == nil or connect_report_subscription_url == nil then html_str = html_email_template_1 .. "<td style=\"padding:12px;\">" .. email_html .. "</td>" .. html_email_template_2 .. html_email_template_3 .. "<p>This message was generated on " .. connect_date_time .. ".</p>\n\n" .. html_email_template_4 else html_str = html_email_template_1 .. "<td style=\"padding:12px;\">" .. email_html .. "</td>" .. html_email_template_2 .. html_email_template_3 .. "<p>This message was generated on " .. connect_date_time .. ".</p>\n\n" .. "<p>This Version: <a href=\"" .. connect_report_rendering_url .. "\">" .. connect_report_rendering_url .. "</a></p>\n\n" .. "Latest Version: <a href=\"" .. connect_report_url .. "\">" .. connect_report_url .. "</a></p>\n\n" .. "<p>If you wish to stop receiving emails for this document, you may <a href=\"" .. connect_report_subscription_url .. "\">unsubscribe here</a>.</p>\n\n" .. html_email_template_4 end return html_str end local subject = "" local email_images = {} local image_tbl = {} local suppress_scheduled_email = false local found_email_div = false function process_meta(meta) if not found_email_div then return end attachments = {} local meta_email_attachments = meta["email-attachments"] meta_email_preview = meta["email-preview"] if meta_email_attachments ~= nil then for _, v in pairs(meta_email_attachments) do if (file_exists(pandoc.utils.stringify(v))) then table.insert(attachments, pandoc.utils.stringify(v)) end end end end -- Function to check whether a div with the 'email' class is present in the document function find_email_div(div) if div.classes:includes("email") then found_email_div = true end end function process_div(div) if not found_email_div then return nil end if div.classes:includes("subject") then subject = pandoc.utils.stringify(div) return {} elseif div.classes:includes("email-text") then email_text = pandoc.write(pandoc.Pandoc({ div }), "plain") return {} elseif div.classes:includes("email-scheduled") then local email_scheduled_str = str_trunc_trim(string.lower(pandoc.utils.stringify(div)), 10) local scheduled_email = str_truthy_falsy(email_scheduled_str) suppress_scheduled_email = not scheduled_email return {} elseif div.classes:includes("email") then --[[ Render of HTML email message body for Connect: `email_html` For each of the <img> tags found we need to modify the tag so that it contains a reference to the Base64 string; this reference is that of the Content-ID (or CID) tag; the basic form is `<img src="cid:image-id"/>` (the `image-id` will be written as `img<n>.<file_extension>`, incrementing <n> from 1) ]] local count = 1 local render_div_cid = quarto._quarto.ast.walk(div, {Image = function(img_el) local file_extension = get_file_extension(img_el.src) local cid = "img" .. tostring(count) .. "." .. file_extension image_tbl[cid] = img_el.src img_el.src = "cid:" .. cid count = count + 1 return img_el end}) email_html = extract_email_div_str(render_div_cid) --[[ Render of HTML email message body for Connect: `email_html_preview` We are keeping a render of the email HTML for previewing purposes (if the option is taken to do so); here, the HTML is self-contained where the image tags contain base64-encoded data ]] local render_div_base64 = quarto._quarto.ast.walk(div, {Image = function(img_el) local image_file = io.open(img_el.src, "rb") if type(image_file) == "userdata" then image_data = image_file:read("*all") image_file:close() end local encoded_data = quarto.base64.encode(image_data) local file_extension = get_file_extension(img_el.src) local base64_str = "data:image/" .. file_extension .. ";base64," .. encoded_data img_el.src = base64_str return img_el end}) email_html_preview = extract_email_div_str(render_div_base64) -- Remove the the `.email` div so it doesn't appear in the main report document return {} end end -- Function to extract the rendered HTML from a Div of class 'email' function extract_email_div_str(doc) return pandoc.write(pandoc.Pandoc( {doc} ), "html") end function process_document(doc) if not found_email_div then return doc end -- Get the current date and time local connect_date_time = os.date("%Y-%m-%d %H:%M:%S") -- Use Connect environment variables to get URLs for the email footer section -- If any of these are nil, a portion of the email footer won't be rendered local connect_report_rendering_url = os.getenv("RSC_REPORT_RENDERING_URL") local connect_report_url = os.getenv("RSC_REPORT_URL") local connect_report_subscription_url = os.getenv("RSC_REPORT_SUBSCRIPTION_URL") -- The following regexes remove the surrounding <div> from the HTML text email_html = string.gsub(email_html, "^<div class=\"email\">", '') email_html = string.gsub(email_html, "</div>$", '') -- Use the Connect email template components along with the `email_html` and -- `email_html_preview` objects to generate the email message body for Connect -- and the email HTML file (as a local preview) html_email_body = generate_html_email_from_template( email_html, connect_date_time, connect_report_rendering_url, connect_report_url, connect_report_subscription_url ) html_preview_body = generate_html_email_from_template( email_html_preview, connect_date_time, connect_report_rendering_url, connect_report_url, connect_report_subscription_url ) -- Right after the </head> tag in `html_preview_body` we need to insert a subject line HTML string; -- this is the string to be inserted: subject_html_preview = "<div style=\"text-align: center; background-color: #fcfcfc; padding-top: 12px; font-size: large;\"><span style=\"margin-left: 25px\"><strong><span style=\"font-variant: small-caps;\">subject: </span></strong>" .. subject .. "</span><hr /></div>" -- insert `subject_html_preview` into `html_preview_body` at the aforementioned location html_preview_body = string.gsub(html_preview_body, "</head>", "</head>\n" .. subject_html_preview) -- For each of the <img> tags we need to create a Base64-encoded representation -- of the image and place that into the table `email_images` (keyed by `cid`) local image_data = nil for cid, img in pairs(image_tbl) do local image_file = io.open(img, "rb") if type(image_file) == "userdata" then image_data = image_file:read("*all") image_file:close() end local encoded_data = quarto.base64.encode(image_data) -- Insert `encoded_data` into `email_images` table with prepared key email_images[cid] = encoded_data end -- Encode all of the strings and tables of strings into the JSON file -- (`.output_metadata.json`) that's needed for Connect's email feature if (is_empty_table(email_images)) then metadata_str = quarto.json.encode({ rsc_email_subject = subject, rsc_email_attachments = attachments, rsc_email_body_html = html_email_body, rsc_email_body_text = email_text, rsc_email_suppress_report_attachment = true, rsc_email_suppress_scheduled = suppress_scheduled_email }) else metadata_str = quarto.json.encode({ rsc_email_subject = subject, rsc_email_attachments = attachments, rsc_email_body_html = html_email_body, rsc_email_body_text = email_text, rsc_email_images = email_images, rsc_email_suppress_report_attachment = true, rsc_email_suppress_scheduled = suppress_scheduled_email }) end -- Determine the location of the Quarto project directory; if not defined -- by the user then set to the location of the input file local project_output_directory = quarto.project.output_directory if (project_output_directory ~= nil) then dir = project_output_directory else local file = quarto.doc.input_file dir = pandoc.path.directory(file) end -- For all file attachments declared by the user, ensure they copied over -- to the project directory (`dir`) for _, v in pairs(attachments) do local source_attachment_file = pandoc.utils.stringify(v) local dest_attachment_path_file = pandoc.path.join({dir, pandoc.utils.stringify(v)}) -- Only if the file exists should it be copied into the project directory if (file_exists(source_attachment_file)) then local attachment_text = io.open(source_attachment_file):read("*a") io.open(dest_attachment_path_file, "w"):write(attachment_text):close() end end -- Write the `.output_metadata.json` file to the project directory local metadata_path_file = pandoc.path.join({dir, ".output_metadata.json"}) io.open(metadata_path_file, "w"):write(metadata_str):close() -- Write the `email-preview/index.html` file unless meta_email_preview is false if meta_email_preview ~= false then quarto._quarto.file.write(pandoc.path.join({dir, "email-preview/index.html"}), html_preview_body) end end function render_email() if not _quarto.format.isEmailOutput() then return {} end return { { Div = find_email_div, }, { Pandoc = process_document, Meta = process_meta, Div = process_div, } } end -- dependencies.lua -- Copyright (C) 2020-2022 Posit Software, PBC function dependencies() return { Meta = function(meta) -- Process the final dependencies into metadata -- and the file responses _quarto.processDependencies(meta) return meta end } end -- book-cleanup.lua -- Copyright (C) 2020-2022 Posit Software, PBC function bookCleanup() if (param("single-file-book", false)) then return { RawInline = cleanupFileMetadata, RawBlock = cleanupFileMetadata, Div = cleanupBookPart, Para = cleanupEmptyParas } else return { RawInline = cleanupFileMetadata, RawBlock = cleanupFileMetadata, Para = cleanupEmptyParas } end end function cleanupEmptyParas(el) if not next(el.content) then return {} end end function cleanupFileMetadata(el) if _quarto.format.isRawHtml(el) then local rawMetadata = string.match(el.text, "^<!%-%- quarto%-file%-metadata: ([^ ]+) %-%->$") if rawMetadata then return {} end end return el end function cleanupBookPart(el) if el.attr.classes:includes('quarto-book-part') and not _quarto.format.isLatexOutput() then return pandoc.Div({}) end end -- quarto-finalize.lua -- Copyright (C) 2022 Posit Software, PBC function mediabag() return { -- mediabag entries need to be re-routed to the filesystem -- if this isn't an office doc (as those formats automatically -- scoop up mediabag files) Image = function(el) if not _quarto.format.isWordProcessorOutput() and not _quarto.format.isPowerPointOutput() then local mt, contents = pandoc.mediabag.lookup(el.src) if contents ~= nil then local mediabagDir = param("mediabag-dir", nil) local mediaFile = pandoc.path.join{mediabagDir, el.src} local file = _quarto.file.write(mediaFile, contents) if not file then warn('failed to write mediabag entry: ' .. mediaFile) end el.src = mediaFile return el end end end } end -- meta-cleanup.lua -- Copyright (C) 2022 Posit Software, PBC function metaCleanup() return { Meta = function(meta) if _quarto.format.isAstOutput() then removeAllEmptyIncludes(meta) return meta end end } end -- coalesceraw.lua -- coalesces sequences of rawblock and rawinline nodes. -- -- Copyright (C) 2023 Posit Software, PBC -- -- Raw blocks are selectively coalesced if they're written -- to: -- - the same format -- - with a suffix of -merge -- -- This specifically matters in the case of some latex rawblocks which -- cannot be separated by a newline (like minipages in a figure) -- -- note that in LaTeX output, we need to strip Div nodes, since they -- can "delimit" two raw blocks and prevent them from being coalesced. function coalesce_raw() local filters = {} if quarto.doc.isFormat("latex") then -- flatten out divs before merging raw blocks table.insert(filters, { Div = function(div) -- only flatten out divs that have no classes or attributes -- (see https://github.com/quarto-dev/quarto-cli/issues/6936) -- or empty identifier (see https://github.com/quarto-dev/quarto-cli/issues/6867) if #div.classes == 0 and #div.attributes == 0 and div.identifier == "" then return div.content end end }) end table.insert(filters, { Inlines = function(inlines) local list_of_lists = collate(inlines, function(block, prev_block) return block.t == "RawInline" and prev_block.t == "RawInline" and prev_block.format == block.format end) local result = pandoc.Inlines({}) for _, lst in ipairs(list_of_lists) do local first_el = lst[1] if first_el.t == "RawInline" then local text = table.concat(lst:map(function(block) return block.text end), "") local new_block = pandoc.RawInline(first_el.format, text) result:insert(new_block) else result:insert(first_el) end end return result end, Blocks = function(blocks) local list_of_lists = collate(blocks, function(block, prev_block) return block.t == "RawBlock" and block.format:match(".*-merge$") and prev_block.t == "RawBlock" and prev_block.format == block.format end) local result = pandoc.Blocks({}) for _, lst in ipairs(list_of_lists) do local first_el = lst[1] if first_el.t == "RawBlock" and first_el.format:match(".*-merge") then local text = table.concat(lst:map(function(block) return block.text end), "%\n") local new_block = pandoc.RawBlock(first_el.format:gsub("-merge$", ""), text) result:insert(new_block) else result:insert(first_el) end end return result end }) return filters end -- descaffold.lua -- Copyright (C) 2023 Posit Software, PBC function descaffold() return { Span = function(el) if el.classes:includes("quarto-scaffold") then return el.content end end, Div = function(el) if el.classes:includes("quarto-scaffold") then return el.content end end } end function make_scaffold(ctor, node) return ctor(node or {}, pandoc.Attr("", {"quarto-scaffold", "hidden"}, {})) end -- typst.lua -- Copyright (C) 2023 Posit Software, PBC function setup_typst_state() if not _quarto.format.isTypstOutput() then return {} end return { Pandoc = function(doc) if doc.meta.crossref ~= nil then local crossref_meta = {} for k, v in pairs(doc.meta.crossref) do v = pandoc.utils.stringify(v) local ref = refType(k) local category = crossref.categories.by_ref_type[ref] if ref ~= nil and category ~= nil then local ref_key = pandoc.utils.stringify(k:sub(ref:len() + 2)) category[ref_key] = v else crossref_meta[k] = v end end local json = quarto.json.encode(crossref_meta) -- FIXME finish this end return doc end } end -- flags.lua -- Copyright (C) 2020-2023 Posit Software, PBC -- computes performance flags in one pass -- so that we can skip as many filters as possible -- when we don't need them local patterns = require("modules/patterns") local constants = require("modules/constants") local lightbox_module = require("modules/lightbox") flags = {} function compute_flags() local table_pattern = patterns.html_table local table_tag_pattern = patterns.html_table_tag_name local gt_table_pattern = patterns.html_gt_table local html_table_caption_pattern = patterns.html_table_caption local latex_caption_pattern = "(\\caption{)(.*)" .. refLabelPattern("tbl") .. "([^}]*})" return { Meta = function(el) local lightbox_auto = lightbox_module.automatic(el) if lightbox_auto then flags.has_lightbox = true elseif lightbox_auto == false then flags.has_lightbox = false end end, Header = function(el) crossref.maxHeading = math.min(crossref.maxHeading, el.level) end, Table = function(node) flags.has_tables = true end, Cite = function(cite) flags.has_cites = true end, RawBlock = function(el) if el.format == "html" then local i, j = string.find(el.text, table_pattern) if i ~= nil then flags.has_raw_html_tables = true end i, j = string.find(el.text, table_tag_pattern) if i ~= nil then flags.has_partial_raw_html_tables = true end i, j = string.find(el.text, gt_table_pattern) if i ~= nil then flags.has_gt_tables = true end end if _quarto.format.isRawLatex(el) then if (el.text:match(_quarto.patterns.latexLongtablePattern) and not el.text:match(_quarto.patterns.latexCaptionPattern)) then flags.has_longtable_no_caption_fixup = true end end if el.text:find("%{%{%<") then flags.has_shortcodes = true end end, Div = function(node) local type = refType(node.attr.identifier) if theorem_types[type] ~= nil or proof_type(node) ~= nil then flags.has_theorem_refs = true end local has_lightbox = lightbox_module.el_has_lightbox(node) if has_lightbox then flags.has_lightbox = true end if node.attr.classes:find("hidden") then flags.has_hidden = true end if node.attr.classes:find("cell") then -- cellcleanup.lua flags.has_output_cells = true -- FIXME: are we actually triggering this with FloatRefTargets? -- table captions local tblCap = extractTblCapAttrib(node,kTblCap) if hasTableRef(node) or tblCap then flags.has_table_captions = true end -- outputs.lua if not param("output-divs", true) then if not (_quarto.format.isPowerPointOutput() and hasLayoutAttributes(node)) then flags.needs_output_unrolling = true end end end end, CodeBlock = function(node) if node.attr.classes:find("hidden") then flags.has_hidden = true end if node.attr.classes:find("content-hidden") or node.attr.classes:find("content-visible") then flags.has_conditional_content = true end if node.text:match('%s*<([0-9]+)>%s*') then flags.has_code_annotations = true end if node.text:find("%{%{%<") then flags.has_shortcodes = true end end, Code = function(node) if node.text:find("%{%{%<") then flags.has_shortcodes = true end end, RawInline = function(el) if el.text:find("%{%{%<") then flags.has_shortcodes = true end end, Image = function(node) if node.src:find("%{%{%<") then flags.has_shortcodes = true end local has_lightbox = lightbox_module.el_has_lightbox(node) if has_lightbox then flags.has_lightbox = true end end, Shortcode = function(node) flags.has_shortcodes = true end, Link = function(node) if node.target:find("%{%{%<") then flags.has_shortcodes = true end end, Span = function(node) if node.attr.classes:find("content-hidden") or node.attr.classes:find("content-visible") then flags.has_conditional_content = true end end, Figure = function(node) flags.has_pandoc3_figure = true end } end -- normalize.lua -- Copyright (C) 2020-2023 Posit Software, PBC -- required version PANDOC_VERSION:must_be_at_least '2.13' -- global state authorsState = {} -- [import] function import(script) local path = PANDOC_SCRIPT_FILE:match("(.*[/\\])") dofile(path .. script) end -- [/import] -- imported elements local authors = require 'modules/authors' local license = require 'modules/license' local shortcode_ast = require 'modules/astshortcode' local function stripNotes(el) local result = _quarto.ast.walk(el, { Note = function(_el) return pandoc.Null() end }) return result end function normalize_filter() return { Meta = function(meta) -- normalizes the author/affiliation metadata local normalized = authors.processAuthorMeta(meta) or meta -- normalizes the citation metadata normalized = processCitationMeta(normalized) -- normalizes the license metadata normalized = license.processLicenseMeta(normalized) -- for JATs, forward keywords or categories to tags if _quarto.format.isJatsOutput() then if normalized.tags == nil then if normalized.keywords ~= nil then normalized.tags = normalized.keywords elseif meta.categories ~= nil then normalized.tags = normalized.categories end end end -- parses the shortcodes that might be in the metadata -- since they're not visible in the text that is available -- to qmd-reader.lua normalized = shortcode_ast.parse(normalized) return normalized end, Div = function(div) -- Don't allow footnotes in the hidden element (markdown pipeline) -- since that will result in duplicate footnotes -- in the rendered output if div.classes:includes('hidden') then return stripNotes(div) end end, Span = function(span) -- Don't allow footnotes in the hidden element (markdown pipeline) -- since that will result in duplicate footnotes -- in the rendered output if span.classes:includes('hidden') then return stripNotes(span) end end } end -- parsehtml.lua -- Copyright (C) 2020-2023 Posit Software, PBC local patterns = require("modules/patterns") local constants = require("modules/constants") local function preprocess_table_text(src) -- html manipulation with regex is fraught, but those specific -- changes are safe assuming that no one is using quarto- as -- a prefix for dataset attributes in the tables. -- See -- * https://www.w3.org/html/wg/spec/syntax.html#start-tags -- * https://www.w3.org/html/wg/spec/syntax.html#end-tags src = src:gsub("<th([%s>])", "<td data-quarto-table-cell-role=\"th\"%1") src = src:gsub("</th([%s>])", "</td%1") src = src:gsub("<table([%s>])", "<table data-quarto-postprocess=\"true\"%1") return src end function parse_html_tables() local filter if param(constants.kHtmlTableProcessing) == "none" then return {} end filter = { traverse = "topdown", Div = function(div) if div.attributes[constants.kHtmlTableProcessing] then -- catch and remove attributes local htmlTableProcessing = div.attributes[constants.kHtmlTableProcessing] div.attributes[constants.kHtmlTableProcessing] = nil if htmlTableProcessing == "none" then if div.attr == pandoc.Attr() then -- if no other attributes are set on the div, don't keep it return div.content, false else -- when set on a div like div.cell-output-display, we need to keep it return div, false end end end end, RawBlock = function(el) if _quarto.format.isRawHtml(el) then -- if we have a raw html table in a format that doesn't handle raw_html -- then have pandoc parse the table into a proper AST table block local pat = patterns.html_table local i, j = string.find(el.text, pat) if i == nil then return nil end -- we're already at a state of sin here, cf https://stackoverflow.com/a/1732454 -- but this is important enough to do a little more work anyway -- -- specifically, we should do our best not to break good HTML when it's there local tableBegin,tableBody,tableEnd = el.text:match(pat) if tableBegin then local before_table = string.sub(el.text, 1, i - 1) local after_table = string.sub(el.text, j + 1) local tableHtml = tableBegin .. "\n" .. tableBody .. "\n" .. tableEnd -- Pandoc's HTML-table -> AST-table processing does not faithfully respect -- `th` vs `td` elements. This causes some complex tables to be parsed incorrectly, -- and changes which elements are `th` and which are `td`. -- -- For quarto, this change is not acceptable because `td` and `th` have -- accessibility impacts (see https://github.com/rstudio/gt/issues/678 for a concrete -- request from a screen-reader user). -- -- To preserve td and th, we replace `th` elements in the input with -- `td data-quarto-table-cell-role="th"`. -- -- Then, in our HTML postprocessor, -- we replace th elements with td (since pandoc chooses to set some of its table -- elements as th, even if the original table requested not to), and replace those -- annotated td elements with th elements. tableHtml = preprocess_table_text(tableHtml) -- process html with raw_html so that contents that are not parseable -- by Pandoc end up as rawblock elements local tableDoc = pandoc.read(tableHtml, "html+raw_html") local skip = false local found = false _quarto.ast.walk(tableDoc, { Table = function(table) found = true if table.attributes[constants.kDisableProcessing] == "true" then skip = true end end, }) if not found then warn("Unable to parse table from raw html block: skipping.") return nil end if skip then return nil end flags.has_tables = true local blocks = pandoc.Blocks({}) if before_table ~= "" then -- this clause is presently redundant, but if we ever -- parse more than one type of element, then we'll -- need it. We keep it here for symmetry with -- the after_table clause. local block = pandoc.RawBlock(el.format, before_table) local result = _quarto.ast.walk(block, filter) if type(result) == "table" then blocks:extend(result) else blocks:insert(result) end end blocks:extend(tableDoc.blocks) if after_table ~= "" then local block = pandoc.RawBlock(el.format, after_table) local result = _quarto.ast.walk(block, filter) if type(result) == "table" then blocks:extend(result) else blocks:insert(result) end end return make_scaffold(pandoc.Div, blocks) end end return el end } return filter end -- extractquartodom.lua -- Copyright (C) 2023 Posit Software, PBC local function process_quarto_markdown_input_element(el) if el.attributes.qmd == nil and el.attributes["qmd-base64"] == nil then error("process_quarto_markdown_input_element called with element that does not have qmd or qmd-base64 attribute") return el end local text = el.attributes.qmd or quarto.base64.decode(el.attributes["qmd-base64"]) return string_to_quarto_ast_blocks(text) end function parse_md_in_html_rawblocks() return { Div = function(div) if div.attributes.qmd ~= nil or div.attributes["qmd-base64"] ~= nil then return make_scaffold(pandoc.Div, process_quarto_markdown_input_element(div)) end end, Span = function(span) if span.attributes.qmd ~= nil or span.attributes["qmd-base64"] ~= nil then local inlines = quarto.utils.as_inlines(process_quarto_markdown_input_element(span)) if #inlines < 1 then return make_scaffold(pandoc.Span, {}) end return make_scaffold(pandoc.Span, inlines) end end } end extracted_qmd_uuid = "3ab579b5-63b4-445d-bc1d-85bf6c4c04de" local count = 0 function extract_latex_quartomarkdown_commands() if not _quarto.format.isLatexOutput() then return {} end if doc == nil then return { RawBlock = function(el) if not _quarto.format.isRawLatex(el) then return nil end local text = el.text local pattern = "(.*)(\\QuartoMarkdownBase64{)([^}]*)(})(.*)" local pre, _, content, _, post = text:match(pattern) if pre == nil then return nil end while pre do count = count + 1 local uuid = extracted_qmd_uuid .. "-" .. tostring(count) _quarto.ast.vault.add(uuid, string_to_quarto_ast_blocks(quarto.base64.decode(content))) text = pre .. uuid .. post pre, _, content, _, post = text:match(pattern) end return pandoc.RawBlock(el.format, text) end } end end function inject_vault_content_into_rawlatex() return { RawBlock = function(el) if not _quarto.format.isRawLatex(el) then return nil end local vault = _quarto.ast.vault.locate() if vault == nil then -- luacov: disable internal_error() return nil -- luacov: enable end local pattern = "(.*)(3ab579b5%-63b4%-445d%-bc1d%-85bf6c4c04de%-[0-9]+)(.*)" local text = el.text local pre, content_id, post = text:match(pattern) while pre do local found = false vault.content = _quarto.ast.walk(vault.content, { Div = function(div) if div.identifier ~= content_id then return end _quarto.ast.vault.remove(content_id) local rendered = pandoc.write(pandoc.Pandoc(div.content), "latex") text = pre .. rendered .. post pre, content_id, post = text:match(pattern) found = true return {} end }) if not found then -- luacov: disable internal_error() return nil -- luacov: enable end end return pandoc.RawBlock(el.format, text) end, } end -- astpipeline.lua -- Copyright (C) 2023 Posit Software, PBC function quarto_ast_pipeline() return { { name = "normalize-table-merge-raw-html", filter = table_merge_raw_html() }, -- this filter can't be combined with others because it's top-down processing. -- unfortunate. { name = "normalize-html-table-processing", filter = parse_html_tables() }, { name = "normalize-combined-1", filter = combineFilters({ extract_latex_quartomarkdown_commands(), forward_cell_subcaps(), parse_extended_nodes(), code_filename(), normalize_fixup_data_uri_image_extension(), }) }, { name = "normalize-combine-2", filter = combineFilters({ parse_md_in_html_rawblocks(), parse_floatreftargets(), parse_blockreftargets() }), }, { name = "normalize-3", filter = handle_subfloatreftargets(), } } end -- extractquartodom.lua -- Copyright (C) 2023 Posit Software, PBC local readqmd = require("readqmd") function normalize_capture_reader_state() return { Meta = function(meta) quarto_global_state.reader_options = readqmd.meta_to_options(meta.quarto_pandoc_reader_opts) meta.quarto_pandoc_reader_opts = nil return meta end } end -- fixupdatauri.lua -- Copyright (C) 2023 Posit Software, PBC -- https://github.com/quarto-dev/quarto-cli/issues/6568 function normalize_fixup_data_uri_image_extension() return { Image = function(img) local src = img.src if src:sub(1, 5) == "data:" then local l = PANDOC_READER_OPTIONS.default_image_extension:len() if src:sub(-l-1) == ("." .. PANDOC_READER_OPTIONS.default_image_extension) then img.src = src:sub(1, -l - 2) return img end end end } end -- meta.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- inject metadata function layout_meta_inject_latex_packages() return { Meta = function(meta) -- inject caption, subfig, tikz metaInjectLatex(meta, function(inject) inject( usePackage("caption") .. "\n" .. usePackage("subcaption") ) if layoutState.usingTikz then inject(usePackage("tikz")) end end) -- This indicates whether the text highlighting theme has a 'light/dark' variant -- if it doesn't adapt, we actually will allow the text highlighting theme to control -- the appearance of the code block (e.g. so solarized will get a consistent yellow bg) local adaptiveTextHighlighting = param('adaptive-text-highlighting', false) -- If the user specifies 'code-block-border-left: false' -- then we should't give the code blocks this treatment local kCodeBlockBorderLeft = 'code-block-border-left' local kCodeBlockBackground = 'code-block-bg' -- Track whether to show a border or background -- Both options could be undefined, true / false or set to a color value local useCodeBlockBorder = (adaptiveTextHighlighting and meta[kCodeBlockBorderLeft] == nil and meta[kCodeBlockBackground] == nil) or (meta[kCodeBlockBorderLeft] ~= nil and meta[kCodeBlockBorderLeft] ~= false) local useCodeBlockBg = meta[kCodeBlockBackground] ~= nil and meta[kCodeBlockBackground] ~= false -- if we're going to display a border or background -- we need to inject color handling as well as the -- box definition for code blocks if (useCodeBlockBorder or useCodeBlockBg) then metaInjectLatex(meta, function(inject) inject( usePackageWithOption("tcolorbox", "skins,breakable") ) end) -- figure out the shadecolor local shadeColor = nil local bgColor = nil if useCodeBlockBorder and meta[kCodeBlockBorderLeft] and type(meta[kCodeBlockBorderLeft]) ~= "boolean" then shadeColor = latexXColor(meta[kCodeBlockBorderLeft]) end if useCodeBlockBg and meta[kCodeBlockBackground] and type(meta[kCodeBlockBackground]) ~= "boolean" then bgColor = latexXColor(meta[kCodeBlockBackground]) end -- ensure shadecolor is defined metaInjectLatex(meta, function(inject) if (shadeColor ~= nil) then inject( "\\@ifundefined{shadecolor}{\\definecolor{shadecolor}" .. shadeColor .. "}{}" ) else inject( "\\@ifundefined{shadecolor}{\\definecolor{shadecolor}{rgb}{.97, .97, .97}}{}" ) end end) metaInjectLatex(meta, function(inject) if (bgColor ~= nil) then inject( "\\@ifundefined{codebgcolor}{\\definecolor{codebgcolor}" .. bgColor .. "}{}" ) end end) -- set color options for code blocks ('Shaded') -- core options local options = { boxrule = '0pt', ['frame hidden'] = "", ['sharp corners'] = "", ['breakable'] = "", enhanced = "", } if bgColor then options.colback = "{codebgcolor}" else options['interior hidden'] = "" end if useCodeBlockBorder then options['borderline west'] = '{3pt}{0pt}{shadecolor}' end -- redefined the 'Shaded' environment that pandoc uses for fenced -- code blocks metaInjectLatex(meta, function(inject) inject("\\ifdefined\\Shaded\\renewenvironment{Shaded}{\\begin{tcolorbox}[" .. tColorOptions(options) .. "]}{\\end{tcolorbox}}\\fi") end) end -- enable column layout (packages and adjust geometry) if (layoutState.hasColumns or marginReferences() or marginCitations()) and _quarto.format.isLatexOutput() then -- inject sidenotes package metaInjectLatex(meta, function(inject) inject( usePackage("sidenotes") ) inject( usePackage("marginnote") ) end) if marginCitations() and meta.bibliography ~= nil then local citeMethod = param('cite-method', 'citeproc') if citeMethod == 'natbib' then metaInjectLatex(meta, function(inject) inject( usePackage("bibentry") ) inject( usePackage("marginfix") ) end) metaInjectLatex(meta, function(inject) inject( '\\nobibliography*' ) end) elseif citeMethod == 'biblatex' then metaInjectLatex(meta, function(inject) inject( usePackage("biblatex") ) end) end end -- add layout configuration based upon the document class -- we will customize any koma templates that have no custom geometries -- specified. If a custom geometry is specified, we're expecting the -- user to address the geometry and layout local documentclassRaw = readOption(meta, 'documentclass'); if documentclassRaw ~= nil then local documentclass = pandoc.utils.stringify(documentclassRaw) if documentclass == 'scrartcl' or documentclass == 'scrarticle' or documentclass == 'scrlttr2' or documentclass == 'scrletter' or documentclass == 'scrreprt' or documentclass == 'scrreport' then oneSidedColumnLayout(meta) elseif documentclass == 'scrbook' then -- better compute sidedness and deal with it -- choices are one, two, or semi local side = booksidedness(meta) if side == 'one' then oneSidedColumnLayout(meta) else twoSidedColumnLayout(meta, side == 'semi') end end end end return meta end } end function booksidedness(meta) local side = 'two' local classoption = readOption(meta, 'classoption') if classoption then for i, v in ipairs(classoption) do local option = pandoc.utils.stringify(v) if option == 'twoside=semi' then side = 'semi' elseif option == 'twoside' or option == 'twoside=on' or option == 'twoside=true' or option == 'twoside=yes' then side = 'two' elseif option == 'twoside=false' or option == 'twoside=no' or option == 'twoside=off' then side = 'one' end end end return side end function marginReferences() return param('reference-location', 'document') == 'margin' end function marginCitations() return param('citation-location', 'document') == 'margin' end function twoSidedColumnLayout(meta, oneside) baseGeometry(meta, oneside) end function oneSidedColumnLayout(meta) local classoption = readOption(meta, 'classoption') if classoption == nil then classoption = pandoc.List({}) end -- set one sided if not sidedness not already set local sideoptions = classoption:filter(function(opt) local text = pandoc.utils.stringify(opt) return text:find('oneside') == 1 or text:find('twoside') == 1 end) if #sideoptions == 0 then classoption:insert('oneside') meta.classoption = classoption end baseGeometry(meta) end function baseGeometry(meta, oneside) -- customize the geometry if not meta.geometry then meta.geometry = pandoc.List({}) end local userDefinedGeometry = #meta.geometry ~= 0 -- if only 'showframe' is passed, we can still modify the geometry if #meta.geometry == 1 then if #meta.geometry[1] == 1 then local val = meta.geometry[1][1] if val.t == 'Str' and val.text == 'showframe' then userDefinedGeometry = false end end end if not userDefinedGeometry then -- if one side geometry is explicitly requested, the -- set that (used for twoside=semi) if oneside then tappend(meta.geometry, {"twoside=false"}) end tappend(meta.geometry, geometryForPaper(meta.papersize)) end end -- We will automatically compute a geometry for a papersize that we know about function geometryForPaper(paperSize) if paperSize ~= nil then local paperSizeStr = paperSize[1].text local width = kPaperWidthsIn[paperSizeStr] if width ~= nil then return geometryFromPaperWidth(width) else return pandoc.List({}) end else return pandoc.List({}) end end function geometryFromPaperWidth(paperWidth) local geometry = pandoc.List({}) geometry:insert(metaInlineStr('left=' .. left(paperWidth) .. 'in')) geometry:insert(metaInlineStr('marginparwidth=' .. marginParWidth(paperWidth) .. 'in')) geometry:insert(metaInlineStr('textwidth=' .. textWidth(paperWidth) .. 'in')) geometry:insert(metaInlineStr('marginparsep=' .. marginParSep(paperWidth) .. 'in')) return geometry end function metaInlineStr(str) return pandoc.Inlines({pandoc.Str(str)}) end -- We will only provide custom geometries for paper widths that we are -- aware of and that would work well for wide margins. Some sizes get -- so small that there just isn't a good way to represent the margin layout -- so we just throw up our hands and take the default geometry kPaperWidthsIn = { a0 = 33.11, a1 = 23.39, a2 = 16.54, a3 = 11.69, a4 = 8.3, a5 = 5.83, a6 = 4.13, a7 = 2.91, a8 = 2.05, b0 = 39.37, b1 = 27.83, b2 = 19.69, b3 = 13.90, b4 = 9.84, b5 = 6.93, b6 = 4.92, b7 = 3.46, b8 = 2.44, b9 = 1.73, b10 = 1.22, letter = 8.5, legal = 8.5, ledger = 11, tabloid = 17, executive = 7.25 } local kLeft = 1 local kMarginParSep = .3 function left(width) if width >= kPaperWidthsIn.a4 then return kLeft else return kLeft * width / kPaperWidthsIn.a4 end end function marginParSep(width) if width >= kPaperWidthsIn.a6 then return kMarginParSep else return kMarginParSep * width / kPaperWidthsIn.a4 end end function marginParWidth(width) return (width - 2*left(width) - marginParSep(width)) / 3 end function textWidth(width) return ((width - 2*left(width) - marginParSep(width)) * 2) / 3 end -- width.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- parse a layout specification function parseLayoutWidths(figLayout, figureCount) -- parse json figLayout = pandoc.List(quarto.json.decode(figLayout)) -- if there are no tables then make a table and stick the items in it if not figLayout:find_if(function(item) return type(item) == "table" end) then figLayout = pandoc.List({figLayout}) end -- validate that layout is now all rows if figLayout:find_if(function(item) return type(item) ~= "table" end) then error("Invalid figure layout specification " .. "(cannot mix rows and items at the top level") end -- convert numbers to strings as appropriate figureLayoutCount = 0 figLayout = figLayout:map(function(row) --- get the cols local cols = pandoc.List(row) -- see if we have a total numeric value (no strings) local numericTotal = 0 for i=1,#cols do local width = cols[i] if type(width) == "number" then numericTotal = numericTotal + math.abs(width) else numericTotal = 0 break end end return cols:map(function(width) figureLayoutCount = figureLayoutCount + 1 if type(width) == "number" then if numericTotal ~= 0 then width = round((width / numericTotal) * 100, 2) elseif width <= 1 then width = round(width * 100, 2) end width = tostring(width) .. "%" end -- negative widths are "spacers" so we need to bump our total fig count if isSpacerWidth(width) then figureCount = figureCount + 1 end -- return the width return width end) end) -- if there aren't enough rows then extend using the last row as a template local figureGap = figureCount - figureLayoutCount if figureGap > 0 then local lastRow = figLayout[#figLayout] local rowsToAdd = math.ceil(figureGap/#lastRow) for i=1,rowsToAdd do figLayout:insert(lastRow:clone()) end end -- return the layout return figLayout end function isSpacerWidth(width) return pandoc.text.sub(width, 1, 1) == "-" end -- convert widths to percentages function widthsToPercent(layout, cols) -- for each row for _,row in ipairs(layout) do -- determine numeric widths (and their total) for the row local widths = pandoc.List() for _,fig in ipairs(row) do widths[#widths+1] = 0 local width = attribute(fig, "width", nil) if width then width = tonumber(string.match(width, "^(-?[%d%.]+)")) if width then widths[#widths] = width end end end -- create virtual fig widths as needed and note the total width local defaultWidth = widths:find_if(function(width) return width > 0 end) if defaultWidth == nil then defaultWidth = 42 -- this value is arbitrary end local totalWidth = 0 for i=1,cols do if (i > #widths) or widths[i] == 0 then widths[i] = defaultWidth end totalWidth = totalWidth + widths[i] end -- allocate widths for i,fig in ipairs(row) do local width = round((widths[i]/totalWidth) * 100, 1) fig.attr.attributes["width"] = tostring(width) .. "%" fig.attr.attributes["height"] = nil end end end -- elements with a percentage width and no height have a 'layout percent' -- which means then should be laid out at a higher level in the tree than -- the individual figure element function horizontalLayoutPercent(el) return sizeToPercent(el.attr.attributes["width"]) end function transferImageWidthToCell(img, divEl) divEl.attr.attributes["width"] = img.attributes["width"] if sizeToPercent(attribute(img, "width", nil)) then img.attributes["width"] = nil end img.attributes["height"] = nil end function transfer_float_image_width_to_cell(float, div_el) local width_attr = float.attributes["width"] div_el.attr.attributes["width"] = width_attr if sizeToPercent(width_attr) then float.attributes["width"] = nil end float.attributes["height"] = nil end -- wp.lua -- Copyright (C) 2020-2022 Posit Software, PBC function tableWpPanel(divEl, layout, caption) return tablePanel(divEl, layout, caption, { pageWidth = wpPageWidth() }) end function wpDivFigure(div) -- options options = { pageWidth = wpPageWidth(), } -- determine divCaption handler (always left-align) local divCaption = nil if _quarto.format.isDocxOutput() then divCaption = docxDivCaption elseif _quarto.format.isOdtOutput() then divCaption = odtDivCaption end if divCaption then options.divCaption = function(el, align) return divCaption(el, "left") end end -- get alignment local align = figAlignAttribute(div) -- create the row/cell for the figure local row = pandoc.List() local cell = div:clone() transferImageWidthToCell(div, cell) row:insert(tableCellContent(cell, align, options)) -- make the table local figureTable = pandoc.SimpleTable( pandoc.List(), -- caption { layoutTableAlign(align) }, { 1 }, -- full width pandoc.List(), -- no headers { row } -- figure ) -- return it return pandoc.utils.from_simple_table(figureTable) end function wpPageWidth() local width = param("page-width", nil) if width then if (type(width) == 'table') then width = tonumber(pandoc.utils.stringify(width)) end if not width then error("You must use a number for page-width") else return width end else return 6.5 end end -- odt.lua -- Copyright (C) 2020-2022 Posit Software, PBC function tableOdtPanel(divEl, layout, caption) return tablePanel(divEl, layout, caption, { pageWidth = wpPageWidth(), divCaption = odtDivCaption }) end -- create a native odt caption (note that because "opendocument" paragraphs -- include their styles as an attribute, we need to stringify the captionEl -- so that it can be embedded in a raw opendocument block function odtDivCaption(captionEl, align) local caption = pandoc.RawBlock("opendocument", "<text:p text:style-name=\"FigureCaption\">" .. pandoc.utils.stringify(captionEl) .. "</text:p>" ) return caption end -- pptx.lua -- Copyright (C) 2020-2022 Posit Software, PBC function pptxPanel(divEl, layout) -- create panel local panel = pandoc.Div({}, pandoc.Attr(divEl.attr.identifier, {"columns"})) -- add a column for each figure (max 2 columns will be displayed) local kMaxCols = 2 for i, row in ipairs(layout) do for _, cell in ipairs(row) do -- break on kMaxCols if #panel.content == kMaxCols then break end -- add the column class cell.attr.classes:insert("column") -- add to the panel panel.content:insert(cell) end end -- return panel return panel end -- table.lua -- Copyright (C) 2020-2022 Posit Software, PBC function tablePanel(divEl, layout, caption, options) -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local divId = divEl.identifier if divId == nil then divId = '' end -- create panel local panel = pandoc.Div({}, pandoc.Attr(divId)) -- layout for i, row in ipairs(layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attr.attributes[kLayoutAlign] -- in docx tables inherit their parent cell alignment (likely a bug) so -- this alignment will force all columns in embedded tables to follow it. -- if the alignment is center this won't make for very nice tables, so -- we force it to pandoc.AlignDefault if tableFromLayoutCell(cell) and _quarto.format.isDocxOutput() and align == "center" then return pandoc.AlignDefault else return layoutTableAlign(align) end end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do local align = cell.attr.attributes[kLayoutAlign] cells:insert(tableCellContent(cell, align, options)) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel.content:insert(pandoc.utils.from_simple_table(panelTable)) -- add empty text frame (to prevent a para from being inserted btw the rows) if i ~= #layout and options.rowBreak then panel.content:insert(options.rowBreak()) end end -- insert caption if caption then if options.divCaption then caption = options.divCaption(caption) end panel.content:insert(caption) end -- return panel return panel end function tableCellContent(cell, align, options) -- there will be special code if this an image or table local image = figureImageFromLayoutCell(cell) local tbl = tableFromLayoutCell(cell) local isSubRef = hasRefParent(cell) or (image and hasRefParent(image)) if image then -- convert layout percent to physical units (if we have a pageWidth) -- this ensures that images don't overflow the column as they have -- been observed to do in docx if options.pageWidth then local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then local inches = (layoutPercent/100) * options.pageWidth image.attr.attributes["width"] = string.format("%2.2f", inches) .. "in" end end -- rtf and odt don't write captions in tables so make this explicit if #image.caption > 0 and (_quarto.format.isRtfOutput() or _quarto.format.isOdtOutput()) then local caption = image.caption:clone() tclear(image.caption) local captionPara = pandoc.Para(caption) if options.divCaption then captionPara = options.divCaption(captionPara, align) end cell.content:insert(captionPara) end -- we've already aligned the image in a table cell so prevent -- extended handling as it would create a nested table cell preventExtendedFigure(image) end if hasFigureRef(cell) then -- style div caption if there is a custom caption function if options.divCaption then local divCaption = options.divCaption(refCaptionFromDiv(cell), align) cell.content[#cell.content] = divCaption end -- we've already aligned the figure in a table cell so prevent -- extended handling as it would create a nested table cell preventExtendedFigure(cell) end if tbl then if align == "center" then -- force widths to occupy 100% layoutEnsureFullTableWidth(tbl) -- for docx output we've forced the alignment of this cell to AlignDefault -- above (see the comment in tablePanel for rationale). Forcing the -- table to 100$% width (done right above) makes it appear "centered" so -- do the same for the caption if _quarto.format.isDocxOutput() then local caption = pandoc.utils.blocks_to_inlines(tbl.caption.long) tclear(tbl.caption.long) if tbl.caption.short then tclear(tbl.caption.short) end cell.content:insert(1, options.divCaption(pandoc.Para(caption), align)) end end -- workaround issue w/ docx nested tables: https://github.com/jgm/pandoc/issues/6983 if _quarto.format.isDocxOutput() then if PANDOC_VERSION < pandoc.types.Version("2.11.3.2") then cell.content:insert(options.rowBreak()) end end end return { cell } end function layoutTableAlign(align) if align == "left" then return pandoc.AlignLeft elseif align == "center" then return pandoc.AlignCenter elseif align == "right" then return pandoc.AlignRight end end function layoutEnsureFullTableWidth(tbl) if not tbl.colspecs:find_if(function(spec) return spec.width ~= nil end) then tbl.colspecs = tbl.colspecs:map(function(spec) return { spec[1], (1 / #tbl.colspecs) * 0.98 } end) end end -- figures.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") function preventExtendedFigure(el) el.attr.attributes[constants.kFigExtended] = "false" end function forceExtendedFigure(el) el.attr.attributes[constants.kFigExtended] = "true" end function shouldHandleExtended(el) return el.attr.attributes[constants.kFigExtended] ~= "false" end -- By default, images without captions should be -- excluded from extended processing. function shouldHandleExtendedImage(el) -- handle extended if there is a caption if el.caption and #el.caption > 0 then return true end -- handle extended if there are fig- attributes local keys = tkeys(el.attr.attributes) for _,k in pairs(keys) do if isFigAttribute(k) then return true end end -- handle extended if there is column or caption -- classes if hasColumnClasses(el) then return true end -- handle extended if it was explicitly enabled if el.attr.attributes[constants.kFigExtended] == "true" then return true end -- otherwise, by default, do not handle return false end -- cites.lua -- Copyright (C) 2021-2022 Posit Software, PBC function cites_preprocess() if (not _quarto.format.isLatexOutput()) and marginCitations() then return { } end return { Note = function(note) if _quarto.format.isLatexOutput() and marginCitations() then return _quarto.ast.walk(note, { Inlines = walkUnresolvedCitations(function(citation, appendInline, appendAtEnd) appendAtEnd(citePlaceholderInline(citation)) end) }) end end, FloatRefTarget = function(float) local inlines_filter local has_margin_column = hasMarginColumn(float) -- general figure caption cites fixups if (_quarto.format.isLatexOutput() and has_margin_column) or hasMarginCaption(float) then -- This is a figure in the margin itself, we need to append citations at the end of the caption -- without any floating inlines_filter = walkUnresolvedCitations(function(citation, appendInline, appendAtEnd) appendAtEnd(citePlaceholderInlineWithProtection(citation)) end) elseif marginCitations() then -- This is a figure is in the body, but the citation should be in the margin. Use -- protection to shift any citations over inlines_filter = walkUnresolvedCitations(function(citation, appendInline, appendAtEnd) appendInline(marginCitePlaceholderInlineWithProtection(citation)) end) end if inlines_filter then float.caption_long = _quarto.ast.walk(float.caption_long, { Inlines = inlines_filter }) end -- table caption cites fixups if (refType(float.identifier) == 'tbl' and _quarto.format.isLatexOutput() and hasMarginColumn(float)) or marginCitations() then local ref_table _quarto.ast.walk(float.content, { Table = function(t) ref_table = t end }) if ref_table ~= nil then -- we don't want to update this inside the float.content walk above -- because the caption_long is part of the content and that -- will cause weirdness float.caption_long = _quarto.ast.walk(float.caption_long, { Inlines = function(inlines) return resolveCaptionCitations(inlines, has_margin_column) end }) end end return float end, Div = function(div) if (_quarto.format.isLatexOutput() and hasMarginColumn(div)) or marginCitations() then return _quarto.ast.walk(div, { Inlines = walkUnresolvedCitations(function(citation, appendInline, appendAtEnd) if hasMarginColumn(div) then appendAtEnd(citePlaceholderInline(citation)) end end) }) end end } end function cites() if not (_quarto.format.isLatexOutput() and marginCitations()) then return { } end return { -- go through inlines and resolve any unresolved citations Inlines = function(inlines) return (walkUnresolvedCitations(function(citation, appendInline) appendInline(marginCitePlaceholderInline(citation)) end)(inlines)) end } end function walkUnresolvedCitations(func) return function(inlines) local modified = false if _quarto.format.isLatexOutput() and marginCitations() then for i,inline in ipairs(inlines) do if inline.t == 'Cite' then for j, citation in ipairs(inline.citations) do if not isResolved(citation) then func( citation, function(inlineToAppend) if inlineToAppend ~= nil then local inlinePos = i local citationCount = j inlines:insert(inlinePos+citationCount, inlineToAppend) modified = true setResolved(citation) end end, function(inlineToAppendAtEnd) if inlineToAppendAtEnd ~= nil then inlines:insert(#inlines + 1, inlineToAppendAtEnd) modified = true setResolved(citation) end end ) end end end end end if modified then return inlines end end end function resolveCaptionCitations(captionContentInlines, inMargin) local citeEls = pandoc.List() for i,inline in ipairs(captionContentInlines) do if inline.t == 'Cite' then for j, citation in ipairs(inline.citations) do if inMargin then citeEls:insert(citePlaceholderInlineWithProtection(citation)) else citeEls:insert(marginCitePlaceholderWithProtection(citation)) end setResolved(citation) end end end if #citeEls > 0 then tappend(captionContentInlines, citeEls) return true else return false end end function marginCitePlaceholderInline(citation) return pandoc.RawInline('latex', '\\marginpar{\\begin{footnotesize}{?quarto-cite:'.. citation.id .. '}\\vspace{2mm}\\par\\end{footnotesize}}') end function citePlaceholderInline(citation) return pandoc.RawInline('latex', '\\linebreak\\linebreak{?quarto-cite:'.. citation.id .. '}\\linebreak') end function citePlaceholderInlineWithProtection(citation) return pandoc.RawInline('latex', '\\linebreak\\linebreak\\protect{?quarto-cite:'.. citation.id .. '}\\linebreak') end function marginCitePlaceholderWithProtection(citation) return pandoc.RawInline('latex', '\\protect\\marginnote{\\begin{footnotesize}\\protect{?quarto-cite:'.. citation.id .. '}\\linebreak\\end{footnotesize}}') end local resolvedCites = {} function keyForCite(citation) local id = citation.id local num = citation.note_num local key = id .. num return key end -- need a way to communicate that this has been resolved function setResolved(citation) resolvedCites[keyForCite(citation)] = true end function isResolved(citation) return resolvedCites[keyForCite(citation)] == true end function discoverTable(div) local tbl = div.content[1] if tbl.t == 'Table' then return tbl else return nil end end -- columns.lua -- Copyright (C) 2021-2022 Posit Software, PBC kSideCaptionClass = 'margin-caption' function columns() return { Div = function(el) -- for any top level divs, render then renderDivColumn(el) return el end, Span = function(el) -- a span that should be placed in the margin if _quarto.format.isLatexOutput() and hasMarginColumn(el) then noteHasColumns() tprepend(el.content, {latexBeginSidenote(false)}) tappend(el.content, {latexEndSidenote(el, false)}) removeColumnClasses(el) return el else -- convert the aside class to a column-margin class if el.classes and tcontains(el.classes, 'aside') then noteHasColumns() el.classes = el.classes:filter(function(attr) return attr ~= "aside" end) tappend(el.classes, {'column-margin', 'margin-aside'}) removeColumnClasses(el) return el end end end, RawBlock = function(el) -- Implements support for raw <aside> tags and replaces them with -- our raw latex representation if _quarto.format.isLatexOutput() then if el.format == 'html' then if el.text == '<aside>' then noteHasColumns() el = latexBeginSidenote() elseif el.text == '</aside>' then el = latexEndSidenote(el) end end end return el end } end function renderDivColumn(el) -- for html output that isn't reveal... if _quarto.format.isHtmlOutput() and not _quarto.format.isHtmlSlideOutput() then -- For HTML output, note that any div marked an aside should -- be marked a column-margin element (so that it is processed -- by post processors). -- For example: https://github.com/quarto-dev/quarto-cli/issues/2701 if el.classes and tcontains(el.classes, 'aside') then noteHasColumns() el.classes = el.classes:filter(function(attr) return attr ~= "aside" end) tappend(el.classes, {'column-margin', "margin-aside"}) return el end elseif el.identifier and el.identifier:find("^lst%-") then -- for listings, fetch column classes from sourceCode element -- and move to the appropriate spot (e.g. caption, container div) local captionEl = el.content[1] local codeEl = el.content[2] if captionEl and codeEl then local columnClasses = resolveColumnClasses(codeEl) if #columnClasses > 0 then noteHasColumns() removeColumnClasses(codeEl) for i, clz in ipairs(columnClasses) do if clz == kSideCaptionClass and _quarto.format.isHtmlOutput() then -- wrap the caption if this is a margin caption -- only do this for HTML output since Latex captions typically appear integrated into -- a tabular type layout in latex documents local captionContainer = pandoc.Div({captionEl}, pandoc.Attr("", {clz})) el.content[1] = codeEl el.content[2] = captionContainer else -- move to container el.classes:insert(clz) end end end end elseif _quarto.format.isLatexOutput() and not requiresPanelLayout(el) then -- see if there are any column classes local columnClasses = resolveColumnClasses(el) if #columnClasses > 0 then noteHasColumns() if el.classes:includes('cell-output-display') and #el.content > 0 then -- this could be a code-display-cell local figOrTable = false local floatRefTarget = false for j=1,#el.content do local contentEl = el.content[j] -- wrap figures local figure = discoverFigure(contentEl, false) if figure ~= nil then applyFigureColumns(columnClasses, figure) figOrTable = true elseif is_regular_node(contentEl, "Div") and hasTableRef(contentEl) then -- wrap table divs latexWrapEnvironment(contentEl, latexTableEnv(el), false) figOrTable = true el.classes = el.classes:filter(function(clz) return not isStarEnv(clz) end) elseif contentEl.attr ~= nil and hasFigureRef(contentEl) then -- wrap figure divs latexWrapEnvironment(contentEl, latexFigureEnv(el), false) figOrTable = true el.classes = el.classes:filter(function(clz) return not isStarEnv(clz) end) elseif contentEl.t == 'Table' then -- TODO do-not-create-environment is hack we add on parsefiguredivs.lua -- to handle floatreftarget that have layout elements. we need -- this to not doubly-emit table* environments, because in this -- specific case, the floatreftarget renderer will handle the -- environment creation. -- -- it's likely that the lines around here which create environments also -- need to get the same treatment if contentEl.classes:includes("do-not-create-environment") then contentEl.classes = contentEl.classes:filter(function(clz) return clz ~= "do-not-create-environment" end) else -- wrap the table in a div and wrap the table environment around it contentEl.classes:insert("render-as-tabular") local tableDiv = pandoc.Div({contentEl}) latexWrapEnvironment(tableDiv, latexTableEnv(el), false) el.content[j] = tableDiv figOrTable = true -- In this case, we need to remove the class from the parent element -- It also means that divs that want to be both a figure* and a table* -- will never work and we won't get the column-* treatment for -- everything, just for the table. el.classes = el.classes:filter(function(clz) return not isStarEnv(clz) end) print(el) end elseif is_custom_node(contentEl, "FloatRefTarget") then -- forward the columns class from the output div -- onto the float ref target, which prevents -- the general purpose `sidenote` processing from capturing this -- element (since floats know how to deal with margin positioning) local custom = _quarto.ast.resolve_custom_data(contentEl) if custom ~= nil then floatRefTarget = true removeColumnClasses(el) addColumnClasses(columnClasses, custom) end end end if not figOrTable and not floatRefTarget then processOtherContent(el.content) end else -- this is not a code cell so process it if el.attr ~= nil then if hasTableRef(el) then latexWrapEnvironment(el, latexTableEnv(el), false) elseif hasFigureRef(el) then latexWrapEnvironment(el, latexFigureEnv(el), false) else -- Look in the div to see if it contains a figure local figure = nil for j=1,#el.content do local contentEl = el.content[j] if figure == nil then figure = discoverFigure(contentEl, false) end end if figure ~= nil then applyFigureColumns(columnClasses, figure) else processOtherContent(el) end end end end else -- Markup any captions for the post processor latexMarkupCaptionEnv(el); end end end function processOtherContent(el) if hasMarginColumn(el) then -- (margin notes) noteHasColumns() tprepend(el.content, {latexBeginSidenote()}); tappend(el.content, {latexEndSidenote(el)}) else -- column classes, but not a table or figure, so -- handle appropriately local otherEnv = latexOtherEnv(el) if otherEnv ~= nil then latexWrapEnvironment(el, otherEnv, false) end end removeColumnClasses(el) end function applyFigureColumns(columnClasses, figure) -- just ensure the classes are - they will be resolved -- when the latex figure is rendered addColumnClasses(columnClasses, figure) -- ensure that extended figures will render this forceExtendedFigure(figure) end function hasColumnClasses(el) return tcontains(el.classes, isColumnClass) or hasMarginColumn(el) end function hasMarginColumn(el) if el.classes ~= nil then return tcontains(el.classes, 'column-margin') or tcontains(el.classes, 'aside') else return false end end function hasMarginCaption(el) if el.classes ~= nil then return tcontains(el.classes, 'margin-caption') else return false end end function noteHasColumns() layoutState.hasColumns = true end function notColumnClass(clz) return not isColumnClass(clz) end function resolveColumnClasses(el) return el.classes:filter(isColumnClass) end function columnToClass(column) if column ~= nil then return 'column-' .. column[1].text else return nil end end function removeColumnClasses(el) if el.classes then for i, clz in ipairs(el.classes) do if isColumnClass(clz) then el.classes:remove(i) end end end end function addColumnClasses(classes, toEl) removeColumnClasses(toEl) for i, clz in ipairs(classes) do if isColumnClass(clz) then toEl.classes:insert(clz) end end end function removeCaptionClasses(el) for i, clz in ipairs(el.classes) do if isCaptionClass(clz) then el.classes:remove(i) end end end function resolveCaptionClasses(el) local filtered = el.classes:filter(isCaptionClass) if #filtered > 0 then return {'margin-caption'} else -- try looking for attributes if el.attributes ~= nil and el.attributes['cap-location'] == "margin" then return {'margin-caption'} else return {} end end end function isCaptionClass(clz) return clz == 'caption-margin' or clz == 'margin-caption' end function isColumnClass(clz) if clz == nil then return false elseif clz == 'aside' then return true else return clz:match('^column%-') end end -- manuscript.lua -- Copyright (C) 2021-2022 Posit Software, PBC local constants = require("modules/constants") local kUnrollMarkdownCells = "unroll-markdown-cells" function manuscriptUnroll() local unrollMdCells = param(kUnrollMarkdownCells, false) -- JATS implements its own custom unrolling if unrollMdCells and not _quarto.format.isJatsOutput() then return { -- Process any cells that originated from notebooks Div = function(divEl) -- If this is a markdown cell, we may need to unroll it if divEl.classes:includes("cell") and divEl.classes:includes("markdown") then return divEl.content end end } else return {} end end function manuscript() if _quarto.format.isWordProcessorOutput() or _quarto.format.isLatexOutput() then local language = param("language", nil); local notebookPrefix = language[constants.kLangSourcePrefix] local manuscriptBaseUrl = param(constants.kManuscriptUrl) local notebookLinks = param(constants.kNotebookLinks) return { traverse = 'topdown', -- Process any cells that originated from notebooks Div = function(divEl) -- Don't process these specially unless 'inline' links -- are enabled if (notebookLinks == false or notebookLinks == "global") then return end -- we can't process links without a base url if not manuscriptBaseUrl then return end -- Read notebook parameters from the cell, if present local nbAbsPath = divEl.attributes[constants.kNotebook] local nbTitle = divEl.attributes[constants.kNotebookTitle] -- If this is a notebook embed cell, 'lift' the contents of any child divs -- up (unroll their contents), this will help us avoid -- labeling divs marked as `cells` more than once local blocks = pandoc.List() for _, childBlock in ipairs(divEl.content) do if is_regular_node(childBlock, "Div") then tappend(blocks, childBlock.content) else blocks:insert(childBlock) end end divEl.content = blocks if nbAbsPath == nil then -- if this is a computational cell, synthesize the nbPath if divEl.classes:includes("cell") then -- See if this cell contains a div with explicit notebook info, if it does, we can safely ignore nbAbsPath = quarto.doc.input_file nbTitle = language['article-notebook-label'] end end if nbAbsPath ~= nil then local nbRelPath = pandoc.path.make_relative(nbAbsPath, quarto.project.directory) -- Use the notebook cotnext to try to determine the name -- of the output file local notebooks = param("notebook-context", {}) local nbFileName = pandoc.path.filename(nbRelPath) local previewFile = nbFileName .. ".html" for _i, notebook in ipairs(notebooks) do if notebook.source == nbAbsPath then if notebook['html-preview'] then previewFile = pandoc.path.filename(notebook['html-preview'].path) end break end end -- Provide preview path for the preview generator - this -- will specify a preview file name to use when generating this preview -- -- NOTE: This is a point of coordinate where the name of the notebooks is important -- and this is relying upon that name being present in order to form these links -- -- TODO: Make the filter params include notebook-context information that -- can be used to resolve links (if they are present) local nbDir = pandoc.path.directory(nbRelPath) if nbDir == "." then nbDir = "" end local previewPath = pandoc.path.join({nbDir, previewFile}) -- The title for the notebook if nbTitle == nil then nbTitle = nbFileName end -- The Id local cellId = divEl.attributes[constants.kNotebookCellId]; if cellId ~= nil then cellId = '#' .. cellId else cellId = '' end -- The label link local notebookUrl if manuscriptBaseUrl:sub(-1) ~= '/' then notebookUrl = manuscriptBaseUrl .. '/' .. previewPath .. cellId; else notebookUrl = manuscriptBaseUrl .. previewPath .. cellId; end local labelInlines = pandoc.List({ pandoc.Str(notebookPrefix), pandoc.Str(':'), pandoc.Space(), pandoc.Link(nbTitle, notebookUrl)}) local did_resolve = false -- Attempt to forward the link into element captions, when possible local resolvedEl = _quarto.ast.walk(divEl, { FloatRefTarget = function(float) if float.caption then did_resolve = true labelInlines:insert(1, pandoc.Space()) tappend(float.caption, labelInlines) return float end end, }) if did_resolve then return resolvedEl else -- We couldn't forward to caption, just place inline divEl.content:insert(pandoc.Subscript(labelInlines)) return divEl end end end } else return {} end end -- pandoc3_figure.lua -- Copyright (C) 2023 Posit Software, PBC -- Figure nodes (from Pandoc3) can exist in our AST. They're -- never cross-referenceable but they need to be rendered as -- if they were. function render_pandoc3_figure() local function html_handle_linked_image(figure) local div = pandoc.Div({}) div.identifier = "fig-yesiamafigure" -- this is a bad hack to make discoverLinkedFigureDiv work local link = nil if figure.content[1].t == "Plain" then local plain = figure.content[1] if plain.content[1].t == "Link" then link = plain.content[1] end end if link == nil then return nil end div.content:insert(pandoc.Para({link})) local pt = pandoc.utils.type(figure.caption.long) if pt == "Blocks" or pt == "Inlines" then div.content:insert(pandoc.Para(quarto.utils.as_inlines(figure.caption.long))) elseif pt == "Inline" or pt == "Block" then div.content:insert(pandoc.Para({figure.caption.long})) else internal_error() end local image = discoverLinkedFigureDiv(div) if image == nil then return nil end div.identifier = "" div.classes:extend(figure.classes) return htmlDivFigure(div) end local function html_handle_image(figure) local image _quarto.ast.walk(figure, { Image = function(img) image = img end }) if image == nil then return figure end if figure.caption.long ~= nil then image.caption = quarto.utils.as_inlines(figure.caption.long) end -- TODO need to find all correct classes to forward for i, v in pairs(figure.classes) do if v:match("^margin%-") or v:match("^quarto%-") or v:match("^column%-") then image.classes:insert(v) end end return htmlImageFigure(image) end if _quarto.format.isHtmlOutput() then return { traverse = "topdown", Figure = function(figure) local result = html_handle_linked_image(figure) if result ~= nil then return result end return html_handle_image(figure) end } elseif _quarto.format.isLatexOutput() then -- split local declaration because Lua's local is not -- a letrec local filter filter = function(state) state = state or {} local function figure_renderer(figure, is_subfig) -- this is a figure that is not cross-referenceable -- if this ends up in a layout without fig-pos = H, it'll fail -- 'H' forces it to not float if figure.identifier == "" then figure = _quarto.ast.walk(figure, { Image = function(image) image.attributes['fig-pos'] = 'H' return image end }) end local image _quarto.ast.walk(figure, { Image = function(img) image = img end }) if image == nil then return figure end if figure.caption.long ~= nil then image.caption = quarto.utils.as_inlines(figure.caption.long) end for k, v in pairs(figure.attributes) do image.attributes[k] = v end if is_subfig then image.attributes['quarto-caption-env'] = 'subcaption' end image.classes:extend(figure.classes) if state.in_column_margin then image.classes:insert("column-margin") end return latexImageFigure(image) end local function float_renderer(float) local count = 0 local new_content = _quarto.ast.walk(float.content, { Figure = function(fig) count = count + 1 return figure_renderer(fig, true), false end }) if count > 0 then float.content = new_content return float, false end end return { traverse = "topdown", PanelLayout = function(panel) panel.rows = _quarto.ast.walk(panel.rows, { Figure = function(fig) return figure_renderer(fig, true), false end }) return panel, false end, FloatRefTarget = float_renderer, Div = function(div) if div.classes:includes("column-margin") then local new_state = {} for k, v in pairs(state) do new_state[k] = v end new_state.in_column_margin = true div.content = _quarto.ast.walk(div.content, filter(new_state)) div.classes = div.classes:filter(function(x) return x ~= "column-margin" end) return div end end, Figure = function(figure) return figure_renderer(figure, false) end } end return filter() elseif _quarto.format.isTypstOutput() then return { traverse = "topdown", Figure = function(figure) return make_typst_figure({ content = figure.content[1], caption = figure.caption.long[1], kind = "quarto-float-fig", caption_location = crossref.categories.by_ref_type["fig"].caption_location, supplement = "Figure", }) end } end return {} end -- lightbox.lua -- Copyright (C) 2020-2022 Posit Software, PBC local lightbox_module = require("modules/lightbox") -- attributes to forward from the image to the newly created link local kDescription = "description" local kForwardedAttr = { "title", kDescription, "desc-position", "type", "effect", "zoomable", "draggable" } local kLightboxClass = "lightbox" local kNoLightboxClass = "nolightbox" local kGalleryPrefix = "quarto-lightbox-gallery-" local function readAttrValue(el, attrName) if attrName == kDescription then local doc = pandoc.read(el.attr.attributes[attrName]) local attrInlines = doc.blocks[1].content return pandoc.write(pandoc.Pandoc(attrInlines), "html") else return el[attrName] end end function lightbox() -- whether we're automatically lightboxing local auto = false -- whether we need lightbox dependencies added local needsLightbox = false -- accumulate any description elements that we see to be emitted later in the document local descriptions = pandoc.List() -- a counter used to ensure each image is in its own gallery local imgCount = 0 local function lightboxImage(imgEl, description, gallery) -- note that we need to include the dependency for lightbox needsLightbox = true imgCount = imgCount + 1 -- remove the class from the image imgEl.attr.classes = imgEl.attr.classes:filter(function(clz) return clz ~= kLightboxClass end) -- attributes for the link local linkAttributes = {} -- mark this image as a lightbox target linkAttributes.class = kLightboxClass -- get the alt text from image and use that as title local title = nil if imgEl.caption ~= nil and #imgEl.caption > 0 then title = pandoc.utils.stringify(imgEl.caption) end -- move a group attribute to the link, if present if imgEl.attr.attributes.group ~= nil then linkAttributes.gallery = imgEl.attr.attributes.group imgEl.attr.attributes.group = nil elseif gallery ~= nil then linkAttributes.gallery = gallery else linkAttributes.gallery = kGalleryPrefix .. imgCount end -- write a description, if provided local descEl = nil if description ~= nil then local descId = "lightbox-desc-" .. imgCount descEl = pandoc.Span({}, pandoc.Attr("", {"glightbox-desc", descId})) descEl.content = _quarto.utils.as_inlines(description) linkAttributes["data-glightbox"] = "description: ." .. descId end -- forward any other known attributes for i, v in ipairs(kForwardedAttr) do if imgEl.attr.attributes[v] ~= nil then -- forward the attribute linkAttributes[v] = readAttrValue(imgEl, v) -- clear the attribute imgEl.attr.attributes[v] = nil end -- clear the title if (imgEl.title == 'fig:') then imgEl.title = "" end end -- wrap decorated images in a link with appropriate attrs local link = pandoc.Link({imgEl}, imgEl.src, title, linkAttributes) if descEl ~= nil then descriptions:insert(descEl) end return link end local function processImg(imgEl, options) local automatic = options.automatic local caption = options.caption local gallery = options.gallery local autolightbox = automatic and auto and not imgEl.classes:includes(kNoLightboxClass) if autolightbox or imgEl.classes:includes('lightbox') then return lightboxImage(imgEl, caption, gallery) end end local function processFigure(figEl) local inMargin = false local resolvedFigEl = _quarto.ast.walk(figEl, { Image = function(imgEl) inMargin = imgEl.classes:includes("column-margin") return processImg(imgEl, { automatic = true, caption = figEl.caption.long }) end }) if resolvedFigEl and inMargin then resolvedFigEl.attr.classes:insert("column-margin") end return resolvedFigEl; end local function processSubFloat(subFloatEl, gallery, parentFloat) local subFloatModified = false subFloatEl = _quarto.ast.walk(subFloatEl, { traverse = 'topdown', Image = function(imgEl) local caption_content = subFloatEl.caption_long.content or subFloatEl.caption_long local caption = full_caption_prefix(parentFloat, subFloatEl) tappend(caption, caption_content) local subImgModified = processImg(imgEl, { automatic = true, caption = caption, gallery = gallery }) if subImgModified ~= nil then subFloatModified = true return subImgModified, false else return nil, false end end }) if subFloatModified then return subFloatEl else return nil end end if quarto.doc.is_format("html:js") then return {{ traverse = "topdown", Meta = function(meta) -- Set auto lightbox mode, if need be auto = lightbox_module.automatic(meta) == true end, -- Find images that are already within links -- we'll use this to filter out these images if -- the most is auto Link = function() -- don't walk images, figures, etc... that are already within a link -- since we rely on being able to Link the image in order to -- lightbox it return nil, false end, Div = function(div) -- Walk code cells and forward any lightbox parameters through to -- the image class that holds them if div.classes:includes("cell") and div.attributes["lightbox"] ~= nil then meta = quarto.json.decode(div.attributes["lightbox"]) local codeImgCount = 0 div = _quarto.ast.walk(div, { Image = function(imgEl) codeImgCount = codeImgCount + 1 if (type(meta) == "table" and meta[kNoLightboxClass] == true) or meta == false then imgEl.classes:insert(kNoLightboxClass) else if not auto and ((type(meta) == "table" and not meta[kNoLightboxClass]) or meta == true) then imgEl.classes:insert(kLightboxClass) end if (type(meta) == "table") then if meta.group then imgEl.attr.attributes.group = meta.group or imgEl.attr.attributes.group end for _, v in next, kForwardedAttr do if type(meta[v]) == "table" and #meta[v] > 1 then -- if list attributes it should be one per plot if codeImgCount > #meta[v] then quarto.log.warning("More plots than '" .. v .. "' passed in YAML chunk options.") else attrLb = meta[v][codeImgCount] end else -- Otherwise reuse the single attributes attrLb = meta[v] end imgEl.attr.attributes[v] = attrLb or imgEl.attr.attributes[v] end end end return imgEl end }) if div then div.attributes["lightbox"] = nil end end return div end, -- this catches images in paragraphs by themselves -- without captions, since Pandoc doesn't convert those to Figures Para = function(para) local image = discoverFigure(para, false) if image ~= nil then local lightboxedFigEl = processImg(image, { automatic = true }) if lightboxedFigEl ~= nil then return pandoc.Para({lightboxedFigEl}), false end end end, -- This catches inline images Image = function(imgEl) -- look only for explicitly targeted images return processImg(imgEl, { automatic = false } ), false end, -- figures are "Block images" with captions: images in a para -- by themselves with a caption Figure = function(figEl) return processFigure(figEl), false end, -- these are ref targets FloatRefTarget = function(floatEl) if floatEl.parent_id == nil then local floatmodified = false floatEl = _quarto.ast.walk(floatEl, { traverse = 'topdown', Image = function(imgEl) local caption_content = floatEl.caption_long.content or floatEl.caption_long local caption = full_caption_prefix(floatEl) tappend(caption, caption_content) local modifiedImg = processImg(imgEl, { automatic = true, caption = caption }) if modifiedImg ~= nil then floatmodified = true end return modifiedImg, false end, FloatRefTarget = function(subFloatEl) if subFloatEl.parent_id ~= nil then local subFloat = processSubFloat(subFloatEl, subFloatEl.parent_id, floatEl) if subFloat ~= nil then floatmodified = true end return subFloat, false end end, Figure = function(figEl) local modifiedFig = processFigure(figEl) if modifiedFig ~= nil then floatmodified = true end return modifiedFig, false end }) if floatmodified then return floatEl, false else return nil, false end end end, }, { Pandoc = function(doc) if #descriptions > 0 then local descContainer = pandoc.Div(descriptions, pandoc.Attr("", {"hidden"}, {["aria-hidden"]="true"})) doc.blocks:extend({descContainer}) return doc end end, Meta = function(meta) -- If we discovered lightbox-able images -- we need to include the dependencies if needsLightbox then -- add the dependency quarto.doc.add_html_dependency({ name = 'glightbox', scripts = {'../formats/html/glightbox/glightbox.min.js'}, stylesheets = {'../formats/html/glightbox/glightbox.min.css', '../formats/html/glightbox/lightbox.css'} }) -- read lightbox options local lbMeta = meta.lightbox local readEffect = function(el) local val = pandoc.utils.stringify(el) if val == "fade" or val == "zoom" or val == "none" then return val else error("Invalid effect " + val) end end -- permitted options include: -- lightbox: -- effect: zoom | fade | none -- desc-position: top | bottom | left |right -- loop: true | false -- class: <class-name> local effect = "zoom" local descPosition = "bottom" local loop = false local skin = nil -- The selector controls which elements are targeted. -- currently, it always targets .lightbox elements -- and there is no way for the user to change this local selector = "." .. kLightboxClass if lbMeta ~= nil and type(lbMeta) == 'table' then if lbMeta.effect ~= nil then effect = readEffect(lbMeta.effect) end if lbMeta['desc-position'] ~= nil then descPosition = pandoc.utils.stringify(lbMeta['desc-position']) end if lbMeta['css-class'] ~= nil then skin = pandoc.utils.stringify(lbMeta['css-class']) end if lbMeta.loop ~= nil then loop = lbMeta.loop end end -- Generate the options to configure lightbox local options = { selector = selector, closeEffect = effect, openEffect = effect, descPosition = descPosition, loop = loop, } if skin ~= nil then options.skin = skin end local optionsJson = quarto.json.encode(options) -- generate the initialization script with the correct options local scriptContents = "var lightboxQuarto = GLightbox(" .. optionsJson .. ");\n" scriptContents = scriptContents .. [[ window.onload = () => { lightboxQuarto.on('slide_before_load', (data) => { const { slideIndex, slideNode, slideConfig, player, trigger } = data; const href = trigger.getAttribute('href'); if (href !== null) { const imgEl = window.document.querySelector(`a[href="${href}"] img`); if (imgEl !== null) { const srcAttr = imgEl.getAttribute("src"); if (srcAttr && srcAttr.startsWith("data:")) { slideConfig.href = srcAttr; } } } }); lightboxQuarto.on('slide_after_load', (data) => { const { slideIndex, slideNode, slideConfig, player, trigger } = data; if (window.Quarto?.typesetMath) { window.Quarto.typesetMath(slideNode); } }); }; ]] local scriptTag = "<script>" .. scriptContents .. "</script>" -- inject the rendering code quarto.doc.include_text("after-body", scriptTag) end end }} else return {} end end -- columns-preprocess.lua -- Copyright (C) 2021-2022 Posit Software, PBC function columns_preprocess() return { FloatRefTarget = function(float) if float.parent_id ~= nil then return nil end local location = cap_location(float) if location == 'margin' then float.classes:insert('margin-caption') noteHasColumns() return float end end, Figure = function(figure) local location = cap_location(figure) if location == 'margin' then figure.classes:insert('margin-caption') noteHasColumns() return figure end end, Div = function(el) if el.classes:includes('cell') then -- for code chunks that aren't layout panels, forward the column classes to the output -- figures or tables (otherwise, the column class should be used to layout the whole panel) resolveColumnClassesForCodeCell(el) else resolveColumnClassesForEl(el) end return el end, Para = function(el) local figure = discoverFigure(el, false) if figure then resolveElementForScopedColumns(figure, 'fig') end return el end } end -- resolves column classes for an element function resolveColumnClassesForEl(el) if not hasRefParent(el) then if hasFigureRef(el) then resolveElementForScopedColumns(el, 'fig') elseif hasTableRef(el) then resolveElementForScopedColumns(el, 'tbl') end end end -- forward column classes from code chunks onto their display / outputs function resolveColumnClassesForCodeCell(el) local float_classes = {} local float_caption_classes = {} local found = false for k, v in ipairs(crossref.categories.all) do local ref_type = v.ref_type float_classes[ref_type] = computeClassesForScopedColumns(el, ref_type) float_caption_classes[ref_type] = computeClassesForScopedCaption(el, ref_type) found = found or (#float_classes[ref_type] > 0 or #float_caption_classes[ref_type] > 0) end -- read the classes that should be forwarded local figClasses = float_classes.fig local tblClasses = float_classes.tbl local figCaptionClasses = float_caption_classes.fig local tblCaptionClasses = float_caption_classes.tbl if found then noteHasColumns() if hasLayoutAttributes(el) then -- This is a panel, don't resolve any internal classes, only resolve -- actually column classes for this element itself resolveColumnClassesForEl(el) else -- Forward the column classes inside code blocks for i, childEl in ipairs(el.content) do if childEl.classes ~= nil and childEl.classes:includes('cell-output-display') then -- look through the children for any figures or tables local forwarded = false for j, figOrTableEl in ipairs(childEl.content) do local custom = _quarto.ast.resolve_custom_data(figOrTableEl) if custom ~= nil then local ref_type = crossref.categories.by_name[custom.type].ref_type local custom_classes = float_classes[ref_type] local custom_caption_classes = float_caption_classes[ref_type] -- applyClasses(colClasses, captionClasses, containerEl, colEl, captionEl, scope) applyClasses(custom_classes, custom_caption_classes, el, childEl, custom, ref_type) else local figure = discoverFigure(figOrTableEl, false) if figure ~= nil then -- forward to figures applyClasses(figClasses, figCaptionClasses, el, childEl, figure, 'fig') forwarded = true elseif hasFigureRef(figOrTableEl) then -- forward to figure divs applyClasses(figClasses, figCaptionClasses, el, childEl, figOrTableEl, 'fig') forwarded = true elseif (is_regular_node(figOrTableEl, "Div") and hasTableRef(figOrTableEl)) then -- for a table div, apply the classes to the figOrTableEl itself applyClasses(tblClasses, tblCaptionClasses, el, childEl, figOrTableEl, 'tbl') forwarded = true elseif figOrTableEl.t == 'Table' then -- the figOrTableEl is a table, just apply the classes to the div around it applyClasses(tblClasses, tblCaptionClasses, el, childEl, childEl, 'tbl') forwarded = true elseif figOrTableEl.t == "Figure" then -- the figOrTableEl is a table, just apply the classes to the div around it applyClasses(figClasses, figCaptionClasses, el, childEl, figOrTableEl, 'fig') forwarded = true end end end -- no known children were discovered, apply the column classes to the cell output display itself if not forwarded then -- figure out whether there are tables inside this element -- if so, use tbl scope, otherwise treat as a fig local tableCount = countTables(el) local scope = 'fig' if tableCount > 0 then scope = 'tbl' end -- forward the classes from the proper scope onto the cell-output-display div local colClasses = computeClassesForScopedColumns(el, scope) local capClasses = computeClassesForScopedCaption(el, scope) applyClasses(colClasses, capClasses, el, childEl, childEl, scope) end end end end end end function applyClasses(colClasses, captionClasses, containerEl, colEl, captionEl, scope) if #colClasses > 0 then applyColumnClasses(colEl, colClasses, scope) clearColumnClasses(containerEl, scope) end if #captionClasses > 0 then applyCaptionClasses(captionEl, captionClasses, scope) clearCaptionClasses(containerEl, scope) end end function resolveElementForScopedColumns(el, scope) local classes = computeClassesForScopedColumns(el, scope) if #classes > 0 then applyColumnClasses(el, classes, scope) end local captionClasses = computeClassesForScopedCaption(el, scope) if #captionClasses > 0 then applyCaptionClasses(el, captionClasses, scope) end end function clearColumnClasses(el, scope) removeColumnClasses(el) if scope ~= nil then removeScopedColumnClasses(el, scope) end end function clearCaptionClasses(el, scope) removeCaptionClasses(el) if scope ~= nil then removeScopedCaptionClasses(el, scope) end end function applyCaptionClasses(el, classes, scope) -- note that we applied a column class noteHasColumns() -- clear existing columns removeCaptionClasses(el) if scope ~= nil then removeScopedCaptionClasses(el, scope) end -- write the resolve scopes tappend(el.classes, classes) end function applyColumnClasses(el, classes, scope) -- note that we applied a column class noteHasColumns() -- clear existing columns removeColumnClasses(el) if scope ~= nil then removeScopedColumnClasses(el, scope) end -- write the resolve scopes tappend(el.classes, classes) end function computeClassesForScopedCaption(el, scope) local globalCaptionClasses = captionOption('cap-location') local elCaptionClasses = resolveCaptionClasses(el) local orderedCaptionClasses = {elCaptionClasses, globalCaptionClasses} -- if a scope has been provided, include that if scope ~= nil then local elScopedCaptionClasses = resolveScopedCaptionClasses(el, scope) local scopedCaptionClasses = captionOption(scope .. '-cap-location') tprepend(orderedCaptionClasses, {elScopedCaptionClasses, scopedCaptionClasses}) end for i, classes in ipairs(orderedCaptionClasses) do if #classes > 0 then return classes end end return {} end -- Computes the classes for a given element, given its scope function computeClassesForScopedColumns(el, scope) local columnGlobalClasses = columnOption('column') local columnElClasses = resolveColumnClasses(el) local orderedClasses = {columnElClasses, columnGlobalClasses} -- if a scope has been provided, include that if scope ~= nil then local scopedGlobalClasses = columnOption(scope .. '-column') local scopedElClasses = resolveScopedColumnClasses(el, scope) tprepend(orderedClasses, {scopedElClasses, scopedGlobalClasses}) end for i, classes in ipairs(orderedClasses) do if #classes > 0 then return classes end end return {} end -- reads a column option key and returns the value -- as a table of strings function columnOption(key) local value = option(key, nil) if value == nil or #value < 1 then return {} else return {'column-' .. inlinesToString(quarto.utils.as_inlines(value[1]))} end end function captionOption(key) local value = option(key, nil) if value ~= nil then end if value ~= nil and value[1].text == 'margin' then return {'margin-caption'} else return {} end end function mergedScopedColumnClasses(el, scope) local scopedClasses = resolveScopedColumnClasses(el, scope) if #scopedClasses == 0 then scopedClasses = scopedColumnClassesOption(scope) end return scopedClasses end function resolveScopedColumnClasses(el, scope) local filtered = el.classes:filter(function(clz) return clz:match('^' .. scope .. '%-column%-') end) return tmap(filtered, function(clz) return clz:sub(5) end) end function resolveScopedCaptionClasses(el, scope) local filtered = el.classes:filter(function(clz) return clz:match('^' .. scope .. '%-cap%-location%-') end) local mapped = tmap(filtered, function(clz) return clz:sub(18) end) if tcontains(mapped, 'margin') then return {'margin-caption'} else return {} end end function is_scoped_column_class(scope) return function(clz) return clz:match('^' .. scope .. '%-column%-') end end function is_scoped_caption_class(scope) return function(clz) return clz:match('^' .. scope .. '%-cap%-location%-') end end function removeScopedColumnClasses(el, scope) for i, clz in ipairs(el.classes) do if clz:match('^' .. scope .. '%-column%-') then el.classes:remove(i) end end end function removeScopedCaptionClasses(el, scope) for i, clz in ipairs(el.classes) do if clz:match('^' .. scope .. '%-cap%-location%-') then el.classes:remove(i) end end end function scopedColumnClassesOption(scope) local clz = option(scope .. '-column', nil); if clz == nil then clz = option('column', nil) end local column = columnToClass(clz) if column then return {column} else return {} end end -- layout.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- required version -- PANDOC_VERSION:must_be_at_least '2.13' -- global layout state layoutState = { hasColumns = false, } function layout_panels() return { Div = function(div) if not attr_requires_panel_layout(div.attr) then return nil end local nested_layout = false _quarto.ast.walk(div, { PanelLayout = function() nested_layout = true end }) -- if we are nested then we assume the layout -- has been handled by the child if nested_layout then return nil end local preamble, cells = partition_cells(div) local layout = layout_cells(div, cells) return quarto.PanelLayout({ attr = div.attr, preamble = preamble, layout = layout, }) end, FloatRefTarget = function(float) local attr = pandoc.Attr(float.identifier, float.classes, float.attributes) if not attr_requires_panel_layout(attr) then return nil end local nested_layout = false _quarto.ast.walk(div, { PanelLayout = function() nested_layout = true end }) -- if we are nested then we assume the layout -- has been handled by the child if nested_layout then return nil end local preamble, cells = partition_cells(float) local layout = layout_cells(float, cells) return quarto.PanelLayout({ float = float, preamble = preamble, layout = layout, }) end, } end function attr_requires_panel_layout(attr) if attr_has_layout_attributes(attr) then return true end return (_quarto.format.isLatexOutput() or _quarto.format.isHtmlOutput()) and attr.classes:includes("tbl-parent") end function partition_cells(float) local preamble = pandoc.List() local cells = pandoc.List() local heading = nil local content = quarto.utils.as_blocks(float.content) local function is_preamble_block(el) return (el.t == "CodeBlock" and el.attr.classes:includes("cell-code")) or (is_regular_node(el, "Div") and (el.attr.classes:includes("cell-output-stderr") or el.attr.classes:includes("cell-annotation"))) end local function handle_preamble_codeblock(block) if block.t == "CodeBlock" and #preamble > 0 and preamble[#preamble].t == "CodeBlock" then preamble[#preamble].text = preamble[#preamble].text .. "\n" .. block.text else preamble:insert(block) end end for _, block in ipairs(content) do if is_preamble_block(block) then handle_preamble_codeblock(block) elseif block.t == "Header" then if _quarto.format.isRevealJsOutput() then heading = pandoc.Para({ pandoc.Strong(block.content)}) else heading = block end else local cell_div = nil local subfloat = _quarto.ast.resolve_custom_data(block) -- if we were given a scaffolding div like cell-output-display, etc, -- we use it. if subfloat == nil and is_regular_node(block, "Div") then -- https://github.com/quarto-dev/quarto-cli/issues/4370 -- there can exist code blocks to be lifted into preamble deep inside divs, we need -- to walk the div to find them cell_div = _quarto.ast.walk(block, { CodeBlock = function(code_block) if is_preamble_block(code_block) then handle_preamble_codeblock(code_block) return {} end end }) or pandoc.Div({}) -- unnecessary but the Lua analyzer doesn't know it else cell_div = pandoc.Div(block) end if subfloat ~= nil and subfloat.t == "FloatRefTarget" then transfer_float_image_width_to_cell(subfloat, cell_div) end -- if we have a heading then insert it if heading then cell_div.content:insert(1, heading) heading = nil end -- if this is .cell-output-display that isn't a figure or table -- then unroll multiple blocks local is_subfloat _quarto.ast.walk(cell_div, { FloatRefTarget = function(float) is_subfloat = true return nil end }) if cell_div.attr.classes:find("cell-output-display") and is_subfloat == nil then for _,output_block in ipairs(cell_div.content) do if is_regular_node(output_block, "Div") then cells:insert(output_block) else cells:insert(pandoc.Div(output_block)) end end else -- add the div cells:insert(cell_div) end end end return preamble, cells end function layout_cells(float_or_div, cells) -- layout to return (list of rows) local rows = pandoc.List() -- note any figure layout attributes local layoutRows = tonumber(float_or_div.attributes[kLayoutNrow]) local layoutCols = tonumber(float_or_div.attributes[kLayoutNcol]) local layout = float_or_div.attributes[kLayout] -- default to 1 column if nothing is specified if not layoutCols and not layoutRows and not layout then layoutCols = 1 end -- if there is layoutRows but no layoutCols then compute layoutCols if not layoutCols and layoutRows ~= nil then layoutCols = math.ceil(#cells / layoutRows) end -- check for cols if layoutCols ~= nil then for i,cell in ipairs(cells) do if math.fmod(i-1, layoutCols) == 0 then rows:insert(pandoc.List()) end rows[#rows]:insert(cell) end -- convert width units to percentages widthsToPercent(rows, layoutCols) -- check for layout elseif layout ~= nil then -- parse the layout layout = parseLayoutWidths(layout, #cells) -- manage/perform next insertion into the layout local cellIndex = 1 local function layoutNextCell(width) -- check for a spacer width (negative percent) if isSpacerWidth(width) then local cell = pandoc.Div({ pandoc.Para({pandoc.Str(" ")}), pandoc.Para({}) }, pandoc.Attr( "", { "quarto-figure-spacer" }, { width = pandoc.text.sub(width, 2, #width) } )) rows[#rows]:insert(cell) -- normal figure layout else local cell = cells[cellIndex] if cell then cellIndex = cellIndex + 1 cell.attr.attributes["width"] = width cell.attr.attributes["height"] = nil rows[#rows]:insert(cell) end end end -- process the layout for _,item in ipairs(layout) do if cellIndex > #cells then break end rows:insert(pandoc.List()) for _,width in ipairs(item) do layoutNextCell(width) end end end -- determine alignment local align = layout_align_attribute(float_or_div) -- some width and alignment handling rows = rows:map(function(row) return row:map(function(cell) -- percentage based layouts need to be scaled down so they don't overflow the page local percentWidth = sizeToPercent(attribute(cell, "width", nil)) if percentWidth then percentWidth = round(percentWidth,1) cell.attr.attributes["width"] = tostring(percentWidth) .. "%" end -- provide default alignment if necessary cell.attr.attributes[kLayoutAlign] = layoutCellAlignment(cell, align) -- return cell return cell end) end) -- return layout return rows end function requiresPanelLayout(divEl) if hasLayoutAttributes(divEl) then return true -- latex and html require special layout markup for subcaptions elseif (_quarto.format.isLatexOutput() or _quarto.format.isHtmlOutput()) and divEl.attr.classes:includes("tbl-parent") then return true else return false end end -- custom.lua -- Copyright (C) 2023 Posit Software, PBC -- -- custom crossref categories function initialize_custom_crossref_categories(meta) local cr = meta["crossref"] if pandoc.utils.type(cr) ~= "table" then return nil end local custom = cr["custom"] if custom == nil then return nil end if type(custom) ~= "table" then -- luacov: disable fail_and_ask_for_bug_report("crossref.custom entry must be a table") return nil -- luacov: enable end local keys = { ["caption-location"] = function(v) return pandoc.utils.stringify(v) end, ["kind"] = function(v) return pandoc.utils.stringify(v) end, ["reference-prefix"] = function(v) return pandoc.utils.stringify(v) end, ["caption-prefix"] = function(v) return pandoc.utils.stringify(v) end, ["key"] = function(v) return pandoc.utils.stringify(v) end, ["latex-env"] = function(v) return pandoc.utils.stringify(v) end, ["latex-list-of-file-extension"] = function(v) return pandoc.utils.stringify(v) end, ["latex-list-of-description"] = function(v) return pandoc.utils.stringify(v) end, ["space-before-numbering"] = function(v) return v end, } local obj_mapping = { ["caption-location"] = "caption_location", ["reference-prefix"] = "name", ["caption-prefix"] = "prefix", ["latex-env"] = "latex_env", ["latex-list-of-file-extension"] = "latex_list_of_file_extension", ["latex-list-of-description"] = "latex_list_of_description", ["key"] = "ref_type", ["space-before-numbering"] = "space_before_numbering", } for _, v in ipairs(custom) do local entry = {} for key, xform in pairs(keys) do if v[key] ~= nil then entry[key] = xform(v[key]) end end if entry["caption-location"] == nil then entry["caption-location"] = "bottom" end -- slightly inefficient because we recompute the indices at -- every call, but should be totally ok for the number of categories -- we expect to see in documents local obj_entry = {} for k, v in pairs(entry) do if obj_mapping[k] ~= nil then obj_entry[obj_mapping[k]] = v else obj_entry[k] = v end end if obj_entry["prefix"] == nil then obj_entry["prefix"] = obj_entry["name"] end add_crossref_category(obj_entry) if quarto.doc.isFormat("pdf") then local function as_latex(inlines) return trim(pandoc.write(pandoc.Pandoc(inlines), "latex")) end metaInjectLatex(meta, function(inject) local env_name = entry["latex-env"] local name = entry["reference-prefix"] local env_prefix = entry["caption-prefix"] or name local ref_type = entry["key"] local list_of_name = entry["latex-list-of-file-extension"] or ("lo" .. ref_type) local list_of_description = entry["latex-list-of-description"] or name local cap_location = entry["caption-location"] or "bottom" local space_before_numbering = entry["space-before-numbering"] if space_before_numbering == nil then space_before_numbering = true end inject( usePackage("float") .. "\n" .. "\\floatstyle{plain}\n" .. "\\@ifundefined{c@chapter}{\\newfloat{" .. env_name .. "}{h}{" .. list_of_name .. "}}{\\newfloat{" .. env_name .. "}{h}{" .. list_of_name .. "}[chapter]}\n" .. "\\floatname{".. env_name .. "}{" .. as_latex(title(ref_type, env_prefix)) .. "}\n" ) if cap_location == "top" then inject("\\floatstyle{plaintop}\n\\restylefloat{" .. env_name .. "}\n") end -- FIXME this is a bit of hack for the case of custom categories with -- space-before-numbering: false -- -- the real unlock here is the custom ref command, which we should -- eventually just make extensible entirely by the user -- -- and we should probably be using cleveref instead of hyperref if not space_before_numbering and name:match(" ") then -- extract last word from name local last_word = name:match("([^ ]+)$") local first_words = name:sub(1, #name - #last_word - 1) local custom_cmd_name = "quarto" .. ref_type .. "ref" local ref_command = "\\newcommand*\\" .. custom_cmd_name .. "[1]{" .. first_words .. " \\hyperref[#1]{" .. last_word .. "\\ref{#1}}}" inject(ref_command) -- mark crossref category as having a custom ref command -- so we can use it in the rendering crossref.categories.by_ref_type[ref_type].custom_ref_command = custom_cmd_name -- inject the caption package includes here because they need to appear before DeclareCaptionFormat inject(usePackage("caption")) -- also declare a custom caption format in this case, so caption -- format also skips spaces: inject("\\DeclareCaptionLabelFormat{" .. custom_cmd_name .. "labelformat}{#1#2}") inject("\\captionsetup[" .. env_name .. "]{labelformat=" .. custom_cmd_name .. "labelformat}") end inject( "\\newcommand*\\listof" .. env_name .. "s{\\listof{" .. env_name .. "}{" .. listOfTitle(list_of_name, "List of " .. list_of_description .. "s") .. "}}\n" ) end) end end end -- index.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- initialize the index function initCrossrefIndex() -- compute section offsets local sectionOffsets = pandoc.List({0,0,0,0,0,0,0}) local numberOffset = pandoc.List(param("number-offset", {})) for i=1,#sectionOffsets do if i > #numberOffset then break end sectionOffsets[i] = numberOffset[i] end -- initialize index crossref.index = { nextOrder = {}, nextSubrefOrder = 1, section = sectionOffsets:clone(), sectionOffsets = sectionOffsets:clone(), numberOffset = sectionOffsets:clone(), entries = {}, headings = pandoc.List() } end -- advance a chapter function indexNextChapter(index, appendix) -- reset nextOrder to 1 for all types if we are in chapters mode if crossrefOption("chapters", false) then -- reset all of the cross type counters for k,v in pairs(crossref.index.nextOrder) do crossref.index.nextOrder[k] = 1 end end -- if this is an appendix the note the start index if appendix == true and crossref.startAppendix == nil then crossref.startAppendix = index end end -- next sequence in index for type function indexNextOrder(type) if not crossref.index.nextOrder[type] then crossref.index.nextOrder[type] = 1 end local nextOrder = crossref.index.nextOrder[type] crossref.index.nextOrder[type] = crossref.index.nextOrder[type] + 1 crossref.index.nextSubrefOrder = 1 return { section = crossref.index.section:clone(), order = nextOrder } end function indexAddHeading(identifier) if identifier ~= nil and identifier ~= '' then crossref.index.headings:insert(identifier) end end -- add an entry to the index function indexAddEntry(label, parent, order, caption, appendix) if caption ~= nil then caption = pandoc.List(caption) else caption = pandoc.List({}) end crossref.index.entries[label] = { parent = parent, order = order, caption = caption, appendix = appendix } end -- advance a subref function nextSubrefOrder() local order = { section = nil, order = crossref.index.nextSubrefOrder } crossref.index.nextSubrefOrder = crossref.index.nextSubrefOrder + 1 return order end -- does our index already contain this element? function indexHasElement(el) return crossref.index.entries[el.attr.identifier] ~= nil end -- filter to write the index function writeIndex() return { Pandoc = function(doc) local indexFile = param("crossref-index-file") if indexFile ~= nil then if isQmdInput() then writeKeysIndex(indexFile) else writeFullIndex(indexFile, doc) end end end } end local function index_caption(v) if #v.caption > 0 then return inlinesToString(quarto.utils.as_inlines(v.caption)) else return "" end end function writeKeysIndex(indexFile) local index = { entries = pandoc.List(), } for k,v in pairs(crossref.index.entries) do -- create entry local entry = { key = k, caption = index_caption(v) } -- add entry index.entries:insert(entry) end -- write the index local json = quarto.json.encode(index) local file = io.open(indexFile, "w") if file then file:write(json) file:close() else warn('Error attempting to write crossref index') end end function writeFullIndex(indexFile, doc) -- create an index data structure to serialize for this file local index = { entries = pandoc.List(), headings = crossref.index.headings:clone() } -- add options if we have them if next(crossref.options) then index.options = {} for k,v in pairs(crossref.options) do if type(v) == "table" then if tisarray(v) and pandoc.utils.type(v) ~= "Inlines" then index.options[k] = v:map(function(item) return pandoc.utils.stringify(item) end) else index.options[k] = pandoc.utils.stringify(v) end else index.options[k] = v end end end -- write a special entry if this is a multi-file chapter with an id local chapterId = crossrefOption("chapter-id") if chapterId then chapterId = pandoc.utils.stringify(chapterId) -- chapter heading index.headings:insert(chapterId) -- chapter entry if refType(chapterId) == "sec" and param("number-offset") ~= nil then local chapterEntry = { key = chapterId, parent = nil, order = { number = 1, section = crossref.index.numberOffset }, caption = pandoc.utils.stringify(doc.meta.title) } index.entries:insert(chapterEntry) end end for k,v in pairs(crossref.index.entries) do -- create entry local entry = { key = k, parent = v.parent, order = { number = v.order.order, }, caption = index_caption(v) } -- add section if we have one if v.order.section ~= nil then entry.order.section = v.order.section end -- add entry index.entries:insert(entry) end -- write the index local json = quarto.json.encode(index) local file = io.open(indexFile, "w") if file then file:write(json) file:close() else warn('Error attempting to write crossref index') end end -- preprocess.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- figures and tables support sub-references. mark them up before -- we proceed with crawling for cross-refs function crossref_mark_subfloats() return { traverse = "topdown", FloatRefTarget = function(float) float.content = _quarto.ast.walk(float.content, { FloatRefTarget = function(subfloat) crossref.subfloats[subfloat.identifier] = { parent_id = float.identifier } subfloat.parent_id = float.identifier subfloat.content = _quarto.ast.walk(subfloat.content, { Image = function(image) image.attributes[kRefParent] = float.identifier return image end }) return subfloat end }) return float, false end } end -- sections.lua -- Copyright (C) 2020-2022 Posit Software, PBC function sections() return { Header = function(el) -- index the heading indexAddHeading(el.attr.identifier) -- skip unnumbered if (el.classes:find("unnumbered")) then return el end -- cap levels at 7 local level = math.min(el.level, 7) -- get the current level local currentLevel = currentSectionLevel() -- if this level is less than the current level -- then set subsequent levels to their offset if level < currentLevel then for i=level+1,#crossref.index.section do crossref.index.section[i] = crossref.index.sectionOffsets[i] end end -- increment the level counter crossref.index.section[level] = crossref.index.section[level] + 1 -- if this is a chapter then notify the index (will be used to -- reset type-counters if we are in "chapters" mode) if level == 1 then indexNextChapter(crossref.index.section[level], currentFileMetadataState().appendix) end -- if this has a section identifier then index it if refType(el.attr.identifier) == "sec" then local order = indexNextOrder("sec") indexAddEntry(el.attr.identifier, nil, order, el.content, currentFileMetadataState().appendix) end -- if the number sections option is enabled then emulate pandoc numbering local section = sectionNumber(crossref.index.section, level) if not _quarto.format.isEpubOutput() and numberSectionsOptionEnabled() and level <= numberDepth() then el.attr.attributes["number"] = section end -- number the section if required if (numberSections() and level <= numberDepth()) then local appendix = (level == 1) and currentFileMetadataState().appendix if appendix then el.content:insert(1, pandoc.Space()) tprepend(el.content, crossrefOption("appendix-delim", stringToInlines(" —"))) elseif level == 1 and not _quarto.format.isHtmlOutput() then el.content:insert(1, pandoc.Str(". ")) else el.content:insert(1, pandoc.Space()) end if _quarto.format.isHtmlOutput() then el.content:insert(1, pandoc.Span( stringToInlines(section), pandoc.Attr("", { "header-section-number"}) )) else tprepend(el.content, stringToInlines(section)) end if appendix then el.content:insert(1, pandoc.Space()) tprepend(el.content, crossrefOption("appendix-title", stringToInlines("Appendix"))) end end -- return return el end } end function currentSectionLevel() -- scan backwards for the first non-zero section level for i=#crossref.index.section,1,-1 do local section = crossref.index.section[i] if section ~= 0 then return i end end -- if we didn't find one then we are at zero (no sections yet) return 0 end function numberSections() return (not _quarto.format.isLatexOutput() or _quarto.format.isBeamerOutput()) and not _quarto.format.isTypstOutput() and not _quarto.format.isMarkdownOutput() and numberSectionsOptionEnabled() end function numberSectionsOptionEnabled() return param("number-sections", false) end function numberDepth() return param("number-depth", 6) end -- figures.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- process all figures function crossref_figures() return { -- process a float -- adding it to the global index of floats (figures, tables, etc) -- -- in 1.4, we won't re-write its caption here, but instead we'll -- do it at the render filter. FloatRefTarget = function(float) -- if figure is unlabeled, do not process if is_unlabeled_float(float) then return nil end -- get label and base caption -- local label = el.attr.identifier local kind = refType(float.identifier) if kind == nil then return nil end -- determine order, parent, and displayed caption local order local parent = float.parent_id if (parent) then order = nextSubrefOrder() else order = indexNextOrder(kind) end float.order = order -- update the index indexAddEntry(float.identifier, parent, order, {float.caption_long}) return float end } end -- tables.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- process all tables (note that cross referenced tables are *always* -- wrapped in a div so they can carry parent information and so that -- we can create a hyperef target for latex) local patterns = require("modules/patterns") function preprocessRawTableBlock(rawEl, parentId) local function divWrap(el, label, caption) local div = pandoc.Div(el, pandoc.Attr(label)) if parentId then div.attr.attributes[kRefParent] = parentId if caption then div.content:insert(pandoc.Para(stringToInlines(caption))) end end return div end if _quarto.format.isRawHtml(rawEl) and _quarto.format.isHtmlOutput() then local captionPattern = patterns.html_table_caption local _, caption, _ = string.match(rawEl.text, captionPattern) if caption then -- extract id if there is one local caption, label = extractRefLabel("tbl", caption) if label then -- remove label from caption rawEl.text = rawEl.text:gsub(captionPattern, "%1" .. caption:gsub("%%", "%%%%") .. "%3", 1) elseif parentId then label = autoSubrefLabel(parentId) end if label then return divWrap(rawEl, label) end end elseif _quarto.format.isRawLatex(rawEl) and _quarto.format.isLatexOutput() then -- remove knitr label local knitrLabelPattern = "\\label{tab:[^}]+} ?" rawEl.text = rawEl.text:gsub(knitrLabelPattern, "", 1) -- try to find a caption with an id local captionPattern = "(\\caption{)(.*)" .. refLabelPattern("tbl") .. "([^}]*})" local _, caption, label, _ = rawEl.text:match(captionPattern) if label then -- remove label from caption rawEl.text = rawEl.text:gsub(captionPattern, "%1%2%4", 1) elseif parentId then label = autoSubrefLabel(parentId) end if label then return divWrap(rawEl, label) end end return rawEl end function preprocessTable(el, parentId) -- if there is a caption then check it for a table suffix if el.caption.long ~= nil then local last = el.caption.long[#el.caption.long] if last and #last.content > 0 then -- check for tbl label local label = nil local caption, attr = parseTableCaption(last.content) if startsWith(attr.identifier, "tbl-") then -- set the label and remove it from the caption label = attr.identifier attr.identifier = "" last.content = createTableCaption(caption, attr) -- provide error caption if there is none if #last.content == 0 then if parentId then tappend(last.content, { emptyCaption() }) else tappend(last.content, { noCaption() }) end end -- if there is a parent then auto-assign a label if there is none elseif parentId then label = autoSubrefLabel(parentId) end if label then -- wrap in a div with the label (so that we have a target -- for the tbl ref, in LaTeX that will be a hypertarget) local div = pandoc.Div(el, pandoc.Attr(label)) -- propagate parent id if the parent is a table if parentId and isTableRef(parentId) then div.attr.attributes[kRefParent] = parentId end -- return the div return div end end end return el end function process(float) local changed = false local content = float.content if pandoc.utils.type(content) ~= "Blocks" then content = pandoc.List({content}) end for _,el in ipairs(content) do if el.t == "Table" then if el.caption.long ~= nil and #el.caption.long > 0 then local label = divEl.attr.identifier local caption = el.caption.long[#el.caption.long] processMarkdownTableEntry(float) changed = true return float end end end if changed then return float end return nil end function processMarkdownTableEntry(float) -- clone the caption so we can add a clean copy to our index local captionClone = caption.content:clone() -- determine order / insert prefix local order local parent = float.parent_id if (parent) then order = nextSubrefOrder() prependSubrefNumber(caption.content, order) else order = indexNextOrder("tbl") prependTitlePrefix(caption, label, order) end -- add the table to the index indexAddEntry(label, parent, order, captionClone) end function processRawTable(divEl) -- look for a raw html or latex table for i,el in pairs(divEl.content) do local rawParentEl, rawEl, rawIndex = rawElement(divEl, el, i) if rawEl then local label = divEl.attr.identifier -- html table if _quarto.format.isRawHtml(rawEl) then local captionPattern = patterns.html_table_caption local _, caption, _ = string.match(rawEl.text, captionPattern) if caption then local order local prefix local parent = divEl.attr.attributes[kRefParent] if (parent) then order = nextSubrefOrder() local subref = pandoc.List() prependSubrefNumber(subref, order) prefix = inlinesToString(subref) else order = indexNextOrder("tbl") prefix = pandoc.utils.stringify(tableTitlePrefix(order)) end indexAddEntry(label, parent, order, stringToInlines(caption)) rawEl.text = rawEl.text:gsub(captionPattern, "%1" .. prefix .. " %2%3", 1) rawParentEl.content[rawIndex] = rawEl return divEl end -- latex table elseif _quarto.format.isRawLatex(rawEl) then -- look for raw latex with a caption captionPattern = "\\caption{([^}]+)}" caption = string.match(rawEl.text, captionPattern) if caption then processLatexTable(divEl, rawEl, captionPattern, label, caption) rawParentEl.content[rawIndex] = rawEl return divEl end end break end end return nil end -- handle either a raw block or raw inline in first paragraph function rawElement(divEl, el, index) if el.t == "RawBlock" then return divEl, el, index elseif el.t == "Para" and #el.content > 0 and el.content[1].t == "RawInline" then return el, el.content[1], 1 end end -- is this a Div containing a table? function isTableDiv(el) return is_regular_node(el, "Div") and hasTableRef(el) end function float_title_prefix(float, withDelimiter) local category = crossref.categories.by_name[float.type] if category == nil then fail("unknown float type '" .. float.type .. "'") return end return titlePrefix(category.ref_type, category.name, float.order, withDelimiter) end function tableTitlePrefix(order) return titlePrefix("tbl", "Table", order) end function processLatexTable(divEl, el, captionPattern, label, caption) local order local parent = divEl.attr.attributes[kRefParent] if (parent) then el.text = el.text:gsub(captionPattern, "", 1) divEl.content:insert(pandoc.Para(stringToInlines(caption))) order = nextSubrefOrder() else el.text = el.text:gsub(captionPattern, "\\caption{\\label{" .. label .. "}" .. caption:gsub("%%", "%%%%") .. "}", 1) order = indexNextOrder("tbl") end indexAddEntry(label, parent, order, stringToInlines(caption)) end function prependTitlePrefix(caption, label, order) if _quarto.format.isLatexOutput() then tprepend(caption.content, { pandoc.RawInline('latex', '\\label{' .. label .. '}') }) elseif not _quarto.format.isAsciiDocOutput() then tprepend(caption.content, tableTitlePrefix(order)) end end -- equations.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- process all equations function equations() return { Para = process_equations, Plain = process_equations } end function process_equations(blockEl) -- alias inlines local inlines = blockEl.content -- do nothing if there is no math herein if inlines:find_if(isDisplayMath) == nil then return nil end local mathInlines = nil local targetInlines = pandoc.List() for i, el in ipairs(inlines) do -- see if we need special handling for pending math, if -- we do then track whether we should still process the -- inline at the end of the loop local processInline = true if mathInlines then if el.t == "Space" then mathInlines:insert(el) processInline = false elseif el.t == "Str" and refLabel("eq", el) then -- add to the index local label = refLabel("eq", el) local order = indexNextOrder("eq") indexAddEntry(label, nil, order) -- get the equation local eq = mathInlines[1] -- write equation if _quarto.format.isLatexOutput() then targetInlines:insert(pandoc.RawInline("latex", "\\begin{equation}")) targetInlines:insert(pandoc.Span(pandoc.RawInline("latex", eq.text), pandoc.Attr(label))) -- Pandoc 3.1.7 started outputting a shadow section with a label as a link target -- which would result in two identical labels being emitted. -- https://github.com/jgm/pandoc/issues/9045 -- https://github.com/lierdakil/pandoc-crossref/issues/402 targetInlines:insert(pandoc.RawInline("latex", "\\end{equation}")) elseif _quarto.format.isTypstOutput() then local is_block = eq.mathtype == "DisplayMath" and "true" or "false" targetInlines:insert(pandoc.RawInline("typst", "#math.equation(block: " .. is_block .. ", numbering: \"(1)\", " .. "[ ")) targetInlines:insert(eq) targetInlines:insert(pandoc.RawInline("typst", " ])<" .. label .. ">")) else local eqNumber = eqQquad local mathMethod = param("html-math-method", nil) if _quarto.format.isHtmlOutput() and (mathMethod == "mathjax" or mathMethod == "katex") then eqNumber = eqTag end eq.text = eq.text .. " " .. eqNumber(inlinesToString(numberOption("eq", order))) local span = pandoc.Span(eq, pandoc.Attr(label)) targetInlines:insert(span) end -- reset state mathInlines = nil processInline = false else targetInlines:extend(mathInlines) mathInlines = nil end end -- process the inline unless it was already taken care of above if processInline then if isDisplayMath(el) then mathInlines = pandoc.List() mathInlines:insert(el) else targetInlines:insert(el) end end end -- flush any pending math inlines if mathInlines then targetInlines:extend(mathInlines) end -- return the processed list blockEl.content = targetInlines return blockEl end function eqTag(eq) return "\\tag{" .. eq .. "}" end function eqQquad(eq) return "\\qquad(" .. eq .. ")" end function isDisplayMath(el) return el.t == "Math" and el.mathtype == "DisplayMath" end -- theorems.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- preprocess theorem to ensure that embedded headings are unnumered function crossref_preprocess_theorems() return { Div = function(el) local type = refType(el.attr.identifier) if theorem_types[type] ~= nil or proof_type(el) ~= nil then return _quarto.ast.walk(el, { Header = function(el) el.classes:insert("unnumbered") return el end }) end end } end function crossref_theorems() return { Theorem = function(thm) local label = thm.identifier local type = refType(label) local title = quarto.utils.as_blocks(thm.name) thm.order = add_crossref(label, type, title) return thm end, Proof = function(proof) local label = proof.identifier if label == "" then return nil -- it's an unnumbered proof end local type = refType(label) local title = quarto.utils.as_blocks(proof.name) proof.order = add_crossref(label, type, title) return proof end, Div = function(el) local type = refType(el.attr.identifier) local theoremType = theorem_types[type] if theoremType then internal_error() else -- see if this is a proof, remark, or solution local proof = proof_type(el) if proof ~= nil then -- ensure requisite latex is injected crossref.using_theorems = true if proof.env ~= "proof" then el.attr.classes:insert("proof") end -- capture then remove name local name = markdownToInlines(el.attr.attributes["name"]) if not name or #name == 0 then name = resolveHeadingCaption(el) end el.attr.attributes["name"] = nil -- output if _quarto.format.isLatexOutput() then local preamble = pandoc.List() preamble:insert(pandoc.RawInline("latex", "\\begin{" .. proof.env .. "}")) if name ~= nil then preamble:insert(pandoc.RawInline("latex", "[")) tappend(preamble, name) preamble:insert(pandoc.RawInline("latex", "]")) end preamble:insert(pandoc.RawInline("latex", "\n")) -- https://github.com/quarto-dev/quarto-cli/issues/6077 if el.content[1].t == "Para" then preamble:extend(el.content[1].content) el.content[1].content = preamble else if (el.content[1].t ~= "Para") then -- required trick to get correct alignement when non Para content first preamble:insert(pandoc.RawInline('latex', "\\leavevmode")) end el.content:insert(1, pandoc.Plain(preamble)) end local end_env = "\\end{" .. proof.env .. "}" -- https://github.com/quarto-dev/quarto-cli/issues/6077 if el.content[#el.content].t == "Para" then el.content[#el.content].content:insert(pandoc.RawInline("latex", "\n" .. end_env)) elseif el.content[#el.content].t == "RawBlock" and el.content[#el.content].format == "latex" then -- this is required for no empty line between end_env and previous latex block el.content[#el.content].text = el.content[#el.content].text .. "\n" .. end_env else el.content:insert(pandoc.RawBlock("latex", end_env)) end elseif _quarto.format.isJatsOutput() then el = jatsTheorem(el, nil, name ) else local span = pandoc.Span( { pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title)))}, pandoc.Attr("", { "proof-title" }) ) if name ~= nil then span.content:insert(pandoc.Str(" (")) tappend(span.content, name) span.content:insert(pandoc.Str(")")) end tappend(span.content, { pandoc.Str(". ")}) -- if the first block is a paragraph, then prepend the title span if #el.content > 0 and el.content[1].t == "Para" and el.content[1].content ~= nil and #el.content[1].content > 0 then el.content[1].content:insert(1, span) else -- else insert a new paragraph el.content:insert(1, pandoc.Para{span}) end end end end return el end } end function jatsTheorem(el, label, title) -- <statement> -- <label>Equation 2</label> -- <title>The Pythagorean</title> -- <p> -- ... -- </p> -- </statement> if #title > 0 then tprepend(el.content, { pandoc.RawBlock("jats", "<title>"), pandoc.Plain(title), pandoc.RawBlock("jats", "</title>")}) end if label then tprepend(el.content, { pandoc.RawBlock("jats", "<label>"), pandoc.Plain(label), pandoc.RawBlock("jats", "</label>")}) end -- Process the caption (if any) -- Emit the statement local stmtPrefix = pandoc.RawBlock("jats", '<statement id="' .. el.attr.identifier .. '">') local stmtSuffix = pandoc.RawBlock("jats", '</statement>') el.content:insert(1, stmtPrefix) el.content:insert(stmtSuffix) return el end function captionPrefix(name, type, theoremType, order) local prefix = title(type, theoremType.title) table.insert(prefix, pandoc.Space()) tappend(prefix, numberOption(type, order)) if #name > 0 then table.insert(prefix, pandoc.Space()) table.insert(prefix, pandoc.Str("(")) tappend(prefix, name) table.insert(prefix, pandoc.Str(")")) end return prefix end -- theorem latex includes function theoremLatexIncludes() -- determine which theorem types we are using local using_theorems = crossref.using_theorems for k,v in pairs(crossref.index.entries) do local type = refType(k) if theorem_types[type] then using_theorems = true theorem_types[type].active = true end end -- return requisite latex if we are using theorems if using_theorems then local secType if crossrefOption("chapters", false) then secType = "chapter" else secType = "section" end local theoremIncludes = "\\usepackage{amsthm}\n" for _, type in ipairs(tkeys(theorem_types)) do if theorem_types[type].active then theoremIncludes = theoremIncludes .. "\\theoremstyle{" .. theorem_types[type].style .. "}\n" .. "\\newtheorem{" .. theorem_types[type].env .. "}{" .. titleString(type, theorem_types[type].title) .. "}[" .. secType .. "]\n" end end theoremIncludes = theoremIncludes .. "\\theoremstyle{remark}\n" .. "\\AtBeginDocument{\\renewcommand*{\\proofname}{" .. envTitle("proof", "Proof") .. "}}\n" .. "\\newtheorem*{remark}{" .. envTitle("remark", "Remark") .. "}\n" .. "\\newtheorem*{solution}{" .. envTitle("solution", "Solution") .. "}\n" .. "\\newtheorem{refremark}{" .. envTitle("remark", "Remark") .. "}[" .. secType .. "]\n" .. "\\newtheorem{refsolution}{" .. envTitle("solution", "Solution") .. "}[" .. secType .. "]\n" return theoremIncludes else return nil end end -- qmd.lua -- Copyright (C) 2020-2022 Posit Software, PBC function isQmdInput() return param("crossref-input-type", "md") == "qmd" end function qmd() if isQmdInput() then return { -- for qmd, look for label: and fig-cap: inside code block text CodeBlock = function(el) local label = el.text:match("|%slabel:%s(%a+%-[^\n]+)\n") if label ~= nil and (isFigureRef(label) or isTableRef(label)) then local type, caption = parseCaption(label, el.text) if type == "fig" or type == "tbl" then local order = indexNextOrder(type) indexAddEntry(label, nil, order, stringToInlines(caption)) end end return el end } else return {} end end function parseCaption(label, elText) local type, caption = elText:match("|%s(%a+)%-cap:%s(.-)\n") if caption ~= nil then -- remove enclosing quotes (if any) if caption:sub(1, 1) == '"' then caption = caption:sub(2, #caption) end if caption:sub(#caption, #caption) == '"' then caption = caption:sub(1, #caption - 1) end -- replace escaped quotes caption = caption:gsub('\\"', '"') -- return return type, caption else return nil end end -- refs.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- FIXME this resolveRefs filter should be in post-processing -- since it emits format-specific AST elements -- resolve references function resolveRefs() return { Cite = function(citeEl) local function add_ref_prefix(ref, ref_type, prefix) local category = crossref.categories.by_ref_type[ref_type] ref:extend(prefix) if category == nil or category.space_before_numbering ~= false then ref:extend({nbspString()}) end end -- all valid ref types (so we can provide feedback when one doesn't match) local refTypes = valid_ref_types() -- scan citations for refs local refs = pandoc.List() for i, cite in ipairs (citeEl.citations) do -- get the label and type, and note if the label is uppercase local label = cite.id local type = refType(label) if type ~= nil and is_valid_ref_type(type) then local upper = not not string.match(cite.id, "^[A-Z]") -- convert the first character of the label to lowercase for lookups label = pandoc.text.lower(label:sub(1, 1)) .. label:sub(2) -- lookup the label local resolve = param("crossref-resolve-refs", true) local entry = crossref.index.entries[label] if entry ~= nil or not resolve then -- preface with delimiter unless this is citation 1 if (i > 1) then refs:extend(refDelim()) refs:extend(stringToInlines(" ")) end -- create ref text local ref = pandoc.List() local category = crossref.categories.by_ref_type[type] if category ~= nil and category.custom_ref_command ~= nil and _quarto.format.isLatexOutput() then -- do nothing else, this was all handled by the custom command ref:extend({pandoc.RawInline('latex', '\\' .. category.custom_ref_command .. '{' .. label .. '}')}) elseif #cite.prefix > 0 then add_ref_prefix(ref, type, cite.prefix) elseif cite.mode ~= pandoc.SuppressAuthor then -- some special handling to detect chapters and use -- an alternate prefix lookup local prefixType = type local chapters = crossrefOption("chapters", false) if chapters and entry then if resolve and type == "sec" and isChapterRef(entry.order.section) then if entry.appendix then prefixType = "apx" else prefixType = "ch" end end end if resolve or type ~= "sec" then local prefix = refPrefix(prefixType, upper) if #prefix > 0 then add_ref_prefix(ref, type, prefix) end end end -- for latex inject a \ref, otherwise format manually if _quarto.format.isLatexOutput() then -- check for custom ref command here, but don't combine the conditional above -- so we don't get the fallthrough else clause in latex when custom ref commands -- are in play if category == nil or category.custom_ref_command == nil then ref:extend({pandoc.RawInline('latex', '\\ref{' .. label .. '}')}) end elseif _quarto.format.isAsciiDocOutput() then ref = pandoc.List({pandoc.RawInline('asciidoc', '<<' .. label .. '>>')}) elseif _quarto.format.isTypstOutput() then -- if we're referencing a subfloat, -- we need to package the parent_id information in the -- supplement as well, so that we can provide -- better numbering in the typst renderer local subfloat_info = crossref.subfloats[label] if subfloat_info == nil then ref = pandoc.List({pandoc.RawInline('typst', '@' .. label)}) else ref = pandoc.List({pandoc.RawInline('typst', '@' .. label .. '[45127368-afa1-446a-820f-fc64c546b2c5%' .. subfloat_info.parent_id .. ']')}) end else if not resolve then local refClasses = pandoc.List({"quarto-unresolved-ref"}) if #cite.prefix > 0 or cite.mode == pandoc.SuppressAuthor then refClasses:insert("ref-noprefix") end local refSpan = pandoc.Span( stringToInlines(label), pandoc.Attr("", refClasses) ) ref:insert(refSpan) elseif entry ~= nil then if entry.parent ~= nil then local parentType = refType(entry.parent) local parent = crossref.index.entries[entry.parent] ref:extend(refNumberOption(parentType,parent)) ref:extend({pandoc.Space(), pandoc.Str("(")}) ref:extend(subrefNumber(entry.order)) ref:extend({pandoc.Str(")")}) else ref:extend(refNumberOption(type, entry)) end end -- link if requested if (refHyperlink()) then ref = {pandoc.Link(ref, "#" .. label, "", pandoc.Attr("", {'quarto-xref'}))} end end -- add the ref refs:extend(ref) -- no entry for this reference, if it has a valid ref prefix -- then yield error text elseif tcontains(refTypes, type) then warn("Unable to resolve crossref @" .. label) local err = pandoc.Strong({ pandoc.Str("?@" .. label) }) refs:extend({err}) end end end -- swap citeEl for refs if we found any if #refs > 0 then return refs else return citeEl end end } end -- we're removing the dashes from this uuid because -- it makes it easier to handling it in lua patterns local quarto_auto_label_safe_latex_uuid = "539a35d47e664c97a50115a146a7f1bd" function autoRefLabel(refType) local index = 1 while true do local label = refType .. "-" .. quarto_auto_label_safe_latex_uuid .. "-" ..tostring(index) if not crossref.autolabels:includes(label) then crossref.autolabels:insert(label) return label else index = index + 1 end end end function autoSubrefLabel(parentId) local index = 1 while true do local label = parentId .. "-" .. tostring(index) if not crossref.autolabels:includes(label) then crossref.autolabels:insert(label) return label else index = index + 1 end end end function refLabel(type, inline) if inline.text then return string.match(inline.text, "^" .. refLabelPattern(type) .. "$") else return nil end end function extractRefLabel(type, text) return string.match(text, "^(.*)" .. refLabelPattern(type) .. "$") end function refLabelPattern(type) return "{#(" .. type .. "%-[^ }]+)}" end function is_valid_ref_type(type) return tcontains(valid_ref_types(), type) end function valid_ref_types() local types = tkeys(theorem_types) for k, _ in pairs(crossref.categories.by_ref_type) do table.insert(types, k) -- if v.type ~= nil and not tcontains(types, v.type) then -- table.insert(types, v.type) -- end end -- table.insert(types, "fig") -- table.insert(types, "tbl") -- table.insert(types, "lst") table.insert(types, "eq") table.insert(types, "sec") return types end -- meta.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- inject metadata function crossrefMetaInject() return { Meta = function(meta) local function as_latex(inlines) return trim(pandoc.write(pandoc.Pandoc(inlines), "latex")) end metaInjectLatex(meta, function(inject) inject(usePackage("caption")) inject( "\\AtBeginDocument{%\n" .. maybeRenewCommand("contentsname", param("toc-title-document", "Table of contents")) .. maybeRenewCommand("listfigurename", listOfTitle("lof", "List of Figures")) .. maybeRenewCommand("listtablename", listOfTitle("lot", "List of Tables")) .. maybeRenewCommand("figurename", as_latex(title("fig", "Figure"))) .. maybeRenewCommand("tablename", as_latex(title("tbl", "Table"))) .. "}\n" ) if param("listings", false) then inject( "\\newcommand*\\listoflistings\\lstlistoflistings\n" .. "\\AtBeginDocument{%\n" .. "\\renewcommand*\\lstlistlistingname{" .. listOfTitle("lol", "List of Listings") .. "}\n" .. "}\n" ) else inject( usePackage("float") .. "\n" .. "\\floatstyle{ruled}\n" .. "\\@ifundefined{c@chapter}{\\newfloat{codelisting}{h}{lop}}{\\newfloat{codelisting}{h}{lop}[chapter]}\n" .. "\\floatname{codelisting}{" .. as_latex(title("lst", "Listing")) .. "}\n" ) inject( "\\newcommand*\\listoflistings{\\listof{codelisting}{" .. listOfTitle("lol", "List of Listings") .. "}}\n" ) end -- title-delim if crossrefOption("title-delim") ~= nil then local titleDelim = pandoc.utils.stringify(crossrefOption("title-delim")) if titleDelim == ":" or titleDelim == "colon" then inject("\\captionsetup{labelsep=colon}\n") elseif titleDelim == "." or titleDelim == "period" then inject("\\captionsetup{labelsep=period}\n") elseif titleDelim == " " or titleDelim == "space" then inject("\\captionsetup{labelsep=space}\n") elseif titleDelim == "quad" then inject("\\captionsetup{labelsep=quad}\n") elseif titleDelim == "none" or titleDelim == "" then inject("\\captionsetup{labelsep=none}\n") else warn("\nIgnoring invalid value for 'title-delim' option in PDF: " .. titleDelim .. "." .. "\nThe valid values in the caption LaTeX package are:" .. "\n'', 'none', ':', 'colon', '.', 'period', ' ', 'space', and 'quad'") end end local theoremIncludes = theoremLatexIncludes() if theoremIncludes then inject(theoremIncludes) end end) return meta end } end function maybeRenewCommand(command, arg) local commandWithArg = command .. "{" .. arg .. "}" return "\\ifdefined\\" .. command .. "\n " .. "\\renewcommand*\\" .. commandWithArg .. "\n\\else\n " .. "\\newcommand\\" .. commandWithArg .. "\n\\fi\n" end -- latex 'listof' title for type function listOfTitle(type, default) local title = crossrefOption(type .. "-title") if title then return pandoc.utils.stringify(title) else return param("crossref-" .. type .. "-title", default) end end -- format.lua -- Copyright (C) 2020-2022 Posit Software, PBC function title(type, default) default = param("crossref-" .. type .. "-title", default) return crossrefOption(type .. "-title", stringToInlines(default)) end function envTitle(type, default) return param("environment-" .. type .. "-title", default) end function titleString(type, default) return pandoc.utils.stringify(title(type, default)) end function titlePrefix(ref_type, default, order, with_title_delimiter) if with_title_delimiter == nil then with_title_delimiter = true end local prefix = title(ref_type, default) local category = crossref.categories.by_ref_type[ref_type] if category == nil or category.space_before_numbering ~= false then table.insert(prefix, nbspString()) end tappend(prefix, numberOption(ref_type, order)) if with_title_delimiter then tappend(prefix, titleDelim()) table.insert(prefix, pandoc.Space()) end return prefix end function titleDelim() return crossrefOption("title-delim", stringToInlines(":")) end function captionSubfig() return crossrefOption("caption-subfig", true) end function captionCollectedDelim() return crossrefOption("caption-collected-delim", stringToInlines(",\u{a0}")) end function captionCollectedLabelSep() return crossrefOption("caption-collected-label-sep", stringToInlines("\u{a0}—\u{a0}")) end function subrefNumber(order) return numberOption("subref", order, {pandoc.Str("alpha"),pandoc.Space(),pandoc.Str("a")}) end function prependSubrefNumber(captionContent, order) if not _quarto.format.isLatexOutput() and not _quarto.format.isAsciiDocOutput() then if #inlinesToString(captionContent) > 0 then tprepend(captionContent, { pandoc.Space() }) end tprepend(captionContent, { pandoc.Str(")") }) tprepend(captionContent, subrefNumber(order)) captionContent:insert(1, pandoc.Str("(")) end end function refPrefix(type, upper) local opt = type .. "-prefix" local default = param("crossref-" .. type .. "-prefix") if default == nil then default = crossref.categories.by_ref_type[type] if default ~= nil then default = default.prefix end end if default == nil then default = type .. "." end default = stringToInlines(default) local prefix = crossrefOption(opt, default) if upper then local el = pandoc.Plain(prefix) local firstStr = true el = _quarto.ast.walk(el, { Str = function(str) if firstStr then local strText = pandoc.text.upper(pandoc.text.sub(str.text, 1, 1)) .. pandoc.text.sub(str.text, 2, -1) str = pandoc.Str(strText) firstStr = false end return str end }) prefix = el.content end return prefix end function refDelim() return crossrefOption("ref-delim", stringToInlines(",")) end function refHyperlink() return crossrefOption("ref-hyperlink", true) end function refNumberOption(type, entry) -- for sections just return the section levels if type == "sec" then local num = nil if entry.appendix then num = string.char(64 + entry.order.section[1] - crossref.startAppendix + 1) elseif crossrefOption("chapters", false) then num = tostring(entry.order.section[1]) end return stringToInlines(sectionNumber(entry.order.section, nil, num)) end -- handle other ref types return formatNumberOption(type, entry.order) end function numberOption(type, order, default) -- for sections, just return the section levels (we don't currently -- support custom numbering for sections since pandoc is often the -- one doing the numbering) if type == "sec" then return stringToInlines(sectionNumber(order.section)) end -- format return formatNumberOption(type, order, default) end function formatNumberOption(type, order, default) -- alias num and section (set section to nil if we aren't using chapters) local num = order.order local section = order.section if not crossrefOption("chapters", false) then section = nil elseif section ~= nil and section[1] == 0 then section = nil elseif crossref.maxHeading ~= 1 then section = nil end -- return a pandoc.Str w/ chapter prefix (if any) local function resolve(num) if section then local sectionIndex = section[1] if crossrefOption("chapters-alpha", false) then sectionIndex = string.char(64 + sectionIndex) elseif crossref.startAppendix ~= nil and sectionIndex >= crossref.startAppendix then sectionIndex = string.char(64 + sectionIndex - crossref.startAppendix + 1) else sectionIndex = tostring(sectionIndex) end num = sectionIndex .. "." .. num end return pandoc.Inlines({ pandoc.Str(num) }) end -- Compute option name and default value local opt = type .. "-labels" if default == nil then default = stringToInlines("arabic") end -- See if there a global label option, if so, use that -- if the type specific label isn't specified local labelOpt = crossrefOption("labels", default); -- determine the style local styleRaw = crossrefOption(opt, labelOpt) local numberStyle = pandoc.utils.stringify(styleRaw) -- process the style if (numberStyle == "arabic") then return resolve(tostring(num)) elseif (string.match(numberStyle, "^alpha ")) then -- permits the user to include the character that they'd like -- to start the numbering with (e.g. alpha a vs. alpha A) local s = split(numberStyle, " ") local startIndexChar = s[2] if (startIndexChar == nil or startIndexChar == " ") then startIndexChar = "a" end -- local startIndexChar = string.sub(numberStyle, -1) -- if (startIndexChar == " ") then -- startIndexChar = "a" -- end -- print(numberStyle) local startIndex = utf8.codepoint(startIndexChar) return resolve(utf8.char(startIndex + num - 1)) elseif (string.match(numberStyle, "^roman")) then -- permits the user to express `roman` or `roman i` or `roman I` to -- use lower / uppper case roman numerals local lower = false if (string.sub(numberStyle, -#"i") == "i") then lower = true end return resolve(toRoman(num, lower)) else -- otherwise treat the value as a list of values to use -- to display the numbers local entryCount = #styleRaw -- select an index based upon the num, wrapping it around local entryIndex = (num - 1) % entryCount + 1 local option = styleRaw[entryIndex]:clone() if section then tprepend(option, { pandoc.Str(tostring(section[1]) .. ".") }) end return pandoc.Inlines({ option }) end end function sectionNumber(section, maxLevel, num) if num == nil then num = "" if crossref.maxHeading == 1 then num = formatChapterIndex(section[1]) end end local endIndex = #section if maxLevel then endIndex = maxLevel end local lastIndex = 1 for i=endIndex,2,-1 do if section[i] > 0 then lastIndex = i break end end for i=2,lastIndex do if num ~= '' then num = num .. "." end num = num .. tostring(section[i]) end return num end function isChapterRef(section) for i=2,#section do if section[i] > 0 then return false end end return true end function formatChapterIndex(index) local fileMetadata = currentFileMetadataState() if fileMetadata.appendix then return string.char(64 + fileMetadata.file.bookItemNumber) elseif crossrefOption("chapters-alpha", false) then return string.char(64 + index) else return tostring(index) end end function toRoman(num, lower) local roman = pandoc.utils.to_roman_numeral(num) if lower then lower = '' for i = 1, #roman do lower = lower .. string.char(utf8.codepoint(string.sub(roman,i,i)) + 32) end return lower else return roman end end -- options.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- initialize options from 'crossref' metadata value function init_crossref_options(meta) crossref.options = readFilterOptions(meta, "crossref") -- automatically set maxHeading to 1 if we are in chapters mode, otherwise set to max (7) if crossrefOption("chapters", false) then crossref.maxHeading = 1 else crossref.maxHeading = 7 end end -- get option value function crossrefOption(name, default) return readOption(crossref.options, name, default) end -- bibliography-formats.lua -- Copyright (C) 2020-2022 Posit Software, PBC function bibliography_formats() return { Pandoc = function(doc) if _quarto.format.isBibliographyOutput() then doc.meta.references = pandoc.utils.references(doc) doc.meta.bibliography = nil return doc end end } end -- book-links.lua -- Copyright (C) 2020-2022 Posit Software, PBC function index_book_file_targets() if not param("single-file-book", false) then return {} else return { Header = function(el) if el.level == 1 then local file = currentFileMetadataState().file if file ~= nil then local filename = file.bookItemFile; if filename ~= nil and quarto_global_state.fileSectionIds[filename] == nil then quarto_global_state.fileSectionIds[filename] = el.identifier end end end end } end end function resolve_book_file_targets() if not param("single-file-book", false) then return {} else return { Link = function(el) local linkTarget = el.target -- if this is a local path if isRelativeRef(linkTarget) then local file = currentFileMetadataState().file -- normalize the linkTarget (collapsing any '..') if #linkTarget > 0 then local fullPath = linkTarget if file ~= nil and file.resourceDir ~= nil then fullPath = pandoc.path.join({file.resourceDir, linkTarget}) end linkTarget = pandoc.path.normalize(flatten(fullPath)); end -- resolve the path local hashPos = string.find(linkTarget, '#') if hashPos ~= nil then -- deal with a link that includes a hash (just remove the prefix) local target = string.sub(linkTarget, hashPos, #linkTarget) el.target = target else -- Deal with bare file links -- escape windows paths if present package.config:sub(1,1) -- Paths are always using '/' separator (even on windows) linkTarget = linkTarget:gsub("\\", "/") local sectionId = quarto_global_state.fileSectionIds[linkTarget]; if sectionId ~= nil then el.target = '#' .. sectionId end end end return el end } end end function flatten(targetPath) local pathParts = pandoc.path.split(targetPath) local resolvedPath = pandoc.List() -- FIXME are we not handling "."? for _, part in ipairs(pathParts) do if part == '..' then table.remove(resolvedPath) else resolvedPath:insert(part) end end return pandoc.path.join(resolvedPath) end -- book-numbering.lua -- Copyright (C) 2020-2022 Posit Software, PBC function book_numbering() return { Header = function(el) local file = currentFileMetadataState().file if file ~= nil then local bookItemType = file.bookItemType local bookItemDepth = file.bookItemDepth if bookItemType ~= nil then -- if we are in an unnumbered chapter then add unnumbered class if bookItemType == "chapter" and file.bookItemNumber == nil then el.attr.classes:insert('unnumbered') end -- handle latex "part" and "appendix" headers if el.level == 1 and _quarto.format.isLatexOutput() then if bookItemType == "part" then local partPara = pandoc.Para({ pandoc.RawInline('latex', '\\part{') }) tappend(partPara.content, el.content) partPara.content:insert( pandoc.RawInline('latex', '}')) return partPara elseif bookItemType == "appendix" then local appendixPara = pandoc.Para({ pandoc.RawInline('latex', '\\cleardoublepage\n\\phantomsection\n\\addcontentsline{toc}{part}{') }) tappend(appendixPara.content, el.content) appendixPara.content:insert(pandoc.RawInline('latex', '}\n\\appendix')) return appendixPara elseif bookItemType == "chapter" and bookItemDepth == 0 then quarto_global_state.usingBookmark = true local bookmarkReset = pandoc.Div({ pandoc.RawInline('latex', '\\bookmarksetup{startatroot}\n'), el }) return bookmarkReset end end -- mark appendix chapters for epub if el.level == 1 and _quarto.format.isEpubOutput() then if file.appendix == true and bookItemType == "chapter" then el.attr.attributes["epub:type"] = "appendix" end end -- part cover pages have unnumbered headings if (bookItemType == "part") then el.attr.classes:insert("unnumbered") end -- return potentially modified heading el return el end end end } end -- code.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") local hasAnnotations = false function isAnnotationCell(el) return el and is_regular_node(el, "Div") and el.attr.classes:includes(constants.kCellAnnotationClass) end -- annotations appear at the end of the line and are of the form -- # <1> -- where they start with a comment character valid for that code cell -- and they contain a number which is the annotation number in the -- OL that will appear after the annotation -- This provider will yield functions for a particular language that -- can be used to resolve annotation numbers and strip them from source -- code local function annoteProvider(lang) local commentChars = constants.kLangCommentChars[lang] or constants.kDefaultCodeAnnotationComment if commentChars ~= nil then local startComment = patternEscape(commentChars[1]) local matchExpr = '.*' .. startComment .. '%s*<([0-9]+)>%s*' local stripPrefix = '%s*' .. startComment .. '%s*<' local stripSuffix = '>%s*' if #commentChars == 2 then local endComment = patternEscape(commentChars[2]) matchExpr = matchExpr .. endComment .. '%s*' stripSuffix = stripSuffix .. endComment .. '%s*' end matchExpr = matchExpr .. '$' stripSuffix = stripSuffix .. '$' local expression = { match = matchExpr, strip = { prefix = stripPrefix, suffix = stripSuffix }, } return { annotationNumber = function(line) local _, _, annoteNumber = string.find(line, expression.match) if annoteNumber ~= nil then return tonumber(annoteNumber) else return nil end end, stripAnnotation = function(line, annoteId) return line:gsub(expression.strip.prefix .. annoteId .. expression.strip.suffix, "") end, replaceAnnotation = function(line, annoteId, replacement) return line:gsub(expression.strip.prefix .. annoteId .. expression.strip.suffix, replacement) end, createComment = function(value) if #commentChars == 0 then return value else if #commentChars == 1 then return commentChars[1] .. ' ' .. value else return commentChars[1] .. ' '.. value .. ' ' .. commentChars[2] end end end } else return nil end end local function toAnnoteId(number) return 'annote-' .. tostring(number) end local function latexListPlaceholder(number) return '5CB6E08D-list-annote-' .. number end local function toLines(s) if s:sub(-1)~="\n" then s=s.."\n" end return s:gmatch("(.-)\n") end -- Finds annotations in a code cell and returns -- the annotations as well as a code cell that -- removes the annotations local function resolveCellAnnotes(codeBlockEl, processAnnotation) -- The start line, this may be shifted for cases like -- fenced code blocks, which will have additional code injected -- and so require an adjusted start line local defaultStartLine = 1 -- collect any annotations on this code cell local lang = codeBlockEl.attr.classes[1] -- handle fenced-echo block which will have no language if lang == "cell-code" then _, _, matchedLang = string.find(codeBlockEl.text, "^`+%{%{([^%}]*)%}%}") lang = matchedLang or lang elseif lang ~= nil and startsWith(lang, '{{') then _, _, matchedLang = string.find(lang, "{{+(.-)}}+") if matchedLang then lang = matchedLang defaultStartLine = defaultStartLine + 1 end end local annotationProvider = annoteProvider(lang) if annotationProvider ~= nil then local annotations = {} local code = codeBlockEl.text local outputs = pandoc.List({}) local i = 1 local offset = codeBlockEl.attr.attributes['startFrom'] or defaultStartLine for line in toLines(code) do -- Look and annotation local annoteNumber = annotationProvider.annotationNumber(line) if annoteNumber then -- Capture the annotation number and strip it local annoteId = toAnnoteId(annoteNumber) local lineNumbers = annotations[annoteId] if lineNumbers == nil then lineNumbers = pandoc.List({}) end -- line numbers stored for targetting annotations line needs to take into account possible startFrom attribute lineNumbers:insert(offset - 1 + i) annotations[annoteId] = lineNumbers outputs:insert(processAnnotation(line, annoteNumber, annotationProvider)) else outputs:insert(line) end i = i + 1 end -- if we capture annotations, then replace the code source -- code, stripping annotation comments if annotations and next(annotations) ~= nil then local outputText = "" for i, output in ipairs(outputs) do outputText = outputText .. output if i < #outputs then outputText = outputText .. '\n' end end codeBlockEl.text = outputText hasAnnotations = true end return codeBlockEl, annotations elseif lang then return codeBlockEl, {} end end local function lineNumberMeta(list) -- accumulates the output string local val = '' local addLines = function(lines) if val == '' then val = lines else val = val .. ',' .. lines end end -- writes out either an individual number of a range -- of numbers (from pending to current) local pending = nil local current = nil local valuesWritten = 0; local writePending = function() if pending == current then addLines(tostring(current)) pending = nil current = nil valuesWritten = valuesWritten + 1 -- one for the pending line number else addLines(tostring(pending) .. '-' .. tostring(current)) pending = nil current = nil valuesWritten = valuesWritten + 2 -- one for pending, one for current end end -- go through the line numbers and collapse sequences of numbers -- into a line number ranges when possible local lineNoStr = "" for _i, v in ipairs(list) do if lineNoStr == "" then lineNoStr = v else lineNoStr = lineNoStr .. ',' .. v end if pending == nil then pending = v current = v else if v == current + 1 then current = v else writePending() pending = v current = v end end end if pending ~= nil then writePending() end return { text = val, count = valuesWritten, lineNumbers = lineNoStr } end function processLaTeXAnnotation(line, annoteNumber, annotationProvider) -- we specially handle LaTeX output in coordination with the post processor -- which will replace any of these tokens as appropriate. local hasHighlighting = param('text-highlighting', false) if param(constants.kCodeAnnotationsParam) == constants.kCodeAnnotationStyleNone then local replaced = annotationProvider.stripAnnotation(line, annoteNumber) return replaced else if hasHighlighting then -- highlighting is enabled, allow the comment through local placeholderComment = annotationProvider.createComment("<" .. tostring(annoteNumber) .. ">") local replaced = annotationProvider.replaceAnnotation(line, annoteNumber, percentEscape(" " .. placeholderComment)) return replaced else -- no highlighting enabled, ensure we use a standard comment character local placeholderComment = "%% (" .. tostring(annoteNumber) .. ")" local replaced = annotationProvider.replaceAnnotation(line, annoteNumber, placeholderComment) return replaced end end end function processAsciidocAnnotation(line, annoteNumber, annotationProvider) if param(constants.kCodeAnnotationsParam) == constants.kCodeAnnotationStyleNone then local replaced = annotationProvider.replaceAnnotation(line, annoteNumber, '') return replaced else local replaced = annotationProvider.replaceAnnotation(line, annoteNumber, " <" .. tostring(annoteNumber) .. ">") return replaced end end function processAnnotation(line, annoteNumber, annotationProvider) -- For all other formats, just strip the annotation- the definition list is converted -- to be based upon line numbers. local stripped = annotationProvider.stripAnnotation(line, annoteNumber) return stripped end function code_meta() return { Meta = function(meta) if _quarto.format.isLatexOutput() and hasAnnotations and param(constants.kCodeAnnotationsParam) ~= constants.kCodeAnnotationStyleNone then -- ensure we have tikx for making the circles quarto.doc.use_latex_package("tikz"); quarto.doc.include_text('in-header', [[ \newcommand*\circled[1]{\tikz[baseline=(char.base)]{ \node[shape=circle,draw,inner sep=1pt] (char) {{\scriptsize#1}};}} ]]); end end, } end -- The actual filter that will look for a code cell and then -- find its annotations, then process the subsequent OL function code_annotations() -- the localized strings local language = param("language", nil) -- an id counter to provide nice numeric ids to cell local idCounter = 1 -- walk the blocks and look for annotated code -- process the list top down so that we see the outer -- code divs first local code_filter = { traverse = 'topdown', Blocks = function(blocks) -- the user request code annotations value local codeAnnotations = param(constants.kCodeAnnotationsParam) -- if code annotations is false, then shut it down if codeAnnotations ~= false then local outputs = pandoc.List() -- annotations[annotation-number] = {list of line numbers} local pendingAnnotations = nil local pendingCellId = nil local pendingCodeCell = nil local clearPending = function() pendingAnnotations = nil pendingCellId = nil pendingCodeCell = nil end local outputBlock = function(block) outputs:insert(block) end local flushPending = function() if pendingCodeCell then outputBlock(pendingCodeCell) end clearPending() end local outputBlockClearPending = function(block) flushPending() outputBlock(block) end local allOutputs = function() return outputs end local resolveCellId = function(identifier) if identifier ~= nil and identifier ~= '' then return identifier else local cellId = 'annotated-cell-' .. tostring(idCounter) idCounter = idCounter + 1 return cellId end end local processCodeCell = function(el, identifier) -- select the process for this format's annotations local annotationProcessor = processAnnotation if _quarto.format.isLatexOutput() then annotationProcessor = processLaTeXAnnotation elseif _quarto.format.isAsciiDocOutput() then annotationProcessor = processAsciidocAnnotation end -- resolve annotations local resolvedCodeBlock, annotations = resolveCellAnnotes(el, annotationProcessor) if annotations and next(annotations) ~= nil then -- store the annotations and cell info pendingAnnotations = annotations pendingCellId = identifier -- decorate the cell and return it if codeAnnotations ~= constants.kCodeAnnotationStyleNone then resolvedCodeBlock.attr.classes:insert(constants.kDataCodeAnnonationClz); end return resolvedCodeBlock else return nil end end for i, block in ipairs(blocks) do local found = is_regular_node(block, "Div") and block.attr.classes:find('cell') if is_custom_node(block) then local custom = _quarto.ast.resolve_custom_data(block) if custom then found = found or (custom.classes or pandoc.List({})):find('cell') end end if found then -- Process executable code blocks -- In the case of executable code blocks, we actually want -- to shift the OL up above the output, so we hang onto this outer -- cell so we can move the OL up into it if there are annotations local processedAnnotation = false local resolvedBlock = _quarto.ast.walk(block, { CodeBlock = function(el) if el.attr.classes:find('cell-code') then local cellId = resolveCellId(el.attr.identifier) local codeCell = processCodeCell(el, cellId) if codeCell then processedAnnotation = true if codeAnnotations ~= constants.kCodeAnnotationStyleNone then codeCell.attr.identifier = cellId; end end return codeCell end end }) if processedAnnotation then -- we found annotations, so hand onto this cell pendingCodeCell = resolvedBlock else -- no annotations, just output it outputBlock(resolvedBlock) end elseif block.t == "Div" then local isDecoratedCodeBlock = is_custom_node(block, "DecoratedCodeBlock") if isDecoratedCodeBlock then -- If there is a pending code cell and we get here, just -- output the pending code cell and continue flushPending() if #block.content == 1 and #block.content[1].content == 1 then -- Find the code block and process that local codeblock = block.content[1].content[1] local cellId = resolveCellId(codeblock.attr.identifier) local codeCell = processCodeCell(codeblock, cellId) if codeCell then if codeAnnotations ~= constants.kCodeAnnotationStyleNone then codeCell.attr.identifier = cellId; end block.content[1].content[1] = codeCell outputBlock(block) else outputBlockClearPending(block) end else outputBlockClearPending(block) end else outputBlockClearPending(block) end elseif block.t == 'CodeBlock' then -- don't process code cell output here - we'll get it above -- This processes non-executable code blocks if not block.attr.classes:find('cell-code') then -- If there is a pending code cell and we get here, just -- output the pending code cell and continue flushPending() local cellId = resolveCellId(block.attr.identifier) local codeCell = processCodeCell(block, cellId) if codeCell then if codeAnnotations ~= constants.kCodeAnnotationStyleNone then codeCell.attr.identifier = cellId; end outputBlock(codeCell) else outputBlockClearPending(block) end else outputBlockClearPending(block) end elseif block.t == 'OrderedList' and pendingAnnotations ~= nil and next(pendingAnnotations) ~= nil then -- There are pending annotations, which means this OL is immediately after -- a code cell with annotations. Use to emit a DL describing the code local items = pandoc.List() for i, v in ipairs(block.content) do -- find the annotation for this OL local annotationNumber = block.start + i - 1 local annoteId = toAnnoteId(annotationNumber) local annotation = pendingAnnotations[annoteId] if annotation then local lineNumMeta = lineNumberMeta(annotation) -- compute the term for the DT local term = "" if _quarto.format.isLatexOutput() then term = latexListPlaceholder(annotationNumber) elseif _quarto.format.isAsciiDocOutput() then term = "<" .. tostring(annotationNumber) .. ">" else if lineNumMeta.count == 1 then term = language[constants.kCodeLine] .. " " .. lineNumMeta.text; else term = language[constants.kCodeLines] .. " " .. lineNumMeta.text; end end -- compute the definition for the DD local definitionContent = v[1].content local annotationToken = tostring(annotationNumber); -- Only output span for certain formats (HTML) -- for markdown / gfm we should drop the spans local definition = nil if _quarto.format.isHtmlOutput() then -- use an attribute list since it then guarantees that the -- order of the attributes is consistent from run to run local attribs = pandoc.AttributeList { {constants.kDataCodeCellTarget, pendingCellId}, {constants.kDataCodeCellLines, lineNumMeta.lineNumbers}, {constants.kDataCodeCellAnnotation, annotationToken} } definition = pandoc.Span(definitionContent, pandoc.Attr(attribs)) else definition = pandoc.Plain(definitionContent) end -- find the lines that annotate this and convert to a DL items:insert({ term, definition}) else -- there was an OL item without a corresponding annotation warn("List item " .. tostring(i) .. " has no corresponding annotation in the code cell\n(" .. pandoc.utils.stringify(v) .. ")") end end -- add the definition list local dl if _quarto.format.isAsciiDocOutput() then local formatted = pandoc.List() for _i,v in ipairs(items) do local annotationMarker = v[1] .. ' ' local definition = v[2] tprepend(definition.content, {annotationMarker}) formatted:insert(definition) end dl = pandoc.Div(formatted) else dl = pandoc.DefinitionList(items) end -- if there is a pending code cell, then insert into that and add it if codeAnnotations ~= constants.kCodeAnnotationStyleNone then if pendingCodeCell ~= nil then -- wrap the definition list in a cell local dlDiv = pandoc.Div({dl}, pandoc.Attr("", {constants.kCellAnnotationClass})) if is_custom_node(pendingCodeCell) then local custom = _quarto.ast.resolve_custom_data(pendingCodeCell) or pandoc.Div({}) -- won't happen but the Lua analyzer doesn't know it custom.content:insert(2, dlDiv) else pendingCodeCell.content:insert(2, dlDiv) end flushPending() else outputBlockClearPending(dl) end else flushPending() end else outputBlockClearPending(block) end end -- Be sure to flush any pending Code Cell (usually when only annotated cell without annotation and no other following blocks) flushPending() return allOutputs() end end } -- return code_filter return { Pandoc = function(doc) local codeAnnotations = param(constants.kCodeAnnotationsParam) -- if code annotations is false, then don't even walk it if codeAnnotations == false then return nil end return _quarto.ast.walk(doc, code_filter) end } end -- for code blocks w/ filename create an enclosing div: -- <div class="code-with-filename"> -- <div class="code-with-filename-file"> -- <pre>filename.py</pre> -- </div> -- <div class="sourceCode" id="cb1" data-filename="filename.py"> -- <pre></pre> -- </div> -- </div> function code_filename() local function codeBlockWithFilename(el, filename) return quarto.DecoratedCodeBlock({ filename = filename, code_block = el:clone() }) end local code_filename_filter = { CodeBlock = function(code) local filename = code.attributes["filename"] if filename then return codeBlockWithFilename(code, filename) end end, -- this is a weird rule, we should make sure to document it -- to users Div = function(div) local filename = div.attributes["filename"] if filename and div.content[1].t == "CodeBlock" then local decorated_block = codeBlockWithFilename(div.content[1], filename) div.attributes["filename"] = nil div.content[1] = decorated_block return div end end, -- -- transform ast for 'filename' -- Blocks = function(blocks) -- local foundFilename = false -- local newBlocks = pandoc.List() -- for _,block in ipairs(blocks) do -- if block.attributes ~= nil and block.attributes["filename"] then -- local filename = block.attributes["filename"] -- if block.t == "CodeBlock" then -- foundFilename = true -- block.attributes["filename"] = nil -- local code_block = codeBlockWithFilename(block, filename) -- newBlocks:insert(code_block) -- elseif is_regular_node(block, "Div") and block.content[1].t == "CodeBlock" then -- foundFilename = true -- block.attributes["filename"] = nil -- block.content[1] = codeBlockWithFilename(block.content[1], filename) -- newBlocks:insert(block) -- else -- newBlocks:insert(block) -- end -- else -- newBlocks:insert(block) -- end -- end -- -- if we found a file name then return the modified list of blocks -- if foundFilename then -- return newBlocks -- else -- return blocks -- end -- end } return code_filename_filter end -- engine-escape.lua -- Copyright (C) 2021-2022 Posit Software, PBC local patterns = require("modules/patterns") function engine_escape() return { CodeBlock = function(el) -- handle code block with 'escaped' language engine if #el.attr.classes == 1 or #el.attr.classes == 2 and el.attr.classes[2] == 'code-annotation-code' then local engine, lang = el.attr.classes[1]:match(patterns.engine_escape) if engine then el.text = "```" .. engine .. "\n" .. el.text .. "\n" .. "```" el.attr.classes[1] = "markdown" return el end end -- handle escaped engines within a code block el.text = el.text:gsub("```" .. patterns.engine_escape, function(engine, lang) if #el.attr.classes == 0 or not isHighlightClass(el.attr.classes[1]) then el.attr.classes:insert(1, "markdown") end return "```" .. engine end) -- handles escaped inline code cells within a code block el.text = el.text:gsub("(\n?[^`\n]+`+)" .. patterns.engine_escape, "%1%2") return el end, Code = function(el) -- don't accidentally process escaped shortcodes if el.text:match("^" .. patterns.shortcode) then return el end -- handle `{{python}} code` el.text = el.text:gsub("^" .. patterns.engine_escape, "%1") -- handles `` `{{python}} code` `` el.text = el.text:gsub("^(`+)" .. patterns.engine_escape, "%1%2") return el end } end -- FIXME these should be determined dynamically local kHighlightClasses = { "abc", "actionscript", "ada", "agda", "apache", "asn1", "asp", "ats", "awk", "bash", "bibtex", "boo", "c", "changelog", "clojure", "cmake", "coffee", "coldfusion", "comments", "commonlisp", "cpp", "cs", "css", "curry", "d", "default", "diff", "djangotemplate", "dockerfile", "dot", "doxygen", "doxygenlua", "dtd", "eiffel", "elixir", "elm", "email", "erlang", "fasm", "fortranfixed", "fortranfree", "fsharp", "gap", "gcc", "glsl", "gnuassembler", "go", "graphql", "groovy", "hamlet", "haskell", "haxe", "html", "idris", "ini", "isocpp", "j", "java", "javadoc", "javascript", "javascriptreact", "json", "jsp", "julia", "kotlin", "latex", "lex", "lilypond", "literatecurry", "literatehaskell", "llvm", "lua", "m4", "makefile", "mandoc", "markdown", "mathematica", "matlab", "maxima", "mediawiki", "metafont", "mips", "modelines", "modula2", "modula3", "monobasic", "mustache", "nasm", "nim", "noweb", "objectivec", "objectivecpp", "ocaml", "octave", "opencl", "pascal", "perl", "php", "pike", "postscript", "povray", "powershell", "prolog", "protobuf", "pure", "purebasic", "python", "qml", "r", "raku", "relaxng", "relaxngcompact", "rest", "rhtml", "roff", "ruby", "rust", "scala", "scheme", "sci", "sed", "sgml", "sml", "spdxcomments", "sql", "sqlmysql", "sqlpostgresql", "stata", "swift", "tcl", "tcsh", "texinfo", "toml", "typescript", "verilog", "vhdl", "xml", "xorg", "xslt", "xul", "yacc", "yaml", "zsh" } function isHighlightClass(class) for _, v in ipairs (kHighlightClasses) do if v == class then return true end end return false end -- figures.lua -- Copyright (C) 2020-2022 Posit Software, PBC function quarto_pre_figures() -- provide default fig-pos or fig-env if specified local function forward_pos_and_env(el) local figPos = param(kFigPos) if figPos and not el.attributes[kFigPos] then el.attributes[kFigPos] = figPos end -- remove fig-pos if it is false, since it -- signals "don't use any value" if el.attributes[kFigPos] == "FALSE" then el.attributes[kFigPos] = nil end local figEnv = param(kFigEnv) if figEnv and not el.attributes[kFigEnv] then el.attributes[kFigEnv] = figEnv end return el end return { FloatRefTarget = function(float) local kind = refType(float.identifier) if kind ~= "fig" then return end -- propagate fig-alt if _quarto.format.isHtmlOutput() then -- read the fig-alt text and set the image alt local altText = attribute(float, kFigAlt, nil) if altText ~= nil then float.attributes["alt"] = altText float.attributes[kFigAlt] = nil return float end elseif _quarto.format.isLatexOutput() then return forward_pos_and_env(float) end end, Figure = function(figure) if _quarto.format.isLatexOutput() then return forward_pos_and_env(figure) end end } end -- hidden.lua -- Copyright (C) 2020-2022 Posit Software, PBC local kNone = "none" local kCode = "code" local kWarning = "warning" local kAll = "all" local kKeepHidden = "keep-hidden" local kRemoveHidden = "remove-hidden" local kClearHiddenClasses = "clear-hidden-classes" function hidden() local keepHidden = param(kKeepHidden, false) local removeHidden = param(kRemoveHidden, "none") local clearHiddenClz = param(kClearHiddenClasses, "none") local function stripHidden(el) if el.attr.classes:find("hidden") then return {} end end local function clearHiddenClasses(el) local val, idx = el.attr.classes:find("hidden") if idx then el.attr.classes:remove(idx); return el else return undefined end end local function isWarning(el) return el.attr.classes:find("cell-output-stderr") end local stripHiddenCellFilter = { Div = stripHidden, CodeBlock = stripHidden } -- Allow additional control of what to do with hidden code and warnings -- in the output. This allows rendering with echo/warning=false and keep-hidden=true -- to do some additional custom processing (for example, marking all as hidden, but -- but then removing the hidden elements from the output). if removeHidden ~= kNone or clearHiddenClz ~= kNone then local function remove(thing) return removeHidden == kAll or removeHidden == thing end local function clear(thing) return clearHiddenClz == kAll or clearHiddenClz == thing end local function clearOrRemoveEl(el) if isWarning(el) then if remove(KWarning) then return stripHidden(el) elseif clear(kWarning) then return clearHiddenClasses(el) end else if remove(kCode) then return stripHidden(el) elseif clear(kCode) then return clearHiddenClasses(el) end end end return { Div = clearOrRemoveEl, CodeBlock = clearOrRemoveEl } elseif keepHidden and not _quarto.format.isHtmlOutput() then return stripHiddenCellFilter else return {} end end -- include-paths.lua -- -- fixes paths from <include> directives -- -- Copyright (C) 2022 Posit Software, PBC function include_paths() return { Link = function(el) local file = currentFileMetadataState().file if file ~= nil and file.include_directory ~= nil then el.target = fixIncludePath(el.target, file.include_directory) end return el end, Image = function(el) local file = currentFileMetadataState().file if file ~= nil and file.include_directory ~= nil then el.src = fixIncludePath(el.src, file.include_directory) end return el end, RawInline = handleRawElementIncludePath, RawBlock = handleRawElementIncludePath, } end function handleRawElementIncludePath(el) if _quarto.format.isRawHtml(el) then local file = currentFileMetadataState().file if file ~= nil and file.include_directory ~= nil then handlePaths(el, file.include_directory, fixIncludePath) end return el end end -- input-traits.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") function addInputTrait(key, value) quarto_global_state.results.inputTraits[key] = value end function input_traits() return { Div = function(el) if el.attr.identifier == 'refs' then addInputTrait(constants.kPositionedRefs, true) end end } end -- line-numbers.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") function line_numbers() return { CodeBlock = function(el) if #el.attr.classes > 0 then local lineNumbers = lineNumbersAttribute(el) el.attr.attributes[constants.kCodeLineNumbers] = nil if lineNumbers ~= false then -- use the pandoc line numbering class el.attr.classes:insert("number-lines") -- remove for all formats except reveal and docusaurus if type(lineNumbers) == "string" and (_quarto.format.isRevealJsOutput() or _quarto.format.isDocusaurusOutput()) then el.attr.attributes[constants.kCodeLineNumbers] = lineNumbers end end return el end end } end function lineNumbersAttribute(el) local default = param(constants.kCodeLineNumbers, false) local lineNumbers = attribute(el, constants.kCodeLineNumbers, default) if lineNumbers == true or lineNumbers == "true" or lineNumbers == "1" then return true elseif lineNumbers == false or lineNumbers == "false" or lineNumbers == "0" then return false else return tostring(lineNumbers) end end -- meta.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- inject metadata function quarto_pre_meta_inject() return { Meta = function(meta) -- injection awesomebox for captions, if needed if quarto_global_state.hasCallouts then metaInjectLatex(meta, function(inject) inject( usePackageWithOption("tcolorbox", "skins,breakable") ) inject( usePackage("fontawesome5") ) inject( "\\definecolor{quarto-callout-color}{HTML}{" .. kColorUnknown .. "}\n" .. "\\definecolor{quarto-callout-note-color}{HTML}{" .. kColorNote .. "}\n" .. "\\definecolor{quarto-callout-important-color}{HTML}{" .. kColorImportant .. "}\n" .. "\\definecolor{quarto-callout-warning-color}{HTML}{" .. kColorWarning .."}\n" .. "\\definecolor{quarto-callout-tip-color}{HTML}{" .. kColorTip .."}\n" .. "\\definecolor{quarto-callout-caution-color}{HTML}{" .. kColorCaution .. "}\n" .. "\\definecolor{quarto-callout-color-frame}{HTML}{" .. kColorUnknownFrame .. "}\n" .. "\\definecolor{quarto-callout-note-color-frame}{HTML}{" .. kColorNoteFrame .. "}\n" .. "\\definecolor{quarto-callout-important-color-frame}{HTML}{" .. kColorImportantFrame .. "}\n" .. "\\definecolor{quarto-callout-warning-color-frame}{HTML}{" .. kColorWarningFrame .."}\n" .. "\\definecolor{quarto-callout-tip-color-frame}{HTML}{" .. kColorTipFrame .."}\n" .. "\\definecolor{quarto-callout-caution-color-frame}{HTML}{" .. kColorCautionFrame .. "}\n" ) end) end if quarto_global_state.usingTikz then metaInjectLatex(meta, function(inject) inject(usePackage("tikz")) end) end if quarto_global_state.usingBookmark then metaInjectLatex(meta, function(inject) inject(usePackage("bookmark")) end) end return meta end } end -- options.lua -- Copyright (C) 2020-2022 Posit Software, PBC local allOptions = {} -- initialize options from 'crossref' metadata value function init_options() return { Meta = function(meta) if meta ~= nil then allOptions = readMetaOptions(meta) end end } end -- get option value function option(name, def) return parseOption(name, allOptions, def) end function option_as_string(name) local result = option(name) if result == nil then return nil end return inlinesToString(result) end local kVarNamespace = "_quarto-vars" function var(name, def) local vars = allOptions[kVarNamespace] if vars ~= nil then return parseOption(name, vars, def) else return nil end end function parseOption(name, options, def) local keys = split(name, ".") local value = nil for i, key in ipairs(keys) do if value == nil then value = readOption(options, key, nil) else key = tonumber(key) or key value = value[key] end -- the key doesn't match a value, stop indexing if value == nil then break end end if value == nil then return def else return value end end function cap_location_from_option(scope, default) local loc = option(scope .. '-cap-location', option('cap-location', nil)) if loc ~= nil then return inlinesToString(loc) else return default end end -- output-location.lua -- Copyright (C) 2021-2022 Posit Software, PBC local function collectCellOutputLocation(el) if is_regular_node(el, "Div") and el.attr.classes:includes("cell") then local outputLoc = el.attr.attributes["output-location"] el.attr.attributes["output-location"] = nil if outputLoc == nil then outputLoc = param('output-location') end return outputLoc else return nil end end local function outputLocationCellHasCode(el) return #el.content > 0 and el.content[1].t == "CodeBlock" and el.content[1].attr.classes:includes("cell-code") end -- note: assumes that outputLocationCellHasCode has been called local function partitionCell(el, outputClass) -- compute the code div, being sure to bring the annotations -- along with the code local code = { el.content[1] } local outputIndex if isAnnotationCell(el.content[2]) then tappend(code, {el.content[2]}) outputIndex = 3 else outputIndex = 2 end local codeDiv = pandoc.Div(code, el.attr) local outputDiv = pandoc.Div(tslice(el.content, outputIndex, #el.content), el.attr) outputDiv.attr.identifier = "" outputDiv.attr.classes:insert(outputClass) return { codeDiv, outputDiv } end local function fragmentOutputLocation(block) return partitionCell(block, "fragment") end local function slideOutputLocation(block) return partitionCell(block, "output-location-slide") end local function columnOutputLocation(el, fragment) local codeDiv = pandoc.Div({ el.content[1] }) local outputDiv = pandoc.Div(tslice(el.content, 2, #el.content)) codeDiv.attr.classes:insert("column") outputDiv.attr.identifier = "" outputDiv.attr.classes:insert("column") if fragment then outputDiv.attr.classes:insert("fragment") end local columnsDiv = pandoc.Div( {codeDiv, outputDiv}, el.attr ) tappend(columnsDiv.attr.classes, { "columns", "column-output-location" }) return { columnsDiv } end function output_location() if _quarto.format.isRevealJsOutput() then return { Blocks = function(blocks) local newBlocks = pandoc.List() for _,block in pairs(blocks) do local outputLoc = collectCellOutputLocation(block) if outputLoc then if outputLocationCellHasCode(block) then if outputLoc == "fragment" then tappend(newBlocks, fragmentOutputLocation(block)) elseif outputLoc == "column" then tappend(newBlocks, columnOutputLocation(block)) elseif outputLoc == "column-fragment" then tappend(newBlocks, columnOutputLocation(block, true)) elseif outputLoc == "slide" then tappend(newBlocks, slideOutputLocation(block)) else newBlocks:insert(block) end else warn("output-location is only valid for cells that echo their code") newBlocks:insert(block) end else newBlocks:insert(block) end end return newBlocks end } else return {} end end function unroll_cell_outputs() -- the param("output-divs", true) check is now done in flags.lua return { -- unroll output divs for formats (like pptx) that don't support them Div = function(div) -- if we don't support output divs then we need to unroll them if tcontains(div.attr.classes, "cell") then -- if this is PowerPoint and it's a figure panel then let it through (as -- we'll use PowerPoint columns to layout at least 2 figures side-by-side) if _quarto.format.isPowerPointOutput() and hasLayoutAttributes(div) then return nil end -- unroll blocks contained in divs local blocks = pandoc.List() for _, childBlock in ipairs(div.content) do if is_regular_node(childBlock, "Div") and not is_custom_node(childBlock) then tappend(blocks, childBlock.content) else blocks:insert(childBlock) end end return blocks end end } end -- panel-input.lua -- Copyright (C) 2021-2022 Posit Software, PBC function bootstrap_panel_input() return { Div = function(el) if hasBootstrap() and el.attr.classes:find("panel-input") then tappend(el.attr.classes, { "card", "bg-light", "p-2", }) end return el end } end -- panel-layout.lua -- Copyright (C) 2021-2022 Posit Software, PBC function bootstrap_panel_layout() return { Div = function(el) if (hasBootstrap() and is_regular_node(el, "Div")) then local fill = el.attr.classes:find("panel-fill") local center = el.attr.classes:find("panel-center") if fill or center then local layoutClass = fill and "panel-fill" or "panel-center" local div = pandoc.Div({ el }) el.attr.classes = el.attr.classes:filter(function(clz) return clz ~= layoutClass end) if fill then tappend(div.attr.classes, { "g-col-24", }) elseif center then tappend(div.attr.classes, { "g-col-24", "g-col-lg-20", "g-start-lg-2" }) end -- return wrapped in a raw return pandoc.Div({ div }, pandoc.Attr("", { layoutClass, "panel-grid" })) end end return el end } end function panel_insert_preamble(result, preamble) if preamble == nil then return end local pt = pandoc.utils.type(preamble) if preamble.content and #preamble.content > 0 then result:extend(preamble.content) elseif pt == "Inline" or pt == "Block" then result:insert(preamble) elseif pt == "Blocks" then result:extend(preamble) else fail("Don't know what to do with preamble of type " .. pt) return nil end end -- panel-sidebar.lua -- Copyright (C) 2021-2022 Posit Software, PBC function bootstrap_panel_sidebar() return { Blocks = function(blocks) if hasBootstrap() or _quarto.format.isRevealJsOutput() then -- functions to determine if an element has a layout class local function isSidebar(el) return el ~= nil and is_regular_node(el, "Div") and el.attr.classes:includes("panel-sidebar") end local function isTabset(el) return is_custom_node(el, "Tabset") end local function fillPanel(el) return pandoc.Div({ el }, pandoc.Attr("", {"panel-fill"})) end local function isContainer(el) return el ~= nil and is_regular_node(el, "Div") and (el.attr.classes:includes("panel-fill") or el.attr.classes:includes("panel-center") or isTabset(el)) end local function isHeader(el) return el ~= nil and el.t == "Header" end local function isQuartoHiddenDiv(el) return el ~= nil and is_regular_node(el, "Div") and string.find(el.attr.identifier, "^quarto%-") and el.attr.classes:includes("hidden") end local function isNotQuartoHiddenDiv(el) return not isQuartoHiddenDiv(el) end -- bail if there are no sidebars local sidebar, sidebarIdx = blocks:find_if(isSidebar) if not sidebar then return blocks end -- create sidebar handler and get attr local sidebarHandler = bootstrapSidebar() if _quarto.format.isRevealJsOutput() then sidebarHandler = revealSidebar() end local sidebarAttr = sidebarHandler.sidebarAttr() local containerAttr = sidebarHandler.containerAttr() -- filter out quarto hidden blocks (they'll get put back in after processing) local quartoHiddenDivs = blocks:filter(isQuartoHiddenDiv) blocks = blocks:filter(isNotQuartoHiddenDiv) -- locate and arrange sidebars until there are none left local sidebar, sidebarIdx = blocks:find_if(isSidebar) while sidebar ~= nil and sidebarIdx ~= nil do -- always transfer sidebar attributes to sidebar transferAttr(sidebarAttr, sidebar.attr) -- sidebar after container if isContainer(blocks[sidebarIdx - 1]) then blocks:remove(sidebarIdx) local container = blocks:remove(sidebarIdx - 1) if isTabset(container) then container = fillPanel(container) end transferAttr(containerAttr, container.attr) blocks:insert(sidebarIdx - 1, pandoc.Div({ container, sidebar }, sidebarHandler.rowAttr({"layout-sidebar-right"})) ) -- sidebar before container elseif isContainer(blocks[sidebarIdx + 1]) then local container = blocks:remove(sidebarIdx + 1) if isTabset(container) then container = fillPanel(container) end transferAttr(containerAttr, container.attr) blocks:remove(sidebarIdx) blocks:insert(sidebarIdx, pandoc.Div({ sidebar, container }, sidebarHandler.rowAttr({"layout-sidebar-left"})) ) else -- look forward for a header local header, headerIdx = blocks:find_if(isHeader, sidebarIdx) if header and headerIdx and (headerIdx ~= (sidebarIdx + 1)) then local panelBlocks = pandoc.List() for i = sidebarIdx + 1, headerIdx - 1, 1 do panelBlocks:insert(blocks:remove(sidebarIdx + 1)) end local panelFill = pandoc.Div(panelBlocks, pandoc.Attr("", { "panel-fill" })) transferAttr(containerAttr, panelFill) blocks:remove(sidebarIdx) blocks:insert(sidebarIdx, pandoc.Div({ sidebar, panelFill }, sidebarHandler.rowAttr({"layout-sidebar-left"})) ) else -- look backwards for a header headerIdx = nil for i = sidebarIdx - 1, 1, -1 do if isHeader(blocks[i]) then headerIdx = i break end end -- if we have a header then collect up to it if headerIdx ~= nil and (headerIdx ~= (sidebarIdx - 1)) then local panelBlocks = pandoc.List() for i = headerIdx + 1, sidebarIdx - 1, 1 do panelBlocks:insert(blocks:remove(headerIdx + 1)) end local panelFill = pandoc.Div(panelBlocks, pandoc.Attr("", { "panel-fill" })) transferAttr(containerAttr, panelFill) blocks:remove(headerIdx + 1) blocks:insert(headerIdx + 1, pandoc.Div({ panelFill, sidebar }, sidebarHandler.rowAttr({"layout-sidebar-right"})) ) else -- no implicit header containment found, strip the sidebar attribute sidebar.attr.classes = sidebar.attr.classes:filter( function(clz) return clz ~= "panel-sidebar" and clz ~= "panel-input" end ) end end end -- try to find another sidebar sidebar, sidebarIdx = blocks:find_if(isSidebar) end -- restore hidden divs and return blocks tappend(blocks, quartoHiddenDivs) return blocks end end } end function bootstrapSidebar() return { rowAttr = function(classes) local attr = pandoc.Attr("", { "panel-grid", "layout-sidebar", "ms-md-0" }) tappend(attr.classes, classes) return attr end, sidebarAttr = function() return pandoc.Attr("", { "card", "bg-light", "p-2", "g-col-24", "g-col-lg-7" }) end, containerAttr = function() return pandoc.Attr("", { "g-col-24", "g-col-lg-17", "pt-3", "pt-lg-0", }) end } end function revealSidebar() return { rowAttr = function(classes) local attr = pandoc.Attr("", { "layout-sidebar" }) tappend(attr.classes, classes) return attr end, sidebarAttr = function() local attr = pandoc.Attr("", {}) return attr end, containerAttr = function() return pandoc.Attr("") end } end function transferAttr(from, to) tappend(to.classes, from.classes) for k,v in pairs(from.attributes) do to.attributes[k] = v end end -- parsefiguredivs.lua -- Copyright (C) 2023 Posit Software, PBC local patterns = require("modules/patterns") local attributes_to_not_merge = pandoc.List({ "width", "height" }) -- Narrow fix for #8000 local classes_to_not_merge = pandoc.List({ "border" }) function handle_subfloatreftargets() -- #7045: pull fig-pos and fig-env attributes from subfloat to parent return { FloatRefTarget = function(float) local pulled_attrs = {} local attrs_to_pull = { "fig-pos", "fig-env", } local result = _quarto.ast.walk(float, { FloatRefTarget = function(subfloat) for _, attr in ipairs(attrs_to_pull) do if subfloat.attributes[attr] then pulled_attrs[attr] = subfloat.attributes[attr] subfloat.attributes[attr] = nil end end return subfloat end, }) or pandoc.Div({}) -- won't happen but the lua analyzer doesn't know that for k, v in pairs(pulled_attrs) do float.attributes[k] = v end return float end } end local function process_div_caption_classes(div) -- knitr forwards "cap-location: top" as `.caption-top`... -- and in that case we don't know if it's a fig- or a tbl- :facepalm: -- so we have to use cap-locatin generically in the attribute if div.classes:find_if( function(class) return class:match("caption%-.+") end) then local matching_classes = div.classes:filter(function(class) return class:match("caption%-.+") end) div.classes = div.classes:filter(function(class) return not class:match("caption%-.+") end) for i, c in ipairs(matching_classes) do div.attributes["cap-location"] = c:match("caption%-(.+)") end return true end return false end local function coalesce_code_blocks(content) local result = pandoc.Blocks({}) local state = "start" for _, element in ipairs(content) do if state == "start" then if is_regular_node(element, "CodeBlock") then state = "coalescing" end result:insert(element) elseif state == "coalescing" then if is_regular_node(element, "CodeBlock") and result[#result].attr == element.attr then result[#result].text = result[#result].text .. "\n" .. element.text else state = "start" result:insert(element) end end end return result end local function remove_latex_crossref_envs(content, name) if name == "Table" then return _quarto.ast.walk(content, { RawBlock = function(raw) if not _quarto.format.isRawLatex(raw) then return nil end local b, e, begin_table, table_body, end_table = raw.text:find(patterns.latex_table) if b ~= nil then raw.text = table_body return raw else return nil end end }) end return content end local function kable_raw_latex_fixups(content, identifier) local matches = 0 content = _quarto.ast.walk(content, { RawBlock = function(raw) if not _quarto.format.isRawLatex(raw) then return nil end if raw.text:match(patterns.latex_long_table) == nil then return nil end local b, e, match1, label_identifier = raw.text:find(patterns.latex_label) if b ~= nil then raw.text = raw.text:sub(1, b - 1) .. raw.text:sub(e + 1) end local b, e, match2, caption_content = raw.text:find(patterns.latex_caption) if b ~= nil then raw.text = raw.text:sub(1, b - 1) .. raw.text:sub(e + 1) end if match1 == nil and match2 == nil then return nil end -- it's a longtable, we'll put it inside a Table FloatRefTarget -- if it has either a label or a caption. -- HACK: kable appears to emit a label that starts with "tab:" -- we strip this and hope for the best if label_identifier ~= nil then label_identifier = label_identifier:gsub("^tab:", "") end -- we found a table, a label, and a caption. This is a FloatRefTarget. matches = matches + 1 return quarto.FloatRefTarget({ identifier = label_identifier, type = "Table", content = pandoc.Blocks({ raw }), caption_long = pandoc.Blocks({pandoc.Plain(string_to_quarto_ast_inlines(caption_content))}), }) end }) if matches > 1 then -- we found more than one table, so these will become subfloats and -- we might need auto-identifiers (since) local counter = 0 content = _quarto.ast.walk(content, { FloatRefTarget = function(target) counter = counter + 1 if target.identifier == identifier then target.identifier = identifier .. "-" .. tostring(counter) end return target end }) end return matches, content end function parse_floatreftargets() local function parse_float_div(div) process_div_caption_classes(div) local ref = refType(div.identifier) if ref == nil then fail("Float div without crossref identifier?") return end local category = crossref.categories.by_ref_type[ref] if category == nil then fail("Float with invalid crossref category? " .. div.identifier) return end if category.kind ~= "float" then return nil -- skip non-float reftargets now that they exist end local content = div.content local caption_attr_key = ref .. "-cap" -- caption location handling -- .*-cap-location local caption_location_attr_key = ref .. "-cap-location" local caption_location_class_pattern = ".*cap%-location%-(.*)" local caption_location_classes = div.classes:filter(function(class) return class:match(caption_location_class_pattern) end) if #caption_location_classes then div.classes = div.classes:filter(function(class) return not class:match(caption_location_class_pattern) end) for _, class in ipairs(caption_location_classes) do local c = class:match(caption_location_class_pattern) div.attributes[caption_location_attr_key] = c end end local caption = refCaptionFromDiv(div) if caption ~= nil then div.content:remove(#div.content) elseif div.attributes[caption_attr_key] ~= nil then caption = pandoc.Plain(string_to_quarto_ast_inlines(div.attributes[caption_attr_key])) div.attributes[caption_attr_key] = nil else -- it's possible that the content of this div includes a table with a caption -- so we'll go root around for that. local found_caption = false content = _quarto.ast.walk(content, { Table = function(table) if table.caption.long ~= nil then found_caption = true caption = table.caption.long[1] -- what if there's more than one entry here? table.caption.long = nil return table end end }) -- luacov: disable if content == nil then internal_error() return nil end -- luacov: enable -- TODO are there other cases where we should look for captions? if not found_caption then caption = pandoc.Plain({}) end end if caption == nil then return nil end local identifier = div.identifier local attr = pandoc.Attr(identifier, div.classes, div.attributes) if (#content == 1 and content[1].t == "Para" and content[1].content[1].t == "Image") then -- if the div contains a single image, then we simply use the image as -- the content content = content[1].content[1] -- don't merge classes because they often have CSS consequences -- but merge attributes because they're needed to correctly resolve -- behavior such as fig-pos="h", etc -- See #8000. -- We also exclude attributes we know to not be relevant to the div for k, v in pairs(content.attr.attributes) do if not attributes_to_not_merge:includes(k) then attr.attributes[k] = v end end for _, v in ipairs(content.attr.classes) do if not classes_to_not_merge:includes(v) then attr.classes:insert(v) end end end local skip_outer_reftarget = false if ref == "tbl" then -- knitr/kable/etc fixups -- attempt to find table and caption local matches matches, content = kable_raw_latex_fixups(content, identifier) skip_outer_reftarget = matches == 1 end if div.classes:includes("cell") then local layout_classes = attr.classes:filter( function(c) return c:match("^column-") end ) if #layout_classes then attr.classes = attr.classes:filter( function(c) return not layout_classes:includes(c) end) div.classes = div.classes:filter( function(c) return not layout_classes:includes(c) end) -- if the div is a cell, then all layout attributes need to be -- forwarded to the cell .cell-output-display content divs content = _quarto.ast.walk(content, { Div = function(div) if div.classes:includes("cell-output-display") then div.classes:extend(layout_classes) return _quarto.ast.walk(div, { Table = function(tbl) tbl.classes:insert("do-not-create-environment") return tbl end }) end end }) end end content = remove_latex_crossref_envs(content, category.name) -- respect single table in latex longtable fixups above if skip_outer_reftarget then -- we also need to strip the div identifier here -- or we end up with duplicate identifiers which latex doesn't like div.identifier = "" div.content = content return div end if div.classes:includes("cell") and div.attributes["layout-ncol"] == nil then -- if this is a non-layout cell, we need to splice the code out of the -- cell-output-display div -- -- layout cells do their own processing later local return_cell = pandoc.Div({}) local final_content = pandoc.Div({}) local found_cell_output_display = false for _, element in ipairs(content or {}) do if is_regular_node(element, "Div") and element.classes:includes("cell-output-display") then found_cell_output_display = true final_content.content:insert(element) else return_cell.content:insert(element) end end if found_cell_output_display then return_cell.content = coalesce_code_blocks(return_cell.content) return_cell.classes = div.classes return_cell.attributes = div.attributes local reftarget = quarto.FloatRefTarget({ attr = attr, type = category.name, content = final_content.content, caption_long = {pandoc.Plain(caption.content)}, }) -- need to reference as a local variable because of the -- second return value from the constructor return_cell.content:insert(reftarget) return return_cell end end return quarto.FloatRefTarget({ attr = attr, type = category.name, content = content, caption_long = {pandoc.Plain(caption.content)}, }), false end return { traverse = "topdown", Figure = function(fig) local key_prefix = refType(fig.identifier) if key_prefix == nil then return nil end local category = crossref.categories.by_ref_type[key_prefix] if category == nil then return nil end if #fig.content ~= 1 and fig.content[1].t ~= "Plain" then -- we don't know how to parse this pandoc 3 figure -- just return as is return nil end local fig_attr = fig.attr local new_content = _quarto.ast.walk(fig.content[1], { Image = function(image) -- don't merge classes because they often have CSS consequences -- but merge attributes because they're needed to correctly resolve -- behavior such as fig-pos="h", etc -- See #8000. for k, v in pairs(image.attributes) do if not attributes_to_not_merge:includes(k) then fig_attr.attributes[k] = v end end for _, v in ipairs(image.classes) do if not classes_to_not_merge:includes(v) then fig_attr.classes:insert(v) end end image.caption = {} return image end }) or fig.content[1] -- this shouldn't be needed but the lua analyzer doesn't know it return quarto.FloatRefTarget({ attr = fig_attr, type = category.name, content = new_content.content, caption_long = fig.caption.long, caption_short = fig.caption.short, }), false end, -- if we see a table with a caption that includes a tbl- label, then -- we normalize that to a FloatRefTarget Table = function(el) if el.caption.long == nil then return nil end local last = el.caption.long[#el.caption.long] if not last or #last.content == 0 then return nil end -- check for tbl label local label = el.identifier local caption, attr = parseTableCaption(last.content) if startsWith(attr.identifier, "tbl-") then -- set the label and remove it from the caption label = attr.identifier attr.identifier = "" caption = createTableCaption(caption, pandoc.Attr()) end -- we've parsed the caption, so we can remove it from the table el.caption.long = pandoc.List({}) if label == "" then return nil end local combined = merge_attrs(el.attr, attr) return quarto.FloatRefTarget({ identifier = label, classes = combined.classes, attributes = as_plain_table(combined.attributes), type = "Table", content = pandoc.Blocks({ el }), caption_long = caption, }), false end, Div = function(div) if isFigureDiv(div, false) then -- The code below is a fixup that existed since the very beginning of -- quarto, see https://github.com/quarto-dev/quarto-cli/commit/12e770616869d43f5a1a3f84f9352491a2034bde -- and parent commits. We replicate it here to try and -- avoid a regression, in the absence of an associated regression test. -- -- pandoc sometimes ends up with a fig prefixed title -- (no idea why right now!) div = _quarto.ast.walk(div, { Image = function(image) if image.title == "fig:" or image.title == "fig-" then image.title = "" return image end end }) return parse_float_div(div) elseif isTableDiv(div) then return parse_float_div(div) end if div.classes:includes("cell") then process_div_caption_classes(div) -- forward cell attributes to potential FloatRefTargets div = _quarto.ast.walk(div, { Figure = function(fig) if div.attributes["cap-location"] then fig.attributes["cap-location"] = div.attributes["cap-location"] end for i, c in ipairs(div.classes) do local c = c:match(".*%-?cap%-location%-(.*)") if c then fig.attributes["cap-location"] = c end end return fig end, CodeBlock = function(block) for _, k in ipairs({"cap-location", "lst-cap-location"}) do if div.attributes[k] then block.attributes[k] = div.attributes[k] end end for i, c in ipairs(div.classes) do local c = c:match(".*%-?cap%-location%-(.*)") if c then block.attributes["cap-location"] = c end end return block end, }) return div end end, Para = function(para) local img = discoverFigure(para, false) if img ~= nil then if img.identifier == "" and #img.caption == 0 then return nil end if img.identifier == "" then img.identifier = autoRefLabel("fig") end local identifier = img.identifier local type = refType(identifier) local category = crossref.categories.by_ref_type[type] if category == nil then warn("Figure with invalid crossref category: " .. identifier .. "\nWon't be able to cross-reference this figure.") return nil end return quarto.FloatRefTarget({ identifier = identifier, classes = {}, attributes = as_plain_table(img.attributes), type = category.name, content = img, caption_long = img.caption, }), false end if discoverLinkedFigure(para) ~= nil then local link = para.content[1] local img = link.content[1] local identifier = img.identifier if img.identifier == "" then local caption = img.caption if #caption > 0 then img.caption = nil return pandoc.Figure(link, { long = { caption } }) else return nil -- return pandoc.Figure(link) end end img.identifier = "" local type = refType(identifier) local category = crossref.categories.by_ref_type[type] if category == nil then fail("Figure with invalid crossref category? " .. identifier) return end local combined = merge_attrs(img.attr, link.attr) return quarto.FloatRefTarget({ identifier = identifier, classes = combined.classes, attributes = as_plain_table(combined.attributes), type = category.name, content = link, caption_long = img.caption, }), false end end, DecoratedCodeBlock = function(decorated_code) local code = decorated_code.code_block local key_prefix = refType(code.identifier) if key_prefix ~= "lst" then return nil end local caption = code.attr.attributes['lst-cap'] if caption == nil then return nil end code.attr.attributes['lst-cap'] = nil local attr = code.attr -- code.attr = pandoc.Attr("", {}, {}) return quarto.FloatRefTarget({ attr = attr, type = "Listing", content = { decorated_code.__quarto_custom_node }, -- this custom AST impedance mismatch here is unfortunate caption_long = caption, }), false end, CodeBlock = function(code) local key_prefix = refType(code.identifier) if key_prefix ~= "lst" then return nil end local caption = code.attr.attributes['lst-cap'] if caption == nil then return nil end local caption_inlines = string_to_quarto_ast_blocks(caption)[1].content code.attr.attributes['lst-cap'] = nil local content = code if code.attr.attributes["filename"] then content = quarto.DecoratedCodeBlock({ filename = code.attr.attributes["filename"], code_block = code:clone() }) end local attr = code.attr code.attr = pandoc.Attr("", {}, {}) return quarto.FloatRefTarget({ attr = attr, type = "Listing", content = { content }, caption_long = caption_inlines, }), false end, RawBlock = function(raw) if not (_quarto.format.isLatexOutput() and _quarto.format.isRawLatex(raw)) then return nil end -- first we check if all of the expected bits are present -- check for {#...} or \label{...} if raw.text:find(patterns.latex_label) == nil and raw.text:find(patterns.attr_identifier) == nil then return nil end -- check for \caption{...} if raw.text:find(patterns.latex_caption) == nil then return nil end -- check for tabular or longtable if raw.text:find(patterns.latex_long_table) == nil and raw.text:find(patterns.latex_tabular) == nil then return nil end -- if we're here, then we're going to parse this as a FloatRefTarget -- and we need to remove the label and caption from the raw block local identifier = "" local b, e, match1, label_identifier = raw.text:find(patterns.latex_label) if b ~= nil then raw.text = raw.text:sub(1, b - 1) .. raw.text:sub(e + 1) identifier = label_identifier else local b, e, match2, attr_identifier = raw.text:find(patterns.attr_identifier) if b ~= nil then raw.text = raw.text:sub(1, b - 1) .. raw.text:sub(e + 1) identifier = attr_identifier else internal_error() return nil end end -- knitr can emit a label that starts with "tab:" -- we don't handle those as floats local ref = refType(identifier) if ref == nil then return nil end local caption local b, e, match3, caption_content = raw.text:find(patterns.latex_caption) if b ~= nil then raw.text = raw.text:sub(1, b - 1) .. raw.text:sub(e + 1) caption = pandoc.RawBlock("latex", caption_content) else internal_error() return nil end -- finally, if the user passed a \\begin{table} float environment -- we just remove it because we'll re-emit later ourselves local b, e, begin_table, table_body, end_table = raw.text:find(patterns.latex_table) if b ~= nil then raw.text = table_body end return quarto.FloatRefTarget({ attr = pandoc.Attr(identifier, {}, {}), type = "Table", content = { raw }, caption_long = quarto.utils.as_blocks(caption) }), false end } end function forward_cell_subcaps() return { Div = function(div) if not div.classes:includes("cell") then return nil end local ref = refType(div.identifier) if ref == nil then return nil end local v = div.attributes[ref .. "-subcap"] if v == nil then return nil end local subcaps = quarto.json.decode(v) local index = 1 local nsubcaps if type(subcaps) == "table" then nsubcaps = #subcaps end div.content = _quarto.ast.walk(div.content, { Div = function(subdiv) if type(nsubcaps) == "number" and index > nsubcaps or not subdiv.classes:includes("cell-output-display") then return nil end local function get_subcap() if type(subcaps) ~= "table" then return pandoc.Str("") else return pandoc.Str(subcaps[index] or "") end end -- now we attempt to insert subcaptions where it makes sense for them to be inserted subdiv.content = _quarto.ast.walk(subdiv.content, { Table = function(pandoc_table) pandoc_table.caption.long = quarto.utils.as_blocks(get_subcap()) pandoc_table.identifier = div.identifier .. "-" .. tostring(index) index = index + 1 return pandoc_table end, Para = function(maybe_float) local fig = discoverFigure(maybe_float, false) or discoverLinkedFigure(maybe_float, false) if fig ~= nil then fig.caption = quarto.utils.as_inlines(get_subcap()) fig.identifier = div.identifier .. "-" .. tostring(index) index = index + 1 return maybe_float end end, }) return subdiv end }) if index ~= 1 then div.attributes[ref .. "-subcap"] = nil end return div end } end -- parseblockreftargets.lua -- Copyright (C) 2023 Posit Software, PBC -- parses Proofs, Theorems, Lemmas, etc. function parse_blockreftargets() local function parse_theorem_div(el) if not has_theorem_ref(el) then return end -- capture then remove name local name = markdownToInlines(el.attr.attributes["name"]) if not name or #name == 0 then name = resolveHeadingCaption(el) end el.attr.attributes["name"] = nil local identifier = el.attr.identifier -- remove identifier to avoid infinite recursion el.attr.identifier = "" return quarto.Theorem { identifier = identifier, name = name, div = el }, false end local function parse_proof_div(el) if not is_proof_div(el) then return end local name = string_to_quarto_ast_inlines(el.attributes["name"] or "") if not name or #name == 0 then name = resolveHeadingCaption(el) or pandoc.Inlines({}) end el.attributes["name"] = nil local identifier = el.identifier el.identifier = "" local ref = refType(identifier) local proof_type if ref ~= nil then proof_type = crossref.categories.by_ref_type[ref].name else proof_type = el.classes:find_if(function(clz) return proof_types[clz] ~= nil end) if proof_type == nil then internal_error() return end proof_type = proof_types[proof_type].title end el.classes = el.classes:filter(function(clz) return proof_types[clz] == nil end) crossref.using_theorems = true local tbl = { identifier = identifier, name = name, div = el, type = proof_type } return quarto.Proof(tbl), false end return { Div = function(div) if is_theorem_div(div) then return parse_theorem_div(div) elseif is_proof_div(div) then return parse_proof_div(div) end end } end -- project_paths.lua -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") local function resolveProjectPath(path) local offset = _quarto.projectOffset() if offset and path and startsWith(path, '/') then return pandoc.path.join({offset, pandoc.text.sub(path, 2, #path)}) else return nil end end -- resources that have '/' prefixed paths are treated as project -- relative paths if there is a project context. For HTML output, -- these elements are dealt with in a post processor in website-resources.ts:resolveTag() -- but for non-HTML output, we fix these here. function project_paths() return { Image = function(el) if el.attr.attributes[constants.kProjectResolverIgnore] then el.attr.attributes[constants.kProjectResolverIgnore] = '' return el end local resolved = false -- Resolve the image source if el.src then local resolvedPath = resolveProjectPath(el.src) if resolvedPath ~= nil then el.src = resolvedPath resolved = true end end -- Resolve image data-src if el.attributes['data-src'] then local resolvedPath = resolveProjectPath(el.attributes['data-src']) if resolvedPath ~= nil then el.attributes['data-src'] = resolvedPath resolved = true end end if resolved then return el end end, Link = function(el) if el.attr.attributes[constants.kProjectResolverIgnore] then el.attr.attributes[constants.kProjectResolverIgnore] = '' return el end if el.target then local resolvedHref = resolveProjectPath(el.target) if resolvedHref then el.target = resolvedHref return el end end end } end -- resourcefiles.lua -- Copyright (C) 2020-2022 Posit Software, PBC function resource_files() return { -- TODO: discover resource files -- Note that currently even if we discover resourceFiles in markdown they don't -- actually register for site preview b/c we don't actually re-render html -- files for preview if they are newer than the source files. we may need to -- record discovered resource files in some sort of index in order to work -- around this Image = function(el) local targetPath = el.src if not targetPath:match('^https?:') and not targetPath:match('^data:') then -- don't include this resource if it is a URL, data file or some not file path if pandoc.path.is_relative(targetPath) then local inputDir = pandoc.path.directory(quarto.doc.input_file) targetPath = pandoc.path.join({inputDir, el.src}) end -- FIXME shouldn't we be using targetPath here? recordFileResource(el.src) end end, } end -- function to record a file resource function recordFileResource(res) quarto_global_state.results.resourceFiles:insert(res) end -- results.lua -- Copyright (C) 2020-2022 Posit Software, PBC local function resultsFile() return pandoc.utils.stringify(param("results-file")) end local function timingsFile() return pandoc.utils.stringify(param("timings-file")) end -- write results function write_results() return { Pandoc = function(doc) local jsonResults = quarto.json.encode(quarto_global_state.results) local rfile = io.open(resultsFile(), "w") if rfile then rfile:write(jsonResults) rfile:close() else warn('Error writing LUA results file') end -- FIXME: we don't use this anymore, remove it if os.getenv("QUARTO_PROFILER_OUTPUT") ~= nil then local jsonTimings = quarto.json.encode(timing_events) local tfile = io.open(timingsFile(), "w") if tfile then tfile:write(jsonTimings) tfile:close() else warn('Error writing profiler timings JSON') end end end } end -- shiny.lua -- Copyright (C) 2020-2023 Posit Software, PBC function server_shiny() if not param("is-shiny-python", false) then return {} end -- get python exec local pythonExec = param("shiny-python-exec", { "python" }) -- Try calling `pandoc.pipe('shiny', ...)` and if it fails, print a message -- about installing shiny. local function callPythonShiny(args) -- build command and args local command = pythonExec[1] tprepend(args, { "-m", "shiny" }) if #pythonExec > 1 then tprepend(args, tslice(pythonExec, 2, #pythonExec)) end local res local status, err = pcall( function() res = pandoc.pipe(command, args, "") end ) if not status then print(err) error( "Error running command 'shiny " .. table.concat(args, " ") .. "'. Please make sure the 'shiny' Python package is installed." ) os.exit(1) end return res end local function getShinyDeps() local depJson = callPythonShiny( { "get-shiny-deps" } ) local deps = quarto.json.decode(depJson) return deps end local codeCells = { schema_version = 1, cells = {}, html_file = "" } return { Div = function(divEl) if not divEl.attr.classes:includes("cell") then return el end -- Start the context as nil and then set it when we hit a relevant Python -- code block. (We don't want to interfere with other types of code -- blocks.) local context = nil local res = pandoc.walk_block(divEl, { CodeBlock = function(el) if el.attr.classes:includes("python") and el.attr.classes:includes("cell-code") then context = divEl.attr.attributes["context"] or "default" -- Translate the context names to ones that are used by the backend -- which writes out the app file. if context == "default" then context = { "ui", "server" } elseif context == "ui" then context = { "ui" } elseif context == "setup" then context = { "ui", "server-setup" } else error( 'Invalid context: "' .. context .. '". Valid context types are "default", "ui", and "setup".' ) end context = pandoc.List(context) table.insert( codeCells.cells, { context = context, classes = el.attr.classes, text = el.text } ) end end, Div = function(el) -- In the HTML output, only include cell-output for ui cells. -- `context` will be non-nil only if there's a CodeBlock in the -- wrapper div which has gone through the CodeBlock function above. if context ~= nil and not context:includes("ui") and el.attr.classes:includes("cell-output") then return {} end end }) return res end, Pandoc = function(doc) codeCells["html_file"] = pandoc.path.split_extension( pandoc.path.filename(quarto.doc.output_file) ) .. ".html" -- Get the shiny dependency placeholder and add it to the document. local baseDeps = getShinyDeps() for idx, dep in ipairs(baseDeps) do quarto.doc.add_html_dependency(dep) end -- Write the code cells to a temporary file. codeCellsOutfile = pandoc.path.split_extension(quarto.doc.output_file) .. "-cells.tmp.json" local file = io.open(codeCellsOutfile, "w") if file == nil then error("Error opening file: " .. codeCellsOutfile .. " for writing.") end file:write(quarto.json.encode(codeCells)) file:close() -- Convert the json file to app.py by calling `shiny convert-cells`. appOutfile = pandoc.path.join({ pandoc.path.directory(quarto.doc.output_file), "app.py" }); callPythonShiny( { "cells-to-app", codeCellsOutfile, appOutfile } ) -- TODO: Add option to keep file for debugging. os.remove(codeCellsOutfile) end } end -- shortcodes-handlers.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- handlers process shortcode into either a list of inlines or into a list of blocks local function shortcodeMetatable(scriptFile) return { -- https://www.lua.org/manual/5.3/manual.html#6.1 assert = assert, collectgarbage = collectgarbage, dofile = dofile, error = error, getmetatable = getmetatable, ipairs = ipairs, load = load, loadfile = loadfile, next = next, pairs = pairs, pcall = pcall, print = print, rawequal = rawequal, rawget = rawget, rawlen = rawlen, rawset = rawset, select = select, setmetatable = setmetatable, tonumber = tonumber, tostring = tostring, type = type, _VERSION = _VERSION, xpcall = xpcall, coroutine = coroutine, require = require, package = package, string = string, utf8 = utf8, table = table, math = math, io = io, ---@diagnostic disable-next-line: undefined-global file = file, os = os, debug = debug, -- https://pandoc.org/lua-filters.html FORMAT = FORMAT, PANDOC_READER_OPTIONS = PANDOC_READER_OPTIONS, PANDOC_WRITER_OPTIONS = PANDOC_WRITER_OPTIONS, PANDOC_VERSION = PANDOC_VERSION, PANDOC_API_VERSION = PANDOC_API_VERSION, PANDOC_SCRIPT_FILE = scriptFile, PANDOC_STATE = PANDOC_STATE, pandoc = pandoc, lpeg = lpeg, re = re, -- quarto functions quarto = quarto } end local handlers = {} function initShortcodeHandlers() -- user provided handlers local shortcodeFiles = pandoc.List(param("shortcodes", {})) for _,shortcodeFile in ipairs(shortcodeFiles) do local env = setmetatable({}, {__index = shortcodeMetatable(shortcodeFile)}) _quarto.withScriptFile(shortcodeFile, function() local chunk, err = loadfile(shortcodeFile, "bt", env) if chunk ~= nil and not err then local result = chunk() if result then for k,v in pairs(result) do handlers[k] = { file = shortcodeFile, handle = v } end else for k,v in pairs(env) do handlers[k] = { file = shortcodeFile, handle = v } end end else fail(err) end end) end -- built in handlers (these override any user handlers) handlers['meta'] = { handle = handleMeta } handlers['var'] = { handle = handleVars } handlers['env'] = { handle = handleEnv } handlers['pagebreak'] = { handle = handlePagebreak } end function handlerForShortcode(shortCode) return handlers[shortCode.name] end local function read_arg(args) local arg = args[1] local varName if type(arg) ~= "string" then varName = inlinesToString(arg) else varName = arg end return varName end -- Implements reading values from envrionment variables function handleEnv(args) if #args > 0 then -- the args are the var name local varName = read_arg(args) -- read the environment variable local envValue = os.getenv(varName) if envValue ~= nil then return { pandoc.Str(envValue) } else warn("Unknown variable " .. varName .. " specified in an env Shortcode.") return { pandoc.Strong({pandoc.Str("?env:" .. varName)}) } end else -- no args, we can't do anything return nil end end -- Implements reading values from document metadata -- as {{< meta title >}} -- or {{< meta key.subkey.subkey >}} -- This only supports emitting simple types (not arrays or maps) function handleMeta(args) if #args > 0 then -- the args are the var name local varName = read_arg(args) -- strip quotes if present -- works around the real bug that we don't have -- great control over quoting in shortcode params -- see https://github.com/quarto-dev/quarto-cli/issues/7882 if varName:sub(1,1) == '"' and varName:sub(-1) == '"' then varName = varName:sub(2,-2) elseif varName:sub(1,1) == "'" and varName:sub(-1) == "'" then varName = varName:sub(2,-2) end -- read the option value local optionValue = option(varName, nil) if optionValue ~= nil then return processValue(optionValue, varName, "meta") else warn("Unknown meta key " .. varName .. " specified in a metadata Shortcode.") return { pandoc.Strong({pandoc.Str("?meta:" .. varName)}) } end else -- no args, we can't do anything return nil end end -- Implements reading variables from quarto vars file -- as {{< var title >}} -- or {{< var key.subkey.subkey >}} -- This only supports emitting simple types (not arrays or maps) function handleVars(args) if #args > 0 then -- the args are the var name local varName = read_arg(args) -- read the option value local varValue = var(varName, nil) if varValue ~= nil then return processValue(varValue, varName, "var") else warn("Unknown var " .. varName .. " specified in a var shortcode.") return { pandoc.Strong({pandoc.Str("?var:" .. varName)}) } end else -- no args, we can't do anything return nil end end function processValue(val, name, t) if type(val) == "table" then if #val == 0 then return { pandoc.Str( "") } elseif pandoc.utils.type(val) == "Inlines" then return val elseif pandoc.utils.type(val) == "Blocks" then return pandoc.utils.blocks_to_inlines(val) elseif pandoc.utils.type(val) == "List" and #val == 1 then return processValue(val[1]) else warn("Unsupported type '" .. pandoc.utils.type(val) .. "' for key " .. name .. " in a " .. t .. " shortcode.") return { pandoc.Strong({pandoc.Str("?invalid " .. t .. " type:" .. name)}) } end else return { pandoc.Str( tostring(val) ) } end end function handlePagebreak() local pagebreak = { epub = '<p style="page-break-after: always;"> </p>', html = '<div style="page-break-after: always;"></div>', latex = '\\newpage{}', ooxml = '<w:p><w:r><w:br w:type="page"/></w:r></w:p>', odt = '<text:p text:style-name="Pagebreak"/>', context = '\\page', typst = '#pagebreak()' } if FORMAT == 'docx' then return pandoc.RawBlock('openxml', pagebreak.ooxml) elseif FORMAT:match 'latex' then return pandoc.RawBlock('tex', pagebreak.latex) elseif FORMAT:match 'odt' then return pandoc.RawBlock('opendocument', pagebreak.odt) elseif FORMAT == 'typst' then return pandoc.RawBlock('typst', pagebreak.typst) elseif FORMAT:match 'html.*' then return pandoc.RawBlock('html', pagebreak.html) elseif FORMAT:match 'epub' then return pandoc.RawBlock('html', pagebreak.epub) elseif FORMAT:match 'context' then return pandoc.RawBlock('context', pagebreak.context) else -- fall back to insert a form feed character return pandoc.Para{pandoc.Str '\f'} end end -- table-classes.lua -- Copyright (C) 2020-2023 Posit Software, PBC -- handle classes to pass to `<table>` element function table_classes() local function process_table(tbl, normalized_classes) -- now, forward classes from float to table -- ensure that classes are appended (do not want to rewrite and wipe out any existing) tbl.classes:extend(normalized_classes) -- if we have a `sm` table class then we need to add the `small` class -- and if we have a `small` class then we need to add the `table-sm` class if tcontains(normalized_classes, "table-sm") then tbl.classes:insert("small") elseif tcontains(normalized_classes, "small") then tbl.classes:insert("table-sm") end return tbl end -- recognized Bootstrap table classes local table_bootstrap_nm = { "primary", "secondary", "success", "danger", "warning", "info", "light", "dark", "striped", "hover", "active", "bordered", "borderless", "sm", "responsive", "responsive-sm", "responsive-md", "responsive-lg", "responsive-xl", "responsive-xxl" } -- determine if we have any supplied classes, these should always begin with a `.` and -- consist of alphanumeric characters local function normalize_class(x) if tcontains(table_bootstrap_nm, x) then return "table-" .. x else return x end end -- the treatment of Table and FloatRefTarget is -- slightly non-uniform because captions are stored slightly differently -- in either case. Cursed code follows... return { Table = function(tbl) -- determine if we have any supplied classes, these should always begin with a `.` and -- consist of alphanumeric characters local caption = tbl.caption.long[#tbl.caption.long] local caption_parsed, attr = parseTableCaption(pandoc.utils.blocks_to_inlines({caption})) tbl.classes = tbl.classes:map(normalize_class) local normalized_classes = attr.classes:map(normalize_class) process_table(tbl, normalized_classes) attr.classes = pandoc.List() tbl.caption.long[#tbl.caption.long] = pandoc.Plain(createTableCaption(caption_parsed, attr)) if #quarto.utils.as_inlines(tbl.caption.long) == 0 then tbl.caption.long = nil end return tbl end, FloatRefTarget = function(float) local kind = refType(float.identifier) if kind ~= "tbl" then return nil end if (float.caption_long == nil or float.caption_long.content == nil or #float.caption_long.content < 1) then return nil end local caption_content = float.caption_long.content local caption_parsed, attr = parseTableCaption(caption_content) local unnormalized_classes = float.classes tappend(unnormalized_classes, attr.classes) local normalized_classes = unnormalized_classes:map(normalize_class) if float.content.t == "Table" then float.content = process_table(float.content, normalized_classes) else float.content = _quarto.ast.walk(float.content, { traverse = "topdown", FloatRefTarget = function() return nil, false -- do not descend into subfloats end, Table = function(tbl) return process_table(tbl, normalized_classes) end }) end attr.classes = pandoc.List() float.caption_long = pandoc.Plain(createTableCaption(caption_parsed, attr)) return float end } end -- table-captions.lua -- Copyright (C) 2020-2022 Posit Software, PBC local patterns = require("modules/patterns") kTblCap = "tbl-cap" kTblSubCap = "tbl-subcap" function table_captions() return { Div = function(el) if tcontains(el.attr.classes, "cell") then -- extract table attributes local tblCap = extractTblCapAttrib(el,kTblCap) local tblSubCap = extractTblCapAttrib(el, kTblSubCap, true) if hasTableRef(el) or tblCap then local tables = countTables(el) if tables > 0 then -- apply captions and labels if we have a tbl-cap or tbl-subcap if tblCap or tblSubCap then -- special case: knitr::kable will generate a \begin{tablular} without -- a \begin{table} wrapper -- put the wrapper in here if need be if _quarto.format.isLatexOutput() then el = _quarto.ast.walk(el, { RawBlock = function(raw) if _quarto.format.isRawLatex(raw) then if raw.text:match(_quarto.patterns.latexTabularPattern) and not raw.text:match(_quarto.patterns.latexTablePattern) then raw.text = raw.text:gsub(_quarto.patterns.latexTabularPattern, "\\begin{table}\n\\centering\n%1%2%3\n\\end{table}\n", 1) return raw end end end }) end -- compute all captions and labels local label = el.attr.identifier local mainCaption, tblCaptions, mainLabel, tblLabels = table_captionsAndLabels( label, tables, tblCap, tblSubCap ) -- apply captions and label el.attr.identifier = mainLabel if mainCaption then el.content:insert(pandoc.Para(mainCaption)) end if #tblCaptions > 0 then el = applyTableCaptions(el, tblCaptions, tblLabels) end return el end end end end end } end function table_captionsAndLabels(label, tables, tblCap, tblSubCap) local mainCaption = nil local tblCaptions = pandoc.List() local mainLabel = "" local tblLabels = pandoc.List() -- case: no subcaps (no main caption or label, apply caption(s) to tables) if not tblSubCap then -- case: single table (no label interpolation) if tables == 1 then tblCaptions:insert(markdownToInlines(tblCap[1])) tblLabels:insert(label) -- case: single caption (apply to entire panel) elseif #tblCap == 1 then mainCaption = tblCap[1] mainLabel = label -- case: multiple tables (label interpolation) else for i=1,tables do if i <= #tblCap then tblCaptions:insert(markdownToInlines(tblCap[i])) if #label > 0 then tblLabels:insert(label .. "-" .. tostring(i)) else tblLabels:insert("") end end end end -- case: subcaps else mainLabel = label if mainLabel == "" then mainLabel = anonymousTblId() end if tblCap then mainCaption = markdownToInlines(tblCap[1]) else mainCaption = noCaption() end for i=1,tables do if tblSubCap and i <= #tblSubCap and tblSubCap[i] ~= "" then tblCaptions:insert(markdownToInlines(tblSubCap[i])) else tblCaptions:insert(pandoc.List()) end if #mainLabel > 0 then tblLabels:insert(mainLabel .. "-" .. tostring(i)) else tblLabels:insert("") end end end return mainCaption, tblCaptions, mainLabel, tblLabels end function applyTableCaptions(el, tblCaptions, tblLabels) local idx = 1 return _quarto.ast.walk(el, { Table = function(el) if idx <= #tblLabels then local cap = pandoc.Inlines({}) if #tblCaptions[idx] > 0 then cap:extend(tblCaptions[idx]) cap:insert(pandoc.Space()) end if #tblLabels[idx] > 0 then cap:insert(pandoc.Str("{#" .. tblLabels[idx] .. "}")) end idx = idx + 1 el.caption.long = pandoc.Plain(cap) return el end end, RawBlock = function(raw) if idx <= #tblLabels then -- (1) if there is no caption at all then populate it from tblCaptions[idx] -- (assuming there is one, might not be in case of empty subcaps) -- (2) Append the tblLabels[idx] to whatever caption is there if hasRawHtmlTable(raw) then -- html table patterns local tablePattern = patterns.html_table local captionPattern = patterns.html_table_caption -- insert caption if there is none local beginCaption, caption = raw.text:match(captionPattern) if not beginCaption then raw.text = raw.text:gsub(tablePattern, "%1" .. "<caption></caption>" .. "%2%3", 1) end -- apply table caption and label local beginCaption, captionText, endCaption = raw.text:match(captionPattern) if #tblCaptions[idx] > 0 then captionText = stringEscape(tblCaptions[idx], "html") end if #tblLabels[idx] > 0 then captionText = captionText .. " {#" .. tblLabels[idx] .. "}" end raw.text = raw.text:gsub(captionPattern, "%1" .. captionText:gsub("%%", "%%%%") .. "%3", 1) idx = idx + 1 elseif hasRawLatexTable(raw) then for i,pattern in ipairs(_quarto.patterns.latexTablePatterns) do if raw.text:match(pattern) then raw.text = applyLatexTableCaption(raw.text, tblCaptions[idx], tblLabels[idx], pattern) break end end idx = idx + 1 elseif hasPagedHtmlTable(raw) then if #tblCaptions[idx] > 0 then local captionText = stringEscape(tblCaptions[idx], "html") if #tblLabels[idx] > 0 then captionText = captionText .. " {#" .. tblLabels[idx] .. "}" end local pattern = "(<div data[-]pagedtable=\"false\">)" -- we don't have a table to insert a caption to, so we'll wrap the caption with a div and the right class instead local replacement = "%1 <div class=\"table-caption\"><caption>" .. captionText:gsub("%%", "%%%%") .. "</caption></div>" raw.text = raw.text:gsub(pattern, replacement) end idx = idx + 1 end return raw end end }) end function applyLatexTableCaption(latex, tblCaption, tblLabel, tablePattern) local latexCaptionPattern = _quarto.patterns.latexCaptionPattern -- insert caption if there is none local beginCaption, caption = latex:match(latexCaptionPattern) if not beginCaption then latex = latex:gsub(tablePattern, "%1" .. "\n\\caption{ }\\tabularnewline\n" .. "%2%3", 1) end -- apply table caption and label local beginCaption, captionText, endCaption = latex:match(latexCaptionPattern) if #tblCaption > 0 then captionText = stringEscape(tblCaption, "latex") end if #tblLabel > 0 then captionText = captionText .. " {#" .. tblLabel .. "}" end latex = latex:gsub(latexCaptionPattern, "%1" .. captionText:gsub("%%", "%%%%") .. "%3", 1) return latex end function extractTblCapAttrib(el, name, subcap) local value = attribute(el, name, nil) if value then if startsWith(value, "[") then value = pandoc.List(quarto.json.decode(value)) elseif subcap and (value == "true") then value = pandoc.List({ "" }) else value = pandoc.List({ value }) end el.attr.attributes[name] = nil return value end return nil end -- table-colwidth.lua -- Copyright (C) 2020-2022 Posit Software, PBC local kTblColwidths = require("modules/constants").kTblColWidths local function noWidths(ncol) local widths = {} for i = 1,ncol do widths[i] = 0 end return widths end -- takes a tblColwidths attribute value (including nil) and returns an table -- of pandoc AST colwidths local function tblColwidthValues(tbl, tblColwidths) -- determine the widths (using any passed param as the default) if tblColwidths == nil then tblColwidths = param(kTblColwidths, true) elseif tblColwidths == "true" then tblColwidths = true elseif tblColwidths == "false" then tblColwidths = false end -- take appropriate action if tblColwidths == "auto" then local foundLink = false _quarto.ast.walk(tbl, { Link = function(el) foundLink = true end }) if foundLink then return noWidths(#tbl.colspecs) else return nil end elseif tblColwidths == true then return nil elseif tblColwidths == false then return noWidths(#tbl.colspecs) else if type(tblColwidths) == "string" then -- provide array brackets if necessary if tblColwidths:find("[", 1, true) ~= 1 then tblColwidths = '[' .. tblColwidths .. ']' end -- decode array tblColwidths = quarto.json.decode(tblColwidths) end if type(tblColwidths) == "table" then local totalWidth = 0 local widths = {} for i = 1,#tbl.colspecs do if i <= #tblColwidths then widths[i] = tblColwidths[i] else widths[i] = tblColwidths[#tblColwidths] end totalWidth = totalWidth + widths[i] end -- normalize to 100 if the total is > 100 if totalWidth > 100 then for i=1,#widths do widths[i] = round((widths[i]/totalWidth) * 100, 1) end end -- convert all widths to decimal for i=1,#widths do widths[i] = round(widths[i] / 100, 2) end return widths else warn("Unexpected tbl-colwidths value: " .. tblColwidths) return nil end end end -- propagate cell level tbl-colwidths to tables function table_colwidth_cell(float) if refType(float.identifier) ~= "tbl" then return end local tblColwidths = float.attributes[kTblColwidths] local function process_table(tbl) tbl.attributes[kTblColwidths] = tblColwidths return tbl end if tblColwidths ~= nil then float.attributes[kTblColwidths] = nil if float.content.t == "Table" then float.content = process_table(float.content) else float.content = _quarto.ast.walk(float.content, { Table = process_table }) end end end -- handle tbl-colwidth function table_colwidth() local div_colwidths = pandoc.List({}) local filter filter = { traverse = "topdown", Div = function(div) local v = div.attributes[kTblColwidths] if not v then return nil end div_colwidths:insert(v) local result = _quarto.ast.walk(div, filter) div_colwidths:remove() return result, false end, Table = function(tbl) -- see if we have a tbl-colwidths attribute local tblColwidths = nil if tbl.caption.long ~= nil and #tbl.caption.long > 0 then local caption = tbl.caption.long[#tbl.caption.long] local tblCaption, attr = parseTableCaption(pandoc.utils.blocks_to_inlines({caption})) tblColwidths = attr.attributes[kTblColwidths] if tblColwidths ~= nil then attr.attributes[kTblColwidths] = nil tbl.caption.long[#tbl.caption.long] = pandoc.Plain(createTableCaption(tblCaption, attr)) end end -- failing that check for an ambient attribute provided by a cell if tblColwidths == nil then tblColwidths = tbl.attr.attributes[kTblColwidths] end -- check enclosing divs if #div_colwidths > 0 then tblColwidths = div_colwidths[#div_colwidths] end tbl.attr.attributes[kTblColwidths] = nil -- if we found a quarto-postprocess attribute, -- that means this was a table parsed from html and -- we don't need to do the fixups if tbl.attr.attributes["quarto-postprocess"] then return nil end -- realize values and apply them local colwidthValues = tblColwidthValues(tbl, tblColwidths) if colwidthValues ~= nil then local simpleTbl = pandoc.utils.to_simple_table(tbl) simpleTbl.widths = colwidthValues return pandoc.utils.from_simple_table(simpleTbl), false end end } return filter end -- table-rawhtml.lua -- Copyright (C) 2020-2022 Posit Software, PBC -- flextable outputs consecutive html blocks so we merge them -- back together here so they can be processed by ourraw table -- caption handling local patterns = require("modules/patterns") function table_merge_raw_html() if not _quarto.format.isHtmlOutput() then return {} end return { Blocks = function(blocks) local pendingRaw = pandoc.List() local merged = pandoc.List() for i,el in ipairs(blocks) do if _quarto.format.isRawHtml(el) and el.text:find(patterns.html_table_tag_name) then pendingRaw:insert(el.text) else if #pendingRaw > 0 then merged:insert(pandoc.RawBlock("html", table.concat(pendingRaw, "\n"))) pendingRaw = pandoc.List() end merged:insert(el) end end if #pendingRaw > 0 then merged:insert(pandoc.RawBlock("html", table.concat(pendingRaw, "\n"))) end return merged end } end -- re-emits GT's CSS with lower specificity function respecifyGtCSS(text) local s, e, v = text:find('<div id="([a-z]+)"') -- if gt does not emit a div, do nothing if v == nil then return text end return text:gsub("\n#" .. v, "\n:where(#" .. v .. ")") end function table_respecify_gt_css() return { RawBlock = function(el) if hasGtHtmlTable(el) then el.text = respecifyGtCSS(el.text) end return el end } end -- theorems.lua -- Copyright (C) 2021-2022 Posit Software, PBC function quarto_pre_theorems() return { Div = function(el) if has_theorem_ref(el) then local capEl = el.content[1] if capEl ~= nil and capEl.t == 'Header' then capEl.attr.classes:insert("unnumbered") capEl.attr.classes:insert("unlisted") end end return el end, } end -- panellayout.lua -- Copyright (C) 2023 Posit Software, PBC local function parse_width(value) if value:sub(-1) == "%" then return tonumber(value:sub(1, -2)) / 100 else return tonumber(value) end end function forward_widths_to_subfloats(layout) -- forward computed widths to the subfloats local width_table = {} for i, row in ipairs(layout.layout) do for j, cell in ipairs(row) do local width = cell.attributes["width"] if is_regular_node(cell, "Div") and width then local data = _quarto.ast.resolve_custom_data(cell) _quarto.ast.walk(cell, { FloatRefTarget = function(float) local id = float.identifier width_table[id] = parse_width(width) end }) end end end _quarto.ast.walk(layout.float, { FloatRefTarget = function(float) local id = float.identifier if width_table[id] then float.width = width_table[id] end end }) end _quarto.ast.add_handler({ -- empty table so this handler is only called programmatically class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "PanelLayout", -- float crossrefs are always blocks kind = "Block", parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, slots = { "preamble", "rows", "caption_long", "caption_short" }, -- NB this constructor mutates the .attributes field! constructor = function(tbl) if tbl.float then tbl.is_float_reftarget = true tbl.classes = tbl.float.classes tbl.identifier = tbl.float.identifier tbl.attributes = tbl.float.attributes tbl.caption_long = tbl.float.caption_long tbl.caption_short = tbl.float.caption_short tbl.order = tbl.float.order tbl.type = tbl.float.type else tbl.is_float_reftarget = false if tbl.attr then tbl.identifier = tbl.attr.identifier tbl.classes = tbl.attr.classes tbl.attributes = as_plain_table(tbl.attr.attributes) tbl.attr = nil end tbl.preamble = pandoc.Div(tbl.preamble) end -- compute vertical alignment and remove attribute if tbl.attributes == nil then tbl.attributes = {} end local vAlign = validatedVAlign(tbl.attributes[kLayoutVAlign]) tbl.attributes[kLayoutVAlign] = nil tbl.valign_class = vAlignClass(vAlign) -- construct a minimal rows-cells div scaffolding -- so contents are properly stored in the cells slot local rows_div = pandoc.Div({}) for i, row in ipairs(tbl.layout) do local row_div = pandoc.Div(row) if tbl.is_float_reftarget then row_div = _quarto.ast.walk(row_div, { traverse = "topdown", Div = function(div) local found = false -- if it has a ref parent then give it another class -- (used to provide subcaption styling) local new_div = _quarto.ast.walk(div, { FloatRefTarget = function(float) if float.parent_id then div.attr.classes:insert("quarto-layout-cell-subref") div.attr.attributes["ref-parent"] = float.parent_id end end, }) return div end, }) or {} -- this isn't needed but the type system doesn't know that end rows_div.content:insert(row_div) end tbl.rows = rows_div if tbl.float then forward_widths_to_subfloats(tbl) end return tbl end }) _quarto.ast.add_renderer("PanelLayout", function (panel) return true end, function(panel) warn("No renderer for PanelLayout") if panel.float then return panel.float end warn("Don't know how to render PanelLayout without a float; will return empty output") return pandoc.Div({}) end) -- we mostly use this function as a template to copy-and-paste into -- other functions. -- This is pretty ugly, but the kinds of things that need to change across -- formats are not always the same, so it's hard to make a generic function function basic_panel_layout(layout) if layout.float == nil then fail_and_ask_for_bug_report("Can't render layouts without floats") return pandoc.Div({}) end decorate_caption_with_crossref(layout.float) -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end local result = pandoc.Div({}) result.content:extend(panel_content) if layout.float.caption_long then result.content:insert(pandoc.Para(quarto.utils.as_inlines(layout.float.caption_long) or {})) end local res = pandoc.Blocks({}) panel_insert_preamble(res, layout.preamble) res:insert(result) end -- html.lua -- Copyright (C) 2020-2022 Posit Software, PBC _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isHtmlOutput() end, function(panel_layout) local panel = pandoc.Div({}) -- layout for i, row in ipairs(panel_layout.rows.content) do local row_div = row row_div.attr.classes:insert("quarto-layout-row") if panel_layout.valign_class then row_div.attr.classes:insert(panel_layout.valign_class) end for j, cell_div in ipairs(row.content) do -- add cell class cell_div.attr.classes:insert("quarto-layout-cell") -- create css style for width local cell_div_style = "" local width = cell_div.attr.attributes["width"] local align = cell_div.attr.attributes[kLayoutAlign] cell_div.attr.attributes[kLayoutAlign] = nil cell_div_style = cell_div_style .. "flex-basis: " .. width .. ";" cell_div.attr.attributes["width"] = nil local justify = flexAlign(align) cell_div_style = cell_div_style .. "justify-content: " .. justify .. ";" cell_div.attr.attributes["style"] = cell_div_style local has_table = false local parent_id -- if it's a table then our table-inline style will cause table headers -- (th) to be centered. set them to left is they are default cell_div = _quarto.ast.walk(cell_div, { FloatRefTarget = function(float) parent_id = float.parent_id return nil end, Table = function(table) has_table = true local changed = false table.colspecs = table.colspecs:map(function(spec) if spec[1] == pandoc.AlignDefault then spec[1] = pandoc.AlignLeft changed = true end return spec end) if changed then return table end end }) or {} -- this isn't needed by the Lua analyzer doesn't know it if has_table and parent_id ~= nil then cell_div.attr.attributes[kRefParent] = parent_id end row_div.content[j] = cell_div end -- add row to the panel panel.content:insert(row_div) end local rendered_panel if panel_layout.is_float_reftarget then if #panel.content == 0 then warn("Panel layout for " .. (panel_layout.identifier or "(unnamed panel)") .. " has no content") return pandoc.Blocks({}) end local float_node, float_tbl = quarto.FloatRefTarget({ identifier = panel_layout.identifier, classes = panel_layout.classes, attributes = panel_layout.attributes, order = panel_layout.order, type = panel_layout.type, content = panel.content, caption_long = pandoc.List({panel_layout.caption_long}), }) decorate_caption_with_crossref(float_tbl) rendered_panel = float_reftarget_render_html_figure(float_tbl) local panel_attr = pandoc.Attr(panel_layout.identifier, {"quarto-layout-panel"}) -- https://github.com/quarto-dev/quarto-cli/issues/1173 if rendered_panel.classes:includes("margin-caption") then panel_attr.classes:insert("margin-caption") end rendered_panel.attr = panel_attr else rendered_panel = panel rendered_panel.attr = pandoc.Attr( panel_layout.identifier or "", panel_layout.classes, panel_layout.attributes) rendered_panel.attr.classes:insert("quarto-layout-panel") end local preamble = panel_layout.preamble if preamble == nil then return rendered_panel end local result = pandoc.Blocks({}) panel_insert_preamble(result, preamble) result:insert(rendered_panel) return result end) function htmlDivFigure(el) return renderHtmlFigure(el, function(figure) -- get figure local figure = tslice(el.content, 1, #el.content-1) -- get caption local caption = refCaptionFromDiv(el) if caption then caption = caption.content else caption = nil end return figure, caption end) end function htmlImageFigure(image) return renderHtmlFigure(image, function(figure) -- make a copy of the caption and clear it local caption = image.caption:clone() tclear(image.caption) -- pandoc sometimes ends up with a fig prefixed title -- (no idea way right now!) if image.title == "fig:" or image.title == "fig-" then image.title = "" end -- insert the figure without the caption local figure = { pandoc.Para({image}) } return figure, caption end) end function renderHtmlFigure(el, render) -- capture relevant figure attributes then strip them local align = figAlignAttributeDefault(el, nil) if align == nil then local img = quarto.utils.match("[1]/Para/[1]/Image")(el) or quarto.utils.match("[1]/Para/[1]/Link/[1]/Image")(el) if img then align = figAlignAttribute(img) else -- fallback to center default align = figAlignAttribute(el) end end for _, k in pairs(tkeys(el.attr.attributes)) do -- can't strip fig-alt here if isFigAttribute(k) and k ~= kFigAlt then el.attr.attributes[k] = nil end end local figureAttr = {} local style = el.attr.attributes["style"] if style then figureAttr["style"] = style el.attributes["style"] = nil end -- create figure div local figureDiv = pandoc.Div({}, pandoc.Attr(el.identifier, {}, figureAttr)) figureDiv.classes = el.classes:filter(function(str) if str:match("quarto%-figure.*") then return true end return false end) -- remove identifier (it is now on the div) el.identifier = "" if not figureDiv.classes:find_if(function(str) return str:match("quarto%-figure%-.+") end) then -- apply standalone figure css if not already set figureDiv.attr.classes:insert("quarto-figure") figureDiv.attr.classes:insert("quarto-figure-" .. align) end -- also forward any column or caption classes local currentClasses = el.attr.classes for _,k in pairs(currentClasses) do if isCaptionClass(k) or isColumnClass(k) then figureDiv.attr.classes:insert(k) end end -- begin figure figureDiv.content:insert(pandoc.RawBlock("html", "<figure>")) -- render (and collect caption) local figure, captionInlines = render(figureDiv) -- render caption if captionInlines and #captionInlines > 0 then local figureCaption = pandoc.Plain({}) figureCaption.content:insert(pandoc.RawInline( "html", "<figcaption>" )) tappend(figureCaption.content, captionInlines) figureCaption.content:insert(pandoc.RawInline("html", "</figcaption>")) if cap_location_from_option('fig', 'bottom') == 'top' then figureDiv.content:insert(figureCaption) tappend(figureDiv.content, figure) else tappend(figureDiv.content, figure) figureDiv.content:insert(figureCaption) end else tappend(figureDiv.content, figure) end -- end figure and return figureDiv.content:insert(pandoc.RawBlock("html", "</figure>")) return figureDiv end function appendStyle(el, style) local baseStyle = attribute(el, "style", "") if baseStyle ~= "" and not string.find(baseStyle, ";$") then baseStyle = baseStyle .. ";" end el.attr.attributes["style"] = baseStyle .. style end function flexAlign(align) if align == "left" then return "flex-start" elseif align == "center" then return "center" elseif align == "right" then return "flex-end" end end function vAlignClass(vAlign) if vAlign == "top" then return "quarto-layout-valign-top" elseif vAlign == "bottom" then return "quarto-layout-valign-bottom" elseif vAlign == "center" then return "quarto-layout-valign-center" end end -- latex.lua -- Copyright (C) 2020-2022 Posit Software, PBC kSideCaptionEnv = 'sidecaption' _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isLatexOutput() end, function(layout) local rendered_panel = latexPanel(layout) local preamble = layout.preamble if preamble == nil then return rendered_panel end local result = pandoc.Blocks({}) panel_insert_preamble(result, preamble) result:insert(rendered_panel) return result end) -- function latexPanel(divEl, layout, caption) function latexPanel(layout) -- begin container local env, pos = latexPanelEnv(layout) local panel_node, panel = quarto.LatexEnvironment({ name = env, pos = pos }) local capLoc = "bottom" if layout.float ~= nil then capLoc = cap_location(layout.float) end local caption = create_latex_caption(layout) -- read vertical alignment and strip attribute local vAlign = validatedVAlign(layout.attributes[kLayoutVAlign]) layout.attributes[kLayoutVAlign] = nil for i, row in ipairs(layout.rows.content) do for j, cell in ipairs(row.content) do -- there should never be \begin{table} inside a panel (as that would -- create a nested float). this can happen if knitr injected it as a -- result of a captioned latex figure. in that case remove it cell = latexRemoveTableDelims(cell) -- process cell (enclose content w/ alignment) local endOfTable = i == #layout.rows.content local endOfRow = j == #row.content local prefix, content, suffix = latexCell(cell, vAlign, endOfRow, endOfTable) panel.content.content:insert(prefix) tappend(panel.content.content, content) panel.content.content:insert(suffix) end end -- surround caption w/ appropriate latex (and end the panel) if caption then if capLoc == "top" then panel.content.content:insert(1, caption) elseif capLoc == "bottom" then panel.content.content:insert(caption) else warn("unknown caption location '" .. capLoc .. "'. Skipping caption.") end end -- conjoin paragraphs panel.content.content = latexJoinParas(panel.content.content) -- return panel return panel_node end -- determine the environment (and pos) to use for a latex panel function latexPanelEnv(layout) -- defaults local env = latexFigureEnv(layout) local pos = attribute(layout.float or { attributes = layout.attributes or {} }, kFigPos) return env, pos end -- conjoin paragraphs (allows % to work correctly between minipages or subfloats) function latexJoinParas(content) local blocks = pandoc.List() for i,block in ipairs(content) do if block.t == "Para" and #blocks > 0 and blocks[#blocks].t == "Para" then tappend(blocks[#blocks].content, block.content) else blocks:insert(block) end end return blocks end function latexCaptionEnv(el) if el.attributes['quarto-caption-env'] then return el.attributes['quarto-caption-env'] end if el.classes:includes(kSideCaptionClass) then return kSideCaptionEnv else return 'caption' end end function create_latex_caption(layout) if layout.float == nil then return nil end local caption_env = latexCaptionEnv(layout.float) if ((layout.caption_long == nil or #layout.caption_long.content == 0) and (layout.caption_short == nil or #layout.caption_short.content == 0)) then return nil end local cap_inlines = quarto.utils.as_inlines(layout.caption_long) or pandoc.Inlines({}) -- unneeded but the Lua analyzer doesn't know that if layout.identifier then -- local label_node = quarto.LatexInlineCommand({ name = "label", arg = layout.identifier }) local label_node = pandoc.RawInline("latex", "\\label{" .. layout.identifier .. "}") cap_inlines:insert(1, label_node) end local caption_node, caption = quarto.LatexInlineCommand({ name = caption_env, arg = scaffold(cap_inlines), }) if layout.caption_short ~= nil then caption.opt_arg = quarto.utils.as_inlines(layout.caption_short) end return caption_node end function latexWrapSignalPostProcessor(el, token) -- this is a table div not in a panel note any caption environment tprepend(el.content, {pandoc.RawBlock('latex', '%quartopost-' .. token)}); tappend(el.content, {pandoc.RawBlock('latex', '%/quartopost-' .. token)}); end function latexMarkupCaptionEnv(el) local captionEnv = latexCaptionEnv(el) if captionEnv == 'sidecaption' then latexWrapSignalPostProcessor(el, 'sidecaption-206BE349'); end end function markupLatexCaption(el, caption, captionEnv) -- by default, just use the caption env if captionEnv == nil then captionEnv = 'caption' end local captionEnv = latexCaptionEnv(el) -- caption prefix (includes \\caption macro + optional [subcap] + {) local captionPrefix = pandoc.List({ pandoc.RawInline("latex", "\\" .. captionEnv) }) local figScap = attribute(el, kFigScap, nil) if figScap then captionPrefix:insert(pandoc.RawInline("latex", "[")) tappend(captionPrefix, markdownToInlines(figScap)) captionPrefix:insert(pandoc.RawInline("latex", "]")) end captionPrefix:insert(pandoc.RawInline("latex", "{")) tprepend(caption, captionPrefix) -- end the caption caption:insert(pandoc.RawInline("latex", "}")) end local kBeginSideNote = '\\marginnote{\\begin{footnotesize}' function latexBeginSidenote(block) if block == nil or block then return pandoc.RawBlock('latex', kBeginSideNote) else return pandoc.RawInline('latex', kBeginSideNote) end end local kEndSideNote = '\\end{footnotesize}}' function latexEndSidenote(el, block) local offset = '' if el.attr ~= nil then local offsetValue = el.attributes['offset'] if offsetValue ~= nil then offset = '[' .. offsetValue .. ']' end end if block == nil or block then return pandoc.RawBlock('latex', kEndSideNote .. offset) else return pandoc.RawInline('latex', kEndSideNote .. offset) end end function latexWrapEnvironment(el, env, inline) tprepend(el.content, {latexBeginEnv(env, nil, inline)}) tappend(el.content, {latexEndEnv(env, inline)}) end function latexBeginAlign(align) if align == "center" then return "{\\centering " elseif align == "right" then return "\\hfill{} " else return "" end end function latexEndAlign(align) if align == "center" then return "\n\n}" elseif align == "left" then return " \\hfill{}" else return "" end end function latexBeginEnv(env, pos, inline) local beginEnv = "\\begin{" .. env .. "}" if pos then if not string.find(pos, "^[%[{]") then pos = "[" .. pos .. "]" end beginEnv = beginEnv .. pos end if inline then return pandoc.RawInline("latex", beginEnv) else return pandoc.RawBlock("latex-merge", beginEnv) end end function latexEndEnv(env, inline) if inline then return pandoc.RawInline("latex", "\\end{" .. env .. "}") else return pandoc.RawBlock("latex-merge", "\\end{" .. env .. "}%") end end function latexCell(cell, vAlign, endOfRow, endOfTable) -- figure out what we are dealing with local label = cell.identifier local image = figureImageFromLayoutCell(cell) local has_pandoc_3_figure = false if image == nil then -- attempt to unwrap a Pandoc Figure cell = _quarto.ast.walk(cell, { Figure = function(figure) has_pandoc_3_figure = true _quarto.ast.walk(figure, { Image = function(img) image = img end }) if image ~= nil then return image end end }) end if (label == "") and image then label = image.identifier end local isFigure = isFigureRef(label) local isTable = isTableRef(label) local isSubRef = hasRefParent(cell) or (image and hasRefParent(image)) or has_pandoc_3_figure local tbl = tableFromLayoutCell(cell) -- determine width local width = cell.attributes["width"] -- derive prefix, content, and suffix local prefix = pandoc.List() local subcap = pandoc.List() local content = pandoc.List() local suffix = pandoc.List() if isSubRef then -- lift the caption out it it's current location and onto the \subfloat local caption = pandoc.List() -- see if it's a captioned figure if image and #image.caption > 0 then caption = image.caption:clone() tclear(image.caption) elseif tbl then caption = pandoc.utils.blocks_to_inlines(tbl.caption.long) tclear(tbl.caption.long) if tbl.caption.short then tclear(tbl.caption.short) end cell.content = { latexTabular(tbl, vAlign) } else local divCaption = refCaptionFromDiv(cell) if divCaption then caption = refCaptionFromDiv(cell).content cell.content = tslice(cell.content, 1, #cell.content-1) else caption = pandoc.List() end end -- only subcap in the passthrough Figure special case if has_pandoc_3_figure then -- subcap latexAppend(subcap, "\\subcaption{\\label{" .. label .. "}") tappend(subcap, caption) latexAppend(subcap, "}\n") end end -- convert to latex percent as necessary width = asLatexSize(width) -- start the minipage local miniPageVAlign = latexMinipageValign(vAlign) latexAppend(prefix, "\\begin{minipage}" .. miniPageVAlign .. "{" .. width .. "}\n") local capLoc = cap_location(cell) if (capLoc == "top") then tappend(prefix, subcap) end -- if we aren't in a sub-ref we may need to do some special work to -- ensure that captions are correctly emitted local cellOutput = false; if not isSubRef then if image and #image.caption > 0 then local caption = image.caption:clone() markupLatexCaption(cell, caption) tclear(image.caption) content:insert(pandoc.RawBlock("latex", "\\raisebox{-\\height}{")) content:insert(pandoc.Para(image)) content:insert(pandoc.RawBlock("latex", "}")) content:insert(pandoc.Para(caption)) cellOutput = true elseif isFigure then local caption = refCaptionFromDiv(cell).content markupLatexCaption(cell, caption) content:insert(pandoc.RawBlock("latex", "\\raisebox{-\\height}{")) tappend(content, tslice(cell.content, 1, #cell.content-1)) content:insert(pandoc.RawBlock("latex", "}")) content:insert(pandoc.Para(caption)) cellOutput = true end end -- if we didn't find a special case then just emit everything if not cellOutput then tappend(content, cell.content) -- vertically align the minipage if miniPageVAlign == "[t]" and image ~= nil then tprepend(content, { pandoc.RawBlock("latex", "\\raisebox{-\\height}{")}) tappend(content, { pandoc.RawBlock("latex", "}") }) end end if (capLoc == "bottom") then tappend(suffix, subcap) end -- close the minipage latexAppend(suffix, "\\end{minipage}%") latexAppend(suffix, "\n") if not endOfRow then latexAppend(suffix, "%") elseif not endOfTable then latexAppend(suffix, "\\newline") end latexAppend(suffix, "\n") -- ensure that pandoc doesn't write any nested figures for i,block in ipairs(content) do latexHandsoffFigure(block) content[i] = _quarto.ast.walk(block, { Para = latexHandsoffFigure }) end return pandoc.Para(prefix), content, pandoc.Para(suffix) end function latexTabular(tbl, vAlign) -- convert to simple table tbl = pandoc.utils.to_simple_table(tbl) -- list of inlines local tabular = pandoc.List() -- vertically align the minipage local tabularVAlign = latexMinipageValign(vAlign) -- caption if #tbl.caption > 0 then latexAppend(tabular, "\\caption{") tappend(tabular, tbl.caption) latexAppend(tabular, "}\n") end -- header local aligns = table.concat(tbl.aligns:map(latexTabularAlign), "") latexAppend(tabular, "\\begin{tabular}" .. tabularVAlign .. "{" .. aligns .. "}\n") latexAppend(tabular, "\\toprule\n") -- headers (optional) local headers = latexTabularRow(tbl.headers) if latexTabularRowHasContent(headers) then latexTabularRowAppend(tabular, headers) latexAppend(tabular, "\\midrule\n") end -- rows for _,row in ipairs(tbl.rows) do latexTabularRowAppend(tabular, latexTabularRow(row)) end -- footer latexAppend(tabular, "\\bottomrule\n") latexAppend(tabular, "\\end{tabular}") -- return tabular return pandoc.Para(tabular) end function latexTabularRow(row) local cells = pandoc.List() for _,cell in ipairs(row) do cells:insert(pandoc.utils.blocks_to_inlines(cell)) end return cells end function latexTabularRowHasContent(row) for _,cell in ipairs(row) do if #cell > 0 then return true end end return false end function latexTabularRowAppend(inlines, row) for i,cell in ipairs(row) do tappend(inlines, cell) if i < #row then latexAppend(inlines, " & ") end end latexAppend(inlines, "\\\\\n") end function latexTabularAlign(align) if align == pandoc.AlignLeft then return "l" elseif align == pandoc.AlignRight then return "r" elseif align == pandoc.AlignCenter then return "c" else return "l" end end function latexAppend(inlines, latex) inlines:insert(pandoc.RawInline("latex", latex)) end function latexHandsoffFigure(el) if discoverFigure(el, false) ~= nil then el.content:insert(pandoc.RawInline("markdown", "<!-- -->")) end end function latexRemoveTableDelims(el) return _quarto.ast.walk(el, { RawBlock = function(el) if _quarto.format.isRawLatex(el) then el.text = el.text:gsub("\\begin{table}[^\n]*\n", "") el.text = el.text:gsub("\\end{table}[^\n]*\n?", "") return el end end }) end local kMarginFigureEnv = "marginfigure" local kOffset = "offset" -- Computes the figure position for a figure environment -- margin figures use offset instead of position function latexFigurePosition(el, env) if env == kMarginFigureEnv then return attribute(el, kOffset, nil) else local prefix = refType(el.identifier) or "fig" return attribute(el, prefix .. "-pos", nil) end end function latexFigureEnv(el) -- Check whether the user has specified a figure environment local figEnv = attribute(el, kFigEnv, nil) if figEnv ~= nil then -- the user specified figure environment return figEnv else local crossref_cat if pandoc.utils.type(el) == "Block" then local ref_type = refType(el.identifier) if ref_type ~= nil then crossref_cat = crossref.categories.by_ref_type[ref_type] else crossref_cat = crossref.categories.by_name.Figure end elseif pandoc.utils.type(el) == "table" then crossref_cat = crossref.categories.by_name[el.type] if crossref_cat == nil then crossref_cat = crossref.categories.by_name.Figure end elseif pandoc.utils.type(el) == "Inline" then local ref_type = refType(el.identifier) if ref_type ~= nil then crossref_cat = crossref.categories.by_ref_type[ref_type] else crossref_cat = crossref.categories.by_name.Figure end else fail("Don't know how to handle " .. pandoc.utils.type(el) .. " in latexFigureEnv") end local env_name = crossref_cat.latex_env -- if not user specified, look for other classes which might determine environment local classes = el.classes for i,class in ipairs(classes) do -- FIXME how to deal with margin custom floats? -- a margin figure or aside if isMarginEnv(class) then noteHasColumns() return kMarginFigureEnv end -- any column that resolves to full width if isStarEnv(class) then noteHasColumns() return env_name .. "*" end end -- the default figure environment return env_name end end function latexOtherEnv(el) -- if not user specified, look for other classes which might determine environment local classes = el.classes if classes ~= nil then for i,class in ipairs(classes) do -- any column that resolves to full width if isStarEnv(class) then noteHasColumns() return "figure*" end end end return nil end function latexTableEnv(el) local classes = el.classes for i,class in ipairs(classes) do -- a margin figure or aside if isMarginEnv(class) then noteHasColumns() return "margintable" end -- any column that resolves to full width if isStarEnv(class) then noteHasColumns() return "table*" end end -- the default figure environment return "table" end -- this is still used by stray Figure nodes from Pandoc 3's AST function latexImageFigure(image) return renderLatexFigure(image, function(figure) -- make a copy of the caption and clear it local caption = image.caption:clone() tclear(image.caption) -- get align local align = figAlignAttribute(image) if align ~= nil then image.attributes[kFigAlign] = nil end -- insert the figure without the caption local figureContent = { pandoc.Para({ pandoc.RawInline("latex", latexBeginAlign(align)), image, pandoc.RawInline("latex", latexEndAlign(align)), pandoc.RawInline("latex", "\n") }) } -- return the figure and caption return figureContent, caption end) end function renderLatexFigure(el, render) -- create container local figure = pandoc.Div({}) -- begin the figure local figEnv = latexFigureEnv(el) local figPos = latexFigurePosition(el, figEnv) figure.content:insert(latexBeginEnv(figEnv, figPos)) -- get the figure content and caption inlines local figureContent, captionInlines = render(figure) local capLoc = cap_location_from_option("fig", "bottom") -- surround caption w/ appropriate latex (and end the figure) if captionInlines and inlinesToString(captionInlines) ~= "" then if capLoc == "top" then insertLatexCaption(el, figure.content, captionInlines) tappend(figure.content, figureContent) else tappend(figure.content, figureContent) insertLatexCaption(el, figure.content, captionInlines) end else tappend(figure.content, figureContent) end -- end figure figure.content:insert(latexEndEnv(figEnv)) -- return the figure return figure end function insertLatexCaption(divEl, content, captionInlines) local captionEnv = latexCaptionEnv(divEl) markupLatexCaption(divEl, captionInlines, captionEnv) if captionEnv == kSideCaptionEnv then if #content > 1 then content:insert(2, pandoc.Para(captionInlines)) else content:insert(#content, pandoc.Para(captionInlines)) end else content:insert(pandoc.Para(captionInlines)) end end function isStarEnv(clz) return (clz:match('^column%-screen') or clz:match('^column%-page')) and not clz:match('%-left$') end function isMarginEnv(clz) return clz == 'column-margin' or clz == 'aside' end function latexMinipageValign(vAlign) if vAlign == "top" then return "[t]" elseif vAlign == "bottom" then return "[b]" elseif vAlign == "center" then return "[c]" else return "" end end -- docx.lua -- Copyright (C) 2020-2022 Posit Software, PBC function docx_content_fixups(el, align, layoutPercent) local width = wpPageWidth() return _quarto.ast.walk(el, { traverse = "topdown", Div = function(div) if div.classes:includes("quarto-layout-cell-subref") then layoutPercent = horizontalLayoutPercent(div) return docx_content_fixups(div, align, layoutPercent), false end end, Image = function(image) if width then if layoutPercent then local inches = (layoutPercent/100) * width image.attr.attributes["width"] = string.format("%2.2f", inches) .. "in" return image end end end, Table = function(tbl) if align == "center" then -- force widths to occupy 100% layoutEnsureFullTableWidth(tbl) return tbl end end }) or pandoc.Div({}) -- not necessary but the lua analyzer doesn't know that end _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isDocxOutput() or _quarto.format.isOdtOutput() end, function(layout) local rendered_panel local div = pandoc.Div({}) local layout_attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local rows = layout.rows.content:map(function(div) return div.content end) if layout.is_float_reftarget then decorate_caption_with_crossref(layout.float) local float_attr = pandoc.Attr(layout.float.identifier or "", layout.float.classes or {}, layout.float.attributes or {}) div.attr = merge_attrs(float_attr, layout_attr) rendered_panel = tableDocxPanel(div, rows, layout.float.caption_long) local align = align_attribute(layout.float) rendered_panel = docx_content_fixups(rendered_panel, align) else div.attr = layout_attr rendered_panel = tableDocxPanel(div, rows, nil) end local preamble = layout.preamble if preamble == nil then return rendered_panel end local result = pandoc.Blocks({}) panel_insert_preamble(result, preamble) result:insert(rendered_panel) return result end) function tableDocxPanel(divEl, layout, caption) return tablePanel(divEl, layout, caption, { pageWidth = wpPageWidth(), rowBreak = docxRowBreak, divCaption = docxDivCaption }) end function docxRowBreak() return pandoc.RawBlock("openxml", [[ <w:p> <w:pPr> <w:framePr w:w="0" w:h="0" w:vAnchor="margin" w:hAnchor="margin" w:xAlign="right" w:yAlign="top"/> </w:pPr> </w:p> ]]) end -- create a native docx caption function docxDivCaption(captionEl, align) local caption = pandoc.Para({ pandoc.RawInline("openxml", docxParaStyles(align)) }) tappend(caption.content, captionEl.content) return caption end function docxParaStyles(align) local styles = "<w:pPr>\n" local captionAlign = docxAlign(align) if captionAlign then styles = styles .. "<w:jc w:val=\"" .. captionAlign .. "\"/>\n" end styles = styles .. "<w:spacing w:before=\"200\" />\n" .. "<w:pStyle w:val=\"ImageCaption\" />\n" .. "</w:pPr>\n" return styles end function docxAlign(align) if align == "left" then return "start" elseif align == "center" then return "center" elseif align == "right" then return "end" else return nil end end -- jats.lua -- Copyright (C) 2020-2022 Posit Software, PBC function jatsDivFigure(divEl) -- ensure that only valid elements are permitted local filteredEl = _quarto.ast.walk(divEl, { Header = function(el) return pandoc.Strong(el.content) end }) local figure = pandoc.List({}) local id = filteredEl.attr.identifier -- append everything before the caption local contents = tslice(filteredEl.content, 1, #filteredEl.content - 1) -- return the figure and caption local caption = refCaptionFromDiv(filteredEl) if not caption then caption = pandoc.Inlines() end -- convert fig-pos to jats position local position = jatsPosition(filteredEl) local posAttr = "" if position then posAttr = ' position="' .. position .. '"' end figure:insert(pandoc.RawBlock('jats', '<fig id="' .. id .. '"' .. posAttr .. '>')) figure:insert(pandoc.RawBlock('jats', '<caption>')) figure:insert(caption); figure:insert(pandoc.RawBlock('jats', '</caption>')) tappend(figure, contents) figure:insert(pandoc.RawBlock('jats', '</fig>')) return figure end function jatsPosition(el) local figPos = attribute(el, kFigPos, nil) if figPos and figPos == 'h' and figPos == 'H' then return "anchor" else return "float" end end _quarto.ast.add_renderer("PanelLayout", function(layout) return _quarto.format.isJatsOutput() end, function(layout) if layout.float == nil then fail_and_ask_for_bug_report("JATS format can't render layouts without floats") return nil end -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do local align = cell.attributes[kLayoutAlign] cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end decorate_caption_with_crossref(layout.float) local result = pandoc.Figure(panel_content, {layout.float.caption_long}, attr) local pt = pandoc.utils.type(layout.preamble) if pt == "Blocks" then layout.preamble:insert(result) return layout.preamble elseif pt == "Block" then return pandoc.Blocks({ layout.preamble, result }) elseif pt == "nil" then return result else internal_error() end end) -- asciidoc.lua -- Copyright (C) 2020-2022 Posit Software, PBC function asciidocFigure(image) -- the figure that we'll make local figure = pandoc.List() -- the identififer if image.attr.identifier and image.attr.identifier ~= '' then figure:extend({"[[" .. image.attr.identifier .. "]]\n"}); end -- caption local captionText = nil if image.caption and #image.caption > 0 then captionText = pandoc.write(pandoc.Pandoc({image.caption}), "asciidoc") captionText = captionText:gsub("\n", " ") end if captionText ~= nil then figure:extend({"." .. captionText .. "\n" }) end -- alt text (ok to use HTML entities since alt is expressly for HTML output) local altText = image.attr.attributes["alt"] or image.attr.attributes[kFigAlt] or "" altText = altText:gsub("\"", """) altText = altText:gsub("<", "<") altText = altText:gsub(">", ">") altText = altText:gsub("&", "&") -- the figure itself figure:extend({"image::" .. image.src .. "[\"" .. altText .. "\"]"}) return pandoc.RawBlock("asciidoc", table.concat(figure, "") .. "\n\n") end function asciidocDivFigure(el) local figure = pandoc.List({}) local id = el.attr.identifier -- append everything before the caption local contents = tslice(el.content, 1, #el.content - 1) -- return the figure and caption local caption = refCaptionFromDiv(el) if caption then local renderedCaption = pandoc.write(pandoc.Pandoc({caption}), "asciidoc") figure:insert(pandoc.RawBlock('asciidoc', '.' .. renderedCaption)) end if id and id ~= '' then figure:insert(pandoc.RawBlock('asciidoc', '[#' .. id .. ']\n')) end tappend(figure, contents) return figure end _quarto.ast.add_renderer("PanelLayout", function(layout) return _quarto.format.isAsciiDocOutput() end, function(layout) if layout.float == nil then fail_and_ask_for_bug_report("asciidoc format doesn't currently support layouts without floats.") return pandoc.Div({}) end -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end -- this is exceedingly hacky, but it works. -- It will fail if the caption contains citations that need to be resolved... local caption_str = pandoc.write(pandoc.Pandoc({layout.float.caption_long}), "asciidoc") -- we need to recurse into render_extended_nodes here, sigh local content_str = pandoc.write(_quarto.ast.walk(pandoc.Pandoc(panel_content), render_extended_nodes()) or {}, "asciidoc") local figure_str = "." .. caption_str .. "[#" .. layout.identifier .. "]\n" .. content_str local pt = pandoc.utils.type(layout.preamble) if pt == "Blocks" then layout.preamble:insert(pandoc.RawBlock("asciidoc", figure_str)) return layout.preamble elseif pt == "Block" then return pandoc.Blocks({ layout.preamble, pandoc.RawBlock("asciidoc", figure_str) }) elseif pt == "nil" then return pandoc.RawBlock("asciidoc", figure_str) else internal_error() end end) -- latexenv.lua -- Copyright (C) 2023 Posit Software, PBC _quarto.ast.add_handler({ -- empty table so this handler is only called programmatically class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "LatexEnvironment", kind = "Block", parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, slots = { "content" }, constructor = function(tbl) tbl.content = pandoc.Div(tbl.content or {}) return tbl end }) _quarto.ast.add_renderer("LatexEnvironment", function(_) return true end, function(env) local result = pandoc.Blocks({}) result:insert(latexBeginEnv(env.name, env.pos)) result:extend(env.content.content or env.content) result:insert(pandoc.RawBlock("latex-merge", "\\end{" .. env.name .. "}%")) return result end) -- latexcmd.lua -- Copyright (C) 2023 Posit Software, PBC _quarto.ast.add_handler({ class_name = {}, ast_name = "LatexInlineCommand", kind = "Inline", -- luacov: disable parse = function() internal_error() end, -- luacov: enable slots = { "arg", "opt_arg" }, constructor = function(tbl) return tbl end }) _quarto.ast.add_handler({ class_name = {}, ast_name = "LatexBlockCommand", kind = "Block", -- luacov: disable parse = function() internal_error() end, -- luacov: enable slots = { "arg", "opt_arg" }, constructor = function(tbl) return tbl end }) _quarto.ast.add_renderer("LatexInlineCommand", function(_) return true end, function(cmd) local result = pandoc.Inlines({}) result:insert(pandoc.RawInline("latex", "\\" .. cmd.name)) local opt_arg = cmd.opt_arg if opt_arg then result:insert(pandoc.RawInline("latex", "[")) if opt_arg.content then result:extend(opt_arg.content) else result:insert(opt_arg) end result:insert(pandoc.RawInline("latex", "]")) end local arg = cmd.arg if arg then result:insert(pandoc.RawInline("latex", "{")) if arg.content then result:extend(arg.content) else result:insert(arg) end result:insert(pandoc.RawInline("latex", "}")) end return result end) _quarto.ast.add_renderer("LatexBlockCommand", function(_) return true end, function(cmd) local result = pandoc.Blocks({}) local preamble = pandoc.Inlines({}) local postamble = pandoc.Inlines({}) preamble:insert(pandoc.RawInline("latex", "\\" .. cmd.name)) local opt_arg = cmd.opt_arg if opt_arg then preamble:insert(pandoc.RawInline("latex", "[")) if opt_arg.content then preamble:extend(opt_arg.content) else preamble:insert(opt_arg) end preamble:insert(pandoc.RawInline("latex", "]")) end preamble:insert(pandoc.RawInline("latex", "{")) result:insert(pandoc.Plain(preamble)) local arg = cmd.arg if arg then local pt = pandoc.utils.type(arg) if pt == "Blocks" then result:extend(arg) elseif pt == "Block" then if arg.content then result:extend(arg.content) else result:insert(arg) end else -- luacov: disable fail_and_ask_for_bug_report("Unexpected type for LatexBlockCommand arg: " .. pt) return nil -- luacov: enable end end postamble:insert(pandoc.RawInline("latex", "}")) result:insert(pandoc.Plain(postamble)) return result end) -- htmltag.lua -- Copyright (C) 2023 Posit Software, PBC _quarto.ast.add_handler({ -- empty table so this handler is only called programmatically class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "HtmlTag", -- float crossrefs are always blocks kind = "Block", parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, slots = { "content" }, constructor = function(tbl) if tbl.attr then tbl.identifier = tbl.attr.identifier tbl.classes = tbl.attr.classes tbl.attributes = as_plain_table(tbl.attr.attributes) tbl.attr = nil end tbl.classes = tbl.classes or {} tbl.attributes = tbl.attributes or {} tbl.identifier = tbl.identifier or "" tbl.content = pandoc.Div(tbl.content or {}) return tbl end }) _quarto.ast.add_renderer("HtmlTag", function(_) return true end, function(tag) local div = pandoc.Blocks({}) local result = div local result_attrs = { class = table.concat(tag.classes, " "), } if tag.identifier ~= nil and tag.identifier ~= "" then result_attrs.id = tag.identifier end for k, v in pairs(tag.attributes) do result_attrs[k] = v end local attr_string = {} for k, v in spairs(result_attrs) do table.insert(attr_string, k .. "=\"" .. html_escape(v, true) .. "\"") end result:insert(pandoc.RawBlock("html", "<" .. tag.name .. " " .. table.concat(attr_string, " ") .. ">")) result:extend(tag.content.content) result:insert(pandoc.RawBlock("html", "</" .. tag.name .. ">")) return div end) -- shortcodes.lua -- Copyright (C) 2020-2022 Posit Software, PBC local shortcode_lpeg = require("lpegshortcode") _quarto.ast.add_handler({ class_name = { "quarto-shortcode__" }, ast_name = "Shortcode", kind = "Inline", parse = function(span) local inner_content = pandoc.List({}) span.content = span.content:filter(function(el) return el.t == "Span" end) local shortcode_content = span.content:map(function(el) if not el.classes:includes("quarto-shortcode__-param") then -- luacov: disable quarto.log.output(el) fatal("Unexpected span in a shortcode parse") -- luacov: enable end -- is it a recursive shortcode? local custom_data, t, kind = _quarto.ast.resolve_custom_data(el) if custom_data ~= nil then local inner_index = #inner_content+1 inner_content:insert(custom_data) return { type = "shortcode", value = inner_index } end -- is it a plain value? if el.attributes["data-key"] == nil and el.attributes["data-value"] then return { type = "param", value = el.attributes["data-value"] } end -- it is a key value. if el.attributes["data-key"] then local key = el.attributes["data-key"] local value = el.attributes["data-value"] if value == nil then -- it's a recursive value value = el.content[1] local inner_index = #inner_content+1 inner_content:insert(value) return { type = "key-value-shortcode", key = key, value = inner_index } else -- it's a plain value return { type = "key-value", key = key, value = value } end else -- luacov: disable quarto.log.output(el) fatal("Unexpected span in a shortcode parse") -- luacov: enable end end) local name = shortcode_content:remove(1) if name.type == "param" then name = name.value end local node = _quarto.ast.create_custom_node_scaffold("Shortcode", "Inline") node.content = inner_content:map(function(el) return pandoc.Span({el}) end) local tbl = { __quarto_custom_node = node, name = name, unparsed_content = span.attributes["data-raw"], params = shortcode_content } return quarto.Shortcode(tbl) end, render = function(node) quarto.log.output(node) -- luacov: disable internal_error() -- luacov: enable end, constructor = function(tbl) return tbl, false end, }) local function handle_shortcode(shortcode_tbl, node) local name if type(shortcode_tbl.name) ~= "string" then -- this is a recursive shortcode call, -- name is a number that indexes into the node's content -- to get the shortcode node to call. -- typically, shortcodes are resolved in a typewise traversal -- which is bottom up, so we should be seeing resolved shortcode -- content here. But in unusual cases, we might be calling -- this function outside of a filter, in which case -- we need to handle this explicitly if type(shortcode_tbl.name) ~= "number" then -- luacov: disable quarto.log.output(shortcode_tbl.name) fatal("Unexpected shortcode name type " .. type(shortcode_tbl.name)) -- luacov: enable end local shortcode_node = node.content[shortcode_tbl.name] -- are we already resolved? for i, v in ipairs(shortcode_node.content) do local custom_data, t, kind = _quarto.ast.resolve_custom_data(v) if custom_data ~= nil then if t ~= "Shortcode" then -- luacov: disable quarto.log.output(t) fatal("Unexpected shortcode content type " .. tostring(t)) -- luacov: enable end -- we are not resolved, so resolve shortcode_node.content[i] = handle_shortcode(custom_data, v) end end name = pandoc.utils.stringify(shortcode_node) -- TODO check that this returns a string as it should else name = shortcode_tbl.name end local args = {} local raw_args = {} for _, v in ipairs(shortcode_tbl.params) do if v.type == "key-value" then table.insert(args, { name = v.key, value = v.value }) table.insert(raw_args, v.value) elseif v.type == "key-value-shortcode" then local result = handle_shortcode(v.value) table.insert(args, { name = v.key, value = result }) table.insert(raw_args, result) elseif v.type == "shortcode" then local shortcode_node = node.content[v.value] local custom_data, t, kind = _quarto.ast.resolve_custom_data(shortcode_node) local result if custom_data == nil then result = pandoc.utils.stringify(shortcode_node) elseif t ~= "Shortcode" then -- luacov: disable quarto.log.output(custom_data) quarto.log.output(t) fatal("Unexpected shortcode content type " .. tostring(t)) -- luacov: enable else local result = handle_shortcode(custom_data, shortcode_node) result = pandoc.utils.stringify(result) end table.insert(args, { value = result }) table.insert(raw_args, result) elseif v.type == "param" then table.insert(args, { value = v.value }) table.insert(raw_args, v.value) else -- luacov: disable quarto.log.output(v) fatal("Unexpected shortcode param type " .. tostring(v.type)) -- luacov: enable end end local shortcode_struct = { args = args, raw_args = raw_args, name = name, unparsed_content = shortcode_tbl.unparsed_content } local handler = handlerForShortcode(shortcode_struct) if handler == nil then return nil, shortcode_struct end return callShortcodeHandler(handler, shortcode_struct), shortcode_struct end function shortcodes_filter() local code_shortcode = shortcode_lpeg.make_shortcode_parser({ escaped = function(s) return "{{<" .. s .. ">}}" end, string = function(s) return { value = s } end, keyvalue = function(r, k, v) return { name = k, value = v } end, shortcode = function(open, space, lst, close) local name = table.remove(lst, 1).value local raw_args = {} for _, v in ipairs(lst) do table.insert(raw_args, v.value) end local shortcode_struct = { args = lst, raw_args = raw_args, name = name } local handler = handlerForShortcode(shortcode_struct) if handler == nil then return open .. space .. name .. " " .. table.concat(raw_args, " ") .. " " .. close end local result = callShortcodeHandler(handler, shortcode_struct) return pandoc.utils.stringify(result) end, }) local filter local block_handler = function(node) if (node.t == "Para" or node.t == "Plain") and #node.content == 1 then node = node.content[1] end local custom_data, t, kind = _quarto.ast.resolve_custom_data(node) if t ~= "Shortcode" then return nil end local result, struct = handle_shortcode(custom_data, node) return _quarto.ast.walk(shortcodeResultAsBlocks(result, struct.name, custom_data), filter) end local inline_handler = function(custom_data, node) local result, struct = handle_shortcode(custom_data, node) local r1 = shortcodeResultAsInlines(result, struct.name, custom_data) local r2 = _quarto.ast.walk(r1, filter) return r2 end local code_handler = function(el) -- don't process shortcodes in code output from engines -- (anything in an engine processed code block was actually -- proccessed by the engine, so should be printed as is) if el.attr and el.attr.classes:includes("cell-code") then return end -- don't process shortcodes if they are explicitly turned off if el.attr and el.attr.attributes["shortcodes"] == "false" then return end el.text = shortcode_lpeg.wrap_lpeg_match(code_shortcode, el.text) return el end filter = { Pandoc = function(doc) -- first walk them in block context doc = _quarto.ast.walk(doc, { Para = block_handler, Plain = block_handler, Code = code_handler, RawBlock = code_handler, CodeBlock = code_handler, }) doc = _quarto.ast.walk(doc, { Shortcode = inline_handler, RawInline = code_handler, Image = function(el) el.src = shortcode_lpeg.wrap_lpeg_match(code_shortcode, el.src) return el end, Link = function(el) el.target = shortcode_lpeg.wrap_lpeg_match(code_shortcode, el.target) return el end, Span = function(el) if el.classes:includes("quarto-shortcode__-escaped") then return pandoc.Str(el.attributes["data-value"]) end end, }) return doc end } return filter end -- helper function to read metadata options local function readMetadata(value) -- We were previously coercing everything to lists of inlines when possible -- which made for some simpler treatment of values in meta, but it also -- meant that reading meta here was different than reading meta in filters -- -- This is now just returning the raw meta value and not coercing it, so -- users will have to be more thoughtful (or just use pandoc.utils.stringify) -- -- Additionally, this used to return an empty list of inlines but now -- it returns nil for an unset value return option(value, nil) end -- call a handler w/ args & kwargs function callShortcodeHandler(handler, shortCode) local args = pandoc.List() local kwargs = setmetatable({}, { __index = function () return pandoc.Inlines({}) end }) for _,arg in ipairs(shortCode.args) do if arg.name then kwargs[arg.name] = arg.value else args:insert(arg.value) end end local meta = setmetatable({}, { __index = function(t, i) return readMetadata(i) end}) local callback = function() return handler.handle(args, kwargs, meta, shortCode.raw_args) end -- set the script file path, if present if handler.file ~= nil then return _quarto.withScriptFile(handler.file, callback) else return callback() end end function shortcodeResultAsInlines(result, name, shortcode_tbl) if result == nil then warn("Shortcode '" .. name .. "' not found") local result = pandoc.Inlines({pandoc.RawInline(FORMAT, shortcode_tbl.unparsed_content)}) return result end local type = quarto.utils.type(result) if type == "Inlines" then return result elseif type == "Blocks" then return pandoc.utils.blocks_to_inlines(result, { pandoc.Space() }) elseif type == "string" then return pandoc.Inlines( { pandoc.Str(result) }) elseif tisarray(result) then local items = pandoc.List(result) local inlines = items:filter(isInlineEl) if #inlines > 0 then return pandoc.Inlines(inlines) else local blocks = items:filter(isBlockEl) return pandoc.utils.blocks_to_inlines(blocks, { pandoc.Space() }) end elseif isInlineEl(result) then return pandoc.Inlines( { result }) elseif isBlockEl(result) then return pandoc.utils.blocks_to_inlines( { result }, { pandoc.Space() }) else -- luacov: disable error("Unexpected result from shortcode " .. name .. "") quarto.log.output(result) fatal("This is a bug in the shortcode. If this is a quarto shortcode, please report it at https://github.com/quarto-dev/quarto-cli") -- luacov: enable end end function shortcodeResultAsBlocks(result, name, shortcode_tbl) if result == nil then if name ~= 'include' then warn("Shortcode '" .. name .. "' not found") end return pandoc.Blocks({pandoc.RawBlock(FORMAT, shortcode_tbl.unparsed_content)}) end local type = quarto.utils.type(result) if type == "Blocks" then return result elseif type == "Inlines" then return pandoc.Blocks( {pandoc.Para(result) }) -- why not a plain? elseif type == "string" then return pandoc.Blocks( {pandoc.Para({pandoc.Str(result)})} ) -- why not a plain? elseif tisarray(result) then local items = pandoc.List(result) local blocks = items:filter(isBlockEl) if #blocks > 0 then return pandoc.Blocks(blocks) else local inlines = items:filter(isInlineEl) return pandoc.Blocks({pandoc.Para(inlines)}) -- why not a plain? end elseif isBlockEl(result) then return pandoc.Blocks( { result } ) elseif isInlineEl(result) then return pandoc.Blocks( {pandoc.Para( {result} ) }) -- why not a plain? else -- luacov: disable error("Unexpected result from shortcode " .. name .. "") quarto.log.output(result) fatal("This is a bug in the shortcode. If this is a quarto shortcode, please report it at https://github.com/quarto-dev/quarto-cli") -- luacov: enable end end -- content-hidden.lua -- Copyright (C) 2022 Posit Software, PBC local constants = require("modules/constants") local kConditions = pandoc.List({ constants.kWhenMeta, constants.kUnlessMeta, constants.kWhenFormat, constants.kUnlessFormat, constants.kWhenProfile, constants.kUnlessProfile }) function is_visible(node) local profiles = pandoc.List(param("quarto_profile", {})) local match = propertiesMatch(node.condition, profiles) if node.behavior == constants.kContentVisible then return match elseif node.behavior == constants.kContentHidden then return not match else -- luacov: disable fatal("Internal Error: invalid behavior for conditional block: " .. node.behavior) return false -- luacov: enable end end _quarto.ast.add_handler({ class_name = { constants.kContentVisible, constants.kContentHidden }, ast_name = "ConditionalBlock", kind = "Block", parse = function(div) local behavior = div.classes:find(constants.kContentVisible) or div.classes:find(constants.kContentHidden) local condition = pandoc.List({}) local remaining_attributes = pandoc.List({}) for i, v in ipairs(div.attributes) do if kConditions:find(v[1]) ~= nil then condition:insert(v) else remaining_attributes:insert(v) end end div.attributes = remaining_attributes div.classes = div.classes:filter(function(k) return k ~= constants.kContentVisible and k ~= constants.kContentHidden end) return quarto.ConditionalBlock({ node = div, behavior = behavior, condition = condition }) end, slots = { "node" }, render = function(node) local visible = is_visible(node) if visible then local el = node.node clearHiddenVisibleAttributes(el) return el.content else return {} end end, constructor = function(tbl) local result = { node = tbl.node, original_node = tbl.node:clone(), -- keep it around in case filters need to inspect it behavior = tbl.behavior, condition = pandoc.List({}) }; for i, v in ipairs(tbl.condition or {}) do if kConditions:find(v[1]) == nil then -- luacov: disable error("Ignoring invalid condition in conditional block: " .. v[1]) -- luacov: enable else result.condition[v[1]] = v[2] end end if not is_visible(result) then -- if the block is not visible, clear out the content -- before filters are run on document result.node.content = {} end flags.has_conditional_content = true return result end, }) local _content_hidden_meta = nil -- we capture a copy of meta here for convenience; -- function content_hidden_meta(meta) -- return { -- Meta = function(meta) _content_hidden_meta = meta -- end -- } end local function get_meta(key) local obj = _content_hidden_meta for _, k in ipairs(key) do if obj == nil then return nil end obj = obj[k] end return obj end function content_hidden() local profiles = pandoc.List(param("quarto_profile", {})) return { -- Div = handleHiddenVisible(profiles), CodeBlock = handleHiddenVisible(profiles), Span = handleHiddenVisible(profiles) } end function handleHiddenVisible(profiles) return function(el) local visible if el.attr.classes:find(constants.kContentVisible) then visible = propertiesMatch(el.attributes, profiles) clearHiddenVisibleAttributes(el) elseif el.attr.classes:find(constants.kContentHidden) then visible = not propertiesMatch(el.attributes, profiles) clearHiddenVisibleAttributes(el) else return el end -- this is only called on spans and codeblocks, so here we keep the scaffolding element -- as opposed to in the Div where we return the inlined content if visible then return el else return {} end end end -- "properties" here will come either from "conditions", in the case of a custom AST node -- or from the attributes of the element itself in the case of spans or codeblocks function propertiesMatch(properties, profiles) local match = true if properties[constants.kWhenMeta] ~= nil then local v = properties[constants.kWhenMeta] v = split(v, ".") or { v } local r = get_meta(v) match = match and (type(r) == "boolean" and r) end if properties[constants.kUnlessMeta] ~= nil then local v = properties[constants.kUnlessMeta] v = split(v, ".") or { v } local r = get_meta(v) match = match and not (type(r) == "boolean" and r) end if properties[constants.kWhenFormat] ~= nil then match = match and _quarto.format.isFormat(properties[constants.kWhenFormat]) end if properties[constants.kUnlessFormat] ~= nil then match = match and not _quarto.format.isFormat(properties[constants.kUnlessFormat]) end if properties[constants.kWhenProfile] ~= nil then match = match and profiles:includes(properties[constants.kWhenProfile]) end if properties[constants.kUnlessProfile] ~= nil then match = match and not profiles:includes(properties[constants.kUnlessProfile]) end return match end function clearHiddenVisibleAttributes(el) el.attributes[constants.kUnlessFormat] = nil el.attributes[constants.kWhenFormat] = nil el.attributes[constants.kUnlessProfile] = nil el.attributes[constants.kWhenProfile] = nil el.attr.classes = removeClass(el.attr.classes, constants.kContentVisible) el.attr.classes = removeClass(el.attr.classes, constants.kContentHidden) end -- decoratedcodeblock.lua -- Copyright (C) 2020-2023 Posit Software, PBC -- A custom AST node for decorated code blocks -- so we can render the decorations in the right order _quarto.ast.add_handler({ -- decorated code blocks can't be represented as divs in markdown, they can -- only be constructed directly in Lua class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "DecoratedCodeBlock", -- DecoratedCodeblocks will be rendered as blocks kind = "Block", slots = { "code_block" }, -- a function that takes the div node as supplied in user markdown -- and returns the custom node parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, constructor = function(tbl) return tbl end }) -- default renderer -- return the code block unadorned -- this probably could be improved _quarto.ast.add_renderer("DecoratedCodeBlock", function(_) return true end, function(node) return _quarto.ast.walk(node.code_block, { CodeBlock = render_folded_block }) end) -- markdown renderer _quarto.ast.add_renderer("DecoratedCodeBlock", function(_) return _quarto.format.isMarkdownOutput() end, function(node) local el = node.code_block -- see https://github.com/quarto-dev/quarto-cli/issues/5112 -- -- This is a narrow fix for the 1.3 regression. -- We still don't support listings output in markdown since that wasn't supported in 1.2 either. -- But that'll be done in 1.4 with crossrefs overhaul. if node.filename then -- a user filter could have replaced -- a single code block in a decorated code block with a list of elements, -- so we need to handle that. local blocks = quarto.utils.as_blocks(el) or pandoc.Blocks({}) -- if we have a filename, add it as a header blocks:insert(1, pandoc.Plain{pandoc.Strong{pandoc.Str(node.filename)}}) return pandoc.Div( blocks, pandoc.Attr("", {"code-with-filename"}) ) else return _quarto.ast.walk(quarto.utils.as_blocks(el), { CodeBlock = render_folded_block }) end end) -- latex renderer _quarto.ast.add_renderer("DecoratedCodeBlock", function(_) return _quarto.format.isLatexOutput() end, function(node) -- add listing class to the code block -- need to walk the code block instead of assigning directly -- because upstream filters might have replaced the code block with -- more than one element node.code_block = _quarto.ast.walk(quarto.utils.as_blocks(node.code_block), { CodeBlock = function(el) el.attr.classes:insert("listing") return render_folded_block(el) end }) or node.code_block -- unneeded but the Lua analyzer doesn't know that -- if we are use the listings package we don't need to do anything -- further, otherwise generate the listing div and return it if not param("listings", false) then local listingDiv = pandoc.Div({}) local position = "" if _quarto.format.isBeamerOutput() then -- Adjust default float positionment for beamer (#5536) position = "[H]" end listingDiv.content:insert(pandoc.RawBlock("latex", "\\begin{codelisting}" .. position)) local captionContent = node.caption if node.filename ~= nil and captionContent ~= nil then -- with both filename and captionContent we need to add a colon local listingCaption = pandoc.Plain({pandoc.RawInline("latex", "\\caption{")}) listingCaption.content:insert( pandoc.RawInline("latex", "\\texttt{" .. stringEscape(node.filename, "latex") .. "}: ") ) listingCaption.content:extend(captionContent) listingCaption.content:insert(pandoc.RawInline("latex", "}")) listingDiv.content:insert(listingCaption) elseif node.filename ~= nil and captionContent == nil then local listingCaption = pandoc.Plain({pandoc.RawInline("latex", "\\caption{")}) -- with just filename we don't add a colon listingCaption.content:insert( pandoc.RawInline("latex", "\\texttt{" .. stringEscape(node.filename, "latex") .. "}") ) listingCaption.content:insert(pandoc.RawInline("latex", "}")) listingDiv.content:insert(listingCaption) elseif node.filename == nil and captionContent ~= nil then local listingCaption = pandoc.Plain({pandoc.RawInline("latex", "\\caption{")}) listingCaption.content:extend(captionContent) listingCaption.content:insert(pandoc.RawInline("latex", "}")) listingDiv.content:insert(listingCaption) end -- a user filter could have replaced -- a single code block in a decorated code block with a list of elements, -- so we need to handle that. listingDiv.content:extend(quarto.utils.as_blocks(node.code_block) or {}) listingDiv.content:insert(pandoc.RawBlock("latex", "\\end{codelisting}")) return listingDiv end return node.code_block end) -- html renderer _quarto.ast.add_renderer("DecoratedCodeBlock", function(_) return _quarto.format.isHtmlOutput() end, function(node) if node.filename == nil then return _quarto.ast.walk(quarto.utils.as_blocks(node.code_block), { CodeBlock = render_folded_block }) end local el = node.code_block local filenameEl local caption local classes = pandoc.List() filenameEl = pandoc.Div({pandoc.Plain{ pandoc.RawInline("html", "<pre>"), pandoc.Strong{pandoc.Str(node.filename)}, pandoc.RawInline("html", "</pre>") }}, pandoc.Attr("", {"code-with-filename-file"})) classes:insert("code-with-filename") local blocks = pandoc.Blocks({}) if caption ~= nil then blocks:insert(caption) end el = _quarto.ast.walk(quarto.utils.as_blocks(el), { CodeBlock = render_folded_block }) or pandoc.Blocks({}) if filenameEl ~= nil then el = _quarto.ast.walk(quarto.utils.as_blocks(el), { CodeBlock = function(block) return pandoc.Blocks({ filenameEl, block }) end }) or pandoc.Blocks({}) end blocks:extend(el) return pandoc.Div(blocks, pandoc.Attr("", classes)) end) -- callout.lua -- Copyright (C) 2021-2022 Posit Software, PBC local constants = require("modules/constants") function calloutType(div) for _, class in ipairs(div.attr.classes) do if isCallout(class) then local type = class:match("^callout%-(.*)") if type == nil then type = "none" end return type end end return nil end _quarto.ast.add_handler({ -- use either string or array of strings class_name = { "callout", "callout-note", "callout-warning", "callout-important", "callout-caution", "callout-tip" }, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "Callout", -- callouts will be rendered as blocks kind = "Block", -- a function that takes the div node as supplied in user markdown -- and returns the custom node parse = function(div) quarto_global_state.hasCallouts = true local title = markdownToInlines(div.attr.attributes["title"]) if not title or #title == 0 then title = resolveHeadingCaption(div) end local old_attr = div.attr local appearanceRaw = div.attr.attributes["appearance"] local icon = div.attr.attributes["icon"] local collapse = div.attr.attributes["collapse"] div.attr.attributes["appearance"] = nil div.attr.attributes["collapse"] = nil div.attr.attributes["icon"] = nil local callout_type = calloutType(div) div.attr.classes = div.attr.classes:filter(function(class) return not isCallout(class) end) return quarto.Callout({ appearance = appearanceRaw, title = title, collapse = collapse, content = div.content, icon = icon, type = callout_type, attr = old_attr, }) end, -- These fields will be stored in the extended ast node -- and available in the object passed to the custom filters -- They must store Pandoc AST data. "Inline" custom nodes -- can store Inlines in these fields, "Block" custom nodes -- can store Blocks (and hence also Inlines implicitly). slots = { "title", "content" }, constructor = function(tbl) quarto_global_state.hasCallouts = true local t = tbl.type local iconDefault = true local appearanceDefault = nil if t == "none" then iconDefault = false appearanceDefault = "simple" end local appearanceRaw = tbl.appearance if appearanceRaw == nil then appearanceRaw = option("callout-appearance", appearanceDefault) end local icon = tbl.icon if icon == nil then icon = option("callout-icon", iconDefault) elseif icon == "false" then icon = false end local appearance = nameForCalloutStyle(appearanceRaw); if appearance == "minimal" then icon = false appearance = "simple" end local content = pandoc.Blocks({}) content:extend(quarto.utils.as_blocks(tbl.content)) local title = tbl.title if type(title) == "string" then title = pandoc.Str(title) end return { title = title, collapse = tbl.collapse, content = content, appearance = appearance, icon = icon, type = t, attr = tbl.attr or pandoc.Attr(), } end }) local calloutidx = 1 function docx_callout_and_table_fixup() if not _quarto.format.isDocxOutput() then return {} end return { -- Insert paragraphs between consecutive callouts or tables for docx Blocks = function(blocks) local lastWasCallout = false local lastWasTableOrFigure = false local newBlocks = pandoc.Blocks({}) for i,el in ipairs(blocks) do -- determine what this block is local isCallout = is_custom_node(el, "Callout") local isTableOrFigure = is_custom_node(el, "FloatRefTarget") or el.t == "Table" or isFigureDiv(el) or (discoverFigure(el, true) ~= nil) local isCodeBlock = el.t == "CodeBlock" -- Determine whether this is a code cell that outputs a table local isCodeCell = is_regular_node(el, "Div") and el.attr.classes:find_if(isCodeCell) if isCodeCell and (isCodeCellTable(el) or isCodeCellFigure(el)) then isTableOrFigure = true end -- insert spacer if appropriate local insertSpacer = false if isCallout and (lastWasCallout or lastWasTableOrFigure) then insertSpacer = true end if isCodeBlock and lastWasCallout then insertSpacer = true end if isTableOrFigure and lastWasTableOrFigure then insertSpacer = true end if insertSpacer then newBlocks:insert(pandoc.Para(stringToInlines(" "))) end -- always insert newBlocks:insert(el) -- record last state lastWasCallout = isCallout lastWasTableOrFigure = isTableOrFigure end if #newBlocks > #blocks then return newBlocks else return nil end end } end function isCallout(class) return class == 'callout' or class:match("^callout%-") end function isDocxCallout(class) return class == "docx-callout" end function isCodeCell(class) return class == "cell" end function isCodeCellDisplay(class) return class == "cell-output-display" end -- Attempts to detect whether this element is a code cell -- whose output is a table function isCodeCellTable(el) local isTable = false _quarto.ast.walk(el, { Div = function(div) if div.attr.classes:find_if(isCodeCellDisplay) then _quarto.ast.walk(div, { Table = function(tbl) isTable = true end }) end end }) return isTable end function isCodeCellFigure(el) local isFigure = false _quarto.ast.walk(el, { Div = function(div) if div.attr.classes:find_if(isCodeCellDisplay) then if (isFigureDiv(div)) then isFigure = true elseif div.content and #div.content > 0 then isFigure = discoverFigure(div.content[1], true) ~= nil end end end }) return isFigure end local function callout_title_prefix(callout, withDelimiter) local category = crossref.categories.by_ref_type[refType(callout.attr.identifier)] if category == nil then fail("unknown callout prefix '" .. refType(callout.attr.identifier) .. "'") return end return titlePrefix(category.ref_type, category.name, callout.order, withDelimiter) end function decorate_callout_title_with_crossref(callout) callout = ensure_custom(callout) if not param("enable-crossref", true) then -- don't decorate captions with crossrefs information if crossrefs are disabled return callout end -- nil should never happen here, but the Lua analyzer doesn't know it if callout == nil then -- luacov: disable internal_error() -- luacov: enable return callout end if not is_valid_ref_type(refType(callout.attr.identifier)) then return callout end local title = callout.title.content -- unlabeled callouts do not get a title prefix local is_uncaptioned = not ((title ~= nil) and (#title > 0)) -- this is a hack but we need it to control styling downstream callout.is_uncaptioned = is_uncaptioned local title_prefix = callout_title_prefix(callout, not is_uncaptioned) tprepend(title, title_prefix) return callout end -- an HTML callout div function calloutDiv(node) node = decorate_callout_title_with_crossref(node) -- the first heading is the title local div = pandoc.Div({}) local c = quarto.utils.as_blocks(node.content) if pandoc.utils.type(c) == "Blocks" then div.content:extend(c) else div.content:insert(c) end local title = quarto.utils.as_inlines(node.title) local callout_type = node.type local calloutAppearance = node.appearance local icon = node.icon local collapse = node.collapse if calloutAppearance == constants.kCalloutAppearanceDefault and pandoc.utils.stringify(title) == "" then title = quarto.utils.as_inlines(pandoc.Plain(displayName(node.type))) end -- Make an outer card div and transfer classes and id local calloutDiv = pandoc.Div({}) calloutDiv.attr = node.attr:clone() local identifier = node.attr.identifier if identifier ~= "" then node.attr.identifier = "" calloutDiv.attr.identifier = identifier -- inject an anchor so callouts can be linked to -- local attr = pandoc.Attr(identifier, {}, {}) -- local anchor = pandoc.Link({}, "", "", attr) -- title:insert(1, anchor) end div.attr.classes = pandoc.List() div.attr.classes:insert("callout-body-container") -- add card attribute calloutDiv.attr.classes:insert("callout") calloutDiv.attr.classes:insert("callout-style-" .. calloutAppearance) if node.type ~= nil then calloutDiv.attr.classes:insert("callout-" .. node.type) end -- the image placeholder local noicon = "" -- Check to see whether this is a recognized type if icon == false or not isBuiltInType(callout_type) or type == nil then noicon = " no-icon" calloutDiv.attr.classes:insert("no-icon") end local imgPlaceholder = pandoc.Plain({pandoc.RawInline("html", "<i class='callout-icon" .. noicon .. "'></i>")}); local imgDiv = pandoc.Div({imgPlaceholder}, pandoc.Attr("", {"callout-icon-container"})); -- show a titled callout if title ~= nil and (pandoc.utils.type(title) == "string" or next(title) ~= nil) then -- mark the callout as being titleed calloutDiv.attr.classes:insert("callout-titled") -- create a unique id for the callout local calloutid = "callout-" .. calloutidx calloutidx = calloutidx + 1 -- create the header to contain the title -- title should expand to fill its space local titleDiv = pandoc.Div(pandoc.Plain(title), pandoc.Attr("", {"callout-title-container", "flex-fill"})) local headerDiv = pandoc.Div({imgDiv, titleDiv}, pandoc.Attr("", {"callout-header", "d-flex", "align-content-center"})) local bodyDiv = div bodyDiv.attr.classes:insert("callout-body") if collapse ~= nil then -- collapse default value local expandedAttrVal = "true" if collapse == "true" or collapse == true then expandedAttrVal = "false" end -- create the collapse button local btnClasses = "callout-btn-toggle d-inline-block border-0 py-1 ps-1 pe-0 float-end" local btnIcon = "<i class='callout-toggle'></i>" local toggleButton = pandoc.RawInline("html", "<div class='" .. btnClasses .. "'>" .. btnIcon .. "</div>") headerDiv.content:insert(pandoc.Plain(toggleButton)); -- configure the header div for collapse local bsTargetClz = calloutid .. "-contents" headerDiv.attr.attributes["bs-toggle"] = "collapse" headerDiv.attr.attributes["bs-target"] = "." .. bsTargetClz headerDiv.attr.attributes["aria-controls"] = calloutid headerDiv.attr.attributes["aria-expanded"] = expandedAttrVal headerDiv.attr.attributes["aria-label"] = 'Toggle callout' -- configure the body div for collapse local collapseDiv = pandoc.Div({}) collapseDiv.attr.identifier = calloutid collapseDiv.attr.classes:insert(bsTargetClz) collapseDiv.attr.classes:insert("callout-collapse") collapseDiv.attr.classes:insert("collapse") if expandedAttrVal == "true" then collapseDiv.attr.classes:insert("show") end -- add the current body to the collapse div and use the collapse div instead collapseDiv.content:insert(bodyDiv) bodyDiv = collapseDiv end -- add the header and body to the div calloutDiv.content:insert(headerDiv) calloutDiv.content:insert(bodyDiv) else -- show an untitleed callout -- create a card body local containerDiv = pandoc.Div({imgDiv, div}, pandoc.Attr("", {"callout-body"})) containerDiv.attr.classes:insert("d-flex") -- add the container to the callout card calloutDiv.content:insert(containerDiv) end return calloutDiv end function epubCallout(node) local title = quarto.utils.as_inlines(node.title) local type = node.type local calloutAppearance = node.appearance local hasIcon = node.icon if calloutAppearance == constants.kCalloutAppearanceDefault and pandoc.utils.stringify(title) == "" then title = displayName(type) end -- the body of the callout local calloutBody = pandoc.Div({}, pandoc.Attr("", {"callout-body"})) local imgPlaceholder = pandoc.Plain({pandoc.RawInline("html", "<i class='callout-icon'></i>")}); local imgDiv = pandoc.Div({imgPlaceholder}, pandoc.Attr("", {"callout-icon-container"})); -- title if title ~= nil and (pandoc.utils.type(title) == "string" or next(title) ~= nil) then local callout_title = pandoc.Div({}, pandoc.Attr("", {"callout-title"})) if hasIcon then callout_title.content:insert(imgDiv) end callout_title.content:insert(pandoc.Para(pandoc.Strong(title))) calloutBody.content:insert(callout_title) else if hasIcon then calloutBody.content:insert(imgDiv) end end -- contents local calloutContents = pandoc.Div(node.content, pandoc.Attr("", {"callout-content"})) calloutBody.content:insert(calloutContents) -- set attributes (including hiding icon) local attributes = pandoc.List({"callout"}) if type ~= nil then attributes:insert("callout-" .. type) end if hasIcon == false then attributes:insert("no-icon") end if title ~= nil and (pandoc.utils.type(title) == "string" or next(title) ~= nil) then attributes:insert("callout-titled") end attributes:insert("callout-style-" .. calloutAppearance) local result = pandoc.Div({ calloutBody }, pandoc.Attr(node.attr.identifier or "", attributes)) -- in revealjs or epub, if the leftover attr is non-trivial, -- then we need to wrap the callout in a div (#5208, #6853) if node.attr.identifier ~= "" or #node.attr.classes > 0 or #node.attr.attributes > 0 then return pandoc.Div({ result }, node.attr) else return result end end function simpleCallout(node) node = decorate_callout_title_with_crossref(node) local contents = resolveCalloutContents(node, true) local callout = pandoc.BlockQuote(contents) local result = pandoc.Div(callout, pandoc.Attr(node.attr.identifier or "")) return result end function resolveCalloutContents(node, require_title) local title = quarto.utils.as_inlines(node.title) local type = node.type local contents = pandoc.List({}) -- Add the titles and contents -- class_name if pandoc.utils.stringify(title) == "" and require_title then ---@diagnostic disable-next-line: need-check-nil title = stringToInlines(type:sub(1,1):upper()..type:sub(2)) end -- raw paragraph with styles (left border, colored) if title ~= nil then contents:insert(pandoc.Para(pandoc.Strong(title))) end tappend(contents, quarto.utils.as_blocks(node.content)) return contents end function removeParagraphPadding(contents) if #contents > 0 then if #contents == 1 then if contents[1].t == "Para" then contents[1] = openXmlPara(contents[1], 'w:before="16" w:after="16"') end else if contents[1].t == "Para" then contents[1] = openXmlPara(contents[1], 'w:before="16"') end if contents[#contents].t == "Para" then contents[#contents] = openXmlPara(contents[#contents], 'w:after="16"') end end end end function openXmlPara(para, spacing) local xmlPara = pandoc.Para({ pandoc.RawInline("openxml", "<w:pPr>\n<w:spacing " .. spacing .. "/>\n</w:pPr>") }) tappend(xmlPara.content, para.content) return xmlPara end function nameForCalloutStyle(calloutType) if calloutType == nil then return "default" else local name = pandoc.utils.stringify(calloutType); if name:lower() == "minimal" then return "minimal" elseif name:lower() == "simple" then return "simple" else return "default" end end end local kDefaultDpi = 96 function docxCalloutImage(type) -- If the DPI has been changed, we need to scale the callout icon local dpi = pandoc.WriterOptions(PANDOC_WRITER_OPTIONS)['dpi'] local scaleFactor = 1 if dpi ~= nil then scaleFactor = dpi / kDefaultDpi end -- try to form the svg name local svg = nil if type ~= nil then svg = param("icon-" .. type, nil) end -- lookup the image if svg ~= nil then local img = pandoc.Image({}, svg, '', {[constants.kProjectResolverIgnore]="true"}) img.attr.attributes["width"] = tostring(16 * scaleFactor) img.attr.attributes["height"] = tostring(16 * scaleFactor) return img else return nil end end local callout_attrs = { note = { color = kColorNote, background_color = kBackgroundColorNote, latex_color = "quarto-callout-note-color", latex_frame_color = "quarto-callout-note-color-frame", fa_icon = "faInfo", fa_icon_typst = "fa-info" }, warning = { color = kColorWarning, background_color = kBackgroundColorWarning, latex_color = "quarto-callout-warning-color", latex_frame_color = "quarto-callout-warning-color-frame", fa_icon = "faExclamationTriangle", fa_icon_typst = "fa-exclamation-triangle" }, important = { color = kColorImportant, background_color = kBackgroundColorImportant, latex_color = "quarto-callout-important-color", latex_frame_color = "quarto-callout-important-color-frame", fa_icon = "faExclamation", fa_icon_typst = "fa-exclamation" }, caution = { color = kColorCaution, background_color = kBackgroundColorCaution, latex_color = "quarto-callout-caution-color", latex_frame_color = "quarto-callout-caution-color-frame", fa_icon = "faFire", fa_icon_typst = "fa-fire" }, tip = { color = kColorTip, background_color = kBackgroundColorTip, latex_color = "quarto-callout-tip-color", latex_frame_color = "quarto-callout-tip-color-frame", fa_icon = "faLightbulb", fa_icon_typst = "fa-lightbulb" }, __other = { color = kColorUnknown, background_color = kColorUnknown, latex_color = "quarto-callout-color", latex_frame_color = "quarto-callout-color-frame", fa_icon = nil, fa_icon_typst = nil } } setmetatable(callout_attrs, { __index = function(tbl, key) return tbl.__other end }) function htmlColorForType(type) return callout_attrs[type].color end function htmlBackgroundColorForType(type) return callout_attrs[type].background_color end function latexColorForType(type) return callout_attrs[type].latex_color end function latexFrameColorForType(type) return callout_attrs[type].latex_frame_color end function iconForType(type) return callout_attrs[type].fa_icon end function isBuiltInType(type) local icon = iconForType(type) return icon ~= nil end function displayName(type) local defaultName = type:sub(1,1):upper()..type:sub(2) return param("callout-" .. type .. "-title", defaultName) end -- default renderer first _quarto.ast.add_renderer("Callout", function(_) return true end, simpleCallout) _quarto.ast.add_renderer("Callout", function(_) return _quarto.format.isHtmlOutput() and hasBootstrap() end, calloutDiv) _quarto.ast.add_renderer("Callout", function(_) return _quarto.format.isEpubOutput() or _quarto.format.isRevealJsOutput() end, epubCallout) _quarto.ast.add_renderer("Callout", function(_) return _quarto.format.isGithubMarkdownOutput() end, function(callout) local result = pandoc.Blocks({}) local header = "[!" .. callout.type:upper() .. "]" result:insert(pandoc.RawBlock("markdown", header)) local tt = pandoc.utils.type(callout.title) if tt ~= "nil" then result:insert(pandoc.Header(3, quarto.utils.as_inlines(callout.title))) end local ct = pandoc.utils.type(callout.content) if ct == "Block" then result:insert(callout.content) elseif ct == "Blocks" then result:extend(callout.content) else internal_error() end return pandoc.BlockQuote(result) end) local included_font_awesome = false local function ensure_typst_font_awesome() if included_font_awesome then return end included_font_awesome = true quarto.doc.include_text("in-header", "#import \"@preview/fontawesome:0.1.0\": *") end _quarto.ast.add_renderer("Callout", function(_) return _quarto.format.isTypstOutput() end, function(callout) ensure_typst_font_awesome() local attrs = callout_attrs[callout.type] local background_color, icon_color, icon if attrs == nil then background_color = "white" icon_color = "black" icon = "fa-info" else background_color = "rgb(\"#" .. attrs.background_color .. "\")"; icon_color = "rgb(\"#" .. attrs.color .. "\")"; icon = attrs.fa_icon_typst end local title = callout.title if title == nil then title = pandoc.Plain(displayName(callout.type)) end local typst_callout = typst_function_call("callout", { { "body", as_typst_content(callout.content) }, { "title", as_typst_content(title) }, { "background_color", pandoc.RawInline("typst", background_color) }, { "icon_color", pandoc.RawInline("typst", icon_color) }, { "icon", pandoc.RawInline("typst", "" .. icon .. "()")} }) if callout.attr.identifier == "" then return typst_callout end local category = crossref.categories.by_ref_type[refType(callout.attr.identifier)] return make_typst_figure { content = typst_callout, caption_location = "top", caption = pandoc.Plain(pandoc.Str("")), kind = "quarto-callout-" .. callout.type, supplement = category.name, numbering = "1", identifier = callout.attr.identifier } end) _quarto.ast.add_renderer("Callout", function(_) return _quarto.format.isDocxOutput() end, function(callout) return calloutDocx(callout) end) function crossref_callouts() return { Callout = function(callout) local type = refType(callout.attr.identifier) if type == nil or not is_valid_ref_type(type) then return nil end local label = callout.attr.identifier local title = quarto.utils.as_blocks(callout.title) callout.order = add_crossref(label, type, title) return callout end } end -- panel-tabset.lua -- Copyright (C) 2022 Posit Software, PBC ---@alias quarto.Tab { content:pandoc.Blocks, title:pandoc.Inlines } --[[ Create a Tab AST node (represented as a Lua table) ]] ---@param params { content:nil|pandoc.Blocks|string, title:pandoc.Inlines|string } ---@return quarto.Tab quarto.Tab = function(params) local content if type(params.content) == "string" then local content_string = params.content ---@cast content_string string content = pandoc.Blocks(pandoc.read(content_string, "markdown").blocks) else content = params.content or pandoc.Blocks({}) end return { content = content, title = pandoc.Inlines(params.title) } end local function render_quarto_tab(tbl, tabset) local content = quarto.utils.as_blocks(tbl.content) local title = quarto.utils.as_inlines(tbl.title) local inner_content = pandoc.List() inner_content:insert(pandoc.Header(tabset.level, title)) inner_content:extend(content) return pandoc.Div(inner_content) end function parse_tabset_contents(div) local heading = div.content:find_if(function(el) return el.t == "Header" end) if heading ~= nil then -- note the level, then build tab buckets for content after these levels local level = heading.level local tabs = pandoc.List() local tab = nil for i=1,#div.content do local el = div.content[i] if el.t == "Header" and el.level == level then tab = quarto.Tab({ title = el.content }) tabs:insert(tab) elseif tab ~= nil then tab.content:insert(el) end end return tabs, level else return nil end end local tabsetidx = 1 function render_tabset(attr, tabs, renderer) -- create a unique id for the tabset local tabsetid = "tabset-" .. tabsetidx tabsetidx = tabsetidx + 1 -- init tab navigation local nav = pandoc.List() nav:insert(pandoc.RawInline('html', '<ul ' .. renderer.ulAttribs(tabsetid) .. '>')) -- init tab panes local panes = pandoc.Div({}, attr) panes.attr.classes = attr.classes:map(function(class) if class == "panel-tabset" then return "tab-content" else return class end end) -- populate for i=1,#tabs do -- alias tab and heading local tab = tabs[i] local heading = tab.content[1] tab.content:remove(1) -- tab id local tabid = tabsetid .. "-" .. i local tablinkid = tabid .. "-tab" -- FIXME unused from before? -- navigation nav:insert(pandoc.RawInline('html', '<li ' .. renderer.liAttribs() .. '>')) nav:insert(pandoc.RawInline('html', '<a ' .. renderer.liLinkAttribs(tabid, i==1) .. '>')) nav:extend(heading.content) nav:insert(pandoc.RawInline('html', '</a></li>')) -- pane local paneAttr = renderer.paneAttribs(tabid, i==1, heading.attr) local pane = pandoc.Div({}, paneAttr) pane.content:extend(tab.content) panes.content:insert(pane) end -- end tab navigation nav:insert(pandoc.RawInline('html', '</ul>')) -- return tabset return pandoc.Div({ pandoc.Plain(nav), panes }, attr:clone()) end _quarto.ast.add_handler({ -- use either string or array of strings class_name = { "panel-tabset" }, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "Tabset", kind = "Block", constructor = function(params) local node = _quarto.ast.create_custom_node_scaffold("Tabset", "Block") local custom_data = { __quarto_custom_node = node, level = params.level or 2, attr = params.attr or pandoc.Attr(), } local function make_tab_metaobject(custom_data, index) local forwarder = { content = 2 * index - 1, title = 2 * index } local result = {} setmetatable(result, _quarto.ast.create_proxy_metatable( function(key) return forwarder[key] end, function(_) return custom_data["__quarto_custom_node"] end )) return result end local function make_tabs_metaobject(custom_data) local result = { } setmetatable(result, { __pairs = function(t) local l = #custom_data["__quarto_custom_node"].content // 2 return function(t, k) local key = k + 1 if key > l then return nil end return key, make_tab_metaobject(t, key) end, t, 0 end, __len = function(t) return #custom_data["__quarto_custom_node"].content // 2 end, __index = function(t, k) if k == "__quarto_custom_node" then return custom_data["__quarto_custom_node"] end if type(k) ~= "number" then return rawget(t, k) end local l = #custom_data["__quarto_custom_node"].content // 2 if k < 1 or k > l then return nil end return make_tab_metaobject(t, k) end, __newindex = function(t, k, v) if type(k) ~= "number" then rawset(t, k, v) return end local tab = make_tab_metaobject(custom_data, k) for key, value in pairs(v) do tab[key] = value end end }) return result end setmetatable(custom_data, { __index = function(t, k) if k ~= "tabs" then return rawget(t, k) end return make_tabs_metaobject(t) end, __newindex = function(t, k, v) if k ~= "tabs" then rawset(t, k, v) return end local tabs = make_tabs_metaobject(t) for key, value in pairs(v) do tabs[key] = value end end }) custom_data.tabs = params.tabs or pandoc.List() return custom_data, false end, -- a function that takes the div node as supplied in user markdown -- and returns the custom node parse = function(div) local tabs, level = parse_tabset_contents(div) return quarto.Tabset({ level = level, tabs = tabs, attr = div.attr }) end, -- a function that renders the extendedNode into output render = function(node) local tabs = tmap(node.tabs, function(tab) return render_quarto_tab(tab, node) end) if hasBootstrap() then return render_tabset(node.attr, tabs, bootstrapTabs()) elseif _quarto.format.isHtmlOutput() then return render_tabset(node.attr, tabs, tabbyTabs()) elseif _quarto.format.isLatexOutput() or _quarto.format.isDocxOutput() or _quarto.format.isEpubOutput() or _quarto.format.isJatsOutput() then return pandoc.Div(render_tabset_with_l4_headings(tabs), node.attr) else print("Warning: couldn't recognize format, using default tabset rendering") return pandoc.Div(render_tabset_with_l4_headings(tabs), node.attr) end end, }) function bootstrapTabs() return { ulAttribs = function(tabsetid) return 'class="nav nav-tabs" role="tablist"' end, liAttribs = function(tabid, isActive) return 'class="nav-item" role="presentation"' end, liLinkAttribs = function(tabid, isActive) local tablinkid = tabid .. "-tab" local active = "" local selected = "false" if isActive then active = " active" selected = "true" end return 'class="nav-link' .. active .. '" id="' .. tablinkid .. '" data-bs-toggle="tab" data-bs-target="#' .. tabid .. '" role="tab" aria-controls="' .. tabid .. '" aria-selected="' .. selected .. '"' end, paneAttribs = function(tabid, isActive, headingAttribs) local tablinkid = tabid .. "-tab" local attribs = headingAttribs:clone() attribs.identifier = tabid attribs.classes:insert("tab-pane") if isActive then attribs.classes:insert("active") end attribs.attributes["role"] = "tabpanel" attribs.attributes["aria-labelledby"] = tablinkid return attribs end } end function tabbyTabs() return { ulAttribs = function(tabsetid) return 'id="' .. tabsetid .. '" class="panel-tabset-tabby"' end, liAttribs = function(tabid, isActive) return '' end, liLinkAttribs = function(tabid, isActive) local default = "" if isActive then default = "data-tabby-default " end return default .. 'href="#' .. tabid .. '"' end, paneAttribs = function(tabid, isActive, headingAttribs) local attribs = headingAttribs:clone() attribs.identifier = tabid return attribs end } end local function min(a, b) if a < b then return a else return b end end function render_tabset_with_l4_headings(tabs) local result = pandoc.List() for i=1,#tabs do local tab = tabs[i] local heading = tab.content[1] local level = heading.level tab.content:remove(1) local tabid = "tab-" .. i result:insert(pandoc.Header(min(4, level), heading.content, heading.attr)) result:extend(tab.content) end return result end -- function tabsetLatex(div_content) -- -- find the first heading in the tabset -- local heading = div_content:find_if(function(el) return el.t == "Header" end) -- if heading ~= nil then -- local level = heading.level -- if level < 4 then -- heading.level = 4 -- for i=1,#div_content do -- local el = div_content[i] -- if el.t == "Header" and el.level == level then -- el.level = 4 -- end -- end -- end -- end -- return div_content -- end -- floatreftarget.lua -- Copyright (C) 2023 Posit Software, PBC local drop_class = require("modules/filters").drop_class local patterns = require("modules/patterns") local function split_longtable_start(content_str) -- we use a hack here to split the content into params and actual content -- see https://github.com/quarto-dev/quarto-cli/issues/7655#issuecomment-1821181132 -- we need to find a matching pair of braces -- we do this by counting the number of open braces -- we need to do this through utf8 because lua strings are not unicode-aware local codepoints = table.pack(utf8.codepoint(content_str, 1, #content_str)) local function find_codepoint(start_idx, ...) if start_idx > #codepoints then return nil end local target_codepoints = table.pack(...) for i = start_idx, #codepoints do local code_point = codepoints[i] for _, target_codepoint in ipairs(target_codepoints) do if code_point == target_codepoint then return i, code_point end end end return nil end local function find_pair_of_braces(start_idx) local count = 0 local open_brace_idx local next_brace_idx, code_point next_brace_idx = find_codepoint(start_idx, 123) -- { if next_brace_idx == nil then return nil end open_brace_idx = next_brace_idx next_brace_idx = next_brace_idx + 1 count = count + 1 while count > 0 do next_brace_idx, code_point = find_codepoint(next_brace_idx, 123, 125) -- {, } if next_brace_idx == nil then return nil end if code_point == 123 then count = count + 1 else count = count - 1 end next_brace_idx = next_brace_idx + 1 end return open_brace_idx, next_brace_idx - 1 end -- first find the start of the environment local start_idx, end_idx = find_pair_of_braces(1) if start_idx == nil then return nil end -- then find the start of the longtable params start_idx, end_idx = find_pair_of_braces(end_idx + 1) if start_idx == nil then return nil end -- now split the string return content_str:sub(1, end_idx), content_str:sub(end_idx + 1) end _quarto.ast.add_handler({ -- empty table so this handler is only called programmatically class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "FloatRefTarget", -- generic names this custom AST node responds to -- this is still unimplemented interfaces = {"Crossref"}, -- float reftargets are always blocks kind = "Block", parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, slots = { "content", "caption_long", "caption_short" }, constructor = function(tbl) if tbl.attr then tbl.identifier = tbl.attr.identifier tbl.classes = tbl.attr.classes tbl.attributes = as_plain_table(tbl.attr.attributes) tbl.attr = nil end tbl.attributes = pandoc.List(tbl.attributes) tbl.classes = pandoc.List(tbl.classes) table_colwidth_cell(tbl) -- table colwidth forwarding return tbl end }) function cap_location(float_or_layout) local ref = refType(float_or_layout.identifier) -- layouts might not have good identifiers, but they might have -- ref-parents if ref == nil then ref = refType(float_or_layout.attributes["ref-parent"] or "") end if ref == nil or crossref.categories.by_ref_type[ref] == nil then if float_or_layout.t == "Table" then ref = "tbl" else -- last resort, pretend we're a figure ref = "fig" end end local qualified_key = ref .. '-cap-location' local result = ( float_or_layout.attributes[qualified_key] or float_or_layout.attributes['cap-location'] or option_as_string(qualified_key) or option_as_string('cap-location') or crossref.categories.by_ref_type[ref].caption_location) if result ~= "margin" and result ~= "top" and result ~= "bottom" then -- luacov: disable error("Invalid caption location for float: " .. float_or_layout.identifier .. " requested " .. result .. ".\nOnly 'top', 'bottom', and 'margin' are supported. Assuming 'bottom'.") result = "bottom" -- luacov: enable end return result end -- we need to expose this function for use in the docusaurus renderer quarto.doc.crossref.cap_location = cap_location local function get_node_from_float_and_type(float, type, filter_base) -- this explicit check appears necessary for the case where -- float.content is directly the node we want, and not a container that -- contains the node. if float.content.t == type then return float.content else local found_node = nil local filter = { traverse = "topdown", [type] = function(node) found_node = node return nil, false -- don't recurse end } if filter_base ~= nil then for k,v in pairs(filter_base) do filter[k] = v end end _quarto.ast.walk(float.content, filter) return found_node end end -- default renderer first _quarto.ast.add_renderer("FloatRefTarget", function(_) return true end, function(float) warn("\nEmitting a placeholder FloatRefTarget\nOutput format " .. FORMAT .. " does not currently support FloatRefTarget nodes.") return scaffold(float.content) end) function is_unlabeled_float(float) -- from src/resources/filters/common/refs.lua return float.identifier:match("^%a+%-539a35d47e664c97a50115a146a7f1bd%-") end function decorate_caption_with_crossref(float) if not param("enable-crossref", true) then -- don't decorate captions with crossrefs information if crossrefs are disabled return float end float = ensure_custom(float) -- nil should never happen here, but the Lua analyzer doesn't know it if float == nil then -- luacov: disable internal_error() -- luacov: enable end local caption_content = float.caption_long.content or float.caption_long if float.parent_id then if float.order == nil then warn("Subfloat without crossref information") else prependSubrefNumber(caption_content, float.order) end else -- in HTML, unlabeled floats do not get a title prefix if (not is_unlabeled_float(float)) then local is_uncaptioned = not ((caption_content ~= nil) and (#caption_content > 0)) -- this is a hack but we need it to control styling downstream float.is_uncaptioned = is_uncaptioned local title_prefix = float_title_prefix(float, not is_uncaptioned) tprepend(caption_content, title_prefix) end end return float end -- we need to expose this function for use in the docusaurus renderer, -- which is technically an extension that doesn't have access to the -- internal filters namespace quarto.doc.crossref.decorate_caption_with_crossref = decorate_caption_with_crossref function full_caption_prefix(float, subfloat) if not param("enable-crossref", true) then -- don't decorate captions with crossrefs information if crossrefs are disabled return {} end float = ensure_custom(float) -- nil should never happen here, but the Lua analyzer doesn't know it if float == nil then -- luacov: disable internal_error() -- luacov: enable end if subfloat ~= nil then subfloat = ensure_custom(subfloat) -- nil should never happen here, but the Lua analyzer doesn't know it if subfloat == nil then -- luacov: disable internal_error() -- luacov: enable end end local float_title = {} if not is_unlabeled_float(float) then float_title = float_title_prefix(float, false) end local subfloat_title = pandoc.Inlines({}) if subfloat ~= nil then if subfloat.order == nil then warn("Subfloat without crossref information") else prependSubrefNumber(subfloat_title, subfloat.order) end end if #subfloat_title > 0 then tappend(float_title,{nbspString()}) end tappend(float_title, subfloat_title) tappend(float_title, titleDelim()) tappend(float_title, {pandoc.Space()}) return pandoc.Inlines(float_title) end -- capture relevant figure attributes then strip them local function get_figure_attributes(el) local align = figAlignAttribute(el) local keys = tkeys(el.attr.attributes) for _,k in pairs(keys) do if isFigAttribute(k) then el.attr.attributes[k] = nil end end local figureAttr = {} local style = el.attr.attributes["style"] if style then figureAttr["style"] = style el.attributes["style"] = nil end return { align = align, figureAttr = figureAttr } end _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isLatexOutput() end, function(float) local figEnv = latexFigureEnv(float) local figPos = latexFigurePosition(float, figEnv) local float_type = refType(float.identifier) local capLoc = cap_location(float) local caption_cmd_name = latexCaptionEnv(float) if float.content == nil then warn("FloatRefTarget with no content: " .. float.identifier) return pandoc.Div({}) end if float.parent_id then if caption_cmd_name == kSideCaptionEnv then fail_and_ask_for_bugreport("Subcaptions for side captions are unimplemented.") return {} end caption_cmd_name = "subcaption" elseif float.content.t == "Table" and float_type == "tbl" then -- float.parent_id is nil here -- special-case the situation where the figure is Table and the content is Table -- -- just return the table itself with the caption inside the table -- FIXME how about tables in margin figures? caption_cmd_name = "caption" float.content.caption.long = float.caption_long float.content.attr = pandoc.Attr(float.identifier, float.classes or {}, float.attributes or {}) return float.content end local fig_scap = attribute(float, kFigScap, nil) if fig_scap then fig_scap = pandoc.Span(markdownToInlines(fig_scap)) end local latex_caption if float.caption_long and type(float.caption_long) ~= "table" then latex_caption = quarto.utils.as_inlines(float.caption_long) else latex_caption = float.caption_long end if #latex_caption == 0 then local caption_setup = quarto.LatexInlineCommand({ name = "captionsetup", arg = "labelsep=none" }) local pt = pandoc.utils.type(float.content) if pt == "Block" then if float.content.content == nil then -- it's a block that doesn't support inner content -- attempt a best-effort fix by replacing it with a wrapping div float.content = pandoc.Div({float.content}) end float.content.content:insert(1, caption_setup) elseif pt == "Blocks" then float.content:insert(1, caption_setup) else internal_error() end end local label_cmd = quarto.LatexInlineCommand({ name = "label", arg = pandoc.RawInline("latex", float.identifier) }) latex_caption:insert(1, label_cmd) local latex_caption_content = latex_caption latex_caption = quarto.LatexInlineCommand({ name = caption_cmd_name, opt_arg = fig_scap, arg = pandoc.Span(quarto.utils.as_inlines(latex_caption_content or {}) or {}) -- unnecessary to do the "or {}" bit but the Lua analyzer doesn't know that }) if float.parent_id then -- need to fixup subtables because nested longtables appear to give latex fits local vAlign = validatedVAlign(float.attributes[kLayoutVAlign]) local function handle_table(tbl) return latexTabular(tbl, vAlign) end if float.content.t == "Table" then float.content = handle_table(float.content) else float.content = _quarto.ast.walk(float.content, { Table = handle_table }) or pandoc.Div({}) -- unnecessary to do the "or {}" bit but the Lua analyzer doesn't know that end end -- we need Pandoc to render its table ahead of time in order to -- do the longtable fixups below float.content = _quarto.ast.walk(quarto.utils.as_blocks(float.content), { traverse = "topdown", Div = function(div) if div.classes:find_if(isStarEnv) then return _quarto.ast.walk(div, { Table = function(tbl) if float.type == "Table" then figEnv = "table*" else figEnv = "figure*" end local result = latexTabular(tbl) return result end }), false end end, Table = function(tbl) local cites = pandoc.List({}) local guid_id = global_table_guid_id local uuid = "85b77c8a-261c-4f58-9b04-f21c67e0a758" tbl = _quarto.ast.walk(tbl, { Cite = function(cite) cites:insert(cite) guid_id = guid_id + 1 -- this uuid is created a little strangely here -- to ensure that no generated uuid will be a prefix of another, -- which would cause our regex replacement to pick up the wrong -- uuid return pandoc.Str(uuid .. "-" .. guid_id .. "-" .. uuid) end }) local raw_output = pandoc.RawBlock("latex", pandoc.write(pandoc.Pandoc({tbl}), "latex")) if #cites > 0 then local local_guid_id = global_table_guid_id local result = pandoc.Blocks({ make_scaffold(pandoc.Span, cites:map(function(cite) local_guid_id = local_guid_id + 1 return make_scaffold(pandoc.Span, pandoc.Inlines({ pandoc.RawInline("latex", "%quarto-define-uuid: " .. uuid .. "-" .. local_guid_id .. "-" .. uuid .. "\n"), cite, pandoc.RawInline("latex", "\n%quarto-end-define-uuid\n") })) end)), raw_output}) global_table_guid_id = global_table_guid_id + #cites return result else return raw_output end end }) if float_type == "tbl" then local made_fix = false local function fix_raw(is_star_env) local function set_raw(el) if _quarto.format.isRawLatex(el) and el.text:match(_quarto.patterns.latexLongtablePattern) then made_fix = true local raw = el -- special case for longtable floats in LaTeX local extended_pattern = "(.-)" .. _quarto.patterns.latexLongtablePattern .. "(.*)" local longtable_preamble, longtable_begin, longtable_content, longtable_end, longtable_postamble = raw.text:match(extended_pattern) if longtable_preamble == nil or longtable_begin == nil or longtable_content == nil or longtable_end == nil or longtable_postamble == nil then warn("Could not parse longtable parameters. This could happen because the longtable parameters\n" .. "are not well-formed or because of a bug in quarto. Please consider filing a bug report at\n" .. "https://github.com/quarto-dev/quarto-cli/issues/, and make sure to include the document that\n" .. "triggered this error.") return {} end -- split the content into params and actual content -- params are everything in the first line of longtable_content -- actual content is everything else local start, content = split_longtable_start(longtable_begin .. longtable_content) if start == nil or content == nil then warn("Could not parse longtable parameters. This could happen because the longtable parameters\n" .. "are not well-formed or because of a bug in quarto. Please consider filing a bug report at\n" .. "https://github.com/quarto-dev/quarto-cli/issues/, and make sure to include the document that\n" .. "triggered this error.") return {} end local cap_loc = cap_location(float) if float.parent_id then -- need to fixup subtables because longtables don't support subcaptions, -- and longtable captions increment the wrong counter -- we try our best here fatal("longtables are not supported in subtables.\n" .. "This is not a Quarto bug - the LaTeX longtable environment doesn't support subcaptions.\n") return {} end if is_star_env then -- content: table payload -- start: \\begin{longtable}... command -- longtable_preamble: everything that came before the \\begin{longtable} command -- longtable_postamble: everything that came after the \\end{longtable} command local result = pandoc.Blocks({ pandoc.RawBlock("latex", longtable_preamble), pandoc.RawBlock("latex", "\\begin{table*}"), -- caption here if cap_loc == "top" pandoc.RawBlock("latex", start .. "\n" .. content .. "\n\\end{longtable}"), -- caption here if cap_loc ~= "top" pandoc.RawBlock("latex", "\\end{table*}"), pandoc.RawBlock("latex", longtable_postamble), }) if cap_loc == "top" then result:insert(3, latex_caption) -- gets around the padding that longtable* adds result:insert(4, pandoc.RawBlock("latex", "\\vspace{-1em}")) else result:insert(4, latex_caption) end return result else local result = pandoc.Blocks({latex_caption, pandoc.RawInline("latex", "\\tabularnewline")}) -- if cap_loc is top, insert content on bottom if cap_loc == "top" then result:insert(pandoc.RawBlock("latex", content)) else result:insert(1, pandoc.RawBlock("latex", content)) end result:insert(1, pandoc.RawBlock("latex", start)) result:insert(1, pandoc.RawBlock("latex", longtable_preamble)) result:insert(pandoc.RawBlock("latex", "\\end{longtable}")) result:insert(pandoc.RawBlock("latex", longtable_postamble)) return result end end end return set_raw end -- have to call as_blocks() again here because assigning to float.content -- goes through our AST metaclasses which coalesce a singleton list to a single AST element local fixed_up_content = _quarto.ast.walk(quarto.utils.as_blocks(float.content), { traverse = "topdown", Div = function(div) if div.classes:find_if(isStarEnv) then return _quarto.ast.walk(div, { RawBlock = fix_raw(true) }), false end end, RawBlock = fix_raw(false) }) if made_fix then return fixed_up_content end end -- As an additional complication, we need to handle the case where the -- content is a table* environment, by stripping the environment raw code -- and recreating it below. -- See #7937 if _quarto.format.isRawLatex(float.content) then local _b, _e, _beginenv, inner_content, _endenv = float.content.text:find(patterns.latex_table_star) if _b ~= nil then figEnv = "table*" float.content.text = inner_content end end local figure_content local pt = pandoc.utils.type(float.content) if pt == "Block" then figure_content = pandoc.Blocks({ float.content }) elseif pt == "Blocks" then figure_content = float.content else -- luacov: disable fail_and_ask_for_bug_report("Unexpected type for float content: " .. pt) return {} -- luacov: enable end assert(figure_content ~= nil) -- align the figure local align = figAlignAttribute(float) if align == "center" then figure_content = pandoc.Blocks({ quarto.LatexBlockCommand({ name = "centering", inside = true, arg = scaffold(figure_content) }) }) elseif align == "right" then local plain = quarto.utils.match("[1]/{Plain}")(figure_content) if plain then local cmd = quarto.LatexInlineCommand({ name = "hfill", }) plain[1].content:insert(1, cmd) else warn("Could not find a Plain node in figure content of " .. float.identifier .. " to right-align.") end end -- otherwise, do nothing -- figure_content:insert(1, pandoc.RawInline("latex", latexBeginAlign(align))) -- figure_content:insert(pandoc.RawInline("latex", latexEndAlign(align))) if latex_caption then if caption_cmd_name == kSideCaptionEnv then if #figure_content > 1 then figure_content:insert(1, latex_caption) -- Since this is a side caption, insert it physically above the figure to improve typsetting else figure_content:insert(latex_caption) end elseif capLoc == "top" then figure_content:insert(1, latex_caption) else figure_content:insert(latex_caption) end end if float.parent_id then -- the environment here is handled by the parent float and -- the panel layout code return figure_content else return quarto.LatexEnvironment({ name = figEnv, pos = figPos, content = _quarto.ast.walk(figure_content, { Image = drop_class("column-margin") }) }) end end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isHtmlOutput() end, function(float) decorate_caption_with_crossref(float) ------------------------------------------------------------------------------------ -- Special handling for listings local found_listing = get_node_from_float_and_type(float, "CodeBlock") if found_listing then found_listing.attr = merge_attrs(found_listing.attr, pandoc.Attr("", float.classes or {}, float.attributes or {})) -- FIXME this seems to be necessary for our postprocessor to kick in -- check this out later found_listing.identifier = float.identifier end ------------------------------------------------------------------------------------ return float_reftarget_render_html_figure(float) end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isDocxOutput() or _quarto.format.isOdtOutput() end, function(float) -- docx format requires us to annotate the caption prefix explicitly decorate_caption_with_crossref(float) -- options local options = { pageWidth = wpPageWidth(), } -- determine divCaption handler (always left-align) local divCaption = nil if _quarto.format.isDocxOutput() then divCaption = docxDivCaption elseif _quarto.format.isOdtOutput() then divCaption = odtDivCaption else -- luacov: disable internal_error() return -- luacov: enable end options.divCaption = function(el, align) return divCaption(el, "left") end -- get alignment local align = align_attribute(float) -- create the row/cell for the figure local row = pandoc.List() local cell = pandoc.Div({}) cell.attr = pandoc.Attr(float.identifier, float.classes or {}, float.attributes or {}) local c = float.content.content or float.content if pandoc.utils.type(c) == "Block" then cell.content:insert(c) elseif pandoc.utils.type(c) == "Blocks" then cell.content = c elseif pandoc.utils.type(c) == "Inlines" then cell.content:insert(pandoc.Plain(c)) end transfer_float_image_width_to_cell(float, cell) row:insert(cell) -- handle caption local new_caption = options.divCaption(float.caption_long, align) local caption_location = cap_location(float) if caption_location == 'top' then cell.content:insert(1, new_caption) else cell.content:insert(new_caption) end -- content fixups for docx, based on old docx.lua code cell = docx_content_fixups(cell, align) -- make the table local figureTable = pandoc.SimpleTable( pandoc.List(), -- caption { layoutTableAlign(align) }, { 1 }, -- full width pandoc.List(), -- no headers { row } -- figure ) -- return it return pandoc.utils.from_simple_table(figureTable) end) local figcaption_uuid = "0ceaefa1-69ba-4598-a22c-09a6ac19f8ca" local function create_figcaption(float) local cap = float.caption_long if float.caption_long == nil or pandoc.utils.stringify(float.caption_long) == "" then cap = pandoc.Blocks({}) end local ref_type = refType(float.identifier) local caption_location = cap_location(float) -- use a uuid to ensure that the figcaption ids won't conflict with real -- ids in the document local caption_id = float.identifier .. "-caption-" .. figcaption_uuid local classes = { } table.insert(classes, "quarto-float-caption-" .. caption_location) if float.parent_id then table.insert(classes, "quarto-subfloat-caption") table.insert(classes, "quarto-subfloat-" .. ref_type) else table.insert(classes, "quarto-float-caption") table.insert(classes, "quarto-float-" .. ref_type) end if float.is_uncaptioned then -- this figcaption will only contain the crossreferenceable label table.insert(classes, "quarto-uncaptioned") end return quarto.HtmlTag({ name = "figcaption", attr = pandoc.Attr(caption_id, classes, {}), content = float.caption_long, }), caption_id, caption_location end function float_reftarget_render_html_figure(float) float = ensure_custom(float) if float == nil then -- luacov: disable internal_error() return pandoc.Div({}) -- luacov: enable end local caption_content, caption_id, caption_location = create_figcaption(float) local caption_location = cap_location(float) local float_content = pandoc.Div(_quarto.ast.walk(float.content, { -- strip image captions Image = function(image) image.caption = {} return image end }) or pandoc.Div({})) -- this should never happen but the lua analyzer doesn't know it if caption_id ~= nil then float_content.attributes["aria-describedby"] = caption_id end -- otherwise, we render the float as a div with the caption local div = pandoc.Div({}) local found_image = pandoc.Div({}) -- #7727: don't recurse into tables when searching for a figure from -- which to get attributes if float.content.t ~= "Table" then found_image = get_node_from_float_and_type(float, "Image", { Table = function(table) return nil, false end, }) or pandoc.Div({}) end local figure_attrs = get_figure_attributes(found_image) div.attr = merge_attrs( pandoc.Attr(float.identifier, float.classes or {}, float.attributes or {}), pandoc.Attr("", {}, figure_attrs.figureAttr)) if float.type == "Listing" then div.attr.classes:insert("listing") elseif float.type == "Figure" then -- apply standalone figure css div.attr.classes:insert("quarto-figure") div.attr.classes:insert("quarto-figure-" .. figure_attrs.align) end div.attr.classes:insert("quarto-float") -- also forward any column or caption classes local currentClasses = found_image.attr.classes for _,k in pairs(currentClasses) do if isCaptionClass(k) or isColumnClass(k) then div.attr.classes:insert(k) end end local ref = refType(float.identifier) local figure_class if float.parent_id then figure_class = "quarto-subfloat-" .. ref else figure_class = "quarto-float-" .. ref end -- Notice that we need to insert the figure_div value -- into the div, but we need to use figure_tbl -- to manipulate the contents of the custom node. -- -- This is because the figure_div is a pandoc.Div (required to -- be inserted into pandoc divs), but figure_tbl is -- the lua table with the metatable required to marshal -- the inner contents of the custom node. -- -- This is relatively ugly, and another instance -- of the impedance mismatch we have in the custom AST -- API. -- -- it's possible that the better API is for custom constructors -- to always return a Lua object and then have a separate -- function to convert that to a pandoc AST node. local figure_div, figure_tbl = quarto.HtmlTag({ name = "figure", attr = pandoc.Attr("", {"quarto-float", figure_class}, {}), }) figure_tbl.content.content:insert(float_content) if caption_content ~= nil then if caption_location == 'top' then figure_tbl.content.content:insert(1, caption_content) else figure_tbl.content.content:insert(caption_content) end end div.content:insert(figure_div) return div end _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isAsciiDocOutput() end, function(float) if float.content.t == "Plain" and #float.content.content == 1 and float.content.content[1].t == "Image" then return pandoc.Figure( {float.content}, {float.caption_long}, float.identifier) end if float.type == "Table" and float.content.t == "Table" then -- special-case the situation where the figure is Table and the content is Table -- -- just return the table itself with the caption inside the table float.content.caption.long = float.caption_long float.content.attr = pandoc.Attr(float.identifier, float.classes or {}, float.attributes or {}) return pandoc.Blocks({ pandoc.RawBlock("asciidoc", "[[" .. float.identifier .. "]]\n"), float.content }) end -- if this is a "linked figure Div", render it as such. local link = quarto.utils.match("Plain/[1]/{Link}/[1]/{Image}")(float.content) if link then link[2].identifier = float.identifier local caption = quarto.utils.as_inlines(float.caption_long) table.insert(caption, 1, pandoc.RawInline("asciidoc", ".")) table.insert(caption, pandoc.RawInline("asciidoc", "\n[[" .. float.identifier .. "]]\n")) table.insert(caption, link[1]) return caption end -- if the float consists of exactly one image, -- render it as a pandoc Figure node. local count = 0 local img _quarto.ast.walk(float.content, { Image = function(node) count = count + 1 img = node end }) if count == 1 then img.identifier = float.identifier img.caption = quarto.utils.as_inlines(float.caption_long) return pandoc.Figure( {img}, {float.caption_long}, float.identifier) end -- Fallthrough case, render into a div. float.caption_long.content:insert(1, pandoc.RawInline("asciidoc", ".")) float.caption_long.content:insert(pandoc.RawInline("asciidoc", "\n[[" .. float.identifier .. "]]\n====")) if pandoc.utils.type(float.content) == "Blocks" then float.content:insert(1, float.caption_long) float.content:insert(pandoc.RawBlock("asciidoc", "====\n")) return float.content else return pandoc.Blocks({ float.caption_long, -- pandoc.RawBlock("asciidoc", "[[" .. float.identifier .. "]]\n====\n"), float.content, pandoc.RawBlock("asciidoc", "====\n\n") }) end end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isJatsOutput() end, function(float) -- don't emit unlabeled floats in JATS if is_unlabeled_float(float) then float.identifier = "" end decorate_caption_with_crossref(float) return pandoc.Figure( {float.content}, {float.caption_long}, float.identifier ) end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isIpynbOutput() and param("enable-crossref", true) end, function(float) decorate_caption_with_crossref(float) if float.content.t == "Plain" and #float.content.content == 1 and float.content.content[1].t == "Image" then return pandoc.Figure( {float.content}, {float.caption_long}, float.identifier) end return pandoc.Div({ float.content, pandoc.Para(quarto.utils.as_inlines(float.caption_long) or {}), }); end) -- this should really be "_quarto.format.isEmbedIpynb()" or something like that.. _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isIpynbOutput() and not param("enable-crossref", true) end, function(float) if float.content.t == "Plain" and #float.content.content == 1 and float.content.content[1].t == "Image" then local imgEl = float.content.content[1] if not float.in_code_cell_output then imgEl.identifier = float.identifier imgEl.caption = quarto.utils.as_inlines(float.caption_long) or {} end return pandoc.Para({imgEl}) elseif float.in_code_cell_output then -- If the float is in a code_cell_output, it is ok to drop the identifier -- and caption, because that infdormation is still carried by the cell itself return float.content else -- TODO: Need to deal with other cases, such as flextable, which results in a -- Table which contains a FloatRefTarget (with an image/figure) inside of it. return float.content end end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isTypstOutput() end, function(float) local ref = refType(float.identifier) local info = crossref.categories.by_ref_type[ref] if info == nil then -- luacov: disable warning("Unknown float type: " .. ref .. "\n Will emit without crossref information.") return float.content -- luacov: enable end local kind local supplement = "" local numbering = "" local content = float.content if float.parent_id then kind = "quarto-subfloat-" .. ref numbering = "(a)" else kind = "quarto-float-" .. ref numbering = "1" supplement = info.name end local caption_location = cap_location(float) -- FIXME: custom numbering doesn't work yet if (ref == "lst") then -- FIXME: -- Listings shouldn't emit centered blocks. -- We don't know how to disable that right now using #show rules for #figures in template. content = { pandoc.RawBlock("typst", "#set align(left)"), content } end return make_typst_figure { content = content, caption_location = caption_location, caption = float.caption_long, kind = kind, supplement = supplement, numbering = numbering, identifier = float.identifier } end) global_table_guid_id = 0 -- theorem.lua -- custom AST node for theorems, lemmata, etc. -- -- Copyright (C) 2023 Posit Software, PBC -- available theorem types theorem_types = { thm = { env = "theorem", style = "plain", title = "Theorem" }, lem = { env = "lemma", style = "plain", title = "Lemma" }, cor = { env = "corollary", style = "plain", title = "Corollary", }, prp = { env = "proposition", style = "plain", title = "Proposition", }, cnj = { env = "conjecture", style = "plain", title = "Conjecture" }, def = { env = "definition", style = "definition", title = "Definition", }, exm = { env = "example", style = "definition", title = "Example", }, exr = { env = "exercise", style = "definition", title = "Exercise" } } function has_theorem_ref(el) local type = refType(el.attr.identifier) return theorem_types[type] ~= nil end function is_theorem_div(div) return is_regular_node(div, "Div") and has_theorem_ref(div) end _quarto.ast.add_handler({ -- empty table so this handler is only called programmatically class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "Theorem", -- generic names this custom AST node responds to -- this is still unimplemented interfaces = {"Crossref"}, -- Theorems are always blocks kind = "Block", parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, slots = { "div", "name" }, constructor = function(tbl) return { name = tbl.name, div = tbl.div, identifier = tbl.identifier } end }) _quarto.ast.add_renderer("Theorem", function() return true end, function(thm) local el = thm.div local pt = pandoc.utils.type(el) if pt == "Blocks" or el.t ~= "Div" then el = pandoc.Div(el) end el.identifier = thm.identifier -- restore identifier to render correctly local label = thm.identifier local type = refType(thm.identifier) local name = quarto.utils.as_inlines(thm.name) local theorem_type = theorem_types[refType(thm.identifier)] local order = thm.order -- add class for type el.attr.classes:insert("theorem") if theorem_type.env ~= "theorem" then el.attr.classes:insert(theorem_type.env) end -- If this theorem has no content, then create a placeholder if #el.content == 0 or el.content[1].t ~= "Para" then tprepend(el.content, {pandoc.Para({pandoc.Str '\u{a0}'})}) end if _quarto.format.isLatexOutput() then local preamble = pandoc.Para(pandoc.RawInline("latex", "\\begin{" .. theorem_type.env .. "}")) preamble.content:insert(pandoc.RawInline("latex", "[")) if name then tappend(preamble.content, name) end preamble.content:insert(pandoc.RawInline("latex", "]")) preamble.content:insert(pandoc.RawInline("latex", "\\protect\\hypertarget{" .. label .. "}{}\\label{" .. label .. "}") ) el.content:insert(1, preamble) el.content:insert(pandoc.Para(pandoc.RawInline("latex", "\\end{" .. theorem_type.env .. "}" ))) -- Remove id on those div to avoid Pandoc inserting \hypertaget #3776 el.attr.identifier = "" elseif _quarto.format.isJatsOutput() then -- JATS XML theorem local lbl = captionPrefix({}, type, theorem_type, order) el = jatsTheorem(el, lbl, name) else -- order might be nil in the case of an ipynb rendering in -- manuscript mode -- -- FIXME format == ipynb and enableCrossRef == false should be -- its own rendering format if order == nil then return el end -- create caption prefix local captionPrefix = captionPrefix(name, type, theorem_type, order) local prefix = { pandoc.Span( pandoc.Strong(captionPrefix), pandoc.Attr("", { "theorem-title" }) ), pandoc.Space() } -- prepend the prefix local caption = el.content[1] if caption.content == nil then -- https://github.com/quarto-dev/quarto-cli/issues/2228 -- caption doesn't always have a content field; in that case, -- use the parent? tprepend(el.content, prefix) else tprepend(caption.content, prefix) end end return el end) -- proof.lua -- custom AST node for proofs, remarks, solutions, etc. -- Copyright (C) 2023 Posit Software, PBC -- available proof types proof_types = { proof = { env = 'proof', title = 'Proof' }, remark = { env = 'remark', title = 'Remark' }, solution = { env = 'solution', title = 'Solution' } } function proof_type(el) local type = el.attr.classes:find_if(function(clz) return proof_types[clz] ~= nil end) if type ~= nil then return proof_types[type] else return nil end end _quarto.ast.add_handler({ -- empty table so this handler is only called programmatically class_name = {}, -- the name of the ast node, used as a key in extended ast filter tables ast_name = "Proof", -- generic names this custom AST node responds to -- this is still unimplemented interfaces = {"Crossref"}, -- Proofs are always blocks kind = "Block", parse = function(div) -- luacov: disable internal_error() -- luacov: enable end, slots = { "div", "name" }, constructor = function(tbl) return { name = tbl.name, div = tbl.div, identifier = tbl.identifier, type = tbl.type -- proofs can be unnumbered and lack an identifier; we need to know the type explicitly } end }) function is_proof_div(div) local ref = refType(div.identifier) if ref ~= nil then local tbl = crossref.categories.by_ref_type[ref] if tbl then local key = tbl.name:lower() return proof_types[key] end end return is_regular_node(div, "Div") and proof_type(div) ~= nil end _quarto.ast.add_renderer("Proof", function() return true end, function(proof_tbl) local el = proof_tbl.div -- see if this is a proof, remark, or solution local proof = proof_types[proof_tbl.type:lower()] if proof == nil then internal_error() return pandoc.Blocks({}) end -- ensure requisite latex is injected crossref.using_theorems = true if proof.env ~= "proof" then el.attr.classes:insert("proof") end local name = quarto.utils.as_inlines(proof_tbl.name or pandoc.Inlines({})) -- output if _quarto.format.isLatexOutput() then local preamble = pandoc.List() local env = proof.env local has_ref = refType(proof_tbl.identifier) ~= nil if has_ref then env = "ref" .. env end preamble:insert(pandoc.RawInline("latex", "\\begin{" .. env .. "}")) if #name ~= 0 then preamble:insert(pandoc.RawInline("latex", "[")) tappend(preamble, name) preamble:insert(pandoc.RawInline("latex", "]")) end preamble:insert(pandoc.RawInline("latex", "\n")) -- https://github.com/quarto-dev/quarto-cli/issues/6077 if el.content[1].t == "Para" then preamble:extend(el.content[1].content) el.content[1].content = preamble else if (el.content[1].t ~= "Para") then -- required trick to get correct alignement when non Para content first preamble:insert(pandoc.RawInline('latex', "\\leavevmode")) end el.content:insert(1, pandoc.Plain(preamble)) end if has_ref then el.content:insert(pandoc.RawInline("latex", "\\label{" .. proof_tbl.identifier .. "}") ) end local end_env = "\\end{" .. env .. "}" -- https://github.com/quarto-dev/quarto-cli/issues/6077 if el.content[#el.content].t == "Para" then el.content[#el.content].content:insert(pandoc.RawInline("latex", "\n" .. end_env)) elseif el.content[#el.content].t == "RawBlock" and el.content[#el.content].format == "latex" then -- this is required for no empty line between end_env and previous latex block el.content[#el.content].text = el.content[#el.content].text .. "\n" .. end_env else el.content:insert(pandoc.RawBlock("latex", end_env)) end elseif _quarto.format.isJatsOutput() then el = jatsTheorem(el, nil, name ) else el.classes:insert(proof.title:lower()) local span_title = pandoc.Emph(pandoc.Str(envTitle(proof.env, proof.title))) local entry = crossref.index.entries[proof_tbl.identifier] local type = refType(proof_tbl.identifier) if type then el.identifier = proof_tbl.identifier end if entry then span_title.content:insert(pandoc.Space()) span_title.content:extend(refNumberOption(type, entry)) end local span = pandoc.Span({ span_title }, pandoc.Attr("", { "proof-title" })) if #name > 0 then span.content:insert(pandoc.Str(" (")) tappend(span.content, name) span.content:insert(pandoc.Str(")")) end tappend(span.content, { pandoc.Str(". ")}) -- if the first block is a paragraph, then prepend the title span if #el.content > 0 and el.content[1].t == "Para" and el.content[1].content ~= nil and #el.content[1].content > 0 then el.content[1].content:insert(1, span) else -- else insert a new paragraph el.content:insert(1, pandoc.Para{span}) end end return el end) -- confluence.lua -- Copyright (C) 2023 Posit Software, PBC -- FIXME this is repeated from overrides.lua but we need to -- sort out our require() situation first. local function interpolate(str, vars) -- Allow replace_vars{str, vars} syntax as well as replace_vars(str, {vars}) if not vars then vars = str str = vars[1] end return (string.gsub(str, "({([^}]+)})", function(whole, i) return vars[i] or whole end)) end local function HTMLAnchorConfluence(id) if (not id or #id == 0) then return pandoc.RawInline("confluence", "") end local SNIPPET = [[<ac:structured-macro ac:name="anchor" ac:schema-version="1" ac:local-id="a6aa6f25-0bee-4a7f-929b-71fcb7eba592" ac:macro-id="d2cb5be1217ae6e086bc60005e9d27b7"><ac:parameter ac:name="">{id}</ac:parameter></ac:structured-macro>]] return pandoc.RawInline("confluence", interpolate { SNIPPET, id = id or '' }) end _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isConfluenceOutput() end, function(float) decorate_caption_with_crossref(float) local attr = pandoc.Attr(float.identifier or "", float.classes or {}, float.attributes or {}) if float.content.t == "Plain" and #float.content.content == 1 and float.content.content[1].t == "Image" then local result = float.content.content[1] result.caption = quarto.utils.as_inlines(float.caption_long) result.attr = merge_attrs(result.attr, attr) return pandoc.Blocks({ HTMLAnchorConfluence(float.identifier), result }) end local div_content = pandoc.Div({}, attr) div_content.content:insert(float.content) if float.caption_long then div_content.content:insert(float.caption_long) end return div_content -- local content = pandoc.Blocks({}) -- return pandoc.Div(content, pandoc.Attr(float.identifier or "", float.classes or {}, float.attributes or {})) end) _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isConfluenceOutput() end, function(layout) decorate_caption_with_crossref(layout.float) if layout.float == nil then fail_and_ask_for_bug_report("Confluence format can't render layouts without floats") return nil end -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do local align = cell.attributes[kLayoutAlign] cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end if layout.float.caption_long then panel_content:insert(pandoc.Para(quarto.utils.as_inlines(layout.float.caption_long) or {})) end local result = pandoc.Div(panel_content, attr) if layout.preamble then local pt = pandoc.utils.type(layout.preamble) if pt == "Blocks" then layout.preamble:insert(result) return result elseif pt == "Block" then return pandoc.Blocks({ layout.preamble, result }) end else return result end end) -- ipynb.lua -- Copyright (C) 2020-2023 Posit Software, PBC local function render_ipynb_layout_no_float(layout) -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end if layout.float ~= nil and layout.float.caption_long then panel_content:insert(pandoc.Para(quarto.utils.as_inlines(layout.float.caption_long) or {})) end if layout.preamble then panel_content:insert(1, layout.preamble) end return pandoc.Div(panel_content, attr) end local function render_ipynb_layout(layout) if layout.float == nil then return render_ipynb_layout_no_float(layout) end decorate_caption_with_crossref(layout.float) -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end if layout.float.caption_long then panel_content:insert(pandoc.Para(quarto.utils.as_inlines(layout.float.caption_long) or {})) end if layout.preamble then panel_content:insert(1, layout.preamble) end return pandoc.Div(panel_content, attr) end _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isIpynbOutput() and param("enable-crossref", true) end, render_ipynb_layout) -- this should really be "_quarto.format.isEmbedIpynb()" or something like that.. _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isIpynbOutput() and not param("enable-crossref", true) end, render_ipynb_layout) -- typst.lua -- Copyright (C) 2023 Posit Software, PBC function make_typst_figure(tbl) local content = tbl.content local caption_location = tbl.caption_location local caption = tbl.caption local kind = tbl.kind local supplement = tbl.supplement local numbering = tbl.numbering local identifier = tbl.identifier local separator = tbl.separator if #caption.content == 0 and tbl.separator == nil then separator = "" end local result = pandoc.Blocks({ pandoc.RawInline("typst", "#figure([") }) result:extend(quarto.utils.as_blocks(content)) result:extend({ pandoc.RawInline("typst", "], caption: figure.caption("), pandoc.RawInline("typst", separator and ("separator: \"" .. separator .. "\", ") or ""), pandoc.RawInline("typst", "position: " .. caption_location .. ", "), pandoc.RawInline("typst", "["), caption, -- apparently typst doesn't allow separate prefix and name pandoc.RawInline("typst", "]), "), pandoc.RawInline("typst", "kind: \"" .. kind .. "\", "), pandoc.RawInline("typst", supplement and ("supplement: \"" .. supplement .. "\", ") or ""), pandoc.RawInline("typst", numbering and ("numbering: \"" .. numbering .. "\", ") or ""), pandoc.RawInline("typst", ")"), pandoc.RawInline("typst", identifier and ("<" .. identifier .. ">") or ""), pandoc.RawInline("typst", "\n\n") }) return result end local function render_floatless_typst_layout(panel) local result = pandoc.Blocks({}) if panel.preamble then result:insert(panel.preamble) end -- render a grid per row of the layout -- https://typst.app/docs/reference/layout/grid/ for i, row in ipairs(panel.layout) do -- synthesize column spec from row local col_spec = {} for j, col in ipairs(row) do table.insert(col_spec, col.attributes["width"]) end -- TODO allow configurable gutter local col_spec_str = "columns: (" .. table.concat(col_spec, ", ") .. "), gutter: 1em, rows: 1," result:insert(pandoc.RawBlock("typst", "#grid(")) result:insert(pandoc.RawBlock("typst", col_spec_str)) for j, col in ipairs(row) do result:insert(pandoc.RawBlock("typst", " rect(stroke: none, width: 100%)[")) -- #7718: if content is a single image with no attributes, -- we need to set the width to 100% to avoid Pandoc from -- specifying a width in pixels, which overrides the -- column's relative constraint. local image = quarto.utils.match("[1]/Para/[1]/{Image}")(col.content) -- we also need to check for Pandoc Figure AST nodes because these -- still linger in our AST (captioned unidentified figures...) image = image or quarto.utils.match("[1]/Figure/[1]/Plain/[1]/{Image}")(col.content) if image and #image[1].attributes == 0 then image[1].attributes["width"] = "100%" end result:extend(col.content) result:insert(pandoc.RawBlock("typst", "],")) end result:insert(pandoc.RawBlock("typst", ")\n")) end return result end _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isTypstOutput() end, function(layout) if layout.float == nil then return render_floatless_typst_layout(layout) end local ref = refType(layout.float.identifier) local kind = "quarto-float-" .. ref local info = crossref.categories.by_ref_type[ref] if info == nil then -- luacov: disable warning("Unknown float type: " .. ref .. "\n Will emit without crossref information.") return float.content -- luacov: enable end -- typst output currently only supports a single grid -- as output, so no rows of varying columns, etc. local n_cols = layout.attributes[kLayoutNcol] or "1" local typst_figure_content = pandoc.Div({}) typst_figure_content.content:insert(pandoc.RawInline("typst", "#grid(columns: " .. n_cols .. ", gutter: 2em,\n")) local is_first = true _quarto.ast.walk(layout.float.content, { FloatRefTarget = function(_, float_obj) if is_first then is_first = false else typst_figure_content.content:insert(pandoc.RawInline("typst", ",\n")) end typst_figure_content.content:insert(pandoc.RawInline("typst", " [")) typst_figure_content.content:insert(float_obj) typst_figure_content.content:insert(pandoc.RawInline("typst", "]")) return nil end }) typst_figure_content.content:insert(pandoc.RawInline("typst", ")\n")) local result = pandoc.Blocks({}) if layout.preamble then result:insert(layout.preamble) end local caption_location = cap_location(layout.float) return make_typst_figure { content = typst_figure_content, caption_location = caption_location, caption = layout.float.caption_long, kind = kind, supplement = info.prefix, numbering = info.numbering, identifier = layout.float.identifier } end) -- hugo.lua -- Copyright (C) 2023 Posit Software, PBC _quarto.ast.add_renderer("PanelLayout", function(_) return _quarto.format.isHugoMarkdownOutput() end, function(layout) if layout.float == nil then fail_and_ask_for_bug_report("Can't render layouts without floats") return pandoc.Div({}) end decorate_caption_with_crossref(layout.float) -- empty options by default if not options then options = {} end -- outer panel to contain css and figure panel local attr = pandoc.Attr(layout.identifier or "", layout.classes or {}, layout.attributes or {}) local panel_content = pandoc.Blocks({}) -- layout for i, row in ipairs(layout.layout) do local aligns = row:map(function(cell) -- get the align local align = cell.attributes[kLayoutAlign] return layoutTableAlign(align) end) local widths = row:map(function(cell) -- propagage percents if they are provided local layoutPercent = horizontalLayoutPercent(cell) if layoutPercent then return layoutPercent / 100 else return 0 end end) local cells = pandoc.List() for _, cell in ipairs(row) do cells:insert(cell) end -- make the table local panelTable = pandoc.SimpleTable( pandoc.List(), -- caption aligns, widths, pandoc.List(), -- headers { cells } ) -- add it to the panel panel_content:insert(pandoc.utils.from_simple_table(panelTable)) end local result = pandoc.Div({}) -- the format for the rawblock is html and not markdown_strict -- because this might end up inside a table, and Pandoc -- ignores markdown_strict raw blocks that are inside tables result.content:insert(pandoc.RawBlock("html", "<div id=\"" .. layout.float.identifier .. "\">")) result.content:extend(panel_content) if layout.float.caption_long then result.content:insert(pandoc.Para(quarto.utils.as_inlines(layout.float.caption_long) or {})) end result.content:insert(pandoc.RawBlock("html", "</div>")) local res = pandoc.Blocks({}) panel_insert_preamble(res, layout.preamble) res:insert(result) return res end) _quarto.ast.add_renderer("FloatRefTarget", function(_) return _quarto.format.isHugoMarkdownOutput() end, function(float) decorate_caption_with_crossref(float) local el = quarto.utils.match("Plain/[1]/Image")(float.content) if el then local text = pandoc.write(pandoc.Pandoc({ pandoc.Image( pandoc.utils.stringify(quarto.utils.as_inlines(float.caption_long)), el.src, "", pandoc.Attr(float.identifier, {}, {}))}), "html") -- we use "html" here because otherwise -- Pandoc appears to not want to emit the right thing -- when the output is inside a table -- Pandoc is also emitting bizarre (but ultimately harmless) newline entities inside tables -- if we add the newline here, but if we don't add newlines, -- then images by themselves don't have line breaks after them local result = pandoc.RawInline("html", text .. "\n") return result end el = quarto.utils.match("Plain/[1]/{Link}/[1]/{Image}")(float.content) if el then local link = el[1] local image = el[2] image.identifier = float.identifier image.caption = quarto.utils.as_inlines(float.caption_long) return link end el = quarto.utils.match("CodeBlock")(float.content) if el then float.content.classes:extend(float.classes) for k, v in pairs(float.attributes) do float.content.attributes[k] = v end end local start_div = pandoc.RawBlock("html", "<div id=\"" .. float.identifier .. "\">") local end_div = pandoc.RawBlock("html", "</div>") local result = pandoc.Blocks({start_div}) quarto.utils.add_to_blocks(result, float.content) quarto.utils.add_to_blocks(result, float.caption_long) result:insert(end_div) return result end) function render_hugo_fixups() if not _quarto.format.isHugoMarkdownOutput() then return {} end return { Div = function(div) if div.identifier:match("ojs%-cell%-") then return pandoc.RawBlock("markdown", '<div id="' .. div.identifier .. '"></div>') end end } end -- metainit.lua -- All initialization functions that require access to metadata function quarto_meta_init() return { Meta = function(meta) configure_filters() read_includes(meta) init_crossref_options(meta) initialize_custom_crossref_categories(meta) end } end -- main.lua -- Copyright (C) 2020-2023 Posit Software, PBC -- required version PANDOC_VERSION:must_be_at_least '2.13' initCrossrefIndex() initShortcodeHandlers() -- see whether the cross ref filter is enabled local enableCrossRef = param("enable-crossref", true) local quarto_init_filters = { { name = "init-quarto-meta-init", filter = quarto_meta_init() }, { name = "init-quarto-custom-meta-init", filter = { Meta = function(meta) content_hidden_meta(meta) end }}, -- FIXME this could probably be moved into the next combineFilters below, -- in quartoNormalize { name = "init-metadata-resource-refs", filter = combineFilters({ file_metadata(), resourceRefs() })}, { name = "init-knitr-syntax-fixup", filter = filterIf( -- only do those fix-up when we know computation engine was knitr function() return param("execution-engine") == "knitr" end, knitr_fixup() ) }, } -- v1.4 change: quartoNormalize is responsible for producing a -- "normalized" document that is ready for quarto-pre, etc. -- notably, user filters will run on the normalized document and -- see a "Quarto AST". For example, Figure nodes are no longer -- going to be present, and will instead be represented by -- our custom AST infrastructure (FloatRefTarget specifically). local quarto_normalize_filters = { { name = "normalize", filter = filterIf(function() if quarto_global_state.active_filters == nil then return false end return quarto_global_state.active_filters.normalization end, normalize_filter()) }, { name = "normalize-capture-reader-state", filter = normalize_capture_reader_state() } } tappend(quarto_normalize_filters, quarto_ast_pipeline()) local quarto_pre_filters = { -- quarto-pre { name = "flags", filter = compute_flags() }, { name = "pre-server-shiny", filter = server_shiny() }, -- https://github.com/quarto-dev/quarto-cli/issues/5031 -- recompute options object in case user filters have changed meta -- this will need to change in the future; users will have to indicate -- when they mutate options { name = "pre-read-options-again", filter = init_options() }, { name = "pre-bibliography-formats", filter = bibliography_formats() }, { name = "pre-shortcodes-filter", filter = shortcodes_filter(), flags = { "has_shortcodes" } }, { name = "pre-hidden", filter = hidden(), flags = { "has_hidden" } }, { name = "pre-content-hidden", filter = content_hidden(), flags = { "has_conditional_content" } }, { name = "pre-table-captions", filter = table_captions(), flags = { "has_table_captions" } }, { name = "pre-code-annotations", filter = code_annotations(), flags = { "has_code_annotations" } }, { name = "pre-code-annotations-meta", filter = code_meta() }, { name = "pre-unroll-cell-outputs", filter = unroll_cell_outputs(), flags = { "needs_output_unrolling" } }, { name = "pre-output-location", filter = output_location() }, { name = "pre-table-colwidth", filter = table_colwidth(), }, { name = "pre-combined-figures-theorems-etc", filter = combineFilters({ file_metadata(), index_book_file_targets(), book_numbering(), include_paths(), resource_files(), quarto_pre_figures(), quarto_pre_theorems(), docx_callout_and_table_fixup(), engine_escape(), line_numbers(), bootstrap_panel_input(), bootstrap_panel_layout(), bootstrap_panel_sidebar(), table_respecify_gt_css(), table_classes(), input_traits(), resolve_book_file_targets(), project_paths() }) }, { name = "pre-quarto-pre-meta-inject", filter = quarto_pre_meta_inject() }, { name = "pre-write-results", filter = write_results() }, } local quarto_post_filters = { { name = "post-cell-cleanup", filter = cell_cleanup(), flags = { "has_output_cells" } }, { name = "post-cites", filter = indexCites() }, { name = "post-bibliography", filter = bibliography() }, { name = "post-ipynb", filters = ipynb()}, { name = "post-figureCleanupCombined", filter = combineFilters({ latexDiv(), responsive(), quartoBook(), reveal(), tikz(), pdfImages(), delink(), figCleanup(), responsive_table(), }) }, { name = "post-postMetaInject", filter = quartoPostMetaInject() }, { name = "post-render-jats", filter = filterIf(function() return quarto_global_state.active_filters.jats_subarticle == nil or not quarto_global_state.active_filters.jats_subarticle end, jats()) }, { name = "post-render-jats-subarticle", filter = filterIf(function() return quarto_global_state.active_filters.jats_subarticle ~= nil and quarto_global_state.active_filters.jats_subarticle end, jatsSubarticle()) }, { name = "post-code-options", filter = filterIf(function() return param("clear-cell-options", false) == true end, removeCodeOptions()) }, -- format-specific rendering { name = "post-render-asciidoc", filter = render_asciidoc() }, { name = "post-render-latex", filter = render_latex() }, { name = "post-render-typst", filters = render_typst() }, { name = "post-render-dashboard", filters = render_dashboard() }, { name = "post-ojs", filter = ojs() }, { name = "post-render-pandoc3-figure", filter = render_pandoc3_figure(), flags = { "has_pandoc3_figure" } }, -- extensible rendering { name = "post-render_extended_nodes", filter = render_extended_nodes() }, -- inject required packages post-rendering { name = "layout-meta-inject-latex-packages", filter = layout_meta_inject_latex_packages() }, -- format fixups post rendering { name = "post-render-latex-fixups", filter = render_latex_fixups() }, { name = "post-render-html-fixups", filter = render_html_fixups() }, { name = "post-render-ipynb-fixups", filter = render_ipynb_fixups() }, { name = "post-render-typst-fixups", filter = render_typst_fixups() }, { name = "post-render-gfm-fixups", filter = render_gfm_fixups() }, { name = "post-render-hugo-fixups", filter = render_hugo_fixups() }, { name = "post-render-email", filters = render_email() }, } local quarto_finalize_filters = { -- quarto-finalize { name = "finalize-combined", filter = combineFilters({ file_metadata(), mediabag(), inject_vault_content_into_rawlatex(), })}, { name = "finalize-bookCleanup", filter = bookCleanup() }, { name = "finalize-cites", filter = writeCites() }, { name = "finalize-metaCleanup", filter = metaCleanup() }, { name = "finalize-dependencies", filter = dependencies() }, { name = "finalize-coalesce-raw", filters = coalesce_raw() }, { name = "finalize-descaffold", filter = descaffold() }, { name = "finalize-wrapped-writer", filter = wrapped_writer() }, { name = "finalize-typst-state", filter = setup_typst_state() } } local quarto_layout_filters = { { name = "manuscript filtering", filter = manuscript() }, { name = "manuscript filtering", filter = manuscriptUnroll() }, { name = "layout-lightbox", filters = lightbox(), flags = { "has_lightbox" }}, { name = "layout-columns-preprocess", filter = columns_preprocess() }, { name = "layout-columns", filter = columns() }, { name = "layout-cites-preprocess", filter = cites_preprocess() }, { name = "layout-cites", filter = cites() }, { name = "layout-panels", filter = layout_panels() }, { name = "post-fold-code-and-lift-codeblocks-from-floats", filter = fold_code_and_lift_codeblocks() }, } local quarto_crossref_filters = { { name = "crossref-preprocess-floats", filter = crossref_mark_subfloats(), }, { name = "crossref-preprocessTheorems", filter = crossref_preprocess_theorems(), flags = { "has_theorem_refs" } }, { name = "crossref-combineFilters", filter = combineFilters({ file_metadata(), qmd(), sections(), crossref_figures(), equations(), crossref_theorems(), crossref_callouts(), })}, { name = "crossref-resolveRefs", filter = resolveRefs(), flags = { "has_cites" } }, { name = "crossref-crossrefMetaInject", filter = crossrefMetaInject() }, { name = "crossref-writeIndex", filter = writeIndex() }, } local quarto_filter_list = {} table.insert(quarto_filter_list, { name = "pre-ast", filter = {} }) -- entry point for user filters tappend(quarto_filter_list, quarto_init_filters) tappend(quarto_filter_list, quarto_normalize_filters) table.insert(quarto_filter_list, { name = "post-ast", filter = {} }) -- entry point for user filters table.insert(quarto_filter_list, { name = "pre-quarto", filter = {} }) -- entry point for user filters tappend(quarto_filter_list, quarto_pre_filters) if enableCrossRef then tappend(quarto_filter_list, quarto_crossref_filters) end table.insert(quarto_filter_list, { name = "post-quarto", filter = {} }) -- entry point for user filters table.insert(quarto_filter_list, { name = "pre-render", filter = {} }) -- entry point for user filters tappend(quarto_filter_list, quarto_layout_filters) tappend(quarto_filter_list, quarto_post_filters) table.insert(quarto_filter_list, { name = "post-render", filter = {} }) -- entry point for user filters table.insert(quarto_filter_list, { name = "pre-finalize", filter = {} }) -- entry point for user filters tappend(quarto_filter_list, quarto_finalize_filters) table.insert(quarto_filter_list, { name = "post-finalize", filter = {} }) -- entry point for user filters -- now inject user-defined filters on appropriate positions inject_user_filters_at_entry_points(quarto_filter_list) local result = run_as_extended_ast({ pre = { init_options() }, afterFilterPass = function() -- After filter pass is called after each pass through a filter group -- allowing state or other items to be handled resetFileMetadata() end, filters = quarto_filter_list, }) return result -- TODO!! -- citeproc detection/toggle --[[ from filters.ts: // citeproc at the very end so all other filters can interact with citations filters = filters.filter((filter) => filter !== kQuartoCiteProcMarker); const citeproc = citeMethod(options) === kQuartoCiteProcMarker; if (citeproc) { // If we're explicitely adding the citeproc filter, turn off // citeproc: true so it isn't run twice // See https://github.com/quarto-dev/quarto-cli/issues/2393 if (options.format.pandoc.citeproc === true) { delete options.format.pandoc.citeproc; } quartoFilters.push(kQuartoCiteProcMarker); } ]]