I think that blog post is a great basis for discussion. Here’s a quick attempt to recreate these Pandoc filters/templates in Typst, for comparison:
Converting bold face to italic
Panflute:
# !/usr/bin/python3
import panflute as pf
def action(elem, doc):
if isinstance(elem, pf.Strong):
return pf.Emph(*elem.content)
if __name__ == '__main__':
pf.toJSONFilter(action)
Save above as myfilter.py
and compile doc with pandoc --filter myfilter.py ...
Typst:
#show strong: it => emph(it.body)
Add this line at the top of the document, compile as usual.
Defining blocks for comments and format-dependent output
Panflute filter to suppress code blocks with n
language, and to add custom formatting to pubnote
blocks but only for HTML output:
import panflute as pf
def action(elem, doc):
if (isinstance(elem, pf.Code) or
isinstance(elem, pf.CodeBlock)):
if ('n' in elem.classes):
return []
if isinstance(elem, pf.CodeBlock):
if 'pubnote' in elem.classes:
if doc.format == 'html':
return pf.convert_text(
'<div class = "pubnote">{}</div>'
.format(elem.text))
else:
return []
if __name__ == '__main__':
pf.toJSONFilter(action)
Typst has built-in syntax for comments. On the other hand it doesn’t expose the output format (see here). Anyway here’s a way to suppress n
blocks and to format pubnote
blocks differently when --input format=svg
is passed on the command-line:
#show raw.where(lang: "n"): none
#show raw.where(lang: "pubnote"): it => {
set text(red) if sys.inputs.at("format", default: "pdf") == "svg"
it
}
Custom quote block with parameters
Panflute:
import panflute as pf
def epigraph(options, data, element, doc):
return pf.convert_text(
'<div class = "epigraph">{}
<span class = "who">{}</span></div>'
.format(options.get('quote'), options.get('who')))
if __name__ == '__main__':
pf.toJSONFilter(pf.yaml_filter,
tags = {'epigraph': epigraph})
used in:
~~~ epigraph
quote: Simplicity is the ultimate sophistication.
who: Leonardo da Vinci
---
~~~
In Typst you’d simply define a function to format the epigraph:
#let epigraph(quote: none, who: none) = block[
#quote
#h(1fr)---#who
]
Used like this:
#epigraph(
quote: [Simplicity is the ultimate sophistication.],
who: [Leonardo da Vinci],
)
though I’d probably define the method without keyword arguments: #let epigraph(who, quote) = ...
and call it like this:
#epigraph[Leonardo da Vinci][
Simplicity is the ultimate sophistication.
]
Template using metadata from YAML file
Panflute:
<!DOCTYPE HTML>
<html dir="ltr" lang="en-US">
<head><meta content="text/html;charset=utf-8"
http-equiv="Content-Type" />
<title>$title$</title>
</head>
<body>
<h1>$title$</h1>
$if(related)$
<div style = 'font-size: 0.8rem; color: green;'>
<h2>Related articles:</h2>
$for(related)$
<p>$related.title$: $related.url$</p>
$endfor$
</div>
$endif$
$body$
</body>
</html>
used in:
---
title: The History of Semicolons
author: Prof. Lexi Graphical
related:
- title: On Neglected Punctuation
url: "http://example.com/neglect/"
- title: "Semicolons: Can We Have Too Many?"
url: "http://example.com/yes.html"
---
Semicolons are one of our most important,
yet most misunderstood punctuation marks.
Typst doesn’t support HTML output yet, but here’s a template that works for PDF:
#let template(md, body) = [
= #md.title
#if "related" in md [
#set text(0.8em, green)
== Related articles:
#for (title, url) in md.related [
#title: #url
#parbreak()
]
]
#body
]
used in:
#show: template.with(yaml("file.yaml"))
Semicolons are one of our most important,
yet most misunderstood punctuation marks.
(Here I load the YAML from a file but you can also pass the content inline to yaml.decode
.)
Formatting gnuplot source code and results
In this example, gnuplot code blocks are processed by Panflute to put some lines in bold face and to execute the code and show the result side by side with the source.
Panflute:
# !/usr/bin/python3
import panflute as pf
import subprocess
c = subprocess.run
import re
import zlib
import os
def action(elem, doc):
if isinstance(elem, pf.CodeBlock):
if 'gnc' in elem.classes:
# pff will hold the checksum
pff = str(zlib.adler32(bytes
(elem.text.replace('@', ''), 'utf-8')))
#The name used for the
#gnuplot output image:
pfn = pff + '.png'
#Check if we've done this one:
if pfn not in os.listdir():
dscript = elem.text.replace('@', '')
script = '''set term pngcairo\n
set out "{}"\n{}'''.format(pfn, dscript)
with open(pff + '.gn', 'w') as scriptfile:
scriptfile.write(dscript)
#Execute gnuplot on the script:
c('''echo '{}' |
gnuplot'''.format(script), shell = True)
if doc.format == 'latex':
#Lot's of escaping needed:
mt = elem.text.replace('{', '\\{')
mt = mt.replace('}', '\\}')
#LaTeX boldface:
mt = re.sub('@(.*?)@', r'\\textbf{\1}', mt)
mt = mt.replace('\\\n', '\\textbackslash\n')
#for newlines in gnuplot labels, etc.
mt = mt.replace('\\\\n', '\\textbackslash{n}')
return pf.RawBlock(
'\n\hypertarget{'+pff+'}{}\\begin{Verbatim}\n'+mt+
'\n\end{Verbatim}\n\\textattachfile[mimetype=text/'
+'plain]{' + pff + '.gn}{' + '\\framebox'+'
{Open script}}\n\plt{' + pfn + '}',
format = 'latex')
else:
return [elem, pf.RawBlock(
'<br /><img alt = "" src = "' + pfn + '" />',
format = 'html')]
if __name__ == '__main__':
pf.toJSONFilter(action)
used in:
\hypertarget{setting-ranges}{\subsection{Setting
Ranges}\label{setting-ranges}}
That was simple. Notice how gnuplot decided to plot
our function from -10 to +10. That's the default,
which we got because we didn't ask for any
particular range. Gnuplot also set the y-axis
limits (the range of the vertical axis) to
encompass the range of the function over that
default x-axis domain. Let's take control of the
limits on the horizontal axis (the new command is
highlighted). Gnuplot happens to know what π is
(but doesn't know any other transcendental
numbers).
\hypertarget{3260812063}{}\begin{Verbatim}
\textbf{set xrange [-pi : pi]}
plot sin(x)
\end{Verbatim}
\plt{3260812063.png}
Typst code cannot run external programs, but typst query
could be used to extract the gnuplot code blocks for execution. typst compile
could then be called use the files generated by gnuplot. But I find it simpler and cleaner to put all code blocks in a Jupyter notebook with the gnuplot kernel, so I’ll show a sketch of a solution based on this idea:
#import "@preview/based:0.1.0": base64
#let parse-line(line) = {
let parts = line.split(" ## ")
let code = parts.first().trim(at: end)
if parts.len() == 1 {
(code, ())
} else {
(code, parts.at(1).split(",").map(str.trim))
}
}
#let parse-images(cell, ..args) = {
cell.at("outputs", default: ())
.filter(x => "image/png" in x.data)
.map(x => image.decode(base64.decode(x.data.at("image/png")), ..args))
}
#let cell-name(cell) = {
if not (cell.cell_type == "code" and cell.source.first().starts-with("##")) {
return none
}
return cell.source.first().trim("##", at: start).trim()
}
#let parse-cell(json-data, name, image-width: 90%) = {
let c = json-data.cells.find(c => cell-name(c) == name)
let lines = c.source.slice(1).map(parse-line)
let source = lines.map(array.first).join("\n")
let bold = lines.map(x => "bold" in x.last())
return (
source: {
show raw.line: it => if bold.at(it.number - 1) {
text(weight: "bold", it)
} else {
it
}
raw(block: true, source)
},
images: parse-images(c, width: image-width).join()
)
}
used like this:
In code_blocks.ipynb
, define and execute a code cell with content
## setting-ranges
set xrange [-pi : pi] ## bold
plot sin(x)
and in the Typst document:
// Alias for parse-cell, pre-configured to work with our notebook
#let cell = parse-cell.with(json("code_blocks.ipynb"))
// Get source and outputs of cell with tag `setting-ranges`
#let setting-ranges = cell("setting-ranges")
== Setting Ranges
#grid(
columns: (1fr, 1fr),
[
That was simple. Notice how gnuplot decided to plot
our function from -10 to +10. That's the default,
which we got because we didn't ask for any
particular range. Gnuplot also set the y-axis
limits (the range of the vertical axis) to
encompass the range of the function over that
default x-axis domain. Let's take control of the
limits on the horizontal axis (the new command is
highlighted). Gnuplot happens to know what π is
(but doesn't know any other transcendental
numbers).
#setting-ranges.source
],
setting-ranges.images,
)
Making/uploading/linking videos
The blog post mentions (just by description) a filter that creates animation frames, stiches them into a movie file, attaches the file to the PDF, copies the file to server and adds a link in the book.
For Typst, I would also do these things in the notebook, using the gnuplot kernel as an extension inside of a Python notebook and make the movie and server copy from there. The notebook cell that does this can output a link which is easy to pick up from the Typst side as shown above.
Conclusion
I’m rather biased since I love the Typst language, how it juggles with markup and script in the same file. But I think it’s fair to say that this kind of things is easier in Typst: you just need to learn Typst, rather than Pandoc Markdown, Pandoc filters and Panflute, maybe Lua, Python and Latex. And the language is optimized for this kind of things so the amount of boilerplate is much much less. Or at least I can say that I had fun writing the above Typst code, using the built-in support for JSON and YAML and a few glances at the Typst documentation, rather than a deep dive in the heterogeneous documentation of several large software projects.
It’s true however that Typst cannot do everything, for example calling external programs (except for some programs ported to function as WASM plugins). Though that particular limitation doesn’t bother me: already when using Quarto (which can run pandoc filters calling external programs) I had decided to keep code blocks in a separate notebook.