--[[ diagram – create images and figures from code blocks. MIT License Copyright © 2019-2023 Albert Krewinkel Copyright © 2019 Thorsten Sommer Copyright © 2018 Florian Schätzig Copyright © 2018 John MacFarlane 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. ]] -- The filter uses the Figure AST element, which was added in pandoc 3. PANDOC_VERSION:must_be_at_least '3.0' local version = pandoc.types.Version '1.2.0' -- Report Lua warnings to stderr if the `warn` function is not plugged into -- pandoc's logging system. if not warn then -- fallback warn = function(...) io.stderr:write(table.concat({ ... })) end elseif PANDOC_VERSION < '3.1.4' then -- starting with pandoc 3.1.4, warnings are reported to pandoc's logging -- system, so no need to print warnings to stderr. warn '@on' end local io = require 'io' local pandoc = require 'pandoc' local system = require 'pandoc.system' local utils = require 'pandoc.utils' local List = require 'pandoc.List' local stringify = utils.stringify local with_temporary_directory = system.with_temporary_directory local with_working_directory = system.with_working_directory --- Returns a filter-specific directory in which cache files can be --- stored, or nil if no such directory is available. local function cachedir () local cache_home = os.getenv 'XDG_CACHE_HOME' if not cache_home or cache_home == '' then local user_home = system.os == 'windows' and os.getenv 'USERPROFILE' or os.getenv 'HOME' if not user_home or user_home == '' then return nil end cache_home = pandoc.path.join{user_home, '.cache'} or nil end -- Create filter cache directory return pandoc.path.join{cache_home, 'pandoc-diagram-filter'} end --- Path holding the image cache, or `nil` if the cache is not used. local image_cache = nil local mimetype_for_extension = { jpeg = 'image/jpeg', jpg = 'image/jpeg', pdf = 'application/pdf', png = 'image/png', svg = 'image/svg+xml', } local extension_for_mimetype = { ['application/pdf'] = 'pdf', ['image/jpeg'] = 'jpg', ['image/png'] = 'png', ['image/svg+xml'] = 'svg', } --- Converts a list of format specifiers to a set of MIME types. local function mime_types_set (tbl) local set = {} local mime_type for _, image_format_spec in ipairs(tbl) do mime_type = mimetype_for_extension[image_format_spec] or image_format_spec set[mime_type] = true end return set end --- Reads the contents of a file. local function read_file (filepath) local fh = io.open(filepath, 'rb') local contents = fh:read('a') fh:close() return contents end --- Writes the contents into a file at the given path. local function write_file (filepath, content) local fh = io.open(filepath, 'wb') fh:write(content) fh:close() end --- Like `pandoc.pipe`, but allows "multi word" paths: -- Supplying a list as the first argument will use the first element as -- the executable path and prepend the remaining elements to the list of -- arguments. local function pipe (command, args, input) local cmd if pandoc.utils.type(command) == 'List' then command = command:map(stringify) cmd = command:remove(1) args = command .. args else cmd = stringify(command) end return pandoc.pipe(cmd, args, input) end -- -- Diagram Engines -- -- PlantUML engine; assumes that there's a `plantuml` binary. local plantuml = { line_comment_start = [[']], mime_types = mime_types_set{'pdf', 'png', 'svg'}, compile = function (self, puml) local mime_type = self.mime_type or 'image/svg+xml' -- PlantUML format identifiers correspond to common file extensions. local format = extension_for_mimetype[mime_type] if not format then format, mime_type = 'svg', 'image/svg+xml' end local args = {'-t' .. format, "-pipe", "-charset", "UTF8"} return pipe(self.execpath or 'plantuml', args, puml), mime_type end, } --- GraphViz engine for the dot language local graphviz = { line_comment_start = '//', mime_types = mime_types_set{'jpg', 'pdf', 'png', 'svg'}, mime_type = 'image/svg+xml', compile = function (self, code) local mime_type = self.mime_type -- GraphViz format identifiers correspond to common file extensions. local format = extension_for_mimetype[mime_type] if not format then format, mime_type = 'svg', 'image/svg+xml' end return pipe(self.execpath or 'dot', {"-T"..format}, code), mime_type end, } --- Mermaid engine local mermaid = { line_comment_start = '%%', mime_types = mime_types_set{'pdf', 'png', 'svg'}, compile = function (self, code) local mime_type = self.mime_type or 'image/svg+xml' local file_extension = extension_for_mimetype[mime_type] return with_temporary_directory("diagram", function (tmpdir) return with_working_directory(tmpdir, function () local infile = 'diagram.mmd' local outfile = 'diagram.' .. file_extension write_file(infile, code) pipe( self.execpath or 'mmdc', {"--pdfFit", "--input", infile, "--output", outfile}, '' ) return read_file(outfile), mime_type end) end) end, } --- TikZ -- --- LaTeX template used to compile TikZ images. local tikz_template = pandoc.template.compile [[ \documentclass{standalone} \usepackage{tikz} $for(header-includes)$ $it$ $endfor$ $additional-packages$ \begin{document} $body$ \end{document} ]] --- The TikZ engine uses pdflatex to compile TikZ code to an image local tikz = { line_comment_start = '%%', mime_types = { ['application/pdf'] = true, }, --- Compile LaTeX with TikZ code to an image compile = function (self, src, user_opts) return with_temporary_directory("tikz", function (tmpdir) return with_working_directory(tmpdir, function () -- Define file names: local file_template = "%s/tikz-image.%s" local tikz_file = file_template:format(tmpdir, "tex") local pdf_file = file_template:format(tmpdir, "pdf") -- Treat string values as raw LaTeX local meta = { ['header-includes'] = user_opts['header-includes'], ['additional-packages'] = {pandoc.RawInline( 'latex', stringify(user_opts['additional-packages'] or '') )}, } local tex_code = pandoc.write( pandoc.Pandoc({pandoc.RawBlock('latex', src)}, meta), 'latex', {template = tikz_template} ) write_file(tikz_file, tex_code) -- Execute the LaTeX compiler: local success, result = pcall( pipe, self.execpath or 'pdflatex', { '-interaction=nonstopmode', '-output-directory', tmpdir, tikz_file }, '' ) if not success then warn(string.format( "The call\n%s\nfailed with error code %s. Output:\n%s", result.command, result.error_code, result.output )) end return read_file(pdf_file), 'application/pdf' end) end) end } --- Asymptote diagram engine local asymptote = { line_comment_start = '%%', mime_types = { ['application/pdf'] = true, }, compile = function (self, code) return with_temporary_directory("asymptote", function(tmpdir) return with_working_directory(tmpdir, function () local pdf_file = "pandoc_diagram.pdf" local args = {'-tex', 'pdflatex', "-o", "pandoc_diagram", '-'} pipe(self.execpath or 'asy', args, code) return read_file(pdf_file), 'application/pdf' end) end) end, } --- Cetz diagram engine local cetz = { line_comment_start = '%%', mime_types = mime_types_set{'jpg', 'pdf', 'png', 'svg'}, mime_type = 'image/svg+xml', compile = function (self, code) local mime_type = self.mime_type local format = extension_for_mimetype[mime_type] if not format then format, mime_type = 'svg', 'image/svg+xml' end local preamble = [[ #import "@preview/cetz:0.3.4" #set page(width: auto, height: auto, margin: .5cm) ]] local typst_code = preamble .. code return with_temporary_directory("diagram", function (tmpdir) return with_working_directory(tmpdir, function () local outfile = 'diagram.' .. format local execpath = self.execpath if not execpath and quarto and quarto.version >= '1.4' then -- fall back to the Typst exec shipped with Quarto. execpath = List{'quarto', 'typst'} end pipe( execpath or 'typst', {"compile", "-f", format, "-", outfile}, typst_code ) return read_file(outfile), mime_type end) end) end, } local default_engines = { asymptote = asymptote, dot = graphviz, mermaid = mermaid, plantuml = plantuml, tikz = tikz, cetz = cetz, } -- -- Configuration -- --- Options for the output format of the given name. local function format_options (name) local pdf2svg = name ~= 'latex' and name ~= 'context' local is_office_format = name == 'docx' or name == 'odt' -- Office formats seem to work better with PNG than with SVG. local preferred_mime_types = is_office_format and pandoc.List{'image/png', 'application/pdf'} or pandoc.List{'application/pdf', 'image/png'} -- Prefer SVG for non-PDF output formats, except for Office formats if is_office_format then preferred_mime_types:insert('image/svg+xml') elseif pdf2svg then preferred_mime_types:insert(1, 'image/svg+xml') end return { name = name, pdf2svg = pdf2svg, preferred_mime_types = preferred_mime_types, best_mime_type = function (self, supported_mime_types, requested) return self.preferred_mime_types:find_if(function (preferred) return supported_mime_types[preferred] and (not requested or (pandoc.utils.type(requested) == 'List' and requested:includes(preferred)) or (pandoc.utils.type(requested) == 'table' and requested[preferred]) or -- Assume string, Inlines, and Blocks values specify the only -- acceptable MIME type. stringify(requested) == preferred) end) end } end --- Returns a configured diagram engine. local function get_engine (name, engopts, format) local engine = default_engines[name] or select(2, pcall(require, stringify(engopts.package))) -- Sanity check if not engine then warn(PANDOC_SCRIPT_FILE, ": No such engine '", name, "'.") return nil elseif engopts == false then -- engine is disabled return nil elseif engopts == true then -- use default options return engine end local execpath = engopts.execpath or os.getenv(name:upper() .. '_BIN') local mime_type = format:best_mime_type( engine.mime_types, engopts['mime-type'] or engopts['mime-types'] ) if not mime_type then warn(PANDOC_SCRIPT_FILE, ": Cannot use ", name, " with ", format.name) return nil end return { execpath = execpath, compile = engine.compile, line_comment_start = engine.line_comment_start, mime_type = mime_type, opt = engopts or {}, } end --- Returns the diagram engine configs. local function configure (meta, format_name) local conf = meta.diagram or {} local format = format_options(format_name) meta.diagram = nil -- cache for image files if conf.cache then image_cache = conf['cache-dir'] and stringify(conf['cache-dir']) or cachedir() pandoc.system.make_directory(image_cache, true) end -- engine configs local engine = {} for name, engopts in pairs(conf.engine or default_engines) do engine[name] = get_engine(name, engopts, format) end return { engine = engine, format = format, cache = image_cache and true, image_cache = image_cache, } end -- -- Format conversion -- --- Converts a PDF to SVG. local pdf2svg = function (imgdata) -- Using `os.tmpname()` instead of a hash would be slightly cleaner, but the -- function causes problems on Windows (and wasm). See, e.g., -- https://github.com/pandoc-ext/diagram/issues/49 local pdf_file = 'diagram-' .. pandoc.utils.sha1(imgdata) .. '.pdf' write_file(pdf_file, imgdata) local args = { '--export-type=svg', '--export-plain-svg', '--export-filename=-', pdf_file } return pandoc.pipe('inkscape', args, ''), os.remove(pdf_file) end local function properties_from_code (code, comment_start) local props = {} local pattern = comment_start:gsub('%p', '%%%1') .. '| ' .. '([-_%w]+): ([^\n]*)\n' for key, value in code:gmatch(pattern) do if key == 'fig-cap' then props['caption'] = value else props[key] = value end end return props end local function diagram_options (cb, comment_start) local attribs = comment_start and properties_from_code(cb.text, comment_start) or {} for key, value in pairs(cb.attributes) do attribs[key] = value end local alt local caption local fig_attr = {id = cb.identifier} local filename local image_attr = {} local user_opt = {} for attr_name, value in pairs(attribs) do if attr_name == 'alt' then alt = value elseif attr_name == 'caption' then -- Read caption attribute as Markdown caption = attribs.caption and pandoc.read(attribs.caption).blocks or nil elseif attr_name == 'filename' then filename = value elseif attr_name == 'label' then fig_attr.id = value elseif attr_name == 'name' then fig_attr.name = value else -- Check for prefixed attributes local prefix, key = attr_name:match '^(%a+)%-(%a[-%w]*)$' if prefix == 'fig' then fig_attr[key] = value elseif prefix == 'image' or prefix == 'img' then image_attr[key] = value elseif prefix == 'opt' then user_opt[key] = value else -- Use as image attribute image_attr[attr_name] = value end end end return { ['alt'] = alt or (caption and pandoc.utils.blocks_to_inlines(caption)) or {}, ['caption'] = caption, ['fig-attr'] = fig_attr, ['filename'] = filename, ['image-attr'] = image_attr, ['opt'] = user_opt, } end local function get_cached_image (hash, mime_type) if not image_cache then return nil end local filename = hash .. '.' .. extension_for_mimetype[mime_type] local imgpath = pandoc.path.join{image_cache, filename} local success, imgdata = pcall(read_file, imgpath) if success then return imgdata, mime_type end return nil end local function cache_image (codeblock, imgdata, mimetype) -- do nothing if caching is disabled or not possible. if not image_cache then return end local ext = extension_for_mimetype[mimetype] local filename = pandoc.sha1(codeblock.text) .. '.' .. ext local imgpath = pandoc.path.join{image_cache, filename} write_file(imgpath, imgdata) end -- Executes each document's code block to find matching code blocks: local function code_to_figure (conf) return function (block) -- Check if a converter exists for this block. If not, return the block -- unchanged. local diagram_type = block.classes[1] if not diagram_type then return nil end local engine = conf.engine[diagram_type] if not engine then return nil end -- Unified properties. local dgr_opt = diagram_options(block, engine.line_comment_start) for optname, value in pairs(engine.opt or {}) do dgr_opt.opt[optname] = dgr_opt.opt[optname] or value end local run_pdf2svg = engine.mime_type == 'application/pdf' and conf.format.pdf2svg -- Try to retrieve the image data from the cache. local imgdata, imgtype if conf.cache then imgdata, imgtype = get_cached_image( pandoc.sha1(block.text), run_pdf2svg and 'image/svg+xml' or engine.mime_type ) end if not imgdata or not imgtype then -- No cached image; call the converter local success success, imgdata, imgtype = pcall(engine.compile, engine, block.text, dgr_opt.opt) -- Bail if an error occurred; imgdata contains the error message -- when that happens. if not success then warn(PANDOC_SCRIPT_FILE, ': ', tostring(imgdata)) return nil elseif not imgdata then warn(PANDOC_SCRIPT_FILE, ': Diagram engine returned no image data.') return nil elseif not imgtype then warn(PANDOC_SCRIPT_FILE, ': Diagram engine did not return a MIME type.') return nil end -- Convert SVG if necessary. if imgtype == 'application/pdf' and conf.format.pdf2svg then imgdata, imgtype = pdf2svg(imgdata), 'image/svg+xml' end -- If we got here, then the transformation went ok and `img` contains -- the image data. cache_image(block, imgdata, imgtype) end -- Use the block's filename attribute or create a new name by hashing the -- image content. local basename, _extension = pandoc.path.split_extension( dgr_opt.filename or pandoc.sha1(imgdata) ) local fname = basename .. '.' .. extension_for_mimetype[imgtype] -- Store the data in the media bag: pandoc.mediabag.insert(fname, imgtype, imgdata) -- Create the image object. local image = pandoc.Image(dgr_opt.alt, fname, "", dgr_opt['image-attr']) -- Create a figure if the diagram has a caption; otherwise return -- just the image. return dgr_opt.caption and pandoc.Figure( pandoc.Plain{image}, dgr_opt.caption, dgr_opt['fig-attr'] ) or pandoc.Plain{image} end end return setmetatable( {{ Pandoc = function (doc) local conf = configure(doc.meta, FORMAT) return doc:walk { CodeBlock = code_to_figure(conf), } end }}, { version = version, } )