├── .editorconfig ├── .github └── workflows │ ├── ci.yaml │ └── website.yml ├── .gitignore ├── .tools ├── Dockerfile ├── anchorlinks.js ├── anchorlinks.lua └── docs.lua ├── LICENSE ├── Makefile ├── README.md ├── _extensions └── imagify │ ├── _extension.yml │ └── imagify.lua ├── docs └── manual.md ├── example-pandoc ├── example.md ├── example_defaults.yaml ├── example_meta.yaml ├── expected.html ├── figure1.tikz ├── figure2.tex ├── fitch.sty ├── website_defaults.yaml └── website_meta.yaml ├── example-quarto ├── example.qmd ├── figure1.tikz ├── figure2.tex └── fitch.sty ├── imagify.lua └── src ├── common.lua ├── file.lua └── main.lua /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [Makefile] 14 | indent_style = tab 15 | 16 | [*.lua] 17 | indent_style = space 18 | indent_size = 2 19 | # Code should stay below 80 characters per line. 20 | max_line_length = 80 21 | 22 | [*.md] 23 | # Text with 60 to 66 characters per line is said to be the easiest 24 | # to read. 25 | max_line_length = 66 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Run on all pull requests that change code. 5 | pull_request: 6 | paths-ignore: 7 | - 'README.md' 8 | - LICENSE 9 | - .editorconfig 10 | # Run every time a code change is pushed. 11 | push: 12 | paths-ignore: 13 | - 'README.md' 14 | - LICENSE 15 | - .editorconfig 16 | # Test if things still work each Tuesday morning at 5:39 UTC. 17 | # This way we will catch incompatible pandoc changes in a timely 18 | # manner. 19 | schedule: 20 | # At 5:39am each Tuesday 21 | - cron: '39 5 * * 2' 22 | 23 | jobs: 24 | test: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | fail-fast: true 28 | matrix: 29 | pandoc: 30 | - edge 31 | - latest 32 | # - 2.19.2 33 | 34 | container: 35 | image: pandoc/latex:${{ matrix.pandoc }} 36 | 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | 41 | - name: Install dependencies 42 | run: apk add make 43 | 44 | - name: Test 45 | run: make test SOURCE_DIR="" 46 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Publish Website 2 | 3 | # Allow one concurrent deployment 4 | concurrency: 5 | group: "pages" 6 | cancel-in-progress: true 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | on: 15 | push: 16 | branches: ['main'] 17 | 18 | jobs: 19 | website: 20 | runs-on: ubuntu-latest 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | - name: Setup Pages 28 | uses: actions/configure-pages@v5 29 | - name: Prepare custom pandoc/latex container 30 | run: | 31 | docker build -t custom-pandoc-latex -f .tools/Dockerfile . 32 | - name: Render Website 33 | run: | 34 | make -B website \ 35 | SOURCE_DIR="" \ 36 | PANDOC="docker run --rm --volume $(pwd):/data \ 37 | --user $(id -u):$(id -g) custom-pandoc-latex" 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | with: 41 | path: '_site' 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@main 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_site/ 2 | /.luarc.json 3 | 4 | /_imagify_files/ -------------------------------------------------------------------------------- /.tools/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pandoc/latex:latest 2 | 3 | # If pandoc/latex uses an older TeXLive, this needs to be changed 4 | RUN tlmgr option repository https://mirror.ctan.org/systems/texlive/tlnet \ 5 | && tlmgr install \ 6 | standalone \ 7 | dvisvgm 8 | 9 | ENTRYPOINT [ "/usr/local/bin/pandoc" ] -------------------------------------------------------------------------------- /.tools/anchorlinks.js: -------------------------------------------------------------------------------- 1 | /* anchorlinks.js: add anchor links before headers h2, h3 2 | */ 3 | document.addEventListener("DOMContentLoaded", function () { 4 | const headers = document.querySelectorAll("h2, h3" ); 5 | var css_needed = false; 6 | 7 | headers.forEach(header => { 8 | if (header.id) { 9 | css_needed = true 10 | // create anchor 11 | const anchor = document.createElement("a"); 12 | anchor.href = `#${header.id}`; 13 | anchor.textContent = "#"; 14 | anchor.className = "header-anchor"; 15 | // insert after header 16 | header.appendChild(anchor); 17 | } 18 | }); 19 | 20 | if (css_needed) { 21 | const style = document.createElement("style"); 22 | style.appendChild(document.createTextNode(` 23 | .header-anchor { 24 | display: inline-block; 25 | text-decoration: none; 26 | margin-left: 8px; 27 | font-size: 0.8em; 28 | opacity: 0.5; 29 | } 30 | 31 | .header-anchor:hover { 32 | opacity: 1; 33 | text-decoration: underline; 34 | } 35 | `)); 36 | document.head.appendChild(style); 37 | } 38 | }); -------------------------------------------------------------------------------- /.tools/anchorlinks.lua: -------------------------------------------------------------------------------- 1 | function Meta(meta) 2 | ptype = pandoc.utils.type 3 | if meta['header-includes'] then 4 | head_inc = meta['header-includes'] 5 | if ptype(head_inc) == 'table' then 6 | head_inc = pandoc.List:new(head_inc) 7 | else 8 | head_inc = pandoc.List:new{head_inc} 9 | end 10 | else 11 | head_inc = pandoc.List:new{} 12 | end 13 | head_inc:insert(pandoc.RawBlock('html', 14 | '')) 15 | meta['header-includes'] = head_inc 16 | return meta 17 | end 18 | 19 | 20 | -------------------------------------------------------------------------------- /.tools/docs.lua: -------------------------------------------------------------------------------- 1 | local path = require 'pandoc.path' 2 | local utils = require 'pandoc.utils' 3 | local stringify = utils.stringify 4 | 5 | local function read_file (filename) 6 | local fh = io.open(filename) 7 | local content = fh:read('*a') 8 | fh:close() 9 | return content 10 | end 11 | 12 | local formats_by_extension = { 13 | md = 'markdown', 14 | latex = 'latex', 15 | native = 'haskell', 16 | tex = 'latex', 17 | html = 'html', 18 | } 19 | 20 | local function sample_blocks (sample_file) 21 | local sample_content = read_file(sample_file) 22 | local extension = select(2, path.split_extension(sample_file)):sub(2) 23 | local format = formats_by_extension[extension] or extension 24 | local filename = path.filename(sample_file) 25 | 26 | local sample_attr = pandoc.Attr('', {format, 'sample'}) 27 | return { 28 | pandoc.Header(3, pandoc.Str(filename), {filename}), 29 | pandoc.CodeBlock(sample_content, sample_attr) 30 | } 31 | end 32 | 33 | local function result_block_raw(result_file, format) 34 | local result_content = read_file(result_file) 35 | 36 | return pandoc.CodeBlock(result_content, 37 | pandoc.Attr('', {format, 'sample'}) 38 | ) 39 | end 40 | 41 | local function result_block_html(filename) 42 | local html = '' 47 | return pandoc.RawBlock('html', html) 48 | end 49 | 50 | local function result_blocks(result_file) 51 | local extension = select(2, path.split_extension(result_file)):sub(2) 52 | local format = formats_by_extension[extension] or extension 53 | local filename = path.filename(result_file) 54 | local result = pandoc.List:new({ 55 | pandoc.Header(3, 56 | pandoc.Link(pandoc.Str(filename), filename), 57 | {filename} 58 | ) 59 | }) 60 | 61 | if format == 'html' then 62 | result:insert(result_block_html(filename)) 63 | else 64 | result:insert(result_block_raw(result_file, format)) 65 | end 66 | 67 | return result 68 | end 69 | 70 | 71 | local function code_blocks (code_file) 72 | local code_content = read_file(code_file) 73 | local code_attr = pandoc.Attr(code_file, {'lua'}) 74 | return { 75 | pandoc.CodeBlock(code_content, code_attr) 76 | } 77 | end 78 | 79 | function Pandoc (doc) 80 | local meta = doc.meta 81 | local blocks = doc.blocks 82 | 83 | -- Set document title from README title. There should usually be just 84 | -- a single level 1 heading. 85 | blocks = blocks:walk{ 86 | Header = function (h) 87 | if h.level == 1 then 88 | meta.title = h.content 89 | return {} 90 | end 91 | end 92 | } 93 | 94 | -- Add the sample file as an example. 95 | blocks:extend{pandoc.Header(2, 'Example', pandoc.Attr('Example'))} 96 | blocks:extend(sample_blocks(stringify(meta['sample-file']))) 97 | blocks:extend(result_blocks(stringify(meta['result-file']))) 98 | 99 | -- Add the filter code. 100 | local code_file = stringify(meta['code-file']) 101 | blocks:extend{pandoc.Header(2, 'Code', pandoc.Attr('Code'))} 102 | blocks:extend{pandoc.Para{pandoc.Link(pandoc.Str(code_file), code_file)}} 103 | blocks:extend(code_blocks(code_file)) 104 | 105 | return pandoc.Pandoc(blocks, meta) 106 | end 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2021–2022 Julien Dutant and contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Name of the filter file, *with* `.lua` file extension. 2 | FILTER_FILE := $(wildcard *.lua) 3 | # Name of the filter, *without* `.lua` file extension 4 | FILTER_NAME = $(patsubst %.lua,%,$(FILTER_FILE)) 5 | # Source files 6 | # Optional: build the filter file from multiple sources. 7 | # *Do not comment out!* To deactivate it's safer to 8 | # define an empty SOURCE_MAIN variable with: 9 | # SOURCE_MAIN = 10 | SOURCE_DIR = src 11 | 12 | # Find source files 13 | SOURCE_FILES := $(wildcard $(SOURCE_DIR)/*.lua) 14 | SOURCE_MODULES := $(SOURCE_FILES:$(SOURCE_DIR)/%.lua=%) 15 | SOURCE_MODULES := $(SOURCE_MODULES:main=) 16 | SOURCE_MAIN = main 17 | 18 | # Pandoc example file 19 | TEST_DIR := example-pandoc 20 | TEST_SRC := $(TEST_DIR)/example.md 21 | TEST_DEFAULTS := $(TEST_DIR)/example_defaults.yaml 22 | TEST_FILES := $(TEST_SRC) \ 23 | $(wildcard $(TEST_DIR)/figure*.tikz) \ 24 | $(wildcard $(TEST_DIR)/figure*.tex) \ 25 | $(wildcard $(TEST_DIR)/test*.yaml) 26 | 27 | # Quarto test dir 28 | QUARTO_DIR := example-quarto 29 | QUARTO_FILES := $(wildcard $(QUARTO_DIR)/*.qmd) 30 | 31 | # Docs 32 | # Source and defaults for docs version of Pandoc example output 33 | DOCS_SRC = docs/manual.md 34 | DOCS_DEFAULTS := $(TEST_DIR)/website_defaults.yaml 35 | 36 | # Allow to use a different pandoc binary, e.g. when testing. 37 | PANDOC ?= pandoc 38 | # Allow to adjust the diff command if necessary 39 | DIFF ?= diff 40 | # Use a POSIX sed with ERE ('v' is specific to GNU sed) 41 | SED := sed $(shell sed v /dev/null 2>&1 && echo " --posix") -E 42 | 43 | # Pandoc formats for test outputs 44 | # Use make generate FORMAT=pdf to try PDF, 45 | # not included in the test as PDF files aren't identical on each run 46 | FORMAT ?= html 47 | 48 | # Directory containing the Quarto extension 49 | QUARTO_EXT_DIR = _extensions/$(FILTER_NAME) 50 | # The extension's name. Used in the Quarto extension metadata 51 | EXT_NAME = $(FILTER_NAME) 52 | # Current version, i.e., the latest tag. Used to version the quarto 53 | # extension. 54 | VERSION = $(shell git tag --sort=-version:refname --merged | head -n1 | \ 55 | sed -e 's/^v//' | tr -d "\n") 56 | ifeq "$(VERSION)" "" 57 | VERSION = 0.0.0 58 | endif 59 | 60 | # GitHub repository; used to setup the filter. 61 | REPO_PATH = $(shell git remote get-url origin | sed -e 's%.*github\.com[/:]%%') 62 | REPO_NAME = $(shell git remote get-url origin | sed -e 's%.*/%%') 63 | USER_NAME = $(shell git config user.name) 64 | 65 | ## Show available targets 66 | # Comments preceding "simple" targets (those which do not use macro 67 | # name or starts with underscore or dot) and introduced by two dashes 68 | # are used as their description. 69 | .PHONY: help 70 | help: 71 | @tabs 22 ; $(SED) -ne \ 72 | '/^## / h ; /^[^_.$$#][^ ]+:/ { G; s/^(.*):.*##(.*)/\1@\2/; P ; h ; }' \ 73 | $(MAKEFILE_LIST) | tr @ '\t' 74 | 75 | # 76 | # Build 77 | # 78 | # automatically triggered on `test` and `generate` 79 | 80 | ## Build the filter file from sources (requires luacc) 81 | # If SOURCE_DIR is not empty, combine source files with 82 | # luacc and replace the filter file. 83 | # ifeq is safer than ifdef (easier for the user to make 84 | # the variable empty than to make it undefined). 85 | ifneq ($(SOURCE_DIR), ) 86 | $(FILTER_FILE): _check_luacc $(SOURCE_FILES) 87 | @if [ -f $(QUARTO_EXT_DIR)/$(FILTER_FILE) ]; then \ 88 | luacc -o $(QUARTO_EXT_DIR)/$(FILTER_FILE) -i $(SOURCE_DIR) \ 89 | $(SOURCE_DIR)/$(SOURCE_MAIN) $(SOURCE_MODULES); \ 90 | if [ ! -L $(FILTER_FILE) ]; then \ 91 | ln -s $(QUARTO_EXT_DIR)/$(FILTER_FILE) $(FILTER_FILE); \ 92 | fi \ 93 | else \ 94 | luacc -o $(FILTER_FILE) -i $(SOURCE_DIR) \ 95 | $(SOURCE_DIR)/$(SOURCE_MAIN) $(SOURCE_MODULES); \ 96 | fi 97 | 98 | .PHONY: check_luacc 99 | _check_luacc: 100 | @if ! command -v luacc &> /dev/null ; then \ 101 | echo "LuaCC is needed to build the filter. Available on LuaRocks:"; \ 102 | echo " https://luarocks.org/modules/mihacooper/luacc"; \ 103 | exit; \ 104 | fi 105 | 106 | endif 107 | # 108 | # Pandoc Test 109 | # 110 | 111 | ## Test that running the filter on the sample input yields expected outputs 112 | # The automatic variable `$<` refers to the first dependency 113 | # (i.e., the filter file). 114 | # let `test` be a PHONY target so that it is run each time it's called. 115 | # NB: not piping into DIFF. We need to set a --output values for 116 | # paths relative to output to be computed as with the generate target. 117 | .PHONY: test 118 | test: $(FILTER_FILE) $(TEST_FILES) 119 | @for ext in $(FORMAT) ; do \ 120 | $(PANDOC) --defaults $(TEST_DEFAULTS) \ 121 | --to $$ext \ 122 | --output $(TEST_DIR)/out.$$ext; \ 123 | $(DIFF) $(TEST_DIR)/expected.$$ext $(TEST_DIR)/out.$$ext; \ 124 | rm $(TEST_DIR)/out.$$ext; \ 125 | done 126 | 127 | ## Generate the expected output 128 | # This target **must not** be a dependency of the `test` target, as that 129 | # would cause it to be regenerated on each run, making the test 130 | # pointless. 131 | .PHONY: generate 132 | generate: $(FILTER_FILE) $(TEST_FILES) 133 | @for ext in $(FORMAT) ; do \ 134 | echo Creating $(TEST_DIR)/expected.$$ext;\ 135 | $(PANDOC) --defaults $(TEST_DEFAULTS) \ 136 | --to $$ext \ 137 | --output $(TEST_DIR)/expected.$$ext ;\ 138 | done 139 | 140 | # 141 | # Quarto test 142 | # 143 | .PHONY: quarto 144 | quarto: $(FILTER_FILE) $(QUARTO_FILES) 145 | echo $(QUARTO_FILES) 146 | @for fmt in $(FORMAT) ; do \ 147 | quarto render $(QUARTO_FILES) --to $$fmt; \ 148 | done 149 | 150 | # 151 | # Website 152 | # 153 | 154 | ## Generate website files in _site 155 | .PHONY: website 156 | website: _site/index.html _site/$(FILTER_FILE) 157 | 158 | _site/index.html: $(DOCS_SRC) $(TEST_FILES) $(FILTER_FILE) .tools/docs.lua \ 159 | _site/output.html _site/style.css 160 | @mkdir -p _site 161 | @cp .tools/anchorlinks.js _site 162 | $(PANDOC) \ 163 | --standalone \ 164 | --lua-filter=.tools/docs.lua \ 165 | --lua-filter=.tools/anchorlinks.lua \ 166 | --lua-filter=$(FILTER_FILE) \ 167 | --metadata=sample-file:$(TEST_SRC) \ 168 | --metadata=result-file:_site/output.html \ 169 | --metadata=code-file:$(FILTER_FILE) \ 170 | --css=style.css \ 171 | --toc \ 172 | --output=$@ $< 173 | 174 | _site/style.css: 175 | @mkdir -p _site 176 | curl \ 177 | --output $@ \ 178 | 'https://cdn.jsdelivr.net/npm/water.css@2/out/water.min.css' 179 | 180 | _site/output.html: $(FILTER_FILE) $(TEST_SRC) $(DOCS_DEFAULTS) 181 | @mkdir -p _site 182 | $(PANDOC) \ 183 | --defaults=$(DOCS_DEFAULTS) \ 184 | --to=html \ 185 | --output=$@ 186 | 187 | _site/$(FILTER_FILE): $(FILTER_FILE) 188 | @mkdir -p _site 189 | (cd _site && ln -sf ../$< $<) 190 | 191 | # 192 | # Quarto extension 193 | # 194 | 195 | ## Creates or updates the quarto extension 196 | .PHONY: quarto-extension 197 | quarto-extension: $(QUARTO_EXT_DIR)/_extension.yml \ 198 | $(QUARTO_EXT_DIR)/$(FILTER_FILE) 199 | 200 | $(QUARTO_EXT_DIR): 201 | mkdir -p $@ 202 | 203 | # This may change, so re-create the file every time 204 | .PHONY: $(QUARTO_EXT_DIR)/_extension.yml 205 | $(QUARTO_EXT_DIR)/_extension.yml: _extensions/$(FILTER_NAME) 206 | @printf 'Creating %s\n' $@ 207 | @printf 'name: %s\n' "$(EXT_NAME)" > $@ 208 | @printf 'author: %s\n' "$(USER_NAME)" >> $@ 209 | @printf 'version: %s\n' "$(VERSION)" >> $@ 210 | @printf 'contributes:\n filters:\n - %s\n' $(FILTER_FILE) >> $@ 211 | 212 | # The filter file must be below the quarto _extensions folder: a 213 | # symlink in the extension would not work due to the way in which 214 | # quarto installs extensions. 215 | $(QUARTO_EXT_DIR)/$(FILTER_FILE): $(FILTER_FILE) $(QUARTO_EXT_DIR) 216 | if [ ! -L $(FILTER_FILE) ]; then \ 217 | mv $(FILTER_FILE) $(QUARTO_EXT_DIR)/$(FILTER_FILE) && \ 218 | ln -s $(QUARTO_EXT_DIR)/$(FILTER_FILE) $(FILTER_FILE); \ 219 | fi 220 | 221 | # 222 | # Release 223 | # 224 | 225 | ## Sets a new release (uses VERSION macro if defined) 226 | ## Usage make release VERSION=x.y.z 227 | .PHONY: release 228 | release: quarto-extension generate 229 | git commit -am "Release $(FILTER_NAME) $(VERSION)" 230 | git tag v$(VERSION) -m "$(FILTER_NAME) $(VERSION)" 231 | @echo 'Do not forget to push the tag back to github with `git push --tags`' 232 | 233 | # 234 | # Set up (normally used only once) 235 | # 236 | 237 | ## Update filter name 238 | .PHONY: update-name 239 | update-name: 240 | sed -i'.bak' -e 's/greetings/$(FILTER_NAME)/g' README.md 241 | sed -i'.bak' -e 's/greetings/$(FILTER_NAME)/g' test/test.yaml 242 | rm README.md.bak test/test.yaml.bak 243 | 244 | ## Set everything up (must be used only once) 245 | .PHONY: setup 246 | setup: update-name 247 | git mv greetings.lua $(REPO_NAME).lua 248 | @# Crude method to updates the examples and links; removes the 249 | @# template instructions from the README. 250 | sed -i'.bak' \ 251 | -e 's/greetings/$(REPO_NAME)/g' \ 252 | -e 's#tarleb/lua-filter-template#$(REPO_PATH)#g' \ 253 | -e '/^\* \*/,/^\* \*/d' \ 254 | README.md 255 | sed -i'.bak' -e 's/greetings/$(REPO_NAME)/g' test/test.yaml 256 | sed -i'.bak' -e 's/Albert Krewinkel/$(USER_NAME)/' LICENSE 257 | rm README.md.bak test/test.yaml.bak LICENSE.bak 258 | 259 | # 260 | # Helpers 261 | # 262 | 263 | ## Clean regenerable files 264 | .PHONY: clean 265 | clean: 266 | rm -rf _site/* 267 | rm -rf $(TEST_DIR)/expected.* 268 | rm -rf _imagify_files 269 | rm -f $(QUARTO_DIR)/example.html 270 | rm -rf $(QUARTO_DIR)/example_files 271 | rm -rf $(QUARTO_DIR)/_imagify_files 272 | 273 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Imagify - Pandoc/Quarto filter to convert selected LaTeX into images 2 | ==================================================================== 3 | 4 | [![GitHub build status][CI badge]][CI workflow] 5 | 6 | [CI badge]: https://img.shields.io/github/actions/workflow/status/dialoa/imagify/ci.yaml?branch=main 7 | [CI workflow]: https://github.com/dialoa/imagify/actions/workflows/ci.yaml 8 | 9 | Lua filter to convert some or all LaTeX and TikZ elements in a document into 10 | images. Also enables using `.tex`/`.tikz` files as image sources. 11 | 12 | Copyright 2021-2023 [Philosophie.ch][Philoch] under MIT License, see 13 | LICENSE file for details. 14 | 15 | Maintained by [Julien Dutant][JDutant]. 16 | 17 | Overview 18 | -------------------------------------------------------------------- 19 | 20 | Imagify turns selected LaTeX elements into images in non-LaTeX/PDF 21 | output. This is useful for web output if you use MathJAX but it 22 | doesn't handle all of your LaTeX code. 23 | 24 | It also allows you to use `.tex` or `.tikz` elements as 25 | image source files, which is useful to create cross-referenceable 26 | figures with [Pandoc-crossref][] or [Quarto][] without having 27 | to convert your LaTeX/TikZ code into images first. 28 | 29 | Imagify tries to match your document's LaTeX output settings 30 | (fonts, LaTeX packages, etc.). Its rendering options are otherwise 31 | extensively configurable, and different rendering options can 32 | be used for different elements. It can embed its images within HTML 33 | output or provide them as separate image files. 34 | 35 | Requirements: [Pandoc][] or [Quarto][], a LaTeX installation 36 | (with `dvisvgm` and, recommended, `latexmk`, which are included 37 | in common LaTeX distributions). 38 | 39 | Limitations 40 | ------------------------------------------------------------------ 41 | 42 | * So far designed with HTML output in mind, LaTeX to SVG conversion, 43 | and LaTeX/PDF outputs with separate `.tikz` or `.tex` files as 44 | image sources. 45 | In other output formats, the images will be inserted or linked as PDFs 46 | and may display in wrong sizes or not at all. 47 | * Embedding within HTML output isn't compatible with Pandoc's 48 | `extract-media` option. 49 | 50 | Use cases 51 | ------------------------------------------------------------------ 52 | 53 | The filter is used to produce the academic journal [Dialectica][]. 54 | See for instance [this 55 | article](https://dialectica.philosophie.ch/dialectica/article/download/20/66). 56 | 57 | Demonstration 58 | ------------------------------------------------------------------ 59 | 60 | See the manual's [example HTML output][ImagifyExample]. 61 | 62 | For a quick try-out, clone the repository and try: 63 | 64 | Pandoc 65 | : make generate && open example-pandoc/expected.html 66 | 67 | Or: 68 | 69 | Quarto 70 | : make quarto && open example-quarto/example.html 71 | 72 | You'll need either [Pandoc][] or [Quarto][] and a 73 | standard LaTeX distribution (that includes [dvisvgm][DvisvgmCTAN]). 74 | 75 | Installation and usage 76 | ------------------------------------------------------------------ 77 | 78 | See the [manual][ImagifyManual]. 79 | 80 | CI Tests 81 | -------- 82 | 83 | CI tests run on the 84 | [pandoc/latex][https://hub.docker.com/r/pandoc/latex] Docker image. 85 | The [Dockerfile](.tools/Dockerfile) installs two LaTeX packages not 86 | included from the current TeXLive repository at 87 | [https://mirror.ctan.org/systems/texlive/tlnet](CTAN). If the pandoc/latex Docker 88 | image is not yet updated to the latest TeXLive version, the Dockerfile should point to a suitable TexLive repository archive. 89 | 90 | Issues and contributing 91 | ------------------------------------------------------------------ 92 | 93 | Issues and PRs welcome. 94 | 95 | Acknowledgements 96 | ------------------------------------------------------------------ 97 | 98 | Development funded by [Philosophie.ch][Philoch]. 99 | 100 | [ImagifyManual]: https://dialoa.github.io/imagify/ 101 | [ImagifyExample]: https://dialoa.github.io/imagify/output.html 102 | [Dialectica]: https://dialectica.philosophie.ch 103 | [Philoch]: https://philosophie.ch 104 | [JDutant]: https://github.com/jdutant 105 | [Pandoc]: https://www.pandoc.org 106 | [Pandoc-crossref]: https://github.com/lierdakil/pandoc-crossref 107 | [Quarto]: https://quarto.org/ 108 | [QuartoDivFigure]: https://quarto.org/docs/authoring/cross-references-divs.html 109 | [DvisvgmCTAN]: https://ctan.org/pkg/dvisvgm 110 | 111 | -------------------------------------------------------------------------------- /_extensions/imagify/_extension.yml: -------------------------------------------------------------------------------- 1 | name: imagify 2 | author: Julien Dutant 3 | version: 0.3.0 4 | contributes: 5 | filters: 6 | - imagify.lua 7 | -------------------------------------------------------------------------------- /_extensions/imagify/imagify.lua: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------- 3 | ----------------Auto generated code block---------------- 4 | --------------------------------------------------------- 5 | 6 | do 7 | local searchers = package.searchers or package.loaders 8 | local origin_seacher = searchers[2] 9 | searchers[2] = function(path) 10 | local files = 11 | { 12 | ------------------------ 13 | -- Modules part begin -- 14 | ------------------------ 15 | 16 | ["common"] = function() 17 | -------------------- 18 | -- Module: 'common' 19 | -------------------- 20 | ---message: send message to std_error 21 | ---comment 22 | ---@param type 'INFO'|'WARNING'|'ERROR' 23 | ---@param text string error message 24 | function message (type, text) 25 | local level = {INFO = 0, WARNING = 1, ERROR = 2} 26 | if level[type] == nil then type = 'ERROR' end 27 | if level[PANDOC_STATE.verbosity] <= level[type] then 28 | io.stderr:write('[' .. type .. '] Imagify: ' 29 | .. text .. '\n') 30 | end 31 | end 32 | 33 | ---tfind: finds a value in an array 34 | ---comment 35 | ---@param tbl table 36 | ---@return number|false result 37 | function tfind(tbl, needle) 38 | local i = 0 39 | for _,v in ipairs(tbl) do 40 | i = i + 1 41 | if v == needle then 42 | return i 43 | end 44 | end 45 | return false 46 | end 47 | 48 | ---concatStrings: concatenate a list of strings into one. 49 | ---@param list string[] list of strings 50 | ---@param separator string separator (optional) 51 | ---@return string result 52 | function concatStrings(list, separator) 53 | separator = separator and separator or '' 54 | local result = '' 55 | for _,str in ipairs(list) do 56 | result = result..separator..str 57 | end 58 | return result 59 | end 60 | 61 | ---mergeMapInto: returns a new map resulting from merging a new one 62 | -- into an old one. 63 | ---@param new table|nil map with overriding values 64 | ---@param old table|nil map with original values 65 | ---@return table result new map with merged values 66 | function mergeMapInto(new,old) 67 | local result = {} -- we need to clone 68 | if type(old) == 'table' then 69 | for k,v in pairs(old) do result[k] = v end 70 | end 71 | if type(new) == 'table' then 72 | for k,v in pairs(new) do result[k] = v end 73 | end 74 | return result 75 | end 76 | 77 | end, 78 | 79 | ["file"] = function() 80 | -------------------- 81 | -- Module: 'file' 82 | -------------------- 83 | -- ## File functions 84 | 85 | local system = pandoc.system 86 | local path = pandoc.path 87 | 88 | ---fileExists: checks whether a file exists 89 | function fileExists(filepath) 90 | local f = io.open(filepath, 'r') 91 | if f ~= nil then 92 | io.close(f) 93 | return true 94 | else 95 | return false 96 | end 97 | end 98 | 99 | ---makeAbsolute: make filepath absolute 100 | ---@param filepath string file path 101 | ---@param root string|nil if relative, use this as root (default working dir) 102 | function makeAbsolute(filepath, root) 103 | root = root or system.get_working_directory() 104 | return path.is_absolute(filepath) and filepath 105 | or path.join{ root, filepath} 106 | end 107 | 108 | ---folderExists: checks whether a folder exists 109 | function folderExists(folderpath) 110 | 111 | -- the empty path always exists 112 | if folderpath == '' then return true end 113 | 114 | -- normalize folderpath 115 | folderpath = folderpath:gsub('[/\\]$','')..path.separator 116 | local ok, err, code = os.rename(folderpath, folderpath) 117 | -- err = 13 permission denied 118 | return ok or err == 13 or false 119 | end 120 | 121 | ---ensureFolderExists: create a folder if needed 122 | function ensureFolderExists(folderpath) 123 | local ok, err, code = true, nil, nil 124 | 125 | -- the empty path always exists 126 | if folderpath == '' then return true, nil, nil end 127 | 128 | -- normalize folderpath 129 | folderpath = folderpath:gsub('[/\\]$','') 130 | 131 | if not folderExists(folderpath) then 132 | ok, err, code = os.execute('mkdir '..folderpath) 133 | end 134 | 135 | return ok, err, code 136 | end 137 | 138 | ---writeToFile: write string to file. 139 | ---@param contents string file contents 140 | ---@param filepath string file path 141 | ---@param mode string 'b' for binary, any other value text mode 142 | ---@return nil | string status error message 143 | function writeToFile(contents, filepath, mode) 144 | local mode = mode == 'b' and 'wb' or 'w' 145 | local f = io.open(filepath, mode) 146 | if f then 147 | f:write(contents) 148 | f:close() 149 | else 150 | return 'File not found' 151 | end 152 | end 153 | 154 | ---readFile: read file as string (default) or binary. 155 | ---@param filepath string file path 156 | ---@param mode string 'b' for binary, any other value text mode 157 | ---@return string contents or empty string if failure 158 | function readFile(filepath, mode) 159 | local mode = mode == 'b' and 'rb' or 'r' 160 | local contents 161 | local f = io.open(filepath, mode) 162 | if f then 163 | contents = f:read('a') 164 | f:close() 165 | end 166 | return contents or '' 167 | end 168 | 169 | ---copyFile: copy file from source to destination 170 | ---Lua's os.rename doesn't work across volumes. This is a 171 | ---problem when Pandoc is run within a docker container: 172 | ---the temp files are in the container, the output typically 173 | ---in a shared volume mounted separately. 174 | ---We use copyFile to avoid this issue. 175 | ---@param source string file path 176 | ---@param destination string file path 177 | function copyFile(source, destination, mode) 178 | local mode = mode == 'b' and 'b' or '' 179 | writeToFile(readFile(source, mode), destination, mode) 180 | end 181 | 182 | -- stripExtension: strip filepath of the filename's extension 183 | ---@param filepath string file path 184 | ---@param extensions string[] list of extensions, e.g. {'tex', 'latex'} 185 | --- if not provided, any alphanumeric extension is stripped 186 | ---@return string filepath revised filepath 187 | function stripExtension(filepath, extensions) 188 | local name, ext = path.split_extension(filepath) 189 | ext = ext:match('^%.(.*)') 190 | 191 | if extensions then 192 | extensions = pandoc.List(extensions) 193 | return extensions:find(ext) and name 194 | or filepath 195 | else 196 | return name 197 | end 198 | end 199 | 200 | end, 201 | 202 | ---------------------- 203 | -- Modules part end -- 204 | ---------------------- 205 | } 206 | if files[path] then 207 | return files[path] 208 | else 209 | return origin_seacher(path) 210 | end 211 | end 212 | end 213 | --------------------------------------------------------- 214 | ----------------Auto generated code block---------------- 215 | --------------------------------------------------------- 216 | --[[-- # Imagify - Pandoc / Quarto filter to convert selected 217 | LaTeX elements into images. 218 | 219 | @author Julien Dutant 220 | @copyright 2021-2023 Philosophie.ch 221 | @license MIT - see LICENSE file for details. 222 | @release 0.3.0 223 | 224 | Converts some or all LaTeX code in a document into 225 | images. 226 | 227 | @todo reader user templates from metadata 228 | 229 | @note Rendering options are provided in the doc's metadata (global), 230 | as Div / Span attribute (regional), on a RawBlock/Inline (local). 231 | They need to be kept track of, then merged before imagifying. 232 | The more local ones override the global ones. 233 | @note LaTeX Raw elements may be tagged as `tex` or `latex`. LaTeX code 234 | directly inserted in markdown (without $...$ or ```....``` wrappers) 235 | is parsed by Pandoc as Raw element with tag `tex` or `latex`. 236 | ]] 237 | 238 | PANDOC_VERSION:must_be_at_least( 239 | '2.19.0', 240 | 'The Imagify filter requires Pandoc version >= 2.19' 241 | ) 242 | 243 | -- # Modules 244 | 245 | require 'common' 246 | require 'file' 247 | 248 | -- # Global variables 249 | 250 | local stringify = pandoc.utils.stringify 251 | local pandoctype = pandoc.utils.type 252 | local system = pandoc.system 253 | local path = pandoc.path 254 | 255 | --- renderOptions type 256 | --- Contains the fields below plus a number of Pandoc metadata 257 | ---keys like header-includes, fontenc, colorlinks etc. 258 | ---See getRenderOptions() for details. 259 | ---@alias ro_force boolean imagify even when targeting LaTeX 260 | ---@alias ro_embed boolean whether to embed (if possible) or output as file 261 | ---@alias ro_debug boolean debug mode (keep .tex source, crash on fail) 262 | ---@alias ro_template string identifier of a Pandoc template (default 'default') 263 | ---@alias ro_pdf_engine 'latex'|'pdflatex'|'xelatex'|'lualatex' latex engine to be used 264 | ---@alias ro_svg_converter 'dvisvgm' pdf/dvi to svg converter (default 'dvisvgm') 265 | ---@alias ro_zoom string to apply when converting pdf/dvi to svg 266 | ---@alias ro_vertical_align string vertical align value (HTML output) 267 | ---@alias ro_block_style string style to apply to blockish elements (DisplayMath, RawBlock) 268 | ---@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, } 269 | ---@type renderOptsType 270 | local globalRenderOptions = { 271 | force = false, 272 | embed = true, 273 | debug = false, 274 | template = 'default', 275 | pdf_engine = 'latex', 276 | svg_converter = 'dvisvgm', 277 | zoom = '1.5', 278 | vertical_align = 'baseline', 279 | block_style = 'display:block; margin: .5em auto;' 280 | } 281 | 282 | ---@alias fo_scope 'manual'|'all'|'images'|'none', # imagify scope 283 | ---@alias fo_lazy boolean, # do not regenerate existing image files 284 | ---@alias fo_no_html_embed boolean, # prohibit html embedding 285 | ---@alias fo_output_folder string, # path for outputs 286 | ---@alias fo_output_folder_exists boolean, # Internal var to avoid repeat checks 287 | ---@alias fo_libgs_path string|nil, # path to Ghostscript lib 288 | ---@alias fo_optionsForClass { string: renderOptsType}, # renderOptions for imagify classes 289 | ---@alias fo_extensionForOutput { default: string, string: string }, # map of image formats (svg|pdf) for some output formats 290 | ---@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 } 291 | ---@type filterOptsType 292 | local filterOptions = { 293 | scope = 'manual', 294 | lazy = true, 295 | no_html_embed = false, 296 | libgs_path = nil, 297 | output_folder = '_imagify', 298 | output_folder_exists = false, 299 | optionsForClass = {}, 300 | extensionForOutput = { 301 | default = 'svg', 302 | html = 'svg', 303 | html4 = 'svg', 304 | html5 = 'svg', 305 | latex = 'pdf', 306 | beamer = 'pdf', 307 | docx = 'pdf', 308 | } 309 | } 310 | 311 | ---@alias tplId string template identifier, 'default' reserved for Pandoc's default template 312 | ---@alias to_source string template source code 313 | ---@alias to_template pandoc.Template compiled template 314 | ---@alias templateOptsType { default: table, string: { source: to_source, compiled: to_template}} 315 | ---@type templateOptsType 316 | local Templates = { 317 | default = {}, 318 | } 319 | 320 | -- ## Pandoc AST functions 321 | 322 | --outputIsLaTeX: checks whether the target output is in LaTeX 323 | ---@return boolean 324 | local function outputIsLaTeX() 325 | return FORMAT:match('latex') or FORMAT:match('beamer') or false 326 | end 327 | 328 | --- ensureList: ensures an object is a pandoc.List. 329 | ---@param obj any|nil 330 | local function ensureList(obj) 331 | 332 | return pandoctype(obj) == 'List' and obj 333 | or pandoc.List:new{obj} 334 | 335 | end 336 | 337 | ---imagifyType: whether an element is imagifiable LaTeX and which type 338 | ---@alias imagifyType nil|'InlineMath'|'DisplayMath'|'RawBlock'|'RawInline'|'TexImage'|'TikzImage' 339 | ---@param elem pandoc.Math|pandoc.RawBlock|pandoc.RawInline|pandoc.Image element 340 | ---@return imagifyType elemType to imagify or nil 341 | function imagifyType(elem) 342 | return elem.t == 'Image' and ( 343 | elem.src:match('%.tex$') and 'TexImage' 344 | or elem.src:match('%.tikz') and 'TikzImage' 345 | ) 346 | or elem.mathtype == 'InlineMath' and 'InlineMath' 347 | or elem.mathtype == 'DisplayMath' and 'DisplayMath' 348 | or (elem.format == 'tex' or elem.format == 'latex') 349 | and ( 350 | elem.t == 'RawBlock' and 'RawBlock' 351 | or elem.t == 'RawInline' and 'RawInline' 352 | ) 353 | or nil 354 | end 355 | 356 | -- ## Smart imagifying functions 357 | 358 | ---usesTikZ: tell whether a source contains a TikZ picture 359 | ---@param source string LaTeX source 360 | ---@return boolean result 361 | local function usesTikZ(source) 362 | return (source:match('\\begin{tikzpicture}') 363 | or source:match('\\tikz')) and true 364 | or false 365 | end 366 | 367 | -- ## Converter functions 368 | 369 | local function dvisvgmVerbosity() 370 | return PANDOC_STATE.verbosity == 'ERROR' and '1' 371 | or PANDOC_STATE.verbosity == 'WARNING' and '2' 372 | or PANDOC_STATE.verbosity == 'INFO' and '4' 373 | or '2' 374 | end 375 | 376 | ---getCodeFromFile: get source code from a file 377 | ---uses Pandoc's resource paths if needed 378 | ---@param src string source file name/path 379 | ---@return string|nil result file contents or nil if not found 380 | function getCodeFromFile(src) 381 | local result 382 | 383 | if fileExists(src) then 384 | result = readFile(src) 385 | else 386 | for _,item in ipairs(PANDOC_STATE.resource_path) do 387 | if fileExists(path.join{item, src}) then 388 | result = readFile(path.join{item, src}) 389 | break 390 | end 391 | end 392 | end 393 | 394 | return result 395 | 396 | end 397 | 398 | ---runLaTeX: runs latex engine on file 399 | ---@param source string filepath of the source file 400 | ---@param options table options 401 | -- format = output format, 'dvi' or 'pdf', 402 | -- pdf_engine = pdf engine, 'latex', 'xelatex', 'xetex', '' etc. 403 | -- texinputs = value for export TEXINPUTS 404 | ---@return boolean success, string result result is filepath or LaTeX log if failed 405 | local function runLaTeX(source, options) 406 | options = options or {} 407 | local format = options.format or 'pdf' 408 | local pdf_engine = options.pdf_engine or 'latex' 409 | local outfile = stripExtension(source, {'tex','latex'}) 410 | local ext = pdf_engine == 'xelatex' and format == 'dvi' and '.xdv' 411 | or '.'..format 412 | local texinputs = options.texinputs or nil 413 | -- Latexmk: extra options come *after* - and *before* 414 | local latex_args = pandoc.List:new{ '--interaction=nonstopmode' } 415 | local latexmk_args = pandoc.List:new{ '-'..pdf_engine } 416 | -- Export the TEXINPUTS variable 417 | local env = texinputs and 'export TEXINPUTS='..texinputs..'; ' 418 | or '' 419 | -- latex command run, for debug purposes 420 | local cmd 421 | 422 | -- @TODO implement verbosity in latex 423 | -- latexmk silent mode 424 | if PANDOC_STATE.verbosity == 'ERROR' then 425 | latexmk_args:insert('-silent') 426 | end 427 | 428 | -- xelatex doesn't accept `output-format`, 429 | -- generates both .pdf and .xdv 430 | if pdf_engine ~= 'xelatex' then 431 | latex_args:insert('--output-format='..format) 432 | end 433 | 434 | 435 | -- try Latexmk first, latex engine second 436 | -- two runs of latex engine 437 | cmd = env..'latexmk '..concatStrings(latexmk_args..latex_args, ' ') 438 | ..' '..source 439 | local success, err, code = os.execute(cmd) 440 | 441 | if not success and code == 127 then 442 | cmd = pdf_engine..' ' 443 | ..concatStrings(latex_args, ' ') 444 | ..' '..source..' 2>&1 > /dev/null '..'; ' 445 | cmd = cmd..cmd -- two runs needed 446 | success = os.execute(env..cmd) 447 | end 448 | 449 | if success then 450 | 451 | return true, outfile..ext 452 | 453 | else 454 | 455 | local result = 'LaTeX compilation failed.\n' 456 | ..'Command used: '..cmd..'\n' 457 | local src_code = readFile(source) 458 | if src_code then 459 | result = result..'LaTeX source code:\n' 460 | result = result..src_code 461 | end 462 | local log = readFile(outfile..'.log') 463 | if log then 464 | result = result..'LaTeX log:\n'..log 465 | end 466 | return false, result 467 | 468 | end 469 | 470 | end 471 | 472 | ---toSVG: convert latex output to SVG. 473 | ---Ghostcript library required to convert PDF files. 474 | -- See divsvgm manual for more details. 475 | -- Options: 476 | -- *output*: string output filepath (directory must exist), 477 | -- *zoom*: string zoom factor, e.g. 1.5. 478 | ---@param source string filepath of dvi, xdv or svg file 479 | ---@param options { output : string, zoom: string} options 480 | ---@return success boolean, result string filepath 481 | local function toSVG(source, options) 482 | if source == nil then return nil end 483 | local options = options or {} 484 | local outfile = options.output 485 | or stripExtension(source, {'pdf', 'svg', 'xdv'})..'.svg' 486 | local source_format = source:match('%.pdf$') and 'pdf' 487 | or source:match('%.dvi$') and 'dvi' 488 | or source:match('%.xdv$') and 'dvi' 489 | local cmd_opts = pandoc.List:new({'--optimize', 490 | '--verbosity='..dvisvgmVerbosity(), 491 | -- '--relative', 492 | -- '--no-fonts', 493 | '--font-format=WOFF', 494 | source 495 | }) 496 | 497 | -- @TODO doesn't work on my machine, why? 498 | if filterOptions.libgs_path and filterOptions.libgs_path ~= '' then 499 | cmd_opts:insert('--libgs='..filterOptions.libgs_path) 500 | end 501 | 502 | -- note "Ghostcript required to process PDF files" 503 | if source_format == 'pdf' then 504 | cmd_opts:insert('--pdf') 505 | end 506 | 507 | if options.zoom then 508 | cmd_opts:insert('--zoom='..options.zoom) 509 | end 510 | 511 | cmd_opts:insert('--output='..outfile) 512 | 513 | success = os.execute('dvisvgm' 514 | ..' '..concatStrings(cmd_opts, ' ') 515 | ) 516 | 517 | if success then 518 | 519 | return success, outfile 520 | 521 | else 522 | 523 | return success, 'DVI/PDF to SVG conversion failed\n' 524 | 525 | end 526 | 527 | end 528 | 529 | --- getSVGFromFile: extract svg tag (with contents) from a SVG file. 530 | -- Assumes the file only contains one SVG tag. 531 | -- @param filepath string file path 532 | local function getSVGFromFile(filepath) 533 | local contents = readFile(filepath) 534 | 535 | return contents and contents:match('') 536 | 537 | end 538 | 539 | 540 | --- urlEncode: URL-encodes a string 541 | -- See 542 | -- Modified to handle UTF-8: %w matches UTF-8 starting bytes, which should 543 | -- be encoded. We specify safe alphanumeric chars explicitly instead. 544 | -- @param str string 545 | local function urlEncode(str) 546 | 547 | --Ensure all newlines are in CRLF form 548 | str = string.gsub (str, "\r?\n", "\r\n") 549 | 550 | --Percent-encode all chars other than unreserved 551 | --as per RFC 3986, Section 2.3 552 | -- 553 | str = str:gsub("[^0-9a-zA-Z%-._~]", 554 | function (c) return string.format ("%%%02X", string.byte(c)) end) 555 | 556 | return str 557 | 558 | end 559 | 560 | -- # Main filter functions 561 | 562 | -- ## Functions to read options 563 | 564 | ---getFilterOptions: read render options 565 | ---returns a map: 566 | --- scope: fo_scope 567 | --- libgs_path: string 568 | --- output_folder: string 569 | ---@param opts table options map from meta.imagify 570 | ---@return table result map of options 571 | local function getFilterOptions(opts) 572 | local stringKeys = {'scope', 'libgs-path', 'output-folder'} 573 | local boolKeys = {'lazy'} 574 | local result = {} 575 | 576 | for _,key in ipairs(boolKeys) do 577 | if opts[key] ~= nil and pandoctype(opts[key]) == 'boolean' then 578 | result[key] = opts[key] 579 | end 580 | end 581 | 582 | for _,key in ipairs(stringKeys) do 583 | opts[key] = opts[key] and stringify(opts[key]) or nil 584 | end 585 | 586 | result.scope = opts.scope and ( 587 | opts.scope == 'all' and 'all' 588 | or (opts.scope == 'selected' or opts.scope == 'manual') and 'manual' 589 | or opts.scope == 'images' and 'images' 590 | or opts.scope == 'none' and 'none' 591 | ) or nil 592 | 593 | result.libgs_path = opts['libgs-path'] and opts['libgs-path'] or nil 594 | 595 | result.output_folder = opts['output-folder'] 596 | and opts['output-folder'] or nil 597 | 598 | return result 599 | 600 | end 601 | 602 | ---getRenderOptions: read render options 603 | ---@param opts table options map, from doc metadata or elem attributes 604 | ---@return table result renderOptions map of options 605 | local function getRenderOptions(opts) 606 | local result = {} 607 | local renderBooleanlKeys = { 608 | 'force', 609 | 'embed', 610 | 'debug', 611 | } 612 | local renderStringKeys = { 613 | 'pdf-engine', 614 | 'svg-converter', 615 | 'zoom', 616 | 'vertical-align', 617 | 'block-style', 618 | } 619 | local renderListKeys = { 620 | 'classoption', 621 | } 622 | -- Pandoc metadata variables used by the LaTeX template 623 | local renderMetaKeys = { 624 | 'header-includes', 625 | 'mathspec', 626 | 'fontenc', 627 | 'fontfamily', 628 | 'fontfamilyoptions', 629 | 'fontsize', 630 | 'mainfont', 'sansfont', 'monofont', 'mathfont', 'CJKmainfont', 631 | 'mainfontoptions', 'sansfontoptions', 'monofontoptions', 632 | 'mathfontoptions', 'CJKoptions', 633 | 'microtypeoptions', 634 | 'colorlinks', 635 | 'boxlinks', 636 | 'linkcolor', 'filecolor', 'citecolor', 'urlcolor', 'toccolor', 637 | -- 'links-as-note': not visible in standalone LaTeX class 638 | 'urlstyle', 639 | } 640 | checks = { 641 | pdf_engine = {'latex', 'xelatex', 'lualatex'}, 642 | svg_converter = {'dvisvgm'}, 643 | } 644 | 645 | -- boolean values 646 | -- @TODO these may be passed as strings in Div attributes 647 | -- convert "xx-yy" to "xx_yy" keys 648 | for _,key in ipairs(renderBooleanlKeys) do 649 | if opts[key] ~= nil then 650 | if pandoctype(opts[key]) == 'boolean' then 651 | result[key:gsub('-','_')] = opts[key] 652 | elseif pandoctype(opts[key]) == 'string' then 653 | if opts[key] == 'false' or opts[key] == 'no' then 654 | result[key:gsub('-','_')] = false 655 | else 656 | result[key:gsub('-','_')] = true 657 | end 658 | end 659 | end 660 | end 661 | 662 | -- string values 663 | -- convert "xx-yy" to "xx_yy" keys 664 | for _,key in ipairs(renderStringKeys) do 665 | if opts[key] then 666 | result[key:gsub('-','_')] = stringify(opts[key]) 667 | end 668 | end 669 | 670 | -- list values 671 | for _,key in ipairs(renderListKeys) do 672 | if opts[key] then 673 | result[key:gsub('-','_')] = ensureList(opts[key]) 674 | end 675 | end 676 | 677 | -- meta values 678 | -- do not change the key names 679 | for _,key in ipairs(renderMetaKeys) do 680 | if opts[key] then 681 | result[key] = opts[key] 682 | end 683 | end 684 | 685 | -- apply checks 686 | for key, accepted_vals in pairs(checks) do 687 | if result[key] and not tfind(accepted_vals, result[key]) then 688 | message('WARNING', 'Option '..key..'has an invalid value: ' 689 | ..result[key]..". I'm ignoring it." 690 | ) 691 | result[key] = nil 692 | end 693 | end 694 | 695 | -- Special cases 696 | -- `embed` not possible with `extract-media` on 697 | if result.embed and filterOptions.no_html_embed then 698 | result.embed = nil 699 | end 700 | 701 | return result 702 | 703 | end 704 | 705 | ---readImagifyClasses: read user's specification of custom classes 706 | -- This can be a string (single class), a pandoc.List of strings 707 | -- or a map { class = renderOptionsForClass }. 708 | -- We update `filterOptions.classes` accordingly. 709 | ---@param opts pandoc.List|pandoc.MetaMap|string 710 | local function readImagifyClasses(opts) 711 | -- ensure it's a list or table 712 | if pandoctype(opts) ~= 'List' and pandoctype(opts) ~= 'table' then 713 | opts = pandoc.List:new({ opts }) 714 | end 715 | 716 | if pandoctype(opts) == 'List' then 717 | for _, val in ipairs(opts) do 718 | local class = stringify(val) 719 | filterOptions.optionsForClass[class] = {} 720 | end 721 | elseif pandoctype(opts) == 'table' then 722 | for key, val in pairs(opts) do 723 | local class = stringify(key) 724 | filterOptions.optionsForClass[class] = getRenderOptions(val) 725 | end 726 | end 727 | 728 | end 729 | 730 | ---init: read metadata options. 731 | -- Classes in `imagify-classes:` override those in `imagify: classes:` 732 | -- If `meta.imagify` isn't a map assume it's a `scope` value 733 | -- Special cases: 734 | -- filterOptions.no_html_embed: Pandoc can't handle URL-embedded images when extract-media is on 735 | ---@param meta pandoc.Meta doc's metadata 736 | local function init(meta) 737 | local userOptions = meta.imagify 738 | and (pandoctype(meta.imagify) == 'table' and meta.imagify 739 | or {scope = stringify(meta.imagify)} 740 | ) 741 | or {} 742 | local userClasses = meta['imagify-classes'] 743 | and pandoctype(meta['imagify-classes'] ) == 'table' 744 | and meta['imagify-classes'] 745 | or nil 746 | local rootKeysUsed = { 747 | 'header-includes', 748 | 'mathspec', 749 | 'fontenc', 750 | 'fontfamily', 751 | 'fontfamilyoptions', 752 | 'fontsize', 753 | 'mainfont', 'sansfont', 'monofont', 'mathfont', 'CJKmainfont', 754 | 'mainfontoptions', 'sansfontoptions', 'monofontoptions', 755 | 'mathfontoptions', 'CJKoptions', 756 | 'microtypeoptions', 757 | 'colorlinks', 758 | 'boxlinks', 759 | 'linkcolor', 'filecolor', 'citecolor', 'urlcolor', 'toccolor', 760 | -- 'links-as-note': no footnotes in standalone LaTeX class 761 | 'urlstyle', 762 | } 763 | 764 | -- pass relevant root options unless overriden in meta.imagify 765 | for _,key in ipairs(rootKeysUsed) do 766 | if meta[key] and not userOptions[key] then 767 | userOptions[key] = meta[key] 768 | end 769 | end 770 | 771 | filterOptions = mergeMapInto( 772 | getFilterOptions(userOptions), 773 | filterOptions 774 | ) 775 | 776 | if meta['extract-media'] and FORMAT:match('html') then 777 | filterOptions.no_html_embed = true 778 | end 779 | 780 | globalRenderOptions = mergeMapInto( 781 | getRenderOptions(userOptions), 782 | globalRenderOptions 783 | ) 784 | 785 | if userOptions.classes then 786 | filterOptions.classes = readImagifyClasses(userOptions.classes) 787 | end 788 | 789 | if userClasses then 790 | filterOptions.classes = readImagifyClasses(userClasses) 791 | end 792 | 793 | end 794 | 795 | -- ## Functions to convert images 796 | 797 | ---getTemplate: get a compiled template 798 | ---@param id string template identifier (key of Templates) 799 | ---@return pandoc.Template|nil tpl result 800 | local function getTemplate(id) 801 | if not Templates[id] then 802 | return nil 803 | end 804 | 805 | -- ensure there's a non-empty source, otherwise return nil 806 | -- special case: default template, fill in source from Pandoc 807 | if id == 'default' and not Templates[id].source then 808 | Templates[id].source = pandoc.template.default('latex') 809 | end 810 | 811 | if not Templates[id].source or Templates[id].source == '' then 812 | return nil 813 | end 814 | 815 | -- compile if needed and return 816 | 817 | if not Templates[id].compiled then 818 | Templates[id].compiled = pandoc.template.compile( 819 | Templates[id].source) 820 | end 821 | 822 | return Templates[id].compiled 823 | 824 | end 825 | 826 | ---buildTeXDoc: turns LaTeX element into a LaTeX doc source. 827 | ---@param code string LaTeX code 828 | ---@param renderOptions table render options 829 | ---@param elemType string 'InlineMath', 'DisplayMath', 'RawInline', 'RawBlock' 830 | local function buildTeXDoc(code, renderOptions, elemType) 831 | local endFormat = filterOptions.extensionForOutput[FORMAT] 832 | or filterOptions.extensionForOutput.default 833 | elemType = elemType and elemType or 'InlineMath' 834 | code = code or '' 835 | renderOptions = renderOptions or {} 836 | local template = renderOptions.template or 'default' 837 | local svg_converter = renderOptions.svg_converter or 'dvisvgm' 838 | local doc = nil 839 | 840 | -- wrap DisplayMath and InlineMath in math mode 841 | -- for display math we must use \displaystyle 842 | -- see 843 | if elemType == 'DisplayMath' then 844 | code = '$\\displaystyle\n'..code..'$' 845 | elseif elemType == 'InlineMath' then 846 | code = '$'..code..'$' 847 | end 848 | 849 | doc = pandoc.Pandoc( 850 | pandoc.RawBlock('latex', code), 851 | pandoc.Meta(renderOptions) 852 | ) 853 | 854 | -- modify the doc's meta values as required 855 | --@TODO set class option class=... 856 | --Standalone tikz needs \standaloneenv{tikzpicture} 857 | local headinc = ensureList(doc.meta['header-includes']) 858 | local classopt = ensureList(doc.meta['classoption']) 859 | 860 | -- Standalone class `dvisvgm` option: make output file 861 | -- dvisvgm-friendly (esp TikZ images). 862 | -- Not compatible with pdflatex 863 | if endFormat == 'svg' and svg_converter == 'dvisvgm' then 864 | classopt:insert(pandoc.Str('dvisvgm')) 865 | end 866 | 867 | -- The standalone class option `tikz` needs to be activated 868 | -- to avoid an empty page of output. 869 | if usesTikZ(code) then 870 | headinc:insert(pandoc.RawBlock('latex', '\\usepackage{tikz}')) 871 | classopt:insert{ 872 | pandoc.Str('tikz') 873 | } 874 | end 875 | 876 | doc.meta['header-includes'] = #headinc > 0 and headinc or nil 877 | doc.meta.classoption = #classopt > 0 and classopt or nil 878 | doc.meta.documentclass = 'standalone' 879 | 880 | return pandoc.write(doc, 'latex', { 881 | template = getTemplate(template), 882 | }) 883 | 884 | end 885 | 886 | ---createUniqueName: return unique identifier for an image source. 887 | ---Combines LaTeX sources and rendering options. 888 | ---@param source string LaTeX source for the image 889 | ---@param renderOptions table render options 890 | ---@return string filename without extension 891 | local function createUniqueName(source, renderOptions) 892 | return pandoc.sha1(source .. 893 | '|Zoom:'..renderOptions.zoom) 894 | end 895 | 896 | ---latexToImage: convert LaTeX to image. 897 | -- The image can be exported as SVG string or as a SVG or PDF file. 898 | ---@param source string LaTeX source document 899 | ---@param renderOptions table rendering options 900 | ---@return success boolean, string result result is file contents or filepath or error message. 901 | local function latexToImage(source, renderOptions) 902 | local renderOptions = renderOptions or {} 903 | local ext = filterOptions.extensionForOutput[FORMAT] 904 | or filterOptions.extensionForOutput.default 905 | local lazy = filterOptions.lazy 906 | local embed = renderOptions.embed 907 | and ext == 'svg' and FORMAT:match('html') and true 908 | or false 909 | local pdf_engine = renderOptions.pdf_engine or 'latex' 910 | local latex_out_format = ext == 'svg' and 'dvi' or 'pdf' 911 | local debug = renderOptions.debug or false 912 | local folder = filterOptions.output_folder or '' 913 | local jobOutFolder = makeAbsolute(PANDOC_STATE.output_file 914 | and path.directory(PANDOC_STATE.output_file) ~= '.' 915 | and path.directory(PANDOC_STATE.output_file) or '') 916 | local texinputs = renderOptions.texinputs or nil 917 | -- to be created 918 | local folderAbs, file, fileAbs, texfileAbs = '', '', '', '' 919 | local fileRelativeToJob = '' 920 | local success, result 921 | 922 | -- default texinputs: all sources folders and output folder 923 | -- and directory folder? 924 | if not texinputs then 925 | texinputs = system.get_working_directory()..'//:' 926 | for _,filepath in ipairs(PANDOC_STATE.input_files) do 927 | texinputs = texinputs 928 | .. makeAbsolute(filepath and path.directory(filepath) or '') 929 | .. '//:' 930 | end 931 | texinputs = texinputs.. jobOutFolder .. '//:' 932 | end 933 | 934 | -- if we output files prepare folder and file names 935 | -- we need absolute paths to move things out of the temp dir 936 | if not embed or debug then 937 | folderAbs = makeAbsolute(folder) 938 | filename = createUniqueName(source, renderOptions) 939 | fileAbs = path.join{folderAbs, filename..'.'..ext} 940 | file = path.join{folder, filename..'.'..ext} 941 | texfileAbs = path.join{folderAbs, filename..'.tex'} 942 | 943 | -- ensure the output folder exists (only once) 944 | if not filterOptions.output_folder_exists then 945 | ensureFolderExists(folderAbs) 946 | filterOptions.output_folder_exists = true 947 | end 948 | 949 | -- path to the image relative to document output 950 | fileRelativeToJob = path.make_relative(fileAbs, jobOutFolder) 951 | 952 | -- if lazy, don't regenerate files that already exist 953 | if not embed and lazy and fileExists(fileAbs) then 954 | success, result = true, fileRelativeToJob 955 | return success, result 956 | end 957 | 958 | end 959 | 960 | system.with_temporary_directory('imagify', function (tmpdir) 961 | system.with_working_directory(tmpdir, function() 962 | 963 | writeToFile(source, 'source.tex') 964 | 965 | -- debug: copy before, LaTeX may crash 966 | if debug then 967 | writeToFile(source, texfileAbs) 968 | end 969 | 970 | -- result = 'source.dvi'|'source.xdv'|'source.pdf'|nil 971 | success, result = runLaTeX('source.tex', { 972 | format = latex_out_format, 973 | pdf_engine = pdf_engine, 974 | texinputs = texinputs 975 | }) 976 | 977 | -- further conversions of dvi/pdf? 978 | 979 | if success and ext == 'svg' then 980 | 981 | success, result = toSVG(result, { 982 | zoom = renderOptions.zoom, 983 | }) 984 | 985 | end 986 | 987 | -- embed or save 988 | 989 | if success then 990 | 991 | if embed and ext == 'svg' then 992 | 993 | -- read svg contents and cleanup 994 | result = "\n" 995 | .. getSVGFromFile(result) 996 | 997 | -- URL encode 998 | result = 'data:image/svg+xml,'..urlEncode(result) 999 | 1000 | else 1001 | 1002 | --- File copy 1003 | --- not os.rename, which doesn't work across volumes 1004 | --- binary in case the output is PDF 1005 | copyFile(result, fileAbs, 'b') 1006 | result = fileRelativeToJob 1007 | 1008 | end 1009 | 1010 | end 1011 | 1012 | end) 1013 | end) 1014 | 1015 | return success, result 1016 | 1017 | end 1018 | 1019 | ---createImageElemFrom(src, renderOptions, elemType) 1020 | ---@param text string source code for the image 1021 | ---@param src string URL (possibly URL encoded data) 1022 | ---@param renderOptions table render Options 1023 | ---@param elemType string 'InlineMath', 'DisplayMath', 'RawInline', 'RawBlock' 1024 | ---@return pandoc.Image img 1025 | local function createImageElemFrom(text, src, renderOptions, elemType) 1026 | local title = text or '' 1027 | local caption = '' -- for future implementation (Raw elems attribute?) 1028 | local block = elemType == 'DisplayMath' or elemType == 'RawBlock' 1029 | local style = '' 1030 | local block_style = renderOptions.block_style 1031 | or 'display: block; margin: .5em auto; ' 1032 | local vertical_align = renderOptions.vertical_align 1033 | or 'baseline' 1034 | 1035 | if block then 1036 | style = style .. block_style 1037 | else 1038 | style = style .. 'vertical-align: '..vertical_align..'; ' 1039 | end 1040 | 1041 | return pandoc.Image(caption, src, title, { style = style }) 1042 | 1043 | end 1044 | 1045 | ---toImage: convert to pandoc.Image using specified rendering options. 1046 | ---Return the original element if conversion failed. 1047 | ---@param elem pandoc.Math|pandoc.RawInline|pandoc.RawBlock|pandoc.Image 1048 | ---@param elemType imagifyType type of element to imagify 1049 | ---@param renderOptions table rendering options 1050 | ---@return pandoc.Image|pandoc.Inlines|pandoc.Para|nil 1051 | local function toImage(elem, elemType, renderOptions) 1052 | local code, doc 1053 | local success, result, img 1054 | 1055 | -- get code, return nil if none 1056 | if elemType == 'TexImage' or elemType == 'TikzImage' then 1057 | code = getCodeFromFile(elem.src) 1058 | if not code then 1059 | message('ERROR', 'Image source file '..elem.src..' not found.') 1060 | end 1061 | else 1062 | code = elem.text 1063 | end 1064 | if not code then return nil end 1065 | 1066 | -- prepare LaTeX source document 1067 | doc = buildTeXDoc(code, renderOptions, elemType) 1068 | 1069 | -- convert to file or string 1070 | success, result = latexToImage(doc, renderOptions) 1071 | 1072 | -- prepare Image element 1073 | if success then 1074 | if (elemType == 'TexImage' or elemType == 'TikzImage') then 1075 | elem.src = result 1076 | img = elem 1077 | elseif elemType == 'RawBlock' then 1078 | img = pandoc.Para( 1079 | createImageElemFrom(code, result, renderOptions, elemType) 1080 | ) 1081 | else 1082 | img = createImageElemFrom(code, result, renderOptions, elemType) 1083 | end 1084 | else 1085 | message('ERROR', result) 1086 | img = pandoc.List:new { 1087 | pandoc.Emph{ pandoc.Str('') }, 1088 | pandoc.Space(), pandoc.Str(code), pandoc.Space(), 1089 | pandoc.Emph{ pandoc.Str('') }, 1090 | } 1091 | end 1092 | 1093 | return img 1094 | 1095 | end 1096 | 1097 | -- ## Functions to traverse the document tree 1098 | 1099 | ---imagifyClass: find an element's imagify class, if any. 1100 | ---If both `imagify` and a custom class is present, return the latter. 1101 | ---@param elem pandoc.Div|pandoc.Span 1102 | ---@return string 1103 | local function imagifyClass(elem) 1104 | -- priority to custom classes other than 'imagify' 1105 | for _,class in ipairs(elem.classes) do 1106 | if filterOptions.optionsForClass[class] then 1107 | return class 1108 | end 1109 | end 1110 | if elem.classes:find('imagify') then 1111 | return 'imagify' 1112 | end 1113 | return nil 1114 | end 1115 | 1116 | ---scanContainer: read imagify options of a Span/Div, imagify if needed. 1117 | ---@param elem pandoc.Div|pandoc.Span 1118 | ---@param renderOptions table render options handed down from higher-level elems 1119 | ---@return pandoc.Span|pandoc.Div|nil span modified element or nil if no change 1120 | local function scanContainer(elem, renderOptions) 1121 | local class = imagifyClass(elem) 1122 | 1123 | if class then 1124 | -- create new rendering options by applying the class options 1125 | local opts = mergeMapInto(filterOptions.optionsForClass[class], 1126 | renderOptions) 1127 | -- apply any locally specified rendering options 1128 | opts = mergeMapInto(getRenderOptions(elem.attributes), opts) 1129 | -- build recursive scanner from updated options 1130 | local scan = function (elem) return scanContainer(elem, opts) end 1131 | --- build imagifier from updated options 1132 | local imagify = function(el) 1133 | local elemType = imagifyType(el) 1134 | if opts.force == true or outputIsLaTeX() == false 1135 | or (elemType == 'TexImage' or elemType == 'TikzImage') then 1136 | return elemType and toImage(el, elemType, opts) or nil 1137 | end 1138 | end 1139 | --- apply recursion first, then imagifier 1140 | return elem:walk({ 1141 | Div = scan, 1142 | Span = scan, 1143 | }):walk({ 1144 | Math = imagify, 1145 | RawInline = imagify, 1146 | RawBlock = imagify, 1147 | Image = imagify, 1148 | }) 1149 | 1150 | else 1151 | 1152 | -- recursion 1153 | local scan = function (elem) return scanContainer(elem, renderOptions) end 1154 | return elem:walk({ 1155 | Span = scan, 1156 | Div = scan, 1157 | }) 1158 | 1159 | end 1160 | 1161 | end 1162 | 1163 | ---main: process the main document's body. 1164 | -- Handles filterOptions `scope` and `force` 1165 | local function main(doc) 1166 | local scope = filterOptions.scope 1167 | local force = globalRenderOptions.force 1168 | 1169 | if scope == 'none' then 1170 | return nil 1171 | end 1172 | 1173 | -- whole doc wrapped in a Div to use the recursive scanner 1174 | local div = pandoc.Div(doc.blocks) 1175 | 1176 | -- recursive scanning in modes other than 'images' 1177 | -- if scope == 'all' we tag the whole doc as `imagify` 1178 | if scope ~= 'images' then 1179 | 1180 | if scope == 'all' then 1181 | div.classes:insert('imagify') 1182 | end 1183 | 1184 | div = scanContainer(div, globalRenderOptions) 1185 | 1186 | end 1187 | 1188 | -- imagify any leftover tikz / tex images 1189 | -- using global render options 1190 | div = div:walk({ 1191 | Image = function (elem) 1192 | local elemType = imagifyType(elem) 1193 | if elemType then 1194 | return toImage(elem, elemType, globalRenderOptions) 1195 | end 1196 | end, 1197 | }) 1198 | 1199 | return div and pandoc.Pandoc(div.content, doc.meta) 1200 | or nil 1201 | 1202 | end 1203 | 1204 | -- # Return filter 1205 | 1206 | return { 1207 | { 1208 | Meta = init, 1209 | Pandoc = main, 1210 | }, 1211 | } 1212 | -------------------------------------------------------------------------------- /docs/manual.md: -------------------------------------------------------------------------------- 1 | Imagify - Pandoc/Quarto conversion of LaTeX and TikZ elements into images 2 | ========================================================================= 3 | 4 | Lua filter to convert some or all LaTeX and TikZ elements in a document into 5 | images. Also enables using `.tex`/`.tikz` files as image sources. 6 | 7 | Copyright 2022-2023 [Philosophie.ch][Philoch]. Maintained by 8 | [Julien Dutant][JDutant]. 9 | 10 | Overview 11 | -------------------------------------------------------------------- 12 | 13 | Imagify turns selected LaTeX elements into images in non-LaTeX/PDF 14 | output. This is useful for web output if you use MathJAX but it 15 | doesn't handle all of your LaTeX code. 16 | 17 | It also allows you to use `.tex` or `.tikz` elements as 18 | image source files, which is useful to create cross-referenceable 19 | figures with [Pandoc-crossref][] or [Quarto][] without having 20 | to convert your LaTeX/TikZ code into images first. 21 | 22 | Pandoc-crossref and Quarto have advanced figure handling including 23 | captions and cross-references, but they require image elements, e.g.: 24 | 25 | ``` markdown 26 | ![Caption](figure.png){#fig-1} 27 | ``` 28 | 29 | To use this with a TikZ/LaTeX figure, you would need to convert it to 30 | an image first, and ideally PDF for PDF, SVG or PNG for other output 31 | formats. Alternatively, with Quarto 1.4+, you could use a 32 | [Div figure][QuartoDivFigure] and place your LaTeX/TikZ figure within 33 | it: 34 | 35 | ``` markdown 36 | ::: {#fig-1} 37 | 38 | \begin{tikzpicture} 39 | ... 40 | \end{tikzpicture} 41 | 42 | ::: 43 | ``` 44 | 45 | But that would only cover LaTeX outputs. This filter allows 46 | you to simply use a `.tex`/`.tikz` file as 47 | source, which is converted to an image according to output format: 48 | 49 | ``` markdown 50 | ![Caption](figure.tikz){#fig-1} 51 | ``` 52 | 53 | Imagify tries to match your document's LaTeX output settings 54 | (fonts, LaTeX packages, etc.). Its rendering options are otherwise 55 | extensively configurable, and different rendering options can 56 | be used for different elements. It can embed its images within HTML 57 | output or provide them as separate image files. 58 | 59 | Requirements: [Pandoc][] or [Quarto][], a LaTeX installation 60 | (with `dvisvgm` and, recommended, `latexmk`, which are included 61 | in common LaTeX distributions). 62 | 63 | Limitations: 64 | 65 | * So far designed with HTML output in mind, LaTeX to SVG conversion, 66 | and LaTeX/PDF outputs with separate `.tikz` or `.tex` files as 67 | image sources. 68 | In other output formats, the images will be inserted or linked as PDFs 69 | and may display in wrong sizes or not at all. 70 | * Embedding within HTML output isn't compatible with Pandoc's 71 | `extract-media` option. 72 | 73 | Installation 74 | ------------------------------------------------------------------ 75 | 76 | ### Pre-requisites 77 | 78 | In addition to Pandoc/Quarto, the filter needs a LaTeX 79 | installation with the package `dvisvgm` installed. 80 | 81 | Quarto ships with a small LaTeX installation (tinytex), but the 82 | filter cannot use it. 83 | 84 | ### Plain pandoc 85 | 86 | Get `imagify.lua` from the Releases page and save it somewhere 87 | Pandoc can find (see [Pandoc][] for details). 88 | 89 | Pass the filter to Pandoc via the `--lua-filter` (or `-L`) command 90 | line option. 91 | 92 | pandoc --lua-filter imagify.lua ... 93 | 94 | ### Quarto 95 | 96 | Install this filter as a Quarto extension with 97 | 98 | quarto install extension dialoa/imagify 99 | 100 | and use it by adding `imagify` to the `filters` entry 101 | in their YAML header: 102 | 103 | ``` yaml 104 | --- 105 | filters: 106 | - imagify 107 | --- 108 | ``` 109 | 110 | See [Quarto's Extensions guide][QuartoExtManagement] for more 111 | details updating and version-controlling filters. 112 | 113 | ### R Markdown 114 | 115 | Use `pandoc_args` to invoke the filter. See the [R Markdown 116 | Cookbook](https://bookdown.org/yihui/rmarkdown-cookbook/lua-filters.html) 117 | for details. 118 | 119 | ``` yaml 120 | --- 121 | output: 122 | word_document: 123 | pandoc_args: ['--lua-filter=imagify.lua'] 124 | --- 125 | ``` 126 | 127 | Basic usage 128 | ------------------------------------------------------------------ 129 | 130 | ### Imagifying selected LaTeX elements 131 | 132 | LaTeX elements to be imagified should be placed in a Div block 133 | with class `imagify`. In markdown source: 134 | 135 | ~~~~~ markdown 136 | ::: imagify 137 | 138 | This display LaTeX formmula will be imagified: 139 | 140 | $$\binom{n}{k} = \frac{n!}{k!(n-k)!}$$ 141 | 142 | As well as this TikZ picture: 143 | 144 | \begin{tikzpicture} 145 | \draw (-2,0) -- (2,0); 146 | \filldraw [gray] (0,0) circle (2pt); 147 | \draw (-2,-2) .. controls (0,0) .. (2,-2); 148 | \draw (-2,2) .. controls (-1,0) and (1,0) .. (2,2); 149 | \end{tikzpicture} 150 | 151 | And this raw LaTeX block: 152 | 153 | ```{=latex} 154 | \fitchprf{ 155 | \pline{A} \\ 156 | \pline{A \rightarrow B} 157 | } 158 | { \pline{B} } 159 | ``` 160 | 161 | ::: 162 | ~~~~~ 163 | 164 | LaTeX math and raw LaTeX elements in the Div are converted to images 165 | unless the output format is LaTeX/PDF. 166 | 167 | If a LaTeX element is or contains a TikZ picture, the TikZ 168 | package is loaded. If you need a specific library, place 169 | a `\usetikzlibrary` command at the beginning of your picture 170 | code. 171 | 172 | 173 | ### Using `.tex`/`.tikz` files as image sources 174 | 175 | The filter allows `.tex`/`.tikz` files to be used as image 176 | sources 177 | 178 | ~~~~~ markdown 179 | ![Figure: a TikZ image](figure1.tikz){#fig-1 .some-attributes} 180 | ~~~~~ 181 | 182 | The source file will be converted to an image in all output 183 | formats, e.g. PDF for LaTeX/PDF output, SVG for HTML. Attributes 184 | on the image are preserved. This is useful for cross-referencing 185 | with Pandoc-Crossref or Quarto. 186 | 187 | Optionally, you can wrap image elements in an `imagify` Div. This 188 | allows you to specify rendering options for some image elements 189 | (see below on rendering options). 190 | 191 | ~~~~~ markdown 192 | ::: {.imagify zoom=2} 193 | 194 | ![Figure: a TikZ image](figure1.tikz){#fig-1 .some-attributes} 195 | 196 | ::: 197 | ~~~~~ 198 | 199 | The source file should not include a LaTeX preamble nor 200 | `\begin{document}...\end{document}`. The two extensions 201 | are treated the same way: if the file contains `\tikz` 202 | or `\begin{tikzpicture}` then TikZ is loaded. 203 | 204 | The source must work with LaTeX's `standalone` class, 205 | which imposes some restrictions. In particular, if 206 | your source is a display formula, it should be 207 | entered as an inline formula in the 'display' style like so: 208 | 209 | __source.md__ 210 | : ~~~~ 211 | ![Figure 1: my equation](figure.tex) 212 | ~~~~ 213 | 214 | __figure.tex__ 215 | : ~~~~ 216 | $\displaystyle 217 | my fancy formula 218 | $ 219 | ~~~~ 220 | 221 | Instead of the usual `$$ ... $$` or `\[ ... \]`. 222 | 223 | ### Rendered images 224 | 225 | Images files are placed in 226 | an `_imagify` folder created in your current working directory. 227 | See the `test/input.md` file for an example. 228 | 229 | Images are generated using any Pandoc LaTeX output options 230 | specified in your document's metadata suited for a `standalone` 231 | class document, such as `fontfamily`, `fontsize` etc. Below 232 | [Pandoc's LaTeX options](#pandoc's-latex-options) is a list of 233 | options preserved; see [Pandoc manual][PManTeX] for what they do. 234 | 235 | You can customize options used in rendering, as detailed below. 236 | 237 | ### Extra LaTeX packages and LaTeX debugging 238 | 239 | Custom LaTeX packages not included in standard LaTeX 240 | distribution (e.g. `fitch.sty`) can be used, provided 241 | you place them in the source file's folder or one of 242 | its subfolder, or specify an appropriate location 243 | via the `texinputs` option. 244 | 245 | If a piece of LaTeX crashes, try debugging it 246 | in a LaTeX document, first in the `article` class 247 | then in the `standalone` class. Try also the 248 | filter's `debug` option to inspect Imagify's 249 | LaTeX output. 250 | 251 | ### Imagifying options 252 | 253 | There are two types of options: for the filter itself, and for 254 | imagifying. The former are specified in the document's metadata 255 | (YAML block at the beginning). The latter can vary from one 256 | imagified element to another and can be specified in three ways: 257 | as global rendering options, as imagifying classes of Divs, on 258 | individual Divs. 259 | 260 | Rendering options are applied in a cascading manner, from the more 261 | general to the more specific, the latter overriding the former. 262 | Thus we apply in the following order: 263 | 264 | 1. The document's Pandoc properties for LaTeX output, e.g. 265 | `fontsize`, 266 | 2. Imagify's global rendering options 267 | 3. For each Div, first the options associated with its custom 268 | imagifying class, if any, then any options specified on the Div 269 | itself. 270 | 4. And so on recursively if an imagify Div is contained within 271 | another. 272 | 273 | #### Global options 274 | 275 | Options are specified via `imagify` and `imagify-classes` metadata 276 | variables (in the document's YAML block). For instance, 277 | temporarily disable Imagify with: 278 | 279 | ``` yaml 280 | imagify: none 281 | ``` 282 | 283 | Set Imagify to convert all LaTeX in a document with: 284 | 285 | ``` yaml 286 | imagify: all 287 | ``` 288 | 289 | This probably not a good idea if your document contains many LaTeX 290 | elements that could be rendered by MathJAX or equivalent. The 291 | default is `manual`, which imagifies only (a) image elements with 292 | a `.tex` or `.tikz` source files and (b) LaTeX contained in 293 | `imagify` Divs. You can also use `images`, which only converts 294 | images elements with a `.tex` or `.tikz` source. 295 | 296 | Set the images to be embedded in the HTML output file, rather than 297 | provided as separate files, with: 298 | 299 | ``` yaml 300 | imagify: 301 | embed: true 302 | ``` 303 | 304 | Change the images' zoom factor with: 305 | 306 | ``` yaml 307 | imagify: 308 | zoom: 1.6 309 | ``` 310 | 311 | The default is 1.5, which seems to work well with Pandoc's default 312 | standalone HTML output. 313 | 314 | If image conversion fails, you can set the debug option 315 | that will give you the `.tex` files that the filter 316 | produces and passes to LaTeX: 317 | 318 | ``` yaml 319 | imagify: 320 | debug: true 321 | ``` 322 | 323 | This places Imagify's intermediate LaTeX files in our output 324 | folder (by default `_imagify` in your working directory). Try to 325 | compile them yourself and see what changes or packages are needed. 326 | 327 | #### Using classes 328 | 329 | Create custom imagifying classes with their own 330 | rendering options with the `imagify-class` variable: 331 | 332 | ``` yaml 333 | imagify: 334 | zoom: 1.6 335 | imagify-classes: 336 | mybigimage: 337 | zoom: 2 338 | mysmallimage: 339 | zoom: 1 340 | ``` 341 | 342 | Use them in your markdown as follows: 343 | 344 | ~~~~ markdown 345 | ::: mybigimage 346 | 347 | The display formula below is rendered with the `mybigimage` 348 | class rendering options: 349 | $$my formula$$ 350 | 351 | ::: 352 | ~~~~ 353 | 354 | Imagify-class rendering options are also applied to any 355 | image elements with `.tex`/`tikz` sources in the Div. 356 | 357 | If a Div has both the class `imagify` and a specific 358 | imagify-class, the latter is used.If a Div has multiple 359 | imagify-classes, only one will be use, and you can't predict 360 | which: avoid this. 361 | 362 | #### Using Div attributes 363 | 364 | You can further specify rendering options on a Div itself: 365 | 366 | ~~~~~ markdown 367 | ::: {.imagify zoom='2' debug='true'} 368 | 369 | ... (text with LaTeX element) 370 | 371 | ::: 372 | ~~~~~ 373 | 374 | The Div must have the `imagify` class or one of your custom 375 | imagify-classes. If it has a custom imagify-classes the class 376 | options are applied, but overridden by any attributes you specify. 377 | 378 | Options reference 379 | ------------------------------------------------------------------ 380 | 381 | Options are provided in the document's metadata. These are 382 | provided either in a YAML block in markdown source, or as a 383 | separate YAML file loaded with the pandoc option 384 | `--metadata-file`. Here is an example: 385 | 386 | ~~~~~ yaml 387 | fontsize: 12pt 388 | header-includes: 389 | ``` {=latex} 390 | \usepackage 391 | ``` 392 | imagify: 393 | scope: all 394 | debug: true 395 | embed: true 396 | lazy: true 397 | output-folder: _imagify_files 398 | pdf-engine: xelatex 399 | keep-sources: false 400 | zoom: 1.5 401 | imagify-classes: 402 | pre-render: 403 | zoom: 4 # will show if it's not overriden 404 | block-style: "border: 1px solid red;" 405 | fitch: 406 | debug: false 407 | header-includes: \usepackage{fitch} 408 | ~~~~~ 409 | 410 | ### `imagify` and `imagify-classes` 411 | 412 | `imagify` 413 | : string or map. If string, assumed to be a `scope` 414 | option. If map, filter options and global rendering options. 415 | 416 | `imagify-class` 417 | : map of class-name: map of rendering options. 418 | 419 | ### Filter options 420 | 421 | Specified within the `imagify` key. 422 | 423 | `scope` 424 | : string `all`, `none`, `selected` (alias `manual`). Default `selected`. 425 | 426 | `lazy` 427 | : boolean. If set to true, existing images won't be regenerated 428 | unless there is a change of code or zoom. Default true. 429 | 430 | `output-folder` 431 | : string, path to the folder where images should be output. 432 | Default `_imagify`. 433 | 434 | `ligs-path` 435 | : string, path to the Ghostscript library. Default nil. 436 | This is not the Ghostscript program, but its library. It's 437 | passed to `dvisvgm`. See [DvisvgmMan] for details. 438 | 439 | ### Rendering options 440 | 441 | These can differ from one imagified element to another. 442 | 443 | Specified within the `imagify` metadata 444 | key, within a key of the `imagify-class` map, or on 445 | as attributes of an imagify class Div elements. 446 | 447 | #### Conversion 448 | 449 | `debug` 450 | : boolean. Save the `.tex` files used to generate images 451 | in the output folder (see `output_folder` filter option). 452 | Default: false. 453 | 454 | `force` 455 | : imagify even when the output is LaTeX/PDF. Default: false. 456 | 457 | `pdf-engine` 458 | : string, one of `latex`, `xelatex`, `lualatex`. 459 | Which engine to use when converting LaTeX to `dvi` or `pdf`. 460 | Defaults to `latex`. 461 | 462 | Pandoc/Quarto filters cannot read which engine you specify to 463 | Pandoc, so if e.g. `xelatex` is needed you must specify this 464 | option explicitly. 465 | 466 | `svg-converter` 467 | : string, DVI/PDF to SVG converter. Only `dvisvgm` available 468 | for the moment. 469 | 470 | #### SVG image 471 | 472 | `zoom` 473 | : number, zoom to apply when converting the DVI/PDF output 474 | to SVG image. Defaults to `1.5`. 475 | 476 | #### HTML specific 477 | 478 | `embed` 479 | : boolean. In HTML output, embed the images within the 480 | file itself using [data 481 | URLs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs). 482 | Default: false. 483 | 484 | `vertical-align` 485 | : string, CSS vertical align property for the generated image 486 | elements. See [CSS 487 | reference](https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align) 488 | for details. Defaults to `baseline`. 489 | 490 | `block-style` 491 | : string, CSS style applied to images generated from Display Math 492 | elements and LaTeX RawBlock elements. Defaults to 493 | `display:block; margin: .5em auto;`. 494 | 495 | #### header-includes 496 | 497 | Specified at the metadata root, within the `imagify` key, within a 498 | key of the `imagify-class` map, or on as attributes of an imagify 499 | class Div elements. 500 | 501 | As the document `header-includes` is often used to include LaTeX 502 | packages, the filter's default behaviour is to picks it up and 503 | insert it in the `.tex` files used to generate images. You can 504 | override that by specifying a custom or empty `header-includes` in 505 | the imagify key: 506 | 507 | ``` yaml 508 | header-includes: | 509 | This content only goes in the document's header. 510 | imagify: 511 | header-includes: | 512 | This content is used in imagify's .tex files. 513 | ``` 514 | 515 | An empty line ensures no header content is included: 516 | 517 | ``` yaml 518 | header-includes: | 519 | This content only goes in the document's header. 520 | imagify: 521 | header-includes: 522 | ``` 523 | 524 | Different header-includes can be specified for each imagify class 525 | or even on a Div attributes. 526 | 527 | #### Pandoc's LaTeX options 528 | 529 | Specified at the metadata root, within the `imagify` key, within a 530 | key of the `imagify-class` map, or on as attributes of an imagify 531 | class Div elements. 532 | 533 | The following Pandoc LaTeX output options are read: 534 | 535 | - `classoption` (for the `standalone` class) 536 | - `mathspec`, 537 | - `fontenc`, 538 | - `fontfamily`, 539 | - `fontfamilyoptions`, 540 | - `fontsize` 541 | - `mainfont`, `sansfont`, `monofont`, `mathfont`, `CJKmainfont`, 542 | - `mainfontoptions`, `sansfontoptions`, `monofontoptions`, 543 | `mathfontoptions`, `CJKoptions`, 544 | - `microtypeoptions`, 545 | - `colorlinks`, 546 | - `boxlinks`, 547 | - `linkcolor`, `filecolor`, `citecolor`, `urlcolor`, `toccolor`, 548 | - `urlstyle`. 549 | 550 | See [Pandoc manual][PManTeX] for details. 551 | 552 | These are passed to the default Pandoc template that is used to 553 | create. The document class is set to `standalone`. 554 | 555 | [Philoch]: https://philosophie.ch 556 | [JDutant]: https://github.com/jdutant 557 | [Pandoc]: https://www.pandoc.org 558 | [Pandoc-crossref]: https://github.com/lierdakil/pandoc-crossref 559 | [Quarto]: https://quarto.org/ 560 | [QuartoExtManagement]: https://quarto.org/docs/extensions/managing.html 561 | [DvisvgmMan]: https://dvisvgm.de/Manpage/ 562 | [DvisvgmCTAN]: https://ctan.org/pkg/dvisvgm 563 | [Standalone]: https://ctan.org/pkg/standalone 564 | [PManTeX]: https://pandoc.org/MANUAL.html#variables-for-latex 565 | -------------------------------------------------------------------------------- /example-pandoc/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Imagify Example" 3 | author: Julien Dutant 4 | ### see example_meta.yaml for the rest of YAML options 5 | --- 6 | 7 | Imagify the following span: [the formula $E = mc^2$]{.imagify}. 8 | 9 | ::: imagify 10 | 11 | For some inline formulas, such as 12 | $x=\frac{-b\pm\sqrt[]{b^2-4ac}}{2a}$, the default `baseline` vertical 13 | alignment is not ideal. You can adjust it manually, using a negative 14 | value to lower the image below the baseline: 15 | [$x=\frac{-b\pm\sqrt[]{b^2-4ac}}{2a}$]{.imagify 16 | vertical-align="-.5em"}. In this case, I've specified a `-0.5em` 17 | value, which is about half a baseline down. 18 | 19 | ::: 20 | 21 | To check that the filter processes elements of arbitrary depth, we've 22 | placed the next bit within a dummy Div block. 23 | 24 | :::: arbitraryDiv 25 | 26 | The display formula below is not explicitly marked to be imagified. 27 | However, it will be imagified if the filter's `scope` option is set 28 | to `all`: 29 | $$P = \frac{T}{V}$$ 30 | 31 | ::: {.highlightme zoom='1'} 32 | 33 | This next formula is imagified with options provided for elements 34 | of a custom class, `highlightme`: 35 | $$P = \frac{T}{V}$$. 36 | They display the formula as an inline instead of a block and 37 | add a red border. They also specify a large zoom (4) but we've 38 | overridden it and locally specified a zoom of 1. 39 | 40 | ::: 41 | 42 | The filter automatically recognizes TikZ pictures and loads the TikZ 43 | package with the `tikz` option for the `standalone`. When `dvisvgm` is 44 | used for conversion to SVG, the required `dvisvgm` option is set too: 45 | 46 | \usetikzlibrary{intersections} 47 | \begin{tikzpicture}[scale=3,line cap=round, 48 | % Styles 49 | axes/.style=, 50 | important line/.style={very thick}] 51 | 52 | % Colors 53 | \colorlet{anglecolor}{green!50!black} 54 | \colorlet{sincolor}{red} 55 | \colorlet{tancolor}{orange!80!black} 56 | \colorlet{coscolor}{blue} 57 | 58 | % The graphic 59 | \draw[help lines,step=0.5cm] (-1.4,-1.4) grid (1.4,1.4); 60 | \draw (0,0) circle [radius=1cm]; 61 | \begin{scope}[axes] 62 | \draw[->] (-1.5,0) -- (1.5,0) node[right] {$x$} coordinate(x axis); 63 | \draw[->] (0,-1.5) -- (0,1.5) node[above] {$y$} coordinate(y axis); 64 | \foreach \x/\xtext in {-1, -.5/-\frac{1}{2}, 1} 65 | \draw[xshift=\x cm] (0pt,1pt) -- (0pt,-1pt) node[below,fill=white] {$\xtext$}; 66 | \foreach \y/\ytext in {-1, -.5/-\frac{1}{2}, .5/\frac{1}{2}, 1} 67 | \draw[yshift=\y cm] (1pt,0pt) -- (-1pt,0pt) node[left,fill=white] {$\ytext$}; 68 | \end{scope} 69 | 70 | \filldraw[fill=green!20,draw=anglecolor] (0,0) -- (3mm,0pt) arc [start angle=0, end angle=30, radius=3mm]; 71 | \draw (15:2mm) node[anglecolor] {$\alpha$}; 72 | \draw[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); 73 | 74 | \path [name path=upward line] (1,0) -- (1,1); 75 | \path [name path=sloped line] (0,0) -- (30:1.5cm); 76 | 77 | \draw [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); 78 | \draw (0,0) -- (t); 79 | \end{tikzpicture} 80 | 81 | :::: 82 | 83 | We can also use separate `.tex` and `.tikz` files as sources for images. The 84 | filter converts them to PDF (for LaTeX/PDF output) or SVG as required. 85 | That is useful to create cross-referencable figures 86 | with Pandoc-Crossref and Quarto. 87 | 88 | ![Figure 1 is a separate TikZ file](figure1.tikz) 89 | 90 | ![Figure 2 is a separate LaTeX file](figure2.tex) 91 | 92 | Currently, these should not contain a LaTeX preamble or `\begin{document}`. 93 | There is no difference between `.tikz` and `.tex` sources here. A TikZ 94 | picture in a `.tikz` file should still have `\begin{tikzpicture}` or `\tikz` commands. 95 | 96 | ::: {.fitch} 97 | 98 | We can also use LaTeX packages that are provided in the document's folder, 99 | here `fitch.sty` (a package not available on CTAN): 100 | 101 | $$\begin{nd} 102 | \hypo[~] {1} {A \lor B} 103 | \open 104 | \hypo[~] {2} {A} 105 | \have[~] {3} {C} 106 | \close 107 | \open 108 | \hypo[~] {4} {B} 109 | \have[~] {5} {D} 110 | \close 111 | \have[~] {6} {C \lor D} 112 | \end{nd}$$ 113 | 114 | ::: 115 | -------------------------------------------------------------------------------- /example-pandoc/example_defaults.yaml: -------------------------------------------------------------------------------- 1 | # Test defaults 2 | 3 | verbosity: ERROR 4 | input-files: 5 | - ${.}/example.md 6 | standalone: true 7 | filters: 8 | - {type: lua, path: imagify.lua} 9 | # Metadata must be provided in a separate file to be parsed 10 | # as Markdown 11 | metadata-file: ${.}/example_meta.yaml 12 | # Resource path needed to find `.tex`/`.tikz` figures in this subfolder 13 | resource-path: 14 | - ${.} 15 | -------------------------------------------------------------------------------- /example-pandoc/example_meta.yaml: -------------------------------------------------------------------------------- 1 | ## For LaTeX/PDF output unimagified bits require TikZ and fitch.sty 2 | header-includes: 3 | - | 4 | ```{=latex} 5 | \usepackage{tikz} 6 | \usepackage{example-pandoc/fitch} 7 | ``` 8 | imagify: 9 | scope: all 10 | embed: false 11 | lazy: true 12 | output-folder: _imagify_files 13 | pdf-engine: latex 14 | zoom: 1.5 15 | imagify-classes: 16 | highlightme: 17 | zoom: 4 # will show if it's not overriden 18 | block-style: "border: 1px solid red;" 19 | debug: false 20 | fitch: 21 | header-includes: \usepackage{fitch} 22 | debug: false 23 | -------------------------------------------------------------------------------- /example-pandoc/expected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Imagify Example 9 | 171 | 172 | 173 | 174 |
175 |

Imagify Example

176 |

Julien Dutant

177 |
178 |

Imagify the following span: the formula .

181 |
182 |

For some inline formulas, such as , the default baseline 186 | vertical alignment is not ideal. You can adjust it manually, using a 187 | negative value to lower the image below the baseline: . In this case, I’ve specified 192 | a -0.5em value, which is about half a baseline down.

193 |
194 |

To check that the filter processes elements of arbitrary depth, we’ve 195 | placed the next bit within a dummy Div block.

196 |
197 |

The display formula below is not explicitly marked to be imagified. 198 | However, it will be imagified in the filter’s scope option 199 | is set to all:

202 |
203 |

This next formula is imagified with options provided for elements of 204 | a custom class, highlightme: . They display 207 | the formula as an inline instead of a block and add a red border. They 208 | also specify a large zoom (4) but we’ve overridden it and locally 209 | specified a zoom of 1.

210 |
211 |

The filter automatically recognizes TikZ pictures and loads the TikZ 212 | package with the tikz option for the 213 | standalone. When dvisvgm is used for 214 | conversion to SVG, the required dvisvgm option is set 215 | too:

216 |

252 |
253 |

We can also use separate .tex and .tikz 254 | files as sources for images. The filter converts them to PDF (for 255 | LaTeX/PDF output) or SVG as required. That is useful to create 256 | cross-referencable figures with Pandoc-Crossref and Quarto.

257 |
258 | Figure 1 is a separate TikZ file 261 | 263 |
264 |
265 | Figure 2 is a separate LaTeX file 268 | 270 |
271 |

Currently, these should not contain a LaTeX preamble or 272 | \begin{document}. There is no difference between 273 | .tikz and .tex sources here. A TikZ picture in 274 | a .tikz file should still have 275 | \begin{tikzpicture} or \tikz commands.

276 |
277 |

We can also use LaTeX packages that are provided in the document’s 278 | folder, here fitch.sty (a package not available on 279 | CTAN):

280 |

294 |
295 | 296 | 297 | -------------------------------------------------------------------------------- /example-pandoc/figure1.tikz: -------------------------------------------------------------------------------- 1 | \usetikzlibrary {arrows.meta,graphs,shapes.misc} 2 | \tikz [>={Stealth[round]}, black!50, text=black, thick, 3 | every new ->/.style = {shorten >=1pt}, 4 | graphs/every graph/.style = {edges=rounded corners}, 5 | skip loop/.style = {to path={-- ++(0,#1) -| (\tikztotarget)}}, 6 | hv path/.style = {to path={-| (\tikztotarget)}}, 7 | vh path/.style = {to path={|- (\tikztotarget)}}, 8 | nonterminal/.style = { 9 | rectangle, minimum size=6mm, very thick, draw=red!50!black!50, top color=white, 10 | bottom color=red!50!black!20, font=\itshape, text height=1.5ex,text depth=.25ex}, 11 | terminal/.style = { 12 | rounded rectangle, minimum size=6mm, very thick, draw=black!50, top color=white, 13 | bottom color=black!20, font=\ttfamily, text height=1.5ex, text depth=.25ex}, 14 | shape = coordinate 15 | ] 16 | \graph [grow right sep, branch down=7mm, simple] { 17 | / -> unsigned integer[nonterminal] -- p1 -> "." [terminal] -- p2 -> digit[terminal] -- p3 -- p4 -- p5 -> E[terminal] -- q1 ->[vh path] 18 | {[nodes={yshift=7mm}] 19 | "+"[terminal], q2, "-"[terminal] 20 | } -> [hv path] 21 | q3 -- /unsigned integer [nonterminal] -- p6 -> /; 22 | p1 ->[skip loop=5mm] p4; 23 | p3 ->[skip loop=-5mm] p2; 24 | p5 ->[skip loop=-11mm] p6; 25 | 26 | q1 -- q2 -- q3; % make these edges plain 27 | }; -------------------------------------------------------------------------------- /example-pandoc/figure2.tex: -------------------------------------------------------------------------------- 1 | $\displaystyle 2 | \left|\int_a^b fg\right| \leq \left(\int_a^b 3 | f^2\right)^{1/2}\left(\int_a^b g^2\right)^{1/2} 4 | $ -------------------------------------------------------------------------------- /example-pandoc/fitch.sty: -------------------------------------------------------------------------------- 1 | % Macros for Fitch-style natural deduction. 2 | % Author: Peter Selinger, University of Ottawa 3 | % Created: Jan 14, 2002 4 | % Modified: Feb 8, 2005 5 | % Version: 0.5 6 | % Copyright: (C) 2002-2005 Peter Selinger 7 | % Filename: fitch.sty 8 | % Documentation: fitchdoc.tex 9 | % URL: http://quasar.mathstat.uottawa.ca/~selinger/fitch/ 10 | % new URL: https://www.mathstat.dal.ca/~selinger/fitch/ 11 | 12 | % License: 13 | % 14 | % This program is free software; you can redistribute it and/or modify 15 | % it under the terms of the GNU General Public License as published by 16 | % the Free Software Foundation; either version 2, or (at your option) 17 | % any later version. 18 | % 19 | % This program is distributed in the hope that it will be useful, but 20 | % WITHOUT ANY WARRANTY; without even the implied warranty of 21 | % MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 22 | % General Public License for more details. 23 | % 24 | % You should have received a copy of the GNU General Public License 25 | % along with this program; if not, write to the Free Software Foundation, 26 | % Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. 27 | 28 | % USAGE EXAMPLE: 29 | % 30 | % The following is a simple example illustrating the usage of this 31 | % package. For detailed instructions and additional functionality, see 32 | % the user guide, which can be found in the file fitchdoc.tex. 33 | % 34 | % \[ 35 | % \begin{nd} 36 | % \hypo{1} {P\vee Q} 37 | % \hypo{2} {\neg Q} 38 | % \open 39 | % \hypo{3a} {P} 40 | % \have{3b} {P} \r{3a} 41 | % \close 42 | % \open 43 | % \hypo{4a} {Q} 44 | % \have{4b} {\neg Q} \r{2} 45 | % \have{4c} {\bot} \ne{4a,4b} 46 | % \have{4d} {P} \be{4c} 47 | % \close 48 | % \have{5} {P} \oe{1,3a-3b,4a-4d} 49 | % \end{nd} 50 | % \] 51 | 52 | {\chardef\x=\catcode`\* 53 | \catcode`\*=11 54 | \global\let\nd*astcode\x} 55 | \catcode`\*=11 56 | 57 | % References 58 | 59 | \newcount\nd*ctr 60 | \def\nd*render{\expandafter\ifx\expandafter\nd*x\nd*base\nd*x\the\nd*ctr\else\nd*base\ifnum\nd*ctr<0\the\nd*ctr\else\ifnum\nd*ctr>0+\the\nd*ctr\fi\fi\fi} 61 | \expandafter\def\csname nd*-\endcsname{} 62 | 63 | \def\nd*num#1{\nd*numo{\nd*render}{#1}\global\advance\nd*ctr1} 64 | \def\nd*numopt#1#2{\nd*numo{$#1$}{#2}} 65 | \def\nd*numo#1#2{\edef\x{#1}\mbox{$\x$}\expandafter\global\expandafter\let\csname nd*-#2\endcsname\x} 66 | \def\nd*ref#1{\expandafter\let\expandafter\x\csname nd*-#1\endcsname\ifx\x\relax% 67 | \errmessage{Undefined natdeduction reference: #1}\else\mbox{$\x$}\fi} 68 | \def\nd*noop{} 69 | \def\nd*set#1#2{\ifx\relax#1\nd*noop\else\global\def\nd*base{#1}\fi\ifx\relax#2\relax\else\global\nd*ctr=#2\fi} 70 | \def\nd*reset{\nd*set{}{1}} 71 | \def\nd*refa#1{\nd*ref{#1}} 72 | \def\nd*aux#1#2{\ifx#2-\nd*refa{#1}--\def\nd*c{\nd*aux{}}% 73 | \else\ifx#2,\nd*refa{#1}, \def\nd*c{\nd*aux{}}% 74 | \else\ifx#2;\nd*refa{#1}; \def\nd*c{\nd*aux{}}% 75 | \else\ifx#2.\nd*refa{#1}. \def\nd*c{\nd*aux{}}% 76 | \else\ifx#2)\nd*refa{#1})\def\nd*c{\nd*aux{}}% 77 | \else\ifx#2(\nd*refa{#1}(\def\nd*c{\nd*aux{}}% 78 | \else\ifx#2\nd*end\nd*refa{#1}\def\nd*c{}% 79 | \else\def\nd*c{\nd*aux{#1#2}}% 80 | \fi\fi\fi\fi\fi\fi\fi\nd*c} 81 | \def\ndref#1{\nd*aux{}#1\nd*end} 82 | 83 | % Layer A 84 | 85 | % define various dimensions (explained in fitchdoc.tex): 86 | \newlength{\nd*dim} 87 | \newdimen\nd*depthdim 88 | \newdimen\nd*hsep 89 | \newdimen\ndindent 90 | \ndindent=1em 91 | % user command to redefine dimensions 92 | \def\nddim#1#2#3#4#5#6#7#8{\nd*depthdim=#3\relax\nd*hsep=#6\relax% 93 | \def\nd*height{#1}\def\nd*thickness{#8}\def\nd*initheight{#2}% 94 | \def\nd*indent{#5}\def\nd*labelsep{#4}\def\nd*justsep{#7}} 95 | % set initial dimensions 96 | \nddim{4.5ex}{3.5ex}{1.5ex}{1em}{1.6em}{.5em}{2.5em}{.2mm} 97 | 98 | \def\nd*v{\rule[-\nd*depthdim]{\nd*thickness}{\nd*height}} 99 | \def\nd*t{\rule[-\nd*depthdim]{0mm}{\nd*height}\rule[-\nd*depthdim]{\nd*thickness}{\nd*initheight}} 100 | \def\nd*i{\hspace{\nd*indent}} 101 | \def\nd*s{\hspace{\nd*hsep}} 102 | \def\nd*g#1{\nd*f{\makebox[\nd*indent][c]{$#1$}}} 103 | \def\nd*f#1{\raisebox{0pt}[0pt][0pt]{$#1$}} 104 | \def\nd*u#1{\makebox[0pt][l]{\settowidth{\nd*dim}{\nd*f{#1}}% 105 | \addtolength{\nd*dim}{2\nd*hsep}\hspace{-\nd*hsep}\rule[-\nd*depthdim]{\nd*dim}{\nd*thickness}}\nd*f{#1}} 106 | 107 | % Lists 108 | 109 | \def\nd*push#1#2{\expandafter\gdef\expandafter#1\expandafter% 110 | {\expandafter\nd*cons\expandafter{#1}{#2}}} 111 | \def\nd*pop#1{{\def\nd*nil{\gdef#1{\nd*nil}}\def\nd*cons##1##2% 112 | {\gdef#1{##1}}#1}} 113 | \def\nd*iter#1#2{{\def\nd*nil{}\def\nd*cons##1##2{##1#2{##2}}#1}} 114 | \def\nd*modify#1#2#3{{\def\nd*nil{\gdef#1{\nd*nil}}\def\nd*cons##1##2% 115 | {\advance#2-1 ##1\advance#2 1 \ifnum#2=1\nd*push#1{#3}\else% 116 | \nd*push#1{##2}\fi}#1}} 117 | 118 | \def\nd*cont#1{{\def\nd*t{\nd*v}\def\nd*v{\nd*v}\def\nd*g##1{\nd*i}% 119 | \def\nd*i{\nd*i}\def\nd*nil{\gdef#1{\nd*nil}}\def\nd*cons##1##2% 120 | {##1\expandafter\nd*push\expandafter#1\expandafter{##2}}#1}} 121 | 122 | % Layer B 123 | 124 | \newcount\nd*n 125 | \def\nd*beginb{\begingroup\nd*reset\gdef\nd*stack{\nd*nil}\nd*push\nd*stack{\nd*t}% 126 | \begin{array}{l@{\hspace{\nd*labelsep}}l@{\hspace{\nd*justsep}}l}} 127 | \def\nd*resumeb{\begingroup\begin{array}{l@{\hspace{\nd*labelsep}}l@{\hspace{\nd*justsep}}l}} 128 | \def\nd*endb{\end{array}\endgroup} 129 | \def\nd*hypob#1#2{\nd*f{\nd*num{#1}}&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*u{#2}&} 130 | \def\nd*haveb#1#2{\nd*f{\nd*num{#1}}&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*f{#2}&} 131 | \def\nd*havecontb#1#2{&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*f{\hspace{\ndindent}#2}&} 132 | \def\nd*hypocontb#1#2{&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*u{\hspace{\ndindent}#2}&} 133 | 134 | \def\nd*openb{\nd*push\nd*stack{\nd*i}\nd*push\nd*stack{\nd*t}} 135 | \def\nd*closeb{\nd*pop\nd*stack\nd*pop\nd*stack} 136 | \def\nd*guardb#1#2{\nd*n=#1\multiply\nd*n by 2 \nd*modify\nd*stack\nd*n{\nd*g{#2}}} 137 | 138 | % Layer C 139 | 140 | \def\nd*clr{\gdef\nd*cmd{}\gdef\nd*typ{\relax}} 141 | \def\nd*sto#1#2#3{\gdef\nd*typ{#1}\gdef\nd*byt{}% 142 | \gdef\nd*cmd{\nd*typ{#2}{#3}\nd*byt\\}} 143 | \def\nd*chtyp{\expandafter\ifx\nd*typ\nd*hypocontb\def\nd*typ{\nd*havecontb}\else\def\nd*typ{\nd*haveb}\fi} 144 | \def\nd*hypoc#1#2{\nd*chtyp\nd*cmd\nd*sto{\nd*hypob}{#1}{#2}} 145 | \def\nd*havec#1#2{\nd*cmd\nd*sto{\nd*haveb}{#1}{#2}} 146 | \def\nd*hypocontc#1{\nd*chtyp\nd*cmd\nd*sto{\nd*hypocontb}{}{#1}} 147 | \def\nd*havecontc#1{\nd*cmd\nd*sto{\nd*havecontb}{}{#1}} 148 | \def\nd*by#1#2{\ifx\nd*x#2\nd*x\gdef\nd*byt{\mbox{#1}}\else\gdef\nd*byt{\mbox{#1, \ndref{#2}}}\fi} 149 | 150 | % multi-line macros 151 | \def\nd*mhypoc#1#2{\nd*mhypocA{#1}#2\\\nd*stop\\} 152 | \def\nd*mhypocA#1#2\\{\nd*hypoc{#1}{#2}\nd*mhypocB} 153 | \def\nd*mhypocB#1\\{\ifx\nd*stop#1\else\nd*hypocontc{#1}\expandafter\nd*mhypocB\fi} 154 | \def\nd*mhavec#1#2{\nd*mhavecA{#1}#2\\\nd*stop\\} 155 | \def\nd*mhavecA#1#2\\{\nd*havec{#1}{#2}\nd*mhavecB} 156 | \def\nd*mhavecB#1\\{\ifx\nd*stop#1\else\nd*havecontc{#1}\expandafter\nd*mhavecB\fi} 157 | \def\nd*mhypocontc#1{\nd*mhypocB#1\\\nd*stop\\} 158 | \def\nd*mhavecontc#1{\nd*mhavecB#1\\\nd*stop\\} 159 | 160 | \def\nd*beginc{\nd*beginb\nd*clr} 161 | \def\nd*resumec{\nd*resumeb\nd*clr} 162 | \def\nd*endc{\nd*cmd\nd*endb} 163 | \def\nd*openc{\nd*cmd\nd*clr\nd*openb} 164 | \def\nd*closec{\nd*cmd\nd*clr\nd*closeb} 165 | \let\nd*guardc\nd*guardb 166 | 167 | % Layer D 168 | 169 | % macros with optional arguments spelled-out 170 | \def\nd*hypod[#1][#2]#3[#4]#5{\ifx\relax#4\relax\else\nd*guardb{1}{#4}\fi\nd*mhypoc{#3}{#5}\nd*set{#1}{#2}} 171 | \def\nd*haved[#1][#2]#3[#4]#5{\ifx\relax#4\relax\else\nd*guardb{1}{#4}\fi\nd*mhavec{#3}{#5}\nd*set{#1}{#2}} 172 | \def\nd*havecont#1{\nd*mhavecontc{#1}} 173 | \def\nd*hypocont#1{\nd*mhypocontc{#1}} 174 | \def\nd*base{undefined} 175 | \def\nd*opend[#1]#2{\nd*cmd\nd*clr\nd*openb\nd*guard{#1}#2} 176 | \def\nd*close{\nd*cmd\nd*clr\nd*closeb} 177 | \def\nd*guardd[#1]#2{\nd*guardb{#1}{#2}} 178 | 179 | % Handling of optional arguments. 180 | 181 | \def\nd*optarg#1#2#3{\ifx[#3\def\nd*c{#2#3}\else\def\nd*c{#2[#1]{#3}}\fi\nd*c} 182 | \def\nd*optargg#1#2#3{\ifx[#3\def\nd*c{#1#3}\else\def\nd*c{#2{#3}}\fi\nd*c} 183 | 184 | \def\nd*five#1{\nd*optargg{\nd*four{#1}}{\nd*two{#1}}} 185 | \def\nd*four#1[#2]{\nd*optarg{0}{\nd*three{#1}[#2]}} 186 | \def\nd*three#1[#2][#3]#4{\nd*optarg{}{#1[#2][#3]{#4}}} 187 | \def\nd*two#1{\nd*three{#1}[\relax][]} 188 | 189 | \def\nd*have{\nd*five{\nd*haved}} 190 | \def\nd*hypo{\nd*five{\nd*hypod}} 191 | \def\nd*open{\nd*optarg{}{\nd*opend}} 192 | \def\nd*guard{\nd*optarg{1}{\nd*guardd}} 193 | 194 | \def\nd*init{% 195 | \let\open\nd*open% 196 | \let\close\nd*close% 197 | \let\hypo\nd*hypo% 198 | \let\have\nd*have% 199 | \let\hypocont\nd*hypocont% 200 | \let\havecont\nd*havecont% 201 | \let\by\nd*by% 202 | \let\guard\nd*guard% 203 | \def\ii{\by{$\Rightarrow$I}}% 204 | \def\ie{\by{$\Rightarrow$E}}% 205 | \def\Ai{\by{$\forall$I}}% 206 | \def\Ae{\by{$\forall$E}}% 207 | \def\Ei{\by{$\exists$I}}% 208 | \def\Ee{\by{$\exists$E}}% 209 | \def\ai{\by{$\wedge$I}}% 210 | \def\ae{\by{$\wedge$E}}% 211 | \def\ai{\by{$\wedge$I}}% 212 | \def\ae{\by{$\wedge$E}}% 213 | \def\oi{\by{$\vee$I}}% 214 | \def\oe{\by{$\vee$E}}% 215 | \def\ni{\by{$\neg$I}}% 216 | \def\ne{\by{$\neg$E}}% 217 | \def\be{\by{$\bot$E}}% 218 | \def\nne{\by{$\neg\neg$E}}% 219 | \def\r{\by{R}}% 220 | } 221 | 222 | \newenvironment{nd}{\begingroup\nd*init\nd*beginc}{\nd*endc\endgroup} 223 | \newenvironment{ndresume}{\begingroup\nd*init\nd*resumec}{\nd*endc\endgroup} 224 | 225 | \catcode`\*=\nd*astcode 226 | 227 | % End of file fitch.sty 228 | 229 | -------------------------------------------------------------------------------- /example-pandoc/website_defaults.yaml: -------------------------------------------------------------------------------- 1 | # Pandoc defaults for generating docs example output 2 | 3 | # Needed to set imagify/output-folder to _site 4 | # in website_medata.yaml 5 | 6 | verbosity: ERROR 7 | input-files: 8 | - ${.}/example.md 9 | standalone: true 10 | filters: 11 | - {type: lua, path: imagify.lua} 12 | # Metadata must be provided in a separate file to be parsed 13 | # as Markdown 14 | metadata-file: ${.}/website_meta.yaml 15 | # Resource path needed to find `.tex`/`.tikz` figures in this subfolder 16 | resource-path: 17 | - ${.} 18 | -------------------------------------------------------------------------------- /example-pandoc/website_meta.yaml: -------------------------------------------------------------------------------- 1 | imagify: 2 | scope: all 3 | embed: false 4 | lazy: false 5 | output-folder: _site/_imagify_files/ 6 | pdf-engine: latex 7 | zoom: 1.5 8 | imagify-classes: 9 | highlightme: 10 | zoom: 4 # will show if it's not overriden 11 | block-style: "border: 1px solid red;" 12 | debug: false 13 | fitch: 14 | header-includes: \usepackage{fitch} 15 | debug: false 16 | -------------------------------------------------------------------------------- /example-quarto/example.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Imagify Example" 3 | filters: 4 | - ../imagify.lua 5 | imagify: 6 | scope: all 7 | embed: false 8 | lazy: true 9 | output-folder: _imagify_files 10 | pdf-engine: latex 11 | zoom: 1.5 12 | imagify-classes: 13 | highlightme: 14 | zoom: 4 # will show if it's not overriden 15 | block-style: "border: 1px solid red;" 16 | debug: false 17 | fitch: 18 | header-includes: \usepackage{fitch} 19 | debug: false 20 | 21 | format: 22 | html: 23 | toc: true 24 | pdf: 25 | include-in-header: | 26 | \usepackage{tikz} 27 | \usepackage{example-pandoc/fitch} 28 | 29 | --- 30 | 31 | Imagify the following span: [the formula $E = mc^2$]{.imagify}. 32 | 33 | 34 | ::: imagify 35 | 36 | For some inline formulas, such as 37 | $x=\frac{-b\pm\sqrt[]{b^2-4ac}}{2a}$, the default `baseline` vertical 38 | alignment is not ideal. You can adjust it manually, using a negative 39 | value to lower the image below the baseline: 40 | [$x=\frac{-b\pm\sqrt[]{b^2-4ac}}{2a}$]{.imagify 41 | vertical-align="-.5em"}. In this case, I've specified a `-0.5em` 42 | value, which is about half a baseline down. 43 | 44 | ::: 45 | 46 | To check that the filter processes elements of arbitrary depth, we've 47 | placed the next bit within a dummy Div block. 48 | 49 | :::: arbitraryDiv 50 | 51 | The display formula below is not explicitly marked to be imagified. 52 | However, it will be imagified if the filter's `scope` option is set 53 | to `all`: 54 | $$P = \frac{T}{V}$$ 55 | 56 | ::: {.highlightme zoom='1'} 57 | 58 | This next formula is imagified with options provided for elements 59 | of a custom class, `highlightme`: 60 | $$P = \frac{T}{V}$$. 61 | They display the formula as an inline instead of a block and 62 | add a red border. They also specify a large zoom (4) but we've 63 | overridden it and locally specified a zoom of 1. 64 | 65 | ::: 66 | 67 | The filter automatically recognizes TikZ pictures and loads the TikZ 68 | package with the `tikz` option for the `standalone`. When `dvisvgm` is 69 | used for conversion to SVG, the required `dvisvgm` option is set too: 70 | 71 | \usetikzlibrary{intersections} 72 | \begin{tikzpicture}[scale=3,line cap=round, 73 | % Styles 74 | axes/.style=, 75 | important line/.style={very thick}] 76 | 77 | % Colors 78 | \colorlet{anglecolor}{green!50!black} 79 | \colorlet{sincolor}{red} 80 | \colorlet{tancolor}{orange!80!black} 81 | \colorlet{coscolor}{blue} 82 | 83 | % The graphic 84 | \draw[help lines,step=0.5cm] (-1.4,-1.4) grid (1.4,1.4); 85 | \draw (0,0) circle [radius=1cm]; 86 | \begin{scope}[axes] 87 | \draw[->] (-1.5,0) -- (1.5,0) node[right] {$x$} coordinate(x axis); 88 | \draw[->] (0,-1.5) -- (0,1.5) node[above] {$y$} coordinate(y axis); 89 | \foreach \x/\xtext in {-1, -.5/-\frac{1}{2}, 1} 90 | \draw[xshift=\x cm] (0pt,1pt) -- (0pt,-1pt) node[below,fill=white] {$\xtext$}; 91 | \foreach \y/\ytext in {-1, -.5/-\frac{1}{2}, .5/\frac{1}{2}, 1} 92 | \draw[yshift=\y cm] (1pt,0pt) -- (-1pt,0pt) node[left,fill=white] {$\ytext$}; 93 | \end{scope} 94 | 95 | \filldraw[fill=green!20,draw=anglecolor] (0,0) -- (3mm,0pt) arc [start angle=0, end angle=30, radius=3mm]; 96 | \draw (15:2mm) node[anglecolor] {$\alpha$}; 97 | \draw[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); 98 | 99 | \path [name path=upward line] (1,0) -- (1,1); 100 | \path [name path=sloped line] (0,0) -- (30:1.5cm); 101 | 102 | \draw [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); 103 | \draw (0,0) -- (t); 104 | \end{tikzpicture} 105 | 106 | :::: 107 | 108 | We can also use separate `.tex` and `.tikz` files as sources for images. The 109 | filter converts them to PDF (for LaTeX/PDF output) or SVG as required. 110 | That is useful to create cross-referencable figures 111 | with Pandoc-Crossref and Quarto. 112 | 113 | ![Figure 1 is a separate TikZ file](figure1.tikz) 114 | 115 | ![Figure 2 is a separate LaTeX file](figure2.tex) 116 | 117 | Currently, these should not contain a LaTeX preamble or `\begin{document}`. 118 | There is no difference between `.tikz` and `.tex` sources here. A TikZ 119 | picture in a `.tikz` file should still have `\begin{tikzpicture}` or `\tikz` commands. 120 | 121 | ::: {.fitch} 122 | 123 | We can also use LaTeX packages that are provided in the document's folder, 124 | here `fitch.sty` (a package not available on CTAN): 125 | 126 | $$\begin{nd} 127 | \hypo[~] {1} {A \lor B} 128 | \open 129 | \hypo[~] {2} {A} 130 | \have[~] {3} {C} 131 | \close 132 | \open 133 | \hypo[~] {4} {B} 134 | \have[~] {5} {D} 135 | \close 136 | \have[~] {6} {C \lor D} 137 | \end{nd}$$ 138 | 139 | ::: 140 | -------------------------------------------------------------------------------- /example-quarto/figure1.tikz: -------------------------------------------------------------------------------- 1 | \usetikzlibrary {arrows.meta,graphs,shapes.misc} 2 | \tikz [>={Stealth[round]}, black!50, text=black, thick, 3 | every new ->/.style = {shorten >=1pt}, 4 | graphs/every graph/.style = {edges=rounded corners}, 5 | skip loop/.style = {to path={-- ++(0,#1) -| (\tikztotarget)}}, 6 | hv path/.style = {to path={-| (\tikztotarget)}}, 7 | vh path/.style = {to path={|- (\tikztotarget)}}, 8 | nonterminal/.style = { 9 | rectangle, minimum size=6mm, very thick, draw=red!50!black!50, top color=white, 10 | bottom color=red!50!black!20, font=\itshape, text height=1.5ex,text depth=.25ex}, 11 | terminal/.style = { 12 | rounded rectangle, minimum size=6mm, very thick, draw=black!50, top color=white, 13 | bottom color=black!20, font=\ttfamily, text height=1.5ex, text depth=.25ex}, 14 | shape = coordinate 15 | ] 16 | \graph [grow right sep, branch down=7mm, simple] { 17 | / -> unsigned integer[nonterminal] -- p1 -> "." [terminal] -- p2 -> digit[terminal] -- p3 -- p4 -- p5 -> E[terminal] -- q1 ->[vh path] 18 | {[nodes={yshift=7mm}] 19 | "+"[terminal], q2, "-"[terminal] 20 | } -> [hv path] 21 | q3 -- /unsigned integer [nonterminal] -- p6 -> /; 22 | p1 ->[skip loop=5mm] p4; 23 | p3 ->[skip loop=-5mm] p2; 24 | p5 ->[skip loop=-11mm] p6; 25 | 26 | q1 -- q2 -- q3; % make these edges plain 27 | }; -------------------------------------------------------------------------------- /example-quarto/figure2.tex: -------------------------------------------------------------------------------- 1 | $\displaystyle 2 | \left|\int_a^b fg\right| \leq \left(\int_a^b 3 | f^2\right)^{1/2}\left(\int_a^b g^2\right)^{1/2} 4 | $ -------------------------------------------------------------------------------- /example-quarto/fitch.sty: -------------------------------------------------------------------------------- 1 | % Macros for Fitch-style natural deduction. 2 | % Author: Peter Selinger, University of Ottawa 3 | % Created: Jan 14, 2002 4 | % Modified: Feb 8, 2005 5 | % Version: 0.5 6 | % Copyright: (C) 2002-2005 Peter Selinger 7 | % Filename: fitch.sty 8 | % Documentation: fitchdoc.tex 9 | % URL: http://quasar.mathstat.uottawa.ca/~selinger/fitch/ 10 | % new URL: https://www.mathstat.dal.ca/~selinger/fitch/ 11 | 12 | % License: 13 | % 14 | % This program is free software; you can redistribute it and/or modify 15 | % it under the terms of the GNU General Public License as published by 16 | % the Free Software Foundation; either version 2, or (at your option) 17 | % any later version. 18 | % 19 | % This program is distributed in the hope that it will be useful, but 20 | % WITHOUT ANY WARRANTY; without even the implied warranty of 21 | % MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 22 | % General Public License for more details. 23 | % 24 | % You should have received a copy of the GNU General Public License 25 | % along with this program; if not, write to the Free Software Foundation, 26 | % Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. 27 | 28 | % USAGE EXAMPLE: 29 | % 30 | % The following is a simple example illustrating the usage of this 31 | % package. For detailed instructions and additional functionality, see 32 | % the user guide, which can be found in the file fitchdoc.tex. 33 | % 34 | % \[ 35 | % \begin{nd} 36 | % \hypo{1} {P\vee Q} 37 | % \hypo{2} {\neg Q} 38 | % \open 39 | % \hypo{3a} {P} 40 | % \have{3b} {P} \r{3a} 41 | % \close 42 | % \open 43 | % \hypo{4a} {Q} 44 | % \have{4b} {\neg Q} \r{2} 45 | % \have{4c} {\bot} \ne{4a,4b} 46 | % \have{4d} {P} \be{4c} 47 | % \close 48 | % \have{5} {P} \oe{1,3a-3b,4a-4d} 49 | % \end{nd} 50 | % \] 51 | 52 | {\chardef\x=\catcode`\* 53 | \catcode`\*=11 54 | \global\let\nd*astcode\x} 55 | \catcode`\*=11 56 | 57 | % References 58 | 59 | \newcount\nd*ctr 60 | \def\nd*render{\expandafter\ifx\expandafter\nd*x\nd*base\nd*x\the\nd*ctr\else\nd*base\ifnum\nd*ctr<0\the\nd*ctr\else\ifnum\nd*ctr>0+\the\nd*ctr\fi\fi\fi} 61 | \expandafter\def\csname nd*-\endcsname{} 62 | 63 | \def\nd*num#1{\nd*numo{\nd*render}{#1}\global\advance\nd*ctr1} 64 | \def\nd*numopt#1#2{\nd*numo{$#1$}{#2}} 65 | \def\nd*numo#1#2{\edef\x{#1}\mbox{$\x$}\expandafter\global\expandafter\let\csname nd*-#2\endcsname\x} 66 | \def\nd*ref#1{\expandafter\let\expandafter\x\csname nd*-#1\endcsname\ifx\x\relax% 67 | \errmessage{Undefined natdeduction reference: #1}\else\mbox{$\x$}\fi} 68 | \def\nd*noop{} 69 | \def\nd*set#1#2{\ifx\relax#1\nd*noop\else\global\def\nd*base{#1}\fi\ifx\relax#2\relax\else\global\nd*ctr=#2\fi} 70 | \def\nd*reset{\nd*set{}{1}} 71 | \def\nd*refa#1{\nd*ref{#1}} 72 | \def\nd*aux#1#2{\ifx#2-\nd*refa{#1}--\def\nd*c{\nd*aux{}}% 73 | \else\ifx#2,\nd*refa{#1}, \def\nd*c{\nd*aux{}}% 74 | \else\ifx#2;\nd*refa{#1}; \def\nd*c{\nd*aux{}}% 75 | \else\ifx#2.\nd*refa{#1}. \def\nd*c{\nd*aux{}}% 76 | \else\ifx#2)\nd*refa{#1})\def\nd*c{\nd*aux{}}% 77 | \else\ifx#2(\nd*refa{#1}(\def\nd*c{\nd*aux{}}% 78 | \else\ifx#2\nd*end\nd*refa{#1}\def\nd*c{}% 79 | \else\def\nd*c{\nd*aux{#1#2}}% 80 | \fi\fi\fi\fi\fi\fi\fi\nd*c} 81 | \def\ndref#1{\nd*aux{}#1\nd*end} 82 | 83 | % Layer A 84 | 85 | % define various dimensions (explained in fitchdoc.tex): 86 | \newlength{\nd*dim} 87 | \newdimen\nd*depthdim 88 | \newdimen\nd*hsep 89 | \newdimen\ndindent 90 | \ndindent=1em 91 | % user command to redefine dimensions 92 | \def\nddim#1#2#3#4#5#6#7#8{\nd*depthdim=#3\relax\nd*hsep=#6\relax% 93 | \def\nd*height{#1}\def\nd*thickness{#8}\def\nd*initheight{#2}% 94 | \def\nd*indent{#5}\def\nd*labelsep{#4}\def\nd*justsep{#7}} 95 | % set initial dimensions 96 | \nddim{4.5ex}{3.5ex}{1.5ex}{1em}{1.6em}{.5em}{2.5em}{.2mm} 97 | 98 | \def\nd*v{\rule[-\nd*depthdim]{\nd*thickness}{\nd*height}} 99 | \def\nd*t{\rule[-\nd*depthdim]{0mm}{\nd*height}\rule[-\nd*depthdim]{\nd*thickness}{\nd*initheight}} 100 | \def\nd*i{\hspace{\nd*indent}} 101 | \def\nd*s{\hspace{\nd*hsep}} 102 | \def\nd*g#1{\nd*f{\makebox[\nd*indent][c]{$#1$}}} 103 | \def\nd*f#1{\raisebox{0pt}[0pt][0pt]{$#1$}} 104 | \def\nd*u#1{\makebox[0pt][l]{\settowidth{\nd*dim}{\nd*f{#1}}% 105 | \addtolength{\nd*dim}{2\nd*hsep}\hspace{-\nd*hsep}\rule[-\nd*depthdim]{\nd*dim}{\nd*thickness}}\nd*f{#1}} 106 | 107 | % Lists 108 | 109 | \def\nd*push#1#2{\expandafter\gdef\expandafter#1\expandafter% 110 | {\expandafter\nd*cons\expandafter{#1}{#2}}} 111 | \def\nd*pop#1{{\def\nd*nil{\gdef#1{\nd*nil}}\def\nd*cons##1##2% 112 | {\gdef#1{##1}}#1}} 113 | \def\nd*iter#1#2{{\def\nd*nil{}\def\nd*cons##1##2{##1#2{##2}}#1}} 114 | \def\nd*modify#1#2#3{{\def\nd*nil{\gdef#1{\nd*nil}}\def\nd*cons##1##2% 115 | {\advance#2-1 ##1\advance#2 1 \ifnum#2=1\nd*push#1{#3}\else% 116 | \nd*push#1{##2}\fi}#1}} 117 | 118 | \def\nd*cont#1{{\def\nd*t{\nd*v}\def\nd*v{\nd*v}\def\nd*g##1{\nd*i}% 119 | \def\nd*i{\nd*i}\def\nd*nil{\gdef#1{\nd*nil}}\def\nd*cons##1##2% 120 | {##1\expandafter\nd*push\expandafter#1\expandafter{##2}}#1}} 121 | 122 | % Layer B 123 | 124 | \newcount\nd*n 125 | \def\nd*beginb{\begingroup\nd*reset\gdef\nd*stack{\nd*nil}\nd*push\nd*stack{\nd*t}% 126 | \begin{array}{l@{\hspace{\nd*labelsep}}l@{\hspace{\nd*justsep}}l}} 127 | \def\nd*resumeb{\begingroup\begin{array}{l@{\hspace{\nd*labelsep}}l@{\hspace{\nd*justsep}}l}} 128 | \def\nd*endb{\end{array}\endgroup} 129 | \def\nd*hypob#1#2{\nd*f{\nd*num{#1}}&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*u{#2}&} 130 | \def\nd*haveb#1#2{\nd*f{\nd*num{#1}}&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*f{#2}&} 131 | \def\nd*havecontb#1#2{&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*f{\hspace{\ndindent}#2}&} 132 | \def\nd*hypocontb#1#2{&\nd*iter\nd*stack\relax\nd*cont\nd*stack\nd*s\nd*u{\hspace{\ndindent}#2}&} 133 | 134 | \def\nd*openb{\nd*push\nd*stack{\nd*i}\nd*push\nd*stack{\nd*t}} 135 | \def\nd*closeb{\nd*pop\nd*stack\nd*pop\nd*stack} 136 | \def\nd*guardb#1#2{\nd*n=#1\multiply\nd*n by 2 \nd*modify\nd*stack\nd*n{\nd*g{#2}}} 137 | 138 | % Layer C 139 | 140 | \def\nd*clr{\gdef\nd*cmd{}\gdef\nd*typ{\relax}} 141 | \def\nd*sto#1#2#3{\gdef\nd*typ{#1}\gdef\nd*byt{}% 142 | \gdef\nd*cmd{\nd*typ{#2}{#3}\nd*byt\\}} 143 | \def\nd*chtyp{\expandafter\ifx\nd*typ\nd*hypocontb\def\nd*typ{\nd*havecontb}\else\def\nd*typ{\nd*haveb}\fi} 144 | \def\nd*hypoc#1#2{\nd*chtyp\nd*cmd\nd*sto{\nd*hypob}{#1}{#2}} 145 | \def\nd*havec#1#2{\nd*cmd\nd*sto{\nd*haveb}{#1}{#2}} 146 | \def\nd*hypocontc#1{\nd*chtyp\nd*cmd\nd*sto{\nd*hypocontb}{}{#1}} 147 | \def\nd*havecontc#1{\nd*cmd\nd*sto{\nd*havecontb}{}{#1}} 148 | \def\nd*by#1#2{\ifx\nd*x#2\nd*x\gdef\nd*byt{\mbox{#1}}\else\gdef\nd*byt{\mbox{#1, \ndref{#2}}}\fi} 149 | 150 | % multi-line macros 151 | \def\nd*mhypoc#1#2{\nd*mhypocA{#1}#2\\\nd*stop\\} 152 | \def\nd*mhypocA#1#2\\{\nd*hypoc{#1}{#2}\nd*mhypocB} 153 | \def\nd*mhypocB#1\\{\ifx\nd*stop#1\else\nd*hypocontc{#1}\expandafter\nd*mhypocB\fi} 154 | \def\nd*mhavec#1#2{\nd*mhavecA{#1}#2\\\nd*stop\\} 155 | \def\nd*mhavecA#1#2\\{\nd*havec{#1}{#2}\nd*mhavecB} 156 | \def\nd*mhavecB#1\\{\ifx\nd*stop#1\else\nd*havecontc{#1}\expandafter\nd*mhavecB\fi} 157 | \def\nd*mhypocontc#1{\nd*mhypocB#1\\\nd*stop\\} 158 | \def\nd*mhavecontc#1{\nd*mhavecB#1\\\nd*stop\\} 159 | 160 | \def\nd*beginc{\nd*beginb\nd*clr} 161 | \def\nd*resumec{\nd*resumeb\nd*clr} 162 | \def\nd*endc{\nd*cmd\nd*endb} 163 | \def\nd*openc{\nd*cmd\nd*clr\nd*openb} 164 | \def\nd*closec{\nd*cmd\nd*clr\nd*closeb} 165 | \let\nd*guardc\nd*guardb 166 | 167 | % Layer D 168 | 169 | % macros with optional arguments spelled-out 170 | \def\nd*hypod[#1][#2]#3[#4]#5{\ifx\relax#4\relax\else\nd*guardb{1}{#4}\fi\nd*mhypoc{#3}{#5}\nd*set{#1}{#2}} 171 | \def\nd*haved[#1][#2]#3[#4]#5{\ifx\relax#4\relax\else\nd*guardb{1}{#4}\fi\nd*mhavec{#3}{#5}\nd*set{#1}{#2}} 172 | \def\nd*havecont#1{\nd*mhavecontc{#1}} 173 | \def\nd*hypocont#1{\nd*mhypocontc{#1}} 174 | \def\nd*base{undefined} 175 | \def\nd*opend[#1]#2{\nd*cmd\nd*clr\nd*openb\nd*guard{#1}#2} 176 | \def\nd*close{\nd*cmd\nd*clr\nd*closeb} 177 | \def\nd*guardd[#1]#2{\nd*guardb{#1}{#2}} 178 | 179 | % Handling of optional arguments. 180 | 181 | \def\nd*optarg#1#2#3{\ifx[#3\def\nd*c{#2#3}\else\def\nd*c{#2[#1]{#3}}\fi\nd*c} 182 | \def\nd*optargg#1#2#3{\ifx[#3\def\nd*c{#1#3}\else\def\nd*c{#2{#3}}\fi\nd*c} 183 | 184 | \def\nd*five#1{\nd*optargg{\nd*four{#1}}{\nd*two{#1}}} 185 | \def\nd*four#1[#2]{\nd*optarg{0}{\nd*three{#1}[#2]}} 186 | \def\nd*three#1[#2][#3]#4{\nd*optarg{}{#1[#2][#3]{#4}}} 187 | \def\nd*two#1{\nd*three{#1}[\relax][]} 188 | 189 | \def\nd*have{\nd*five{\nd*haved}} 190 | \def\nd*hypo{\nd*five{\nd*hypod}} 191 | \def\nd*open{\nd*optarg{}{\nd*opend}} 192 | \def\nd*guard{\nd*optarg{1}{\nd*guardd}} 193 | 194 | \def\nd*init{% 195 | \let\open\nd*open% 196 | \let\close\nd*close% 197 | \let\hypo\nd*hypo% 198 | \let\have\nd*have% 199 | \let\hypocont\nd*hypocont% 200 | \let\havecont\nd*havecont% 201 | \let\by\nd*by% 202 | \let\guard\nd*guard% 203 | \def\ii{\by{$\Rightarrow$I}}% 204 | \def\ie{\by{$\Rightarrow$E}}% 205 | \def\Ai{\by{$\forall$I}}% 206 | \def\Ae{\by{$\forall$E}}% 207 | \def\Ei{\by{$\exists$I}}% 208 | \def\Ee{\by{$\exists$E}}% 209 | \def\ai{\by{$\wedge$I}}% 210 | \def\ae{\by{$\wedge$E}}% 211 | \def\ai{\by{$\wedge$I}}% 212 | \def\ae{\by{$\wedge$E}}% 213 | \def\oi{\by{$\vee$I}}% 214 | \def\oe{\by{$\vee$E}}% 215 | \def\ni{\by{$\neg$I}}% 216 | \def\ne{\by{$\neg$E}}% 217 | \def\be{\by{$\bot$E}}% 218 | \def\nne{\by{$\neg\neg$E}}% 219 | \def\r{\by{R}}% 220 | } 221 | 222 | \newenvironment{nd}{\begingroup\nd*init\nd*beginc}{\nd*endc\endgroup} 223 | \newenvironment{ndresume}{\begingroup\nd*init\nd*resumec}{\nd*endc\endgroup} 224 | 225 | \catcode`\*=\nd*astcode 226 | 227 | % End of file fitch.sty 228 | 229 | -------------------------------------------------------------------------------- /imagify.lua: -------------------------------------------------------------------------------- 1 | _extensions/imagify/imagify.lua -------------------------------------------------------------------------------- /src/common.lua: -------------------------------------------------------------------------------- 1 | ---message: send message to std_error 2 | ---comment 3 | ---@param type 'INFO'|'WARNING'|'ERROR' 4 | ---@param text string error message 5 | function message (type, text) 6 | local level = {INFO = 0, WARNING = 1, ERROR = 2} 7 | if level[type] == nil then type = 'ERROR' end 8 | if level[PANDOC_STATE.verbosity] <= level[type] then 9 | io.stderr:write('[' .. type .. '] Imagify: ' 10 | .. text .. '\n') 11 | end 12 | end 13 | 14 | ---tfind: finds a value in an array 15 | ---comment 16 | ---@param tbl table 17 | ---@return number|false result 18 | function tfind(tbl, needle) 19 | local i = 0 20 | for _,v in ipairs(tbl) do 21 | i = i + 1 22 | if v == needle then 23 | return i 24 | end 25 | end 26 | return false 27 | end 28 | 29 | ---concatStrings: concatenate a list of strings into one. 30 | ---@param list string[] list of strings 31 | ---@param separator string separator (optional) 32 | ---@return string result 33 | function concatStrings(list, separator) 34 | separator = separator and separator or '' 35 | local result = '' 36 | for _,str in ipairs(list) do 37 | result = result..separator..str 38 | end 39 | return result 40 | end 41 | 42 | ---mergeMapInto: returns a new map resulting from merging a new one 43 | -- into an old one. 44 | ---@param new table|nil map with overriding values 45 | ---@param old table|nil map with original values 46 | ---@return table result new map with merged values 47 | function mergeMapInto(new,old) 48 | local result = {} -- we need to clone 49 | if type(old) == 'table' then 50 | for k,v in pairs(old) do result[k] = v end 51 | end 52 | if type(new) == 'table' then 53 | for k,v in pairs(new) do result[k] = v end 54 | end 55 | return result 56 | end 57 | -------------------------------------------------------------------------------- /src/file.lua: -------------------------------------------------------------------------------- 1 | -- ## File functions 2 | 3 | local system = pandoc.system 4 | local path = pandoc.path 5 | 6 | ---fileExists: checks whether a file exists 7 | function fileExists(filepath) 8 | local f = io.open(filepath, 'r') 9 | if f ~= nil then 10 | io.close(f) 11 | return true 12 | else 13 | return false 14 | end 15 | end 16 | 17 | ---makeAbsolute: make filepath absolute 18 | ---@param filepath string file path 19 | ---@param root string|nil if relative, use this as root (default working dir) 20 | function makeAbsolute(filepath, root) 21 | root = root or system.get_working_directory() 22 | return path.is_absolute(filepath) and filepath 23 | or path.join{ root, filepath} 24 | end 25 | 26 | ---folderExists: checks whether a folder exists 27 | function folderExists(folderpath) 28 | 29 | -- the empty path always exists 30 | if folderpath == '' then return true end 31 | 32 | -- normalize folderpath 33 | folderpath = folderpath:gsub('[/\\]$','')..path.separator 34 | local ok, err, code = os.rename(folderpath, folderpath) 35 | -- err = 13 permission denied 36 | return ok or err == 13 or false 37 | end 38 | 39 | ---ensureFolderExists: create a folder if needed 40 | function ensureFolderExists(folderpath) 41 | local ok, err, code = true, nil, nil 42 | 43 | -- the empty path always exists 44 | if folderpath == '' then return true, nil, nil end 45 | 46 | -- normalize folderpath 47 | folderpath = folderpath:gsub('[/\\]$','') 48 | 49 | if not folderExists(folderpath) then 50 | ok, err, code = os.execute('mkdir '..folderpath) 51 | end 52 | 53 | return ok, err, code 54 | end 55 | 56 | ---writeToFile: write string to file. 57 | ---@param contents string file contents 58 | ---@param filepath string file path 59 | ---@param mode string 'b' for binary, any other value text mode 60 | ---@return nil | string status error message 61 | function writeToFile(contents, filepath, mode) 62 | local mode = mode == 'b' and 'wb' or 'w' 63 | local f = io.open(filepath, mode) 64 | if f then 65 | f:write(contents) 66 | f:close() 67 | else 68 | return 'File not found' 69 | end 70 | end 71 | 72 | ---readFile: read file as string (default) or binary. 73 | ---@param filepath string file path 74 | ---@param mode string 'b' for binary, any other value text mode 75 | ---@return string contents or empty string if failure 76 | function readFile(filepath, mode) 77 | local mode = mode == 'b' and 'rb' or 'r' 78 | local contents 79 | local f = io.open(filepath, mode) 80 | if f then 81 | contents = f:read('a') 82 | f:close() 83 | end 84 | return contents or '' 85 | end 86 | 87 | ---copyFile: copy file from source to destination 88 | ---Lua's os.rename doesn't work across volumes. This is a 89 | ---problem when Pandoc is run within a docker container: 90 | ---the temp files are in the container, the output typically 91 | ---in a shared volume mounted separately. 92 | ---We use copyFile to avoid this issue. 93 | ---@param source string file path 94 | ---@param destination string file path 95 | function copyFile(source, destination, mode) 96 | local mode = mode == 'b' and 'b' or '' 97 | writeToFile(readFile(source, mode), destination, mode) 98 | end 99 | 100 | -- stripExtension: strip filepath of the filename's extension 101 | ---@param filepath string file path 102 | ---@param extensions string[] list of extensions, e.g. {'tex', 'latex'} 103 | --- if not provided, any alphanumeric extension is stripped 104 | ---@return string filepath revised filepath 105 | function stripExtension(filepath, extensions) 106 | local name, ext = path.split_extension(filepath) 107 | ext = ext:match('^%.(.*)') 108 | 109 | if extensions then 110 | extensions = pandoc.List(extensions) 111 | return extensions:find(ext) and name 112 | or filepath 113 | else 114 | return name 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /src/main.lua: -------------------------------------------------------------------------------- 1 | --[[-- # Imagify - Pandoc / Quarto filter to convert selected 2 | LaTeX elements into images. 3 | 4 | @author Julien Dutant 5 | @copyright 2021-2023 Philosophie.ch 6 | @license MIT - see LICENSE file for details. 7 | @release 0.3.0 8 | 9 | Converts some or all LaTeX code in a document into 10 | images. 11 | 12 | @todo reader user templates from metadata 13 | 14 | @note Rendering options are provided in the doc's metadata (global), 15 | as Div / Span attribute (regional), on a RawBlock/Inline (local). 16 | They need to be kept track of, then merged before imagifying. 17 | The more local ones override the global ones. 18 | @note LaTeX Raw elements may be tagged as `tex` or `latex`. LaTeX code 19 | directly inserted in markdown (without $...$ or ```....``` wrappers) 20 | is parsed by Pandoc as Raw element with tag `tex` or `latex`. 21 | ]] 22 | 23 | PANDOC_VERSION:must_be_at_least( 24 | '2.19.0', 25 | 'The Imagify filter requires Pandoc version >= 2.19' 26 | ) 27 | 28 | -- # Modules 29 | 30 | require 'common' 31 | require 'file' 32 | 33 | -- # Global variables 34 | 35 | local stringify = pandoc.utils.stringify 36 | local pandoctype = pandoc.utils.type 37 | local system = pandoc.system 38 | local path = pandoc.path 39 | 40 | --- renderOptions type 41 | --- Contains the fields below plus a number of Pandoc metadata 42 | ---keys like header-includes, fontenc, colorlinks etc. 43 | ---See getRenderOptions() for details. 44 | ---@alias ro_force boolean imagify even when targeting LaTeX 45 | ---@alias ro_embed boolean whether to embed (if possible) or output as file 46 | ---@alias ro_debug boolean debug mode (keep .tex source, crash on fail) 47 | ---@alias ro_template string identifier of a Pandoc template (default 'default') 48 | ---@alias ro_pdf_engine 'latex'|'pdflatex'|'xelatex'|'lualatex' latex engine to be used 49 | ---@alias ro_svg_converter 'dvisvgm' pdf/dvi to svg converter (default 'dvisvgm') 50 | ---@alias ro_zoom string to apply when converting pdf/dvi to svg 51 | ---@alias ro_vertical_align string vertical align value (HTML output) 52 | ---@alias ro_block_style string style to apply to blockish elements (DisplayMath, RawBlock) 53 | ---@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, } 54 | ---@type renderOptsType 55 | local globalRenderOptions = { 56 | force = false, 57 | embed = true, 58 | debug = false, 59 | template = 'default', 60 | pdf_engine = 'latex', 61 | svg_converter = 'dvisvgm', 62 | zoom = '1.5', 63 | vertical_align = 'baseline', 64 | block_style = 'display:block; margin: .5em auto;' 65 | } 66 | 67 | ---@alias fo_scope 'manual'|'all'|'images'|'none', # imagify scope 68 | ---@alias fo_lazy boolean, # do not regenerate existing image files 69 | ---@alias fo_no_html_embed boolean, # prohibit html embedding 70 | ---@alias fo_output_folder string, # path for outputs 71 | ---@alias fo_output_folder_exists boolean, # Internal var to avoid repeat checks 72 | ---@alias fo_libgs_path string|nil, # path to Ghostscript lib 73 | ---@alias fo_optionsForClass { string: renderOptsType}, # renderOptions for imagify classes 74 | ---@alias fo_extensionForOutput { default: string, string: string }, # map of image formats (svg|pdf) for some output formats 75 | ---@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 } 76 | ---@type filterOptsType 77 | local filterOptions = { 78 | scope = 'manual', 79 | lazy = true, 80 | no_html_embed = false, 81 | libgs_path = nil, 82 | output_folder = '_imagify', 83 | output_folder_exists = false, 84 | optionsForClass = {}, 85 | extensionForOutput = { 86 | default = 'svg', 87 | html = 'svg', 88 | html4 = 'svg', 89 | html5 = 'svg', 90 | latex = 'pdf', 91 | beamer = 'pdf', 92 | docx = 'pdf', 93 | } 94 | } 95 | 96 | ---@alias tplId string template identifier, 'default' reserved for Pandoc's default template 97 | ---@alias to_source string template source code 98 | ---@alias to_template pandoc.Template compiled template 99 | ---@alias templateOptsType { default: table, string: { source: to_source, compiled: to_template}} 100 | ---@type templateOptsType 101 | local Templates = { 102 | default = {}, 103 | } 104 | 105 | -- ## Pandoc AST functions 106 | 107 | --outputIsLaTeX: checks whether the target output is in LaTeX 108 | ---@return boolean 109 | local function outputIsLaTeX() 110 | return FORMAT:match('latex') or FORMAT:match('beamer') or false 111 | end 112 | 113 | --- ensureList: ensures an object is a pandoc.List. 114 | ---@param obj any|nil 115 | local function ensureList(obj) 116 | 117 | return pandoctype(obj) == 'List' and obj 118 | or pandoc.List:new{obj} 119 | 120 | end 121 | 122 | ---imagifyType: whether an element is imagifiable LaTeX and which type 123 | ---@alias imagifyType nil|'InlineMath'|'DisplayMath'|'RawBlock'|'RawInline'|'TexImage'|'TikzImage' 124 | ---@param elem pandoc.Math|pandoc.RawBlock|pandoc.RawInline|pandoc.Image element 125 | ---@return imagifyType elemType to imagify or nil 126 | function imagifyType(elem) 127 | return elem.t == 'Image' and ( 128 | elem.src:match('%.tex$') and 'TexImage' 129 | or elem.src:match('%.tikz') and 'TikzImage' 130 | ) 131 | or elem.mathtype == 'InlineMath' and 'InlineMath' 132 | or elem.mathtype == 'DisplayMath' and 'DisplayMath' 133 | or (elem.format == 'tex' or elem.format == 'latex') 134 | and ( 135 | elem.t == 'RawBlock' and 'RawBlock' 136 | or elem.t == 'RawInline' and 'RawInline' 137 | ) 138 | or nil 139 | end 140 | 141 | -- ## Smart imagifying functions 142 | 143 | ---usesTikZ: tell whether a source contains a TikZ picture 144 | ---@param source string LaTeX source 145 | ---@return boolean result 146 | local function usesTikZ(source) 147 | return (source:match('\\begin{tikzpicture}') 148 | or source:match('\\tikz')) and true 149 | or false 150 | end 151 | 152 | -- ## Converter functions 153 | 154 | local function dvisvgmVerbosity() 155 | return PANDOC_STATE.verbosity == 'ERROR' and '1' 156 | or PANDOC_STATE.verbosity == 'WARNING' and '2' 157 | or PANDOC_STATE.verbosity == 'INFO' and '4' 158 | or '2' 159 | end 160 | 161 | ---getCodeFromFile: get source code from a file 162 | ---uses Pandoc's resource paths if needed 163 | ---@param src string source file name/path 164 | ---@return string|nil result file contents or nil if not found 165 | function getCodeFromFile(src) 166 | local result 167 | 168 | if fileExists(src) then 169 | result = readFile(src) 170 | else 171 | for _,item in ipairs(PANDOC_STATE.resource_path) do 172 | if fileExists(path.join{item, src}) then 173 | result = readFile(path.join{item, src}) 174 | break 175 | end 176 | end 177 | end 178 | 179 | return result 180 | 181 | end 182 | 183 | ---runLaTeX: runs latex engine on file 184 | ---@param source string filepath of the source file 185 | ---@param options table options 186 | -- format = output format, 'dvi' or 'pdf', 187 | -- pdf_engine = pdf engine, 'latex', 'xelatex', 'xetex', '' etc. 188 | -- texinputs = value for export TEXINPUTS 189 | ---@return boolean success, string result result is filepath or LaTeX log if failed 190 | local function runLaTeX(source, options) 191 | options = options or {} 192 | local format = options.format or 'pdf' 193 | local pdf_engine = options.pdf_engine or 'latex' 194 | local outfile = stripExtension(source, {'tex','latex'}) 195 | local ext = pdf_engine == 'xelatex' and format == 'dvi' and '.xdv' 196 | or '.'..format 197 | local texinputs = options.texinputs or nil 198 | -- Latexmk: extra options come *after* - and *before* 199 | local latex_args = pandoc.List:new{ '--interaction=nonstopmode' } 200 | local latexmk_args = pandoc.List:new{ '-'..pdf_engine } 201 | -- Export the TEXINPUTS variable 202 | local env = texinputs and 'export TEXINPUTS='..texinputs..'; ' 203 | or '' 204 | -- latex command run, for debug purposes 205 | local cmd 206 | 207 | -- @TODO implement verbosity in latex 208 | -- latexmk silent mode 209 | if PANDOC_STATE.verbosity == 'ERROR' then 210 | latexmk_args:insert('-silent') 211 | end 212 | 213 | -- xelatex doesn't accept `output-format`, 214 | -- generates both .pdf and .xdv 215 | if pdf_engine ~= 'xelatex' then 216 | latex_args:insert('--output-format='..format) 217 | end 218 | 219 | 220 | -- try Latexmk first, latex engine second 221 | -- two runs of latex engine 222 | cmd = env..'latexmk '..concatStrings(latexmk_args..latex_args, ' ') 223 | ..' '..source 224 | local success, err, code = os.execute(cmd) 225 | 226 | if not success and code == 127 then 227 | cmd = pdf_engine..' ' 228 | ..concatStrings(latex_args, ' ') 229 | ..' '..source..' 2>&1 > /dev/null '..'; ' 230 | cmd = cmd..cmd -- two runs needed 231 | success = os.execute(env..cmd) 232 | end 233 | 234 | if success then 235 | 236 | return true, outfile..ext 237 | 238 | else 239 | 240 | local result = 'LaTeX compilation failed.\n' 241 | ..'Command used: '..cmd..'\n' 242 | local src_code = readFile(source) 243 | if src_code then 244 | result = result..'LaTeX source code:\n' 245 | result = result..src_code 246 | end 247 | local log = readFile(outfile..'.log') 248 | if log then 249 | result = result..'LaTeX log:\n'..log 250 | end 251 | return false, result 252 | 253 | end 254 | 255 | end 256 | 257 | ---toSVG: convert latex output to SVG. 258 | ---Ghostcript library required to convert PDF files. 259 | -- See divsvgm manual for more details. 260 | -- Options: 261 | -- *output*: string output filepath (directory must exist), 262 | -- *zoom*: string zoom factor, e.g. 1.5. 263 | ---@param source string filepath of dvi, xdv or svg file 264 | ---@param options { output : string, zoom: string} options 265 | ---@return success boolean, result string filepath 266 | local function toSVG(source, options) 267 | if source == nil then return nil end 268 | local options = options or {} 269 | local outfile = options.output 270 | or stripExtension(source, {'pdf', 'svg', 'xdv'})..'.svg' 271 | local source_format = source:match('%.pdf$') and 'pdf' 272 | or source:match('%.dvi$') and 'dvi' 273 | or source:match('%.xdv$') and 'dvi' 274 | local cmd_opts = pandoc.List:new({'--optimize', 275 | '--verbosity='..dvisvgmVerbosity(), 276 | -- '--relative', 277 | -- '--no-fonts', 278 | '--font-format=WOFF', 279 | source 280 | }) 281 | 282 | -- @TODO doesn't work on my machine, why? 283 | if filterOptions.libgs_path and filterOptions.libgs_path ~= '' then 284 | cmd_opts:insert('--libgs='..filterOptions.libgs_path) 285 | end 286 | 287 | -- note "Ghostcript required to process PDF files" 288 | if source_format == 'pdf' then 289 | cmd_opts:insert('--pdf') 290 | end 291 | 292 | if options.zoom then 293 | cmd_opts:insert('--zoom='..options.zoom) 294 | end 295 | 296 | cmd_opts:insert('--output='..outfile) 297 | 298 | success = os.execute('dvisvgm' 299 | ..' '..concatStrings(cmd_opts, ' ') 300 | ) 301 | 302 | if success then 303 | 304 | return success, outfile 305 | 306 | else 307 | 308 | return success, 'DVI/PDF to SVG conversion failed\n' 309 | 310 | end 311 | 312 | end 313 | 314 | --- getSVGFromFile: extract svg tag (with contents) from a SVG file. 315 | -- Assumes the file only contains one SVG tag. 316 | -- @param filepath string file path 317 | local function getSVGFromFile(filepath) 318 | local contents = readFile(filepath) 319 | 320 | return contents and contents:match('') 321 | 322 | end 323 | 324 | 325 | --- urlEncode: URL-encodes a string 326 | -- See 327 | -- Modified to handle UTF-8: %w matches UTF-8 starting bytes, which should 328 | -- be encoded. We specify safe alphanumeric chars explicitly instead. 329 | -- @param str string 330 | local function urlEncode(str) 331 | 332 | --Ensure all newlines are in CRLF form 333 | str = string.gsub (str, "\r?\n", "\r\n") 334 | 335 | --Percent-encode all chars other than unreserved 336 | --as per RFC 3986, Section 2.3 337 | -- 338 | str = str:gsub("[^0-9a-zA-Z%-._~]", 339 | function (c) return string.format ("%%%02X", string.byte(c)) end) 340 | 341 | return str 342 | 343 | end 344 | 345 | -- # Main filter functions 346 | 347 | -- ## Functions to read options 348 | 349 | ---getFilterOptions: read render options 350 | ---returns a map: 351 | --- scope: fo_scope 352 | --- libgs_path: string 353 | --- output_folder: string 354 | ---@param opts table options map from meta.imagify 355 | ---@return table result map of options 356 | local function getFilterOptions(opts) 357 | local stringKeys = {'scope', 'libgs-path', 'output-folder'} 358 | local boolKeys = {'lazy'} 359 | local result = {} 360 | 361 | for _,key in ipairs(boolKeys) do 362 | if opts[key] ~= nil and pandoctype(opts[key]) == 'boolean' then 363 | result[key] = opts[key] 364 | end 365 | end 366 | 367 | for _,key in ipairs(stringKeys) do 368 | opts[key] = opts[key] and stringify(opts[key]) or nil 369 | end 370 | 371 | result.scope = opts.scope and ( 372 | opts.scope == 'all' and 'all' 373 | or (opts.scope == 'selected' or opts.scope == 'manual') and 'manual' 374 | or opts.scope == 'images' and 'images' 375 | or opts.scope == 'none' and 'none' 376 | ) or nil 377 | 378 | result.libgs_path = opts['libgs-path'] and opts['libgs-path'] or nil 379 | 380 | result.output_folder = opts['output-folder'] 381 | and opts['output-folder'] or nil 382 | 383 | return result 384 | 385 | end 386 | 387 | ---getRenderOptions: read render options 388 | ---@param opts table options map, from doc metadata or elem attributes 389 | ---@return table result renderOptions map of options 390 | local function getRenderOptions(opts) 391 | local result = {} 392 | local renderBooleanlKeys = { 393 | 'force', 394 | 'embed', 395 | 'debug', 396 | } 397 | local renderStringKeys = { 398 | 'pdf-engine', 399 | 'svg-converter', 400 | 'zoom', 401 | 'vertical-align', 402 | 'block-style', 403 | } 404 | local renderListKeys = { 405 | 'classoption', 406 | } 407 | -- Pandoc metadata variables used by the LaTeX template 408 | local renderMetaKeys = { 409 | 'header-includes', 410 | 'mathspec', 411 | 'fontenc', 412 | 'fontfamily', 413 | 'fontfamilyoptions', 414 | 'fontsize', 415 | 'mainfont', 'sansfont', 'monofont', 'mathfont', 'CJKmainfont', 416 | 'mainfontoptions', 'sansfontoptions', 'monofontoptions', 417 | 'mathfontoptions', 'CJKoptions', 418 | 'microtypeoptions', 419 | 'colorlinks', 420 | 'boxlinks', 421 | 'linkcolor', 'filecolor', 'citecolor', 'urlcolor', 'toccolor', 422 | -- 'links-as-note': not visible in standalone LaTeX class 423 | 'urlstyle', 424 | } 425 | checks = { 426 | pdf_engine = {'latex', 'xelatex', 'lualatex'}, 427 | svg_converter = {'dvisvgm'}, 428 | } 429 | 430 | -- boolean values 431 | -- @TODO these may be passed as strings in Div attributes 432 | -- convert "xx-yy" to "xx_yy" keys 433 | for _,key in ipairs(renderBooleanlKeys) do 434 | if opts[key] ~= nil then 435 | if pandoctype(opts[key]) == 'boolean' then 436 | result[key:gsub('-','_')] = opts[key] 437 | elseif pandoctype(opts[key]) == 'string' then 438 | if opts[key] == 'false' or opts[key] == 'no' then 439 | result[key:gsub('-','_')] = false 440 | else 441 | result[key:gsub('-','_')] = true 442 | end 443 | end 444 | end 445 | end 446 | 447 | -- string values 448 | -- convert "xx-yy" to "xx_yy" keys 449 | for _,key in ipairs(renderStringKeys) do 450 | if opts[key] then 451 | result[key:gsub('-','_')] = stringify(opts[key]) 452 | end 453 | end 454 | 455 | -- list values 456 | for _,key in ipairs(renderListKeys) do 457 | if opts[key] then 458 | result[key:gsub('-','_')] = ensureList(opts[key]) 459 | end 460 | end 461 | 462 | -- meta values 463 | -- do not change the key names 464 | for _,key in ipairs(renderMetaKeys) do 465 | if opts[key] then 466 | result[key] = opts[key] 467 | end 468 | end 469 | 470 | -- apply checks 471 | for key, accepted_vals in pairs(checks) do 472 | if result[key] and not tfind(accepted_vals, result[key]) then 473 | message('WARNING', 'Option '..key..'has an invalid value: ' 474 | ..result[key]..". I'm ignoring it." 475 | ) 476 | result[key] = nil 477 | end 478 | end 479 | 480 | -- Special cases 481 | -- `embed` not possible with `extract-media` on 482 | if result.embed and filterOptions.no_html_embed then 483 | result.embed = nil 484 | end 485 | 486 | return result 487 | 488 | end 489 | 490 | ---readImagifyClasses: read user's specification of custom classes 491 | -- This can be a string (single class), a pandoc.List of strings 492 | -- or a map { class = renderOptionsForClass }. 493 | -- We update `filterOptions.classes` accordingly. 494 | ---@param opts pandoc.List|pandoc.MetaMap|string 495 | local function readImagifyClasses(opts) 496 | -- ensure it's a list or table 497 | if pandoctype(opts) ~= 'List' and pandoctype(opts) ~= 'table' then 498 | opts = pandoc.List:new({ opts }) 499 | end 500 | 501 | if pandoctype(opts) == 'List' then 502 | for _, val in ipairs(opts) do 503 | local class = stringify(val) 504 | filterOptions.optionsForClass[class] = {} 505 | end 506 | elseif pandoctype(opts) == 'table' then 507 | for key, val in pairs(opts) do 508 | local class = stringify(key) 509 | filterOptions.optionsForClass[class] = getRenderOptions(val) 510 | end 511 | end 512 | 513 | end 514 | 515 | ---init: read metadata options. 516 | -- Classes in `imagify-classes:` override those in `imagify: classes:` 517 | -- If `meta.imagify` isn't a map assume it's a `scope` value 518 | -- Special cases: 519 | -- filterOptions.no_html_embed: Pandoc can't handle URL-embedded images when extract-media is on 520 | ---@param meta pandoc.Meta doc's metadata 521 | local function init(meta) 522 | local userOptions = meta.imagify 523 | and (pandoctype(meta.imagify) == 'table' and meta.imagify 524 | or {scope = stringify(meta.imagify)} 525 | ) 526 | or {} 527 | local userClasses = meta['imagify-classes'] 528 | and pandoctype(meta['imagify-classes'] ) == 'table' 529 | and meta['imagify-classes'] 530 | or nil 531 | local rootKeysUsed = { 532 | 'header-includes', 533 | 'mathspec', 534 | 'fontenc', 535 | 'fontfamily', 536 | 'fontfamilyoptions', 537 | 'fontsize', 538 | 'mainfont', 'sansfont', 'monofont', 'mathfont', 'CJKmainfont', 539 | 'mainfontoptions', 'sansfontoptions', 'monofontoptions', 540 | 'mathfontoptions', 'CJKoptions', 541 | 'microtypeoptions', 542 | 'colorlinks', 543 | 'boxlinks', 544 | 'linkcolor', 'filecolor', 'citecolor', 'urlcolor', 'toccolor', 545 | -- 'links-as-note': no footnotes in standalone LaTeX class 546 | 'urlstyle', 547 | } 548 | 549 | -- pass relevant root options unless overriden in meta.imagify 550 | for _,key in ipairs(rootKeysUsed) do 551 | if meta[key] and not userOptions[key] then 552 | userOptions[key] = meta[key] 553 | end 554 | end 555 | 556 | filterOptions = mergeMapInto( 557 | getFilterOptions(userOptions), 558 | filterOptions 559 | ) 560 | 561 | if meta['extract-media'] and FORMAT:match('html') then 562 | filterOptions.no_html_embed = true 563 | end 564 | 565 | globalRenderOptions = mergeMapInto( 566 | getRenderOptions(userOptions), 567 | globalRenderOptions 568 | ) 569 | 570 | if userOptions.classes then 571 | filterOptions.classes = readImagifyClasses(userOptions.classes) 572 | end 573 | 574 | if userClasses then 575 | filterOptions.classes = readImagifyClasses(userClasses) 576 | end 577 | 578 | end 579 | 580 | -- ## Functions to convert images 581 | 582 | ---getTemplate: get a compiled template 583 | ---@param id string template identifier (key of Templates) 584 | ---@return pandoc.Template|nil tpl result 585 | local function getTemplate(id) 586 | if not Templates[id] then 587 | return nil 588 | end 589 | 590 | -- ensure there's a non-empty source, otherwise return nil 591 | -- special case: default template, fill in source from Pandoc 592 | if id == 'default' and not Templates[id].source then 593 | Templates[id].source = pandoc.template.default('latex') 594 | end 595 | 596 | if not Templates[id].source or Templates[id].source == '' then 597 | return nil 598 | end 599 | 600 | -- compile if needed and return 601 | 602 | if not Templates[id].compiled then 603 | Templates[id].compiled = pandoc.template.compile( 604 | Templates[id].source) 605 | end 606 | 607 | return Templates[id].compiled 608 | 609 | end 610 | 611 | ---buildTeXDoc: turns LaTeX element into a LaTeX doc source. 612 | ---@param code string LaTeX code 613 | ---@param renderOptions table render options 614 | ---@param elemType string 'InlineMath', 'DisplayMath', 'RawInline', 'RawBlock' 615 | local function buildTeXDoc(code, renderOptions, elemType) 616 | local endFormat = filterOptions.extensionForOutput[FORMAT] 617 | or filterOptions.extensionForOutput.default 618 | elemType = elemType and elemType or 'InlineMath' 619 | code = code or '' 620 | renderOptions = renderOptions or {} 621 | local template = renderOptions.template or 'default' 622 | local svg_converter = renderOptions.svg_converter or 'dvisvgm' 623 | local doc = nil 624 | 625 | -- wrap DisplayMath and InlineMath in math mode 626 | -- for display math we must use \displaystyle 627 | -- see 628 | if elemType == 'DisplayMath' then 629 | code = '$\\displaystyle\n'..code..'$' 630 | elseif elemType == 'InlineMath' then 631 | code = '$'..code..'$' 632 | end 633 | 634 | doc = pandoc.Pandoc( 635 | pandoc.RawBlock('latex', code), 636 | pandoc.Meta(renderOptions) 637 | ) 638 | 639 | -- modify the doc's meta values as required 640 | --@TODO set class option class=... 641 | --Standalone tikz needs \standaloneenv{tikzpicture} 642 | local headinc = ensureList(doc.meta['header-includes']) 643 | local classopt = ensureList(doc.meta['classoption']) 644 | 645 | -- Standalone class `dvisvgm` option: make output file 646 | -- dvisvgm-friendly (esp TikZ images). 647 | -- Not compatible with pdflatex 648 | if endFormat == 'svg' and svg_converter == 'dvisvgm' then 649 | classopt:insert(pandoc.Str('dvisvgm')) 650 | end 651 | 652 | -- The standalone class option `tikz` needs to be activated 653 | -- to avoid an empty page of output. 654 | if usesTikZ(code) then 655 | headinc:insert(pandoc.RawBlock('latex', '\\usepackage{tikz}')) 656 | classopt:insert{ 657 | pandoc.Str('tikz') 658 | } 659 | end 660 | 661 | doc.meta['header-includes'] = #headinc > 0 and headinc or nil 662 | doc.meta.classoption = #classopt > 0 and classopt or nil 663 | doc.meta.documentclass = 'standalone' 664 | 665 | return pandoc.write(doc, 'latex', { 666 | template = getTemplate(template), 667 | }) 668 | 669 | end 670 | 671 | ---createUniqueName: return unique identifier for an image source. 672 | ---Combines LaTeX sources and rendering options. 673 | ---@param source string LaTeX source for the image 674 | ---@param renderOptions table render options 675 | ---@return string filename without extension 676 | local function createUniqueName(source, renderOptions) 677 | return pandoc.sha1(source .. 678 | '|Zoom:'..renderOptions.zoom) 679 | end 680 | 681 | ---latexToImage: convert LaTeX to image. 682 | -- The image can be exported as SVG string or as a SVG or PDF file. 683 | ---@param source string LaTeX source document 684 | ---@param renderOptions table rendering options 685 | ---@return success boolean, string result result is file contents or filepath or error message. 686 | local function latexToImage(source, renderOptions) 687 | local renderOptions = renderOptions or {} 688 | local ext = filterOptions.extensionForOutput[FORMAT] 689 | or filterOptions.extensionForOutput.default 690 | local lazy = filterOptions.lazy 691 | local embed = renderOptions.embed 692 | and ext == 'svg' and FORMAT:match('html') and true 693 | or false 694 | local pdf_engine = renderOptions.pdf_engine or 'latex' 695 | local latex_out_format = ext == 'svg' and 'dvi' or 'pdf' 696 | local debug = renderOptions.debug or false 697 | local folder = filterOptions.output_folder or '' 698 | local jobOutFolder = makeAbsolute(PANDOC_STATE.output_file 699 | and path.directory(PANDOC_STATE.output_file) ~= '.' 700 | and path.directory(PANDOC_STATE.output_file) or '') 701 | local texinputs = renderOptions.texinputs or nil 702 | -- to be created 703 | local folderAbs, file, fileAbs, texfileAbs = '', '', '', '' 704 | local fileRelativeToJob = '' 705 | local success, result 706 | 707 | -- default texinputs: all sources folders and output folder 708 | -- and directory folder? 709 | if not texinputs then 710 | texinputs = system.get_working_directory()..'//:' 711 | for _,filepath in ipairs(PANDOC_STATE.input_files) do 712 | texinputs = texinputs 713 | .. makeAbsolute(filepath and path.directory(filepath) or '') 714 | .. '//:' 715 | end 716 | texinputs = texinputs.. jobOutFolder .. '//:' 717 | end 718 | 719 | -- if we output files prepare folder and file names 720 | -- we need absolute paths to move things out of the temp dir 721 | if not embed or debug then 722 | folderAbs = makeAbsolute(folder) 723 | filename = createUniqueName(source, renderOptions) 724 | fileAbs = path.join{folderAbs, filename..'.'..ext} 725 | file = path.join{folder, filename..'.'..ext} 726 | texfileAbs = path.join{folderAbs, filename..'.tex'} 727 | 728 | -- ensure the output folder exists (only once) 729 | if not filterOptions.output_folder_exists then 730 | ensureFolderExists(folderAbs) 731 | filterOptions.output_folder_exists = true 732 | end 733 | 734 | -- path to the image relative to document output 735 | fileRelativeToJob = path.make_relative(fileAbs, jobOutFolder) 736 | 737 | -- if lazy, don't regenerate files that already exist 738 | if not embed and lazy and fileExists(fileAbs) then 739 | success, result = true, fileRelativeToJob 740 | return success, result 741 | end 742 | 743 | end 744 | 745 | system.with_temporary_directory('imagify', function (tmpdir) 746 | system.with_working_directory(tmpdir, function() 747 | 748 | writeToFile(source, 'source.tex') 749 | 750 | -- debug: copy before, LaTeX may crash 751 | if debug then 752 | writeToFile(source, texfileAbs) 753 | end 754 | 755 | -- result = 'source.dvi'|'source.xdv'|'source.pdf'|nil 756 | success, result = runLaTeX('source.tex', { 757 | format = latex_out_format, 758 | pdf_engine = pdf_engine, 759 | texinputs = texinputs 760 | }) 761 | 762 | -- further conversions of dvi/pdf? 763 | 764 | if success and ext == 'svg' then 765 | 766 | success, result = toSVG(result, { 767 | zoom = renderOptions.zoom, 768 | }) 769 | 770 | end 771 | 772 | -- embed or save 773 | 774 | if success then 775 | 776 | if embed and ext == 'svg' then 777 | 778 | -- read svg contents and cleanup 779 | result = "\n" 780 | .. getSVGFromFile(result) 781 | 782 | -- URL encode 783 | result = 'data:image/svg+xml,'..urlEncode(result) 784 | 785 | else 786 | 787 | --- File copy 788 | --- not os.rename, which doesn't work across volumes 789 | --- binary in case the output is PDF 790 | copyFile(result, fileAbs, 'b') 791 | result = fileRelativeToJob 792 | 793 | end 794 | 795 | end 796 | 797 | end) 798 | end) 799 | 800 | return success, result 801 | 802 | end 803 | 804 | ---createImageElemFrom(src, renderOptions, elemType) 805 | ---@param text string source code for the image 806 | ---@param src string URL (possibly URL encoded data) 807 | ---@param renderOptions table render Options 808 | ---@param elemType string 'InlineMath', 'DisplayMath', 'RawInline', 'RawBlock' 809 | ---@return pandoc.Image img 810 | local function createImageElemFrom(text, src, renderOptions, elemType) 811 | local title = text or '' 812 | local caption = '' -- for future implementation (Raw elems attribute?) 813 | local block = elemType == 'DisplayMath' or elemType == 'RawBlock' 814 | local style = '' 815 | local block_style = renderOptions.block_style 816 | or 'display: block; margin: .5em auto; ' 817 | local vertical_align = renderOptions.vertical_align 818 | or 'baseline' 819 | 820 | if block then 821 | style = style .. block_style 822 | else 823 | style = style .. 'vertical-align: '..vertical_align..'; ' 824 | end 825 | 826 | return pandoc.Image(caption, src, title, { style = style }) 827 | 828 | end 829 | 830 | ---toImage: convert to pandoc.Image using specified rendering options. 831 | ---Return the original element if conversion failed. 832 | ---@param elem pandoc.Math|pandoc.RawInline|pandoc.RawBlock|pandoc.Image 833 | ---@param elemType imagifyType type of element to imagify 834 | ---@param renderOptions table rendering options 835 | ---@return pandoc.Image|pandoc.Inlines|pandoc.Para|nil 836 | local function toImage(elem, elemType, renderOptions) 837 | local code, doc 838 | local success, result, img 839 | 840 | -- get code, return nil if none 841 | if elemType == 'TexImage' or elemType == 'TikzImage' then 842 | code = getCodeFromFile(elem.src) 843 | if not code then 844 | message('ERROR', 'Image source file '..elem.src..' not found.') 845 | end 846 | else 847 | code = elem.text 848 | end 849 | if not code then return nil end 850 | 851 | -- prepare LaTeX source document 852 | doc = buildTeXDoc(code, renderOptions, elemType) 853 | 854 | -- convert to file or string 855 | success, result = latexToImage(doc, renderOptions) 856 | 857 | -- prepare Image element 858 | if success then 859 | if (elemType == 'TexImage' or elemType == 'TikzImage') then 860 | elem.src = result 861 | img = elem 862 | elseif elemType == 'RawBlock' then 863 | img = pandoc.Para( 864 | createImageElemFrom(code, result, renderOptions, elemType) 865 | ) 866 | else 867 | img = createImageElemFrom(code, result, renderOptions, elemType) 868 | end 869 | else 870 | message('ERROR', result) 871 | img = pandoc.List:new { 872 | pandoc.Emph{ pandoc.Str('') }, 873 | pandoc.Space(), pandoc.Str(code), pandoc.Space(), 874 | pandoc.Emph{ pandoc.Str('') }, 875 | } 876 | end 877 | 878 | return img 879 | 880 | end 881 | 882 | -- ## Functions to traverse the document tree 883 | 884 | ---imagifyClass: find an element's imagify class, if any. 885 | ---If both `imagify` and a custom class is present, return the latter. 886 | ---@param elem pandoc.Div|pandoc.Span 887 | ---@return string 888 | local function imagifyClass(elem) 889 | -- priority to custom classes other than 'imagify' 890 | for _,class in ipairs(elem.classes) do 891 | if filterOptions.optionsForClass[class] then 892 | return class 893 | end 894 | end 895 | if elem.classes:find('imagify') then 896 | return 'imagify' 897 | end 898 | return nil 899 | end 900 | 901 | ---scanContainer: read imagify options of a Span/Div, imagify if needed. 902 | ---@param elem pandoc.Div|pandoc.Span 903 | ---@param renderOptions table render options handed down from higher-level elems 904 | ---@return pandoc.Span|pandoc.Div|nil span modified element or nil if no change 905 | local function scanContainer(elem, renderOptions) 906 | local class = imagifyClass(elem) 907 | 908 | if class then 909 | -- create new rendering options by applying the class options 910 | local opts = mergeMapInto(filterOptions.optionsForClass[class], 911 | renderOptions) 912 | -- apply any locally specified rendering options 913 | opts = mergeMapInto(getRenderOptions(elem.attributes), opts) 914 | -- build recursive scanner from updated options 915 | local scan = function (elem) return scanContainer(elem, opts) end 916 | --- build imagifier from updated options 917 | local imagify = function(el) 918 | local elemType = imagifyType(el) 919 | if opts.force == true or outputIsLaTeX() == false 920 | or (elemType == 'TexImage' or elemType == 'TikzImage') then 921 | return elemType and toImage(el, elemType, opts) or nil 922 | end 923 | end 924 | --- apply recursion first, then imagifier 925 | return elem:walk({ 926 | Div = scan, 927 | Span = scan, 928 | }):walk({ 929 | Math = imagify, 930 | RawInline = imagify, 931 | RawBlock = imagify, 932 | Image = imagify, 933 | }) 934 | 935 | else 936 | 937 | -- recursion 938 | local scan = function (elem) return scanContainer(elem, renderOptions) end 939 | return elem:walk({ 940 | Span = scan, 941 | Div = scan, 942 | }) 943 | 944 | end 945 | 946 | end 947 | 948 | ---main: process the main document's body. 949 | -- Handles filterOptions `scope` and `force` 950 | local function main(doc) 951 | local scope = filterOptions.scope 952 | local force = globalRenderOptions.force 953 | 954 | if scope == 'none' then 955 | return nil 956 | end 957 | 958 | -- whole doc wrapped in a Div to use the recursive scanner 959 | local div = pandoc.Div(doc.blocks) 960 | 961 | -- recursive scanning in modes other than 'images' 962 | -- if scope == 'all' we tag the whole doc as `imagify` 963 | if scope ~= 'images' then 964 | 965 | if scope == 'all' then 966 | div.classes:insert('imagify') 967 | end 968 | 969 | div = scanContainer(div, globalRenderOptions) 970 | 971 | end 972 | 973 | -- imagify any leftover tikz / tex images 974 | -- using global render options 975 | div = div:walk({ 976 | Image = function (elem) 977 | local elemType = imagifyType(elem) 978 | if elemType then 979 | return toImage(elem, elemType, globalRenderOptions) 980 | end 981 | end, 982 | }) 983 | 984 | return div and pandoc.Pandoc(div.content, doc.meta) 985 | or nil 986 | 987 | end 988 | 989 | -- # Return filter 990 | 991 | return { 992 | { 993 | Meta = init, 994 | Pandoc = main, 995 | }, 996 | } 997 | 998 | --------------------------------------------------------------------------------