Lua filter to convert some or all LaTeX and TikZ elements in a
document into images. Also enables using
.tex
/.tikz
files as image sources.
Copyright 2022-2023 Philosophie.ch. Maintained by Julien Dutant.
Imagify turns selected LaTeX elements into images in non-LaTeX/PDF output. This is useful for web output if you use MathJAX but it doesn’t handle all of your LaTeX code.
It also allows you to use .tex
or .tikz
elements as image source files, which is useful to create
cross-referenceable figures with Pandoc-crossref
or Quarto without having to convert
your LaTeX/TikZ code into images first.
Pandoc-crossref and Quarto have advanced figure handling including captions and cross-references, but they require image elements, e.g.:
![Caption](figure.png){#fig-1}
To use this with a TikZ/LaTeX figure, you would need to convert it to an image first, and ideally PDF for PDF, SVG or PNG for other output formats. Alternatively, with Quarto 1.4+, you could use a [Div figure][QuartoDivFigure] and place your LaTeX/TikZ figure within it:
::: {#fig-1}
\begin{tikzpicture}
...
\end{tikzpicture}
:::
But that would only cover LaTeX outputs. This filter allows you to
simply use a .tex
/.tikz
file as source, which
is converted to an image according to output format:
![Caption](figure.tikz){#fig-1}
Imagify tries to match your document’s LaTeX output settings (fonts, LaTeX packages, etc.). Its rendering options are otherwise extensively configurable, and different rendering options can be used for different elements. It can embed its images within HTML output or provide them as separate image files.
Requirements: Pandoc or Quarto, a LaTeX installation (with
dvisvgm
and, recommended, latexmk
, which are
included in common LaTeX distributions).
Limitations:
.tikz
or
.tex
files as image sources. In other output formats, the
images will be inserted or linked as PDFs and may display in wrong sizes
or not at all.extract-media
option.In addition to Pandoc/Quarto, the filter needs a LaTeX installation
with the package dvisvgm
installed.
Quarto ships with a small LaTeX installation (tinytex), but the filter cannot use it.
Get imagify.lua
from the Releases page and save it
somewhere Pandoc can find (see Pandoc for details).
Pass the filter to Pandoc via the --lua-filter
(or
-L
) command line option.
pandoc --lua-filter imagify.lua ...
Install this filter as a Quarto extension with
quarto install extension dialoa/imagify
and use it by adding imagify
to the filters
entry in their YAML header:
---
filters:
- imagify
---
See Quarto’s Extensions guide for more details updating and version-controlling filters.
Use pandoc_args
to invoke the filter. See the R
Markdown Cookbook for details.
---
output:
word_document:
pandoc_args: ['--lua-filter=imagify.lua']
---
LaTeX elements to be imagified should be placed in a Div block with
class imagify
. In markdown source:
::: imagify
This display LaTeX formmula will be imagified:
$$\binom{n}{k} = \frac{n!}{k!(n-k)!}$$
As well as this TikZ picture:
\begin{tikzpicture}
\draw (-2,0) -- (2,0);[gray] (0,0) circle (2pt);
\filldraw
\draw (-2,-2) .. controls (0,0) .. (2,-2);
\draw (-2,2) .. controls (-1,0) and (1,0) .. (2,2);
\end{tikzpicture}
And this raw LaTeX block:
```{=latex}
\fitchprf{
\pline{A} \\
\pline{A \rightarrow B}
}
{ \pline{B} }
```
:::
LaTeX math and raw LaTeX elements in the Div are converted to images unless the output format is LaTeX/PDF.
If a LaTeX element is or contains a TikZ picture, the TikZ package is
loaded. If you need a specific library, place a
\usetikzlibrary
command at the beginning of your picture
code.
.tex
/.tikz
files as image sourcesThe filter allows .tex
/.tikz
files to be
used as image sources
![Figure: a TikZ image](figure1.tikz){#fig-1 .some-attributes}
The source file will be converted to an image in all output formats, e.g. PDF for LaTeX/PDF output, SVG for HTML. Attributes on the image are preserved. This is useful for cross-referencing with Pandoc-Crossref or Quarto.
Optionally, you can wrap image elements in an imagify
Div. This allows you to specify rendering options for some image
elements (see below on rendering options).
::: {.imagify zoom=2}
![Figure: a TikZ image](figure1.tikz){#fig-1 .some-attributes}
:::
The source file should not include a LaTeX preamble nor
\begin{document}...\end{document}
. The two extensions are
treated the same way: if the file contains \tikz
or
\begin{tikzpicture}
then TikZ is loaded.
The source must work with LaTeX’s standalone
class,
which imposes some restrictions. In particular, if your source is a
display formula, it should be entered as an inline formula in the
‘display’ style like so:
![Figure 1: my equation](figure.tex)
$\displaystyle
my fancy formula
$
Instead of the usual $$ ... $$
or
\[ ... \]
.
Images files are placed in an _imagify
folder created in
your current working directory. See the test/input.md
file
for an example.
Images are generated using any Pandoc LaTeX output options specified
in your document’s metadata suited for a standalone
class
document, such as fontfamily
, fontsize
etc.
Below Pandoc’s LaTeX options
is a list of options preserved; see Pandoc
manual for what they do.
You can customize options used in rendering, as detailed below.
Custom LaTeX packages not included in standard LaTeX distribution
(e.g. fitch.sty
) can be used, provided you place them in
the source file’s folder or one of its subfolder, or specify an
appropriate location via the texinputs
option.
If a piece of LaTeX crashes, try debugging it in a LaTeX document,
first in the article
class then in the
standalone
class. Try also the filter’s debug
option to inspect Imagify’s LaTeX output.
There are two types of options: for the filter itself, and for imagifying. The former are specified in the document’s metadata (YAML block at the beginning). The latter can vary from one imagified element to another and can be specified in three ways: as global rendering options, as imagifying classes of Divs, on individual Divs.
Rendering options are applied in a cascading manner, from the more general to the more specific, the latter overriding the former. Thus we apply in the following order:
fontsize
,Options are specified via imagify
and
imagify-classes
metadata variables (in the document’s YAML
block). For instance, temporarily disable Imagify with:
imagify: none
Set Imagify to convert all LaTeX in a document with:
imagify: all
This probably not a good idea if your document contains many LaTeX
elements that could be rendered by MathJAX or equivalent. The default is
manual
, which imagifies only (a) image elements with a
.tex
or .tikz
source files and (b) LaTeX
contained in imagify
Divs. You can also use
images
, which only converts images elements with a
.tex
or .tikz
source.
Set the images to be embedded in the HTML output file, rather than provided as separate files, with:
imagify:
embed: true
Change the images’ zoom factor with:
imagify:
zoom: 1.6
The default is 1.5, which seems to work well with Pandoc’s default standalone HTML output.
If image conversion fails, you can set the debug option that will
give you the .tex
files that the filter produces and passes
to LaTeX:
imagify:
debug: true
This places Imagify’s intermediate LaTeX files in our output folder
(by default _imagify
in your working directory). Try to
compile them yourself and see what changes or packages are needed.
Create custom imagifying classes with their own rendering options
with the imagify-class
variable:
imagify:
zoom: 1.6
imagify-classes:
mybigimage:
zoom: 2
mysmallimage:
zoom: 1
Use them in your markdown as follows:
::: mybigimage
`mybigimage`
The display formula below is rendered with the
class rendering options:
$$my formula$$
:::
Imagify-class rendering options are also applied to any image
elements with .tex
/tikz
sources in the
Div.
If a Div has both the class imagify
and a specific
imagify-class, the latter is used.If a Div has multiple imagify-classes,
only one will be use, and you can’t predict which: avoid this.
You can further specify rendering options on a Div itself:
::: {.imagify zoom='2' debug='true'}
... (text with LaTeX element)
:::
The Div must have the imagify
class or one of your
custom imagify-classes. If it has a custom imagify-classes the class
options are applied, but overridden by any attributes you specify.
Options are provided in the document’s metadata. These are provided
either in a YAML block in markdown source, or as a separate YAML file
loaded with the pandoc option --metadata-file
. Here is an
example:
fontsize: 12pt
header-includes:
``` {=latex}
\usepackage
```
imagify:
scope: all
debug: true
embed: true
lazy: true
output-folder: _imagify_files
pdf-engine: xelatex
keep-sources: false
zoom: 1.5
imagify-classes:
pre-render:
zoom: 4 # will show if it's not overriden
block-style: "border: 1px solid red;"
fitch:
debug: false
header-includes: \usepackage{fitch}
imagify
and
imagify-classes
imagify
scope
option. If
map, filter options and global rendering options.
imagify-class
Specified within the imagify
key.
scope
all
, none
, selected
(alias
manual
). Default selected
.
lazy
output-folder
_imagify
.
ligs-path
dvisvgm
. See DvisvgmMan for details.
These can differ from one imagified element to another.
Specified within the imagify
metadata key, within a key
of the imagify-class
map, or on as attributes of an imagify
class Div elements.
debug
.tex
files used to generate images in the
output folder (see output_folder
filter option). Default:
false.
force
pdf-engine
latex
, xelatex
,
lualatex
. Which engine to use when converting LaTeX to
dvi
or pdf
. Defaults to latex
.
Pandoc/Quarto filters cannot read which engine you specify to Pandoc,
so if e.g. xelatex
is needed you must specify this option
explicitly.
svg-converter
dvisvgm
available
for the moment.
zoom
1.5
.
embed
vertical-align
baseline
.
block-style
display:block; margin: .5em auto;
.
Specified at the metadata root, within the imagify
key,
within a key of the imagify-class
map, or on as attributes
of an imagify class Div elements.
As the document header-includes
is often used to include
LaTeX packages, the filter’s default behaviour is to picks it up and
insert it in the .tex
files used to generate images. You
can override that by specifying a custom or empty
header-includes
in the imagify key:
header-includes: |
This content only goes in the document's header.imagify:
header-includes: |
This content is used in imagify's .tex files.
An empty line ensures no header content is included:
header-includes: |
This content only goes in the document's header.imagify:
header-includes:
Different header-includes can be specified for each imagify class or even on a Div attributes.
Specified at the metadata root, within the imagify
key,
within a key of the imagify-class
map, or on as attributes
of an imagify class Div elements.
The following Pandoc LaTeX output options are read:
classoption
(for the standalone
class)mathspec
,fontenc
,fontfamily
,fontfamilyoptions
,fontsize
mainfont
, sansfont
, monofont
,
mathfont
, CJKmainfont
,mainfontoptions
, sansfontoptions
,
monofontoptions
, mathfontoptions
,
CJKoptions
,microtypeoptions
,colorlinks
,boxlinks
,linkcolor
, filecolor
,
citecolor
, urlcolor
,
toccolor
,urlstyle
.See Pandoc manual for details.
These are passed to the default Pandoc template that is used to
create. The document class is set to standalone
.
---
title: "Imagify Example"
author: Julien Dutant
### see example_meta.yaml for the rest of YAML options
---
[the formula $E = mc^2$]{.imagify}.
Imagify the following span:
::: imagify
For some inline formulas, such as`baseline` vertical
$x=\frac{-b\pm\sqrt[]{b^2-4ac}}{2a}$, the default
alignment is not ideal. You can adjust it manually, using a negative
value to lower the image below the baseline:[$x=\frac{-b\pm\sqrt[]{b^2-4ac}}{2a}$]{.imagify
`-0.5em`
vertical-align="-.5em"}. In this case, I've specified a
value, which is about half a baseline down.
:::
To check that the filter processes elements of arbitrary depth, we've
placed the next bit within a dummy Div block.
:::: arbitraryDiv
The display formula below is not explicitly marked to be imagified. `scope` option is set
However, it will be imagified if the filter's `all`:
to
$$P = \frac{T}{V}$$
::: {.highlightme zoom='1'}
This next formula is imagified with options provided for elements`highlightme`:
of a custom class,
$$P = \frac{T}{V}$$.
They display the formula as an inline instead of a block and
add a red border. They also specify a large zoom (4) but we've
overridden it and locally specified a zoom of 1.
:::
The filter automatically recognizes TikZ pictures and loads the TikZ`tikz` option for the `standalone`. When `dvisvgm` is
package with the `dvisvgm` option is set too:
used for conversion to SVG, the required
\usetikzlibrary{intersections}
\begin{tikzpicture}[scale=3,line cap=round,
% Styles
axes/.style=,
important line/.style={very thick}]
% Colors
\colorlet{anglecolor}{green!50!black}
\colorlet{sincolor}{red}
\colorlet{tancolor}{orange!80!black}
\colorlet{coscolor}{blue}
% The graphic[help lines,step=0.5cm] (-1.4,-1.4) grid (1.4,1.4);
\draw[radius=1cm];
\draw (0,0) circle [axes]
\begin{scope}[->] (-1.5,0) -- (1.5,0) node[right] {$x$} coordinate(x axis);
\draw[->] (0,-1.5) -- (0,1.5) node[above] {$y$} coordinate(y axis);
\draw
\foreach \x/\xtext in {-1, -.5/-\frac{1}{2}, 1}[xshift=\x cm] (0pt,1pt) -- (0pt,-1pt) node[below,fill=white] {$\xtext$};
\draw
\foreach \y/\ytext in {-1, -.5/-\frac{1}{2}, .5/\frac{1}{2}, 1}[yshift=\y cm] (1pt,0pt) -- (-1pt,0pt) node[left,fill=white] {$\ytext$};
\draw
\end{scope}
[fill=green!20,draw=anglecolor] (0,0) -- (3mm,0pt) arc [start angle=0, end angle=30, radius=3mm];
\filldraw[anglecolor] {$\alpha$};
\draw (15:2mm) node[important line,sincolor] (30:1cm) -- node[left=1pt,fill=white] {$\sin \alpha$} (30:1cm |- x axis); \draw[important line,coscolor] (30:1cm |- x axis) -- node[below=2pt,fill=white] {$\cos \alpha$} (0,0);
\draw
[name path=upward line] (1,0) -- (1,1);
\path [name path=sloped line] (0,0) -- (30:1.5cm);
\path
[name intersections={of=upward line and sloped line, by=t}] [very thick,orange] (1,0) -- node [right=1pt,fill=white] {$\displaystyle \tan \alpha \color{black}=\frac{{\color{red}\sin \alpha}}{\color{blue}\cos \alpha}$} (t);
\draw
\draw (0,0) -- (t);
\end{tikzpicture}
::::
`.tex` and `.tikz` files as sources for images. The
We can also use separate
filter converts them to PDF (for LaTeX/PDF output) or SVG as required.
That is useful to create cross-referencable figures
with Pandoc-Crossref and Quarto.
![Figure 1 is a separate TikZ file](figure1.tikz)
![Figure 2 is a separate LaTeX file](figure2.tex)
`\begin{document}`.
Currently, these should not contain a LaTeX preamble or `.tikz` and `.tex` sources here. A TikZ
There is no difference between `.tikz` file should still have `\begin{tikzpicture}` or `\tikz` commands.
picture in a
::: {.fitch}
We can also use LaTeX packages that are provided in the document's folder, `fitch.sty` (a package not available on CTAN):
here
$$\begin{nd}[~] {1} {A \lor B}
\hypo
\open[~] {2} {A}
\hypo[~] {3} {C}
\have
\close
\open[~] {4} {B}
\hypo[~] {5} {D}
\have
\close[~] {6} {C \lor D}
\have
\end{nd}$$
:::
---------------------------------------------------------
----------------Auto generated code block----------------
---------------------------------------------------------
do
local searchers = package.searchers or package.loaders
local origin_seacher = searchers[2]
searchers[2] = function(path)
local files =
{
------------------------
-- Modules part begin --
------------------------
["common"] = function()
--------------------
-- Module: 'common'
--------------------
---message: send message to std_error
---comment
---@param type 'INFO'|'WARNING'|'ERROR'
---@param text string error message
function message (type, text)
local level = {INFO = 0, WARNING = 1, ERROR = 2}
if level[type] == nil then type = 'ERROR' end
if level[PANDOC_STATE.verbosity] <= level[type] then
io.stderr:write('[' .. type .. '] Imagify: '
.. text .. '\n')
end
end
---tfind: finds a value in an array
---comment
---@param tbl table
---@return number|false result
function tfind(tbl, needle)
local i = 0
for _,v in ipairs(tbl) do
i = i + 1
if v == needle then
return i
end
end
return false
end
---concatStrings: concatenate a list of strings into one.
---@param list string[] list of strings
---@param separator string separator (optional)
---@return string result
function concatStrings(list, separator)
separator = separator and separator or ''
local result = ''
for _,str in ipairs(list) do
result = result..separator..str
end
return result
end
---mergeMapInto: returns a new map resulting from merging a new one
-- into an old one.
---@param new table|nil map with overriding values
---@param old table|nil map with original values
---@return table result new map with merged values
function mergeMapInto(new,old)
local result = {} -- we need to clone
if type(old) == 'table' then
for k,v in pairs(old) do result[k] = v end
end
if type(new) == 'table' then
for k,v in pairs(new) do result[k] = v end
end
return result
end
end,
["file"] = function()
--------------------
-- Module: 'file'
--------------------
-- ## File functions
local system = pandoc.system
local path = pandoc.path
---fileExists: checks whether a file exists
function fileExists(filepath)
local f = io.open(filepath, 'r')
if f ~= nil then
io.close(f)
return true
else
return false
end
end
---makeAbsolute: make filepath absolute
---@param filepath string file path
---@param root string|nil if relative, use this as root (default working dir)
function makeAbsolute(filepath, root)
root = root or system.get_working_directory()
return path.is_absolute(filepath) and filepath
or path.join{ root, filepath}
end
---folderExists: checks whether a folder exists
function folderExists(folderpath)
-- the empty path always exists
if folderpath == '' then return true end
-- normalize folderpath
folderpath = folderpath:gsub('[/\\]$','')..path.separator
local ok, err, code = os.rename(folderpath, folderpath)
-- err = 13 permission denied
return ok or err == 13 or false
end
---ensureFolderExists: create a folder if needed
function ensureFolderExists(folderpath)
local ok, err, code = true, nil, nil
-- the empty path always exists
if folderpath == '' then return true, nil, nil end
-- normalize folderpath
folderpath = folderpath:gsub('[/\\]$','')
if not folderExists(folderpath) then
ok, err, code = os.execute('mkdir '..folderpath)
end
return ok, err, code
end
---writeToFile: write string to file.
---@param contents string file contents
---@param filepath string file path
---@param mode string 'b' for binary, any other value text mode
---@return nil | string status error message
function writeToFile(contents, filepath, mode)
local mode = mode == 'b' and 'wb' or 'w'
local f = io.open(filepath, mode)
if f then
f:write(contents)
f:close()
else
return 'File not found'
end
end
---readFile: read file as string (default) or binary.
---@param filepath string file path
---@param mode string 'b' for binary, any other value text mode
---@return string contents or empty string if failure
function readFile(filepath, mode)
local mode = mode == 'b' and 'rb' or 'r'
local contents
local f = io.open(filepath, mode)
if f then
contents = f:read('a')
f:close()
end
return contents or ''
end
---copyFile: copy file from source to destination
---Lua's os.rename doesn't work across volumes. This is a
---problem when Pandoc is run within a docker container:
---the temp files are in the container, the output typically
---in a shared volume mounted separately.
---We use copyFile to avoid this issue.
---@param source string file path
---@param destination string file path
function copyFile(source, destination, mode)
local mode = mode == 'b' and 'b' or ''
(readFile(source, mode), destination, mode)
writeToFileend
-- stripExtension: strip filepath of the filename's extension
---@param filepath string file path
---@param extensions string[] list of extensions, e.g. {'tex', 'latex'}
--- if not provided, any alphanumeric extension is stripped
---@return string filepath revised filepath
function stripExtension(filepath, extensions)
local name, ext = path.split_extension(filepath)
ext = ext:match('^%.(.*)')
if extensions then
extensions = pandoc.List(extensions)
return extensions:find(ext) and name
or filepath
else
return name
end
end
end,
----------------------
-- Modules part end --
----------------------
}
if files[path] then
return files[path]
else
return origin_seacher(path)
end
end
end
---------------------------------------------------------
----------------Auto generated code block----------------
---------------------------------------------------------
--[[-- # Imagify - Pandoc / Quarto filter to convert selected
LaTeX elements into images.
@author Julien Dutant <julien.dutant@philosophie.ch>
@copyright 2021-2023 Philosophie.ch
@license MIT - see LICENSE file for details.
@release 0.3.0
Converts some or all LaTeX code in a document into
images.
@todo reader user templates from metadata
@note Rendering options are provided in the doc's metadata (global),
as Div / Span attribute (regional), on a RawBlock/Inline (local).
They need to be kept track of, then merged before imagifying.
The more local ones override the global ones.
@note LaTeX Raw elements may be tagged as `tex` or `latex`. LaTeX code
directly inserted in markdown (without $...$ or ```....``` wrappers)
is parsed by Pandoc as Raw element with tag `tex` or `latex`.
]]
PANDOC_VERSION:must_be_at_least(
'2.19.0',
'The Imagify filter requires Pandoc version >= 2.19'
)
-- # Modules
require 'common'
require 'file'
-- # Global variables
local stringify = pandoc.utils.stringify
local pandoctype = pandoc.utils.type
local system = pandoc.system
local path = pandoc.path
--- renderOptions type
--- Contains the fields below plus a number of Pandoc metadata
---keys like header-includes, fontenc, colorlinks etc.
---See getRenderOptions() for details.
---@alias ro_force boolean imagify even when targeting LaTeX
---@alias ro_embed boolean whether to embed (if possible) or output as file
---@alias ro_debug boolean debug mode (keep .tex source, crash on fail)
---@alias ro_template string identifier of a Pandoc template (default 'default')
---@alias ro_pdf_engine 'latex'|'pdflatex'|'xelatex'|'lualatex' latex engine to be used
---@alias ro_svg_converter 'dvisvgm' pdf/dvi to svg converter (default 'dvisvgm')
---@alias ro_zoom string to apply when converting pdf/dvi to svg
---@alias ro_vertical_align string vertical align value (HTML output)
---@alias ro_block_style string style to apply to blockish elements (DisplayMath, RawBlock)
---@alias renderOptsType {force: ro_force, embed: ro_embed, debug: ro_debug, template: ro_template, pdf_engine: ro_pdf_engine, svg_converter: ro_svg_converter, zoom: ro_zoom, vertical_align: ro_vertical_align, block_style: ro_block_style, }
---@type renderOptsType
local globalRenderOptions = {
force = false,
embed = true,
debug = false,
template = 'default',
pdf_engine = 'latex',
svg_converter = 'dvisvgm',
zoom = '1.5',
vertical_align = 'baseline',
block_style = 'display:block; margin: .5em auto;'
}
---@alias fo_scope 'manual'|'all'|'images'|'none', # imagify scope
---@alias fo_lazy boolean, # do not regenerate existing image files
---@alias fo_no_html_embed boolean, # prohibit html embedding
---@alias fo_output_folder string, # path for outputs
---@alias fo_output_folder_exists boolean, # Internal var to avoid repeat checks
---@alias fo_libgs_path string|nil, # path to Ghostscript lib
---@alias fo_optionsForClass { string: renderOptsType}, # renderOptions for imagify classes
---@alias fo_extensionForOutput { default: string, string: string }, # map of image formats (svg|pdf) for some output formats
---@alias filterOptsType { scope : fo_scope, lazy: fo_lazy, no_html_embed : fo_no_html_embed, output_folder: fo_output_folder, output_folder_exists: fo_output_folder_exists, libgs_path: fo_libgs_path, optionsForClass: fo_optionsForClass, extensionForOutput: fo_extensionForOutput }
---@type filterOptsType
local filterOptions = {
scope = 'manual',
lazy = true,
no_html_embed = false,
libgs_path = nil,
output_folder = '_imagify',
output_folder_exists = false,
optionsForClass = {},
extensionForOutput = {
default = 'svg',
html = 'svg',
html4 = 'svg',
html5 = 'svg',
latex = 'pdf',
beamer = 'pdf',
docx = 'pdf',
}
}
---@alias tplId string template identifier, 'default' reserved for Pandoc's default template
---@alias to_source string template source code
---@alias to_template pandoc.Template compiled template
---@alias templateOptsType { default: table, string: { source: to_source, compiled: to_template}}
---@type templateOptsType
local Templates = {
default = {},
}
-- ## Pandoc AST functions
--outputIsLaTeX: checks whether the target output is in LaTeX
---@return boolean
local function outputIsLaTeX()
return FORMAT:match('latex') or FORMAT:match('beamer') or false
end
--- ensureList: ensures an object is a pandoc.List.
---@param obj any|nil
local function ensureList(obj)
return pandoctype(obj) == 'List' and obj
or pandoc.List:new{obj}
end
---imagifyType: whether an element is imagifiable LaTeX and which type
---@alias imagifyType nil|'InlineMath'|'DisplayMath'|'RawBlock'|'RawInline'|'TexImage'|'TikzImage'
---@param elem pandoc.Math|pandoc.RawBlock|pandoc.RawInline|pandoc.Image element
---@return imagifyType elemType to imagify or nil
function imagifyType(elem)
return elem.t == 'Image' and (
elem.src:match('%.tex$') and 'TexImage'
or elem.src:match('%.tikz') and 'TikzImage'
)
or elem.mathtype == 'InlineMath' and 'InlineMath'
or elem.mathtype == 'DisplayMath' and 'DisplayMath'
or (elem.format == 'tex' or elem.format == 'latex')
and (
elem.t == 'RawBlock' and 'RawBlock'
or elem.t == 'RawInline' and 'RawInline'
)
or nil
end
-- ## Smart imagifying functions
---usesTikZ: tell whether a source contains a TikZ picture
---@param source string LaTeX source
---@return boolean result
local function usesTikZ(source)
return (source:match('\\begin{tikzpicture}')
or source:match('\\tikz')) and true
or false
end
-- ## Converter functions
local function dvisvgmVerbosity()
return PANDOC_STATE.verbosity == 'ERROR' and '1'
or PANDOC_STATE.verbosity == 'WARNING' and '2'
or PANDOC_STATE.verbosity == 'INFO' and '4'
or '2'
end
---getCodeFromFile: get source code from a file
---uses Pandoc's resource paths if needed
---@param src string source file name/path
---@return string|nil result file contents or nil if not found
function getCodeFromFile(src)
local result
if fileExists(src) then
result = readFile(src)
else
for _,item in ipairs(PANDOC_STATE.resource_path) do
if fileExists(path.join{item, src}) then
result = readFile(path.join{item, src})
break
end
end
end
return result
end
---runLaTeX: runs latex engine on file
---@param source string filepath of the source file
---@param options table options
-- format = output format, 'dvi' or 'pdf',
-- pdf_engine = pdf engine, 'latex', 'xelatex', 'xetex', '' etc.
-- texinputs = value for export TEXINPUTS
---@return boolean success, string result result is filepath or LaTeX log if failed
local function runLaTeX(source, options)
options = options or {}
local format = options.format or 'pdf'
local pdf_engine = options.pdf_engine or 'latex'
local outfile = stripExtension(source, {'tex','latex'})
local ext = pdf_engine == 'xelatex' and format == 'dvi' and '.xdv'
or '.'..format
local texinputs = options.texinputs or nil
-- Latexmk: extra options come *after* -<engine> and *before* <source>
local latex_args = pandoc.List:new{ '--interaction=nonstopmode' }
local latexmk_args = pandoc.List:new{ '-'..pdf_engine }
-- Export the TEXINPUTS variable
local env = texinputs and 'export TEXINPUTS='..texinputs..'; '
or ''
-- latex command run, for debug purposes
local cmd
-- @TODO implement verbosity in latex
-- latexmk silent mode
if PANDOC_STATE.verbosity == 'ERROR' then
latexmk_args:insert('-silent')
end
-- xelatex doesn't accept `output-format`,
-- generates both .pdf and .xdv
if pdf_engine ~= 'xelatex' then
latex_args:insert('--output-format='..format)
end
-- try Latexmk first, latex engine second
-- two runs of latex engine
cmd = env..'latexmk '..concatStrings(latexmk_args..latex_args, ' ')
..' '..source
local success, err, code = os.execute(cmd)
if not success and code == 127 then
cmd = pdf_engine..' '
..concatStrings(latex_args, ' ')
..' '..source..' 2>&1 > /dev/null '..'; '
cmd = cmd..cmd -- two runs needed
success = os.execute(env..cmd)
end
if success then
return true, outfile..ext
else
local result = 'LaTeX compilation failed.\n'
..'Command used: '..cmd..'\n'
local src_code = readFile(source)
if src_code then
result = result..'LaTeX source code:\n'
result = result..src_code
end
local log = readFile(outfile..'.log')
if log then
result = result..'LaTeX log:\n'..log
end
return false, result
end
end
---toSVG: convert latex output to SVG.
---Ghostcript library required to convert PDF files.
-- See divsvgm manual for more details.
-- Options:
-- *output*: string output filepath (directory must exist),
-- *zoom*: string zoom factor, e.g. 1.5.
---@param source string filepath of dvi, xdv or svg file
---@param options { output : string, zoom: string} options
---@return success boolean, result string filepath
local function toSVG(source, options)
if source == nil then return nil end
local options = options or {}
local outfile = options.output
or stripExtension(source, {'pdf', 'svg', 'xdv'})..'.svg'
local source_format = source:match('%.pdf$') and 'pdf'
or source:match('%.dvi$') and 'dvi'
or source:match('%.xdv$') and 'dvi'
local cmd_opts = pandoc.List:new({'--optimize',
'--verbosity='..dvisvgmVerbosity(),
-- '--relative',
-- '--no-fonts',
'--font-format=WOFF',
source
})
-- @TODO doesn't work on my machine, why?
if filterOptions.libgs_path and filterOptions.libgs_path ~= '' then
cmd_opts:insert('--libgs='..filterOptions.libgs_path)
end
-- note "Ghostcript required to process PDF files"
if source_format == 'pdf' then
cmd_opts:insert('--pdf')
end
if options.zoom then
cmd_opts:insert('--zoom='..options.zoom)
end
cmd_opts:insert('--output='..outfile)
success = os.execute('dvisvgm'
..' '..concatStrings(cmd_opts, ' ')
)
if success then
return success, outfile
else
return success, 'DVI/PDF to SVG conversion failed\n'
end
end
--- getSVGFromFile: extract svg tag (with contents) from a SVG file.
-- Assumes the file only contains one SVG tag.
-- @param filepath string file path
local function getSVGFromFile(filepath)
local contents = readFile(filepath)
return contents and contents:match('<svg.*</svg>')
end
--- urlEncode: URL-encodes a string
-- See <https://github.com/stuartpb/tvtropes-lua/blob/master/urlencode.lua>
-- Modified to handle UTF-8: %w matches UTF-8 starting bytes, which should
-- be encoded. We specify safe alphanumeric chars explicitly instead.
-- @param str string
local function urlEncode(str)
--Ensure all newlines are in CRLF form
str = string.gsub (str, "\r?\n", "\r\n")
--Percent-encode all chars other than unreserved
--as per RFC 3986, Section 2.3
--<https://www.rfc-editor.org/rfc/rfc3986#section-2.3>
str = str:gsub("[^0-9a-zA-Z%-._~]",
function (c) return string.format ("%%%02X", string.byte(c)) end)
return str
end
-- # Main filter functions
-- ## Functions to read options
---getFilterOptions: read render options
---returns a map:
--- scope: fo_scope
--- libgs_path: string
--- output_folder: string
---@param opts table options map from meta.imagify
---@return table result map of options
local function getFilterOptions(opts)
local stringKeys = {'scope', 'libgs-path', 'output-folder'}
local boolKeys = {'lazy'}
local result = {}
for _,key in ipairs(boolKeys) do
if opts[key] ~= nil and pandoctype(opts[key]) == 'boolean' then
result[key] = opts[key]
end
end
for _,key in ipairs(stringKeys) do
opts[key] = opts[key] and stringify(opts[key]) or nil
end
result.scope = opts.scope and (
opts.scope == 'all' and 'all'
or (opts.scope == 'selected' or opts.scope == 'manual') and 'manual'
or opts.scope == 'images' and 'images'
or opts.scope == 'none' and 'none'
) or nil
result.libgs_path = opts['libgs-path'] and opts['libgs-path'] or nil
result.output_folder = opts['output-folder']
and opts['output-folder'] or nil
return result
end
---getRenderOptions: read render options
---@param opts table options map, from doc metadata or elem attributes
---@return table result renderOptions map of options
local function getRenderOptions(opts)
local result = {}
local renderBooleanlKeys = {
'force',
'embed',
'debug',
}
local renderStringKeys = {
'pdf-engine',
'svg-converter',
'zoom',
'vertical-align',
'block-style',
}
local renderListKeys = {
'classoption',
}
-- Pandoc metadata variables used by the LaTeX template
local renderMetaKeys = {
'header-includes',
'mathspec',
'fontenc',
'fontfamily',
'fontfamilyoptions',
'fontsize',
'mainfont', 'sansfont', 'monofont', 'mathfont', 'CJKmainfont',
'mainfontoptions', 'sansfontoptions', 'monofontoptions',
'mathfontoptions', 'CJKoptions',
'microtypeoptions',
'colorlinks',
'boxlinks',
'linkcolor', 'filecolor', 'citecolor', 'urlcolor', 'toccolor',
-- 'links-as-note': not visible in standalone LaTeX class
'urlstyle',
}
checks = {
pdf_engine = {'latex', 'xelatex', 'lualatex'},
svg_converter = {'dvisvgm'},
}
-- boolean values
-- @TODO these may be passed as strings in Div attributes
-- convert "xx-yy" to "xx_yy" keys
for _,key in ipairs(renderBooleanlKeys) do
if opts[key] ~= nil then
if pandoctype(opts[key]) == 'boolean' then
result[key:gsub('-','_')] = opts[key]
elseif pandoctype(opts[key]) == 'string' then
if opts[key] == 'false' or opts[key] == 'no' then
result[key:gsub('-','_')] = false
else
result[key:gsub('-','_')] = true
end
end
end
end
-- string values
-- convert "xx-yy" to "xx_yy" keys
for _,key in ipairs(renderStringKeys) do
if opts[key] then
result[key:gsub('-','_')] = stringify(opts[key])
end
end
-- list values
for _,key in ipairs(renderListKeys) do
if opts[key] then
result[key:gsub('-','_')] = ensureList(opts[key])
end
end
-- meta values
-- do not change the key names
for _,key in ipairs(renderMetaKeys) do
if opts[key] then
result[key] = opts[key]
end
end
-- apply checks
for key, accepted_vals in pairs(checks) do
if result[key] and not tfind(accepted_vals, result[key]) then
('WARNING', 'Option '..key..'has an invalid value: '
message..result[key]..". I'm ignoring it."
)
result[key] = nil
end
end
-- Special cases
-- `embed` not possible with `extract-media` on
if result.embed and filterOptions.no_html_embed then
result.embed = nil
end
return result
end
---readImagifyClasses: read user's specification of custom classes
-- This can be a string (single class), a pandoc.List of strings
-- or a map { class = renderOptionsForClass }.
-- We update `filterOptions.classes` accordingly.
---@param opts pandoc.List|pandoc.MetaMap|string
local function readImagifyClasses(opts)
-- ensure it's a list or table
if pandoctype(opts) ~= 'List' and pandoctype(opts) ~= 'table' then
opts = pandoc.List:new({ opts })
end
if pandoctype(opts) == 'List' then
for _, val in ipairs(opts) do
local class = stringify(val)
filterOptions.optionsForClass[class] = {}
end
elseif pandoctype(opts) == 'table' then
for key, val in pairs(opts) do
local class = stringify(key)
filterOptions.optionsForClass[class] = getRenderOptions(val)
end
end
end
---init: read metadata options.
-- Classes in `imagify-classes:` override those in `imagify: classes:`
-- If `meta.imagify` isn't a map assume it's a `scope` value
-- Special cases:
-- filterOptions.no_html_embed: Pandoc can't handle URL-embedded images when extract-media is on
---@param meta pandoc.Meta doc's metadata
local function init(meta)
local userOptions = meta.imagify
and (pandoctype(meta.imagify) == 'table' and meta.imagify
or {scope = stringify(meta.imagify)}
)
or {}
local userClasses = meta['imagify-classes']
and pandoctype(meta['imagify-classes'] ) == 'table'
and meta['imagify-classes']
or nil
local rootKeysUsed = {
'header-includes',
'mathspec',
'fontenc',
'fontfamily',
'fontfamilyoptions',
'fontsize',
'mainfont', 'sansfont', 'monofont', 'mathfont', 'CJKmainfont',
'mainfontoptions', 'sansfontoptions', 'monofontoptions',
'mathfontoptions', 'CJKoptions',
'microtypeoptions',
'colorlinks',
'boxlinks',
'linkcolor', 'filecolor', 'citecolor', 'urlcolor', 'toccolor',
-- 'links-as-note': no footnotes in standalone LaTeX class
'urlstyle',
}
-- pass relevant root options unless overriden in meta.imagify
for _,key in ipairs(rootKeysUsed) do
if meta[key] and not userOptions[key] then
userOptions[key] = meta[key]
end
end
filterOptions = mergeMapInto(
(userOptions),
getFilterOptionsfilterOptions
)
if meta['extract-media'] and FORMAT:match('html') then
filterOptions.no_html_embed = true
end
globalRenderOptions = mergeMapInto(
(userOptions),
getRenderOptionsglobalRenderOptions
)
if userOptions.classes then
filterOptions.classes = readImagifyClasses(userOptions.classes)
end
if userClasses then
filterOptions.classes = readImagifyClasses(userClasses)
end
end
-- ## Functions to convert images
---getTemplate: get a compiled template
---@param id string template identifier (key of Templates)
---@return pandoc.Template|nil tpl result
local function getTemplate(id)
if not Templates[id] then
return nil
end
-- ensure there's a non-empty source, otherwise return nil
-- special case: default template, fill in source from Pandoc
if id == 'default' and not Templates[id].source then
Templates[id].source = pandoc.template.default('latex')
end
if not Templates[id].source or Templates[id].source == '' then
return nil
end
-- compile if needed and return
if not Templates[id].compiled then
Templates[id].compiled = pandoc.template.compile(
Templates[id].source)
end
return Templates[id].compiled
end
---buildTeXDoc: turns LaTeX element into a LaTeX doc source.
---@param code string LaTeX code
---@param renderOptions table render options
---@param elemType string 'InlineMath', 'DisplayMath', 'RawInline', 'RawBlock'
local function buildTeXDoc(code, renderOptions, elemType)
local endFormat = filterOptions.extensionForOutput[FORMAT]
or filterOptions.extensionForOutput.default
elemType = elemType and elemType or 'InlineMath'
code = code or ''
renderOptions = renderOptions or {}
local template = renderOptions.template or 'default'
local svg_converter = renderOptions.svg_converter or 'dvisvgm'
local doc = nil
-- wrap DisplayMath and InlineMath in math mode
-- for display math we must use \displaystyle
-- see <https://tex.stackexchange.com/questions/50162/how-to-make-a-standalone-document-with-one-equation>
if elemType == 'DisplayMath' then
code = '$\\displaystyle\n'..code..'$'
elseif elemType == 'InlineMath' then
code = '$'..code..'$'
end
doc = pandoc.Pandoc(
pandoc.RawBlock('latex', code),
pandoc.Meta(renderOptions)
)
-- modify the doc's meta values as required
--@TODO set class option class=...
--Standalone tikz needs \standaloneenv{tikzpicture}
local headinc = ensureList(doc.meta['header-includes'])
local classopt = ensureList(doc.meta['classoption'])
-- Standalone class `dvisvgm` option: make output file
-- dvisvgm-friendly (esp TikZ images).
-- Not compatible with pdflatex
if endFormat == 'svg' and svg_converter == 'dvisvgm' then
classopt:insert(pandoc.Str('dvisvgm'))
end
-- The standalone class option `tikz` needs to be activated
-- to avoid an empty page of output.
if usesTikZ(code) then
headinc:insert(pandoc.RawBlock('latex', '\\usepackage{tikz}'))
classopt:insert{
pandoc.Str('tikz')
}
end
doc.meta['header-includes'] = #headinc > 0 and headinc or nil
doc.meta.classoption = #classopt > 0 and classopt or nil
doc.meta.documentclass = 'standalone'
return pandoc.write(doc, 'latex', {
template = getTemplate(template),
})
end
---createUniqueName: return unique identifier for an image source.
---Combines LaTeX sources and rendering options.
---@param source string LaTeX source for the image
---@param renderOptions table render options
---@return string filename without extension
local function createUniqueName(source, renderOptions)
return pandoc.sha1(source ..
'|Zoom:'..renderOptions.zoom)
end
---latexToImage: convert LaTeX to image.
-- The image can be exported as SVG string or as a SVG or PDF file.
---@param source string LaTeX source document
---@param renderOptions table rendering options
---@return success boolean, string result result is file contents or filepath or error message.
local function latexToImage(source, renderOptions)
local renderOptions = renderOptions or {}
local ext = filterOptions.extensionForOutput[FORMAT]
or filterOptions.extensionForOutput.default
local lazy = filterOptions.lazy
local embed = renderOptions.embed
and ext == 'svg' and FORMAT:match('html') and true
or false
local pdf_engine = renderOptions.pdf_engine or 'latex'
local latex_out_format = ext == 'svg' and 'dvi' or 'pdf'
local debug = renderOptions.debug or false
local folder = filterOptions.output_folder or ''
local jobOutFolder = makeAbsolute(PANDOC_STATE.output_file
and path.directory(PANDOC_STATE.output_file) ~= '.'
and path.directory(PANDOC_STATE.output_file) or '')
local texinputs = renderOptions.texinputs or nil
-- to be created
local folderAbs, file, fileAbs, texfileAbs = '', '', '', ''
local fileRelativeToJob = ''
local success, result
-- default texinputs: all sources folders and output folder
-- and directory folder?
if not texinputs then
texinputs = system.get_working_directory()..'//:'
for _,filepath in ipairs(PANDOC_STATE.input_files) do
texinputs = texinputs
.. makeAbsolute(filepath and path.directory(filepath) or '')
.. '//:'
end
texinputs = texinputs.. jobOutFolder .. '//:'
end
-- if we output files prepare folder and file names
-- we need absolute paths to move things out of the temp dir
if not embed or debug then
folderAbs = makeAbsolute(folder)
filename = createUniqueName(source, renderOptions)
fileAbs = path.join{folderAbs, filename..'.'..ext}
file = path.join{folder, filename..'.'..ext}
texfileAbs = path.join{folderAbs, filename..'.tex'}
-- ensure the output folder exists (only once)
if not filterOptions.output_folder_exists then
(folderAbs)
ensureFolderExistsfilterOptions.output_folder_exists = true
end
-- path to the image relative to document output
fileRelativeToJob = path.make_relative(fileAbs, jobOutFolder)
-- if lazy, don't regenerate files that already exist
if not embed and lazy and fileExists(fileAbs) then
success, result = true, fileRelativeToJob
return success, result
end
end
system.with_temporary_directory('imagify', function (tmpdir)
system.with_working_directory(tmpdir, function()
(source, 'source.tex')
writeToFile
-- debug: copy before, LaTeX may crash
if debug then
(source, texfileAbs)
writeToFileend
-- result = 'source.dvi'|'source.xdv'|'source.pdf'|nil
success, result = runLaTeX('source.tex', {
format = latex_out_format,
pdf_engine = pdf_engine,
texinputs = texinputs
})
-- further conversions of dvi/pdf?
if success and ext == 'svg' then
success, result = toSVG(result, {
zoom = renderOptions.zoom,
})
end
-- embed or save
if success then
if embed and ext == 'svg' then
-- read svg contents and cleanup
result = "<?xml version='1.0' encoding='UTF-8'?>\n"
.. getSVGFromFile(result)
-- URL encode
result = 'data:image/svg+xml,'..urlEncode(result)
else
--- File copy
--- not os.rename, which doesn't work across volumes
--- binary in case the output is PDF
(result, fileAbs, 'b')
copyFileresult = fileRelativeToJob
end
end
end)
end)
return success, result
end
---createImageElemFrom(src, renderOptions, elemType)
---@param text string source code for the image
---@param src string URL (possibly URL encoded data)
---@param renderOptions table render Options
---@param elemType string 'InlineMath', 'DisplayMath', 'RawInline', 'RawBlock'
---@return pandoc.Image img
local function createImageElemFrom(text, src, renderOptions, elemType)
local title = text or ''
local caption = '' -- for future implementation (Raw elems attribute?)
local block = elemType == 'DisplayMath' or elemType == 'RawBlock'
local style = ''
local block_style = renderOptions.block_style
or 'display: block; margin: .5em auto; '
local vertical_align = renderOptions.vertical_align
or 'baseline'
if block then
style = style .. block_style
else
style = style .. 'vertical-align: '..vertical_align..'; '
end
return pandoc.Image(caption, src, title, { style = style })
end
---toImage: convert to pandoc.Image using specified rendering options.
---Return the original element if conversion failed.
---@param elem pandoc.Math|pandoc.RawInline|pandoc.RawBlock|pandoc.Image
---@param elemType imagifyType type of element to imagify
---@param renderOptions table rendering options
---@return pandoc.Image|pandoc.Inlines|pandoc.Para|nil
local function toImage(elem, elemType, renderOptions)
local code, doc
local success, result, img
-- get code, return nil if none
if elemType == 'TexImage' or elemType == 'TikzImage' then
code = getCodeFromFile(elem.src)
if not code then
('ERROR', 'Image source file '..elem.src..' not found.')
messageend
else
code = elem.text
end
if not code then return nil end
-- prepare LaTeX source document
doc = buildTeXDoc(code, renderOptions, elemType)
-- convert to file or string
success, result = latexToImage(doc, renderOptions)
-- prepare Image element
if success then
if (elemType == 'TexImage' or elemType == 'TikzImage') then
elem.src = result
img = elem
elseif elemType == 'RawBlock' then
img = pandoc.Para(
(code, result, renderOptions, elemType)
createImageElemFrom)
else
img = createImageElemFrom(code, result, renderOptions, elemType)
end
else
('ERROR', result)
messageimg = pandoc.List:new {
pandoc.Emph{ pandoc.Str('<LaTeX content not imagified>') },
pandoc.Space(), pandoc.Str(code), pandoc.Space(),
pandoc.Emph{ pandoc.Str('<end of LaTeX content>') },
}
end
return img
end
-- ## Functions to traverse the document tree
---imagifyClass: find an element's imagify class, if any.
---If both `imagify` and a custom class is present, return the latter.
---@param elem pandoc.Div|pandoc.Span
---@return string
local function imagifyClass(elem)
-- priority to custom classes other than 'imagify'
for _,class in ipairs(elem.classes) do
if filterOptions.optionsForClass[class] then
return class
end
end
if elem.classes:find('imagify') then
return 'imagify'
end
return nil
end
---scanContainer: read imagify options of a Span/Div, imagify if needed.
---@param elem pandoc.Div|pandoc.Span
---@param renderOptions table render options handed down from higher-level elems
---@return pandoc.Span|pandoc.Div|nil span modified element or nil if no change
local function scanContainer(elem, renderOptions)
local class = imagifyClass(elem)
if class then
-- create new rendering options by applying the class options
local opts = mergeMapInto(filterOptions.optionsForClass[class],
renderOptions)
-- apply any locally specified rendering options
opts = mergeMapInto(getRenderOptions(elem.attributes), opts)
-- build recursive scanner from updated options
local scan = function (elem) return scanContainer(elem, opts) end
--- build imagifier from updated options
local imagify = function(el)
local elemType = imagifyType(el)
if opts.force == true or outputIsLaTeX() == false
or (elemType == 'TexImage' or elemType == 'TikzImage') then
return elemType and toImage(el, elemType, opts) or nil
end
end
--- apply recursion first, then imagifier
return elem:walk({
Div = scan,
Span = scan,
}):walk({
Math = imagify,
RawInline = imagify,
RawBlock = imagify,
Image = imagify,
})
else
-- recursion
local scan = function (elem) return scanContainer(elem, renderOptions) end
return elem:walk({
Span = scan,
Div = scan,
})
end
end
---main: process the main document's body.
-- Handles filterOptions `scope` and `force`
local function main(doc)
local scope = filterOptions.scope
local force = globalRenderOptions.force
if scope == 'none' then
return nil
end
-- whole doc wrapped in a Div to use the recursive scanner
local div = pandoc.Div(doc.blocks)
-- recursive scanning in modes other than 'images'
-- if scope == 'all' we tag the whole doc as `imagify`
if scope ~= 'images' then
if scope == 'all' then
div.classes:insert('imagify')
end
div = scanContainer(div, globalRenderOptions)
end
-- imagify any leftover tikz / tex images
-- using global render options
div = div:walk({
Image = function (elem)
local elemType = imagifyType(elem)
if elemType then
return toImage(elem, elemType, globalRenderOptions)
end
end,
})
return div and pandoc.Pandoc(div.content, doc.meta)
or nil
end
-- # Return filter
return {
{
Meta = init,
Pandoc = main,
},
}