LaTeX & Typst (papers, theses, resumes and slides) Module 12 – Automation, collaboration, conversion and publishing
Converting between LaTeX, Typst, Word and Markdown
Convert documents between LaTeX, Typst, Word and Markdown with pandoc, see exactly what each conversion dropped, and check the result before you trust it.
What you will learn
- Convert a document between LaTeX, Typst, Word and Markdown with pandoc
- Identify what a conversion kept, changed and dropped, from pandoc's messages and the compiled PDFs
- Convert LaTeX maths to Typst and check every formula
- Generate HTML from LaTeX with TeX4ht and from Typst, and compare what each leaves out
- Verify a converted document by its counts of headings, equations and tables and by the text of its PDF
Before you start
What you’ll learn · Before you start
What you will learn
- Convert a document between LaTeX, Typst, Word and Markdown with pandoc
- Identify what a conversion kept, changed and dropped, from pandoc's messages and the compiled PDFs
- Convert LaTeX maths to Typst and check every formula
- Generate HTML from LaTeX with TeX4ht and from Typst, and compare what each leaves out
- Verify a converted document by its counts of headings, equations and tables and by the text of its PDF
Before you start
On this page
A short LaTeX note, converted to Typst by one pandoc command and compiled inside a small frame written by hand, still said “Table Table 1”, “Equation [eq:mean]” and “Section Section 1”, and it had lost a drawing without an error. A conversion that compiles is not yet a conversion that is right.
This lesson converts one note between LaTeX, Typst, Word and Markdown with pandoc, and turns it into HTML twice. For each conversion you will see what pandoc reported, what the new file says on the page, and how to prove, by counting and by comparing the text of the PDFs, that nothing went missing. Every session was recorded with pandoc 3.12, TeX Live 2026 and Typst 0.15.1.
pandoc reads a document into a tree, then writes the tree again
pandoc does not translate LaTeX into Typst directly. A reader parses the input into pandoc’s own document tree: pandoc's own representation of a document: blocks such as headings, paragraphs and tables, holding inlines such as words, emphasis, maths and citations. Every reader builds it and every writer works from it., and a writer turns the tree into the output format, so any reader can feed any writer. Its manual says the cost plainly: the tree is less expressive than many formats, so “one should not expect perfect conversions between every format and every other”. This is the note, and the start of its tree:
% note.tex: a short field note with the things a conversion has to carry.
\documentclass{article}
\usepackage{amsmath}
\usepackage{booktabs}
\usepackage{tikz}
\usepackage[numbers]{natbib}
\newcommand{\mean}[1]{\bar{#1}}
\title{Rain on two roofs}
\author{Meera Pillai}
\date{}
\begin{document}
\maketitle
\section{Method}\label{sec:method}
Two gauges logged the rain for \emph{two weeks}\footnote{Readings were taken at 8 a.m.} \citep{rao2025gauges}.
The daily mean of roof $r$ is
\begin{equation}\label{eq:mean}
\mean{x}_r = \frac{1}{n}\sum_{i=1}^{n} x_{r,i}
\end{equation}
and Table~\ref{tab:totals} lists the totals.
\begin{itemize}
\item Block A faces east.
\item Block B faces west.
\end{itemize}
\section{Result}
\begin{table}[h]
\centering
\caption{Totals for two weeks}\label{tab:totals}
\begin{tabular}{lr}
\toprule
Roof & Rain (mm) \\
\midrule
Block A & 83.0 \\
Block B & 76.5 \\
\bottomrule
\end{tabular}
\end{table}
Equation~\eqref{eq:mean} gives $\mean{x}_A = 5.93$\,mm a day, as Section~\ref{sec:method} explains.
\begin{tikzpicture}
\draw (0,0) rectangle (2,1) node[midway] {Block A};
\end{tikzpicture}
\bibliographystyle{plainnat}
\bibliography{refs}
\end{document} from session import files, needs, run
# What pandoc reads: the start of note.tex as pandoc's own document tree (-t native), which every writer works from.
needs("pandoc")
files("note/note.tex")
run("""
pandoc note.tex -t native | head -24
""") Output
$ pandoc note.tex -t native | head -24
[ Header 1 ( "sec:method" , [] , [] ) [ Str "Method" ]
, Para
[ Str "Two"
, Space
, Str "gauges"
, Space
, Str "logged"
, Space
, Str "the"
, Space
, Str "rain"
, Space
, Str "for"
, Space
, Emph [ Str "two" , Space , Str "weeks" ]
, Note
[ Para
[ Str "Readings"
, Space
, Str "were"
, Space
, Str "taken"
, Space
, Str "at"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tree_session.py
The first section became Header 1 ( "sec:method" , [] , [] ) [ Str "Method" ]: a level, the label, and the words. A
sentence became a Para of Str and Space items, the emphasis an Emph, the footnote a Note. Step through what
happened to the whole note on its way to Typst:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One file in. pandoc note.tex -t typst reads a short note with a title, two sections, a footnote, a citation, an equation with a custom macro, a booktabs table and a TikZ picture.
- The reader builds a tree. The LaTeX reader turns the text into pandoc's own document tree: -t native printed Header 1 ( "sec:method" , [] , [] ) [ Str "Method" ] for the first section, and a Para of Str and Space items for its sentence.
- Macros are expanded on the way. The reader parsed \newcommand{\mean}[1]{\bar{#1}} and applied it, so the tree holds the bar accent itself: the Typst output says macron(x), and no \mean is left.
- Some commands are dropped. With --verbose, pandoc listed four things it skipped: \maketitle, \centering, the whole tikzpicture and \bibliographystyle{plainnat}. Nothing else said the drawing was gone.
- The writer makes Typst. The Typst writer turned the table into figure(table(...), kind: table), the citation into @rao2025gauges, the footnote into #footnote[...] and the label of the section into <sec:method>.
- The body, not a document. Without -s, pandoc wrote only the body. main.typ, written by hand, sets numbering for headings and equations, puts table captions on top and includes body.typ, so the layout is yours, not pandoc's template.
Two things follow from the tree. Anything the tree has no node for, such as a TikZ picture, cannot reach any output:
the reader skips it, and says so only when asked with --verbose. And a custom macro is applied, not kept: pandoc read
\newcommand{\mean}[1]{\bar{#1}} and expanded every use, so the Typst file has macron(x) where note.tex had
\mean{x}, and a reader of the Typst file never learns that the macro existed.
Checkpoint
Check: pandoc's document tree
From LaTeX to Typst, the text arrives and the references need work
pandoc can write a whole Typst document with its own template (-s), or only the body, which a Typst file of your own
then includes. This session tries the first, converts the body with --verbose to see what was skipped, compiles it
inside a hand-written main.typ, and compares three sentences of the two PDFs:
// main.typ: the frame of the converted note, written by hand. pandoc converts only the body, into body.typ.
#set document(title: [Rain on two roofs], author: "Meera Pillai")
#set page(paper: "a4")
#set heading(numbering: "1")
#set math.equation(numbering: "(1)")
#show figure.where(kind: table): set figure.caption(position: top)
#align(center)[
#text(17pt)[Rain on two roofs] \
Meera Pillai
]
#include "body.typ"
#bibliography("refs.bib", title: [References]) from session import files, needs, run
# note.tex converted to Typst twice: first as a whole document with pandoc's own template, then only the body, which
# main.typ, written by hand, includes. Then three sentences of the LaTeX PDF and of the Typst PDF side by side, and
# how often each PDF's text says Block A.
needs("pandoc", "typst", "pdflatex", "latexmk", "pdftotext", "git")
files("note/note.tex", "note/refs.bib", "note/main.typ")
run("""
!pandoc -s note.tex -o whole.typ && typst compile whole.typ
pandoc --verbose note.tex -t typst -o body.typ 2>&1 | grep 'Skipped'
typst compile main.typ note-typst.pdf
latexmk -pdf -silent note.tex > /dev/null 2>&1
pdftotext note.pdf latex.txt && pdftotext note-typst.pdf typst.txt
grep -E 'daily mean|lists the totals|explains' latex.txt typst.txt
grep -c 'Block A' latex.txt typst.txt
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ pandoc -s note.tex -o whole.typ && typst compile whole.typ
error: cannot reference heading without numbering
┌─ whole.typ:182:16
│
182 │ day, as Section~@sec:method explains.
│ ^^^^^^^^^^^
│
= hint: you can enable heading numbering with `#set heading(numbering: "1.")`
[exit status 1]
$ pandoc --verbose note.tex -t typst -o body.typ 2>&1 | grep 'Skipped'
[INFO] Skipped '\maketitle' at note.tex line 12 column 11
[INFO] Skipped '\centering' at note.tex line 26 column 13
[INFO] Skipped '\begin{tikzpicture}
[INFO] Skipped '\bibliographystyle{plainnat}' at note.tex line 41 column 29
$ typst compile main.typ note-typst.pdf
$ latexmk -pdf -silent note.tex > /dev/null 2>&1
$ pdftotext note.pdf latex.txt && pdftotext note-typst.pdf typst.txt
$ grep -E 'daily mean|lists the totals|explains' latex.txt typst.txt
latex.txt:Two gauges logged the rain for two weeks 1 [1]. The daily mean of roof r is
latex.txt:and Table 1 lists the totals.
latex.txt:Equation (1) gives x̄A = 5.93 mm a day, as Section 1 explains.
typst.txt:Two gauges logged the rain for two weeks1 [1]. The daily mean of roof 𝑟 is
typst.txt:and Table Table 1 lists the totals.
typst.txt:Equation [eq:mean] gives 𝑥̄𝐴 = 5.93 mm a day, as Section Section 1 explains.
$ grep -c 'Block A' latex.txt typst.txt
latex.txt:3
typst.txt:2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 convert_session.py
The whole document stopped at once: cannot reference heading without numbering, because pandoc’s template leaves
headings unnumbered while the text refers to them. The body compiled inside main.typ, which numbers headings and
equations, and the three sentences then showed the faults a reader would see:
| What note.tex says | What the converted note printed | Why |
|---|---|---|
Table~\ref{tab:totals} |
Table Table 1 | Typst’s @tab:totals prints the word Table itself, so the word before it doubles |
Section~\ref{sec:method} |
Section Section 1 | The same doubling, for a section |
Equation~\eqref{eq:mean} |
Equation [eq:mean] | pandoc wrote \eqref as a link with the label as its text |
a tikzpicture labelled Block A |
nothing | Skipped by the reader; Block A appeared 3 times in the LaTeX text and 2 in the Typst text |
pandoc’s --verbose messages named four skipped commands, \maketitle, \centering, the tikzpicture and
\bibliographystyle, but the doubled words and the wrong reference compiled without a message. The Typst guide for
LaTeX users points to the same route, pandoc, and says the Typst web app has the conversion built in; either way, the
result has to be checked.
Convert the body, write the frame
Let pandoc convert the text and keep the layout in a Typst file you write yourself, such as main.typ: numbering,
captions, fonts and the title are then yours, and a later conversion of the body changes none of them.
Checkpoint
Check: LaTeX to Typst with pandoc
Maths converts formula by formula
Maths is the part pandoc converts best, because LaTeX maths has a node of its own in the tree. Ten formulas, each as pandoc wrote it, and the converted file compiled as a check:
$\frac{a+b}{2}$
$\sqrt[3]{x}$
$x \in \mathbb{R}$
$\hat{\theta} = \operatorname{arg\,max}_\theta L(\theta)$
$|x| = \begin{cases} x & \text{if } x \ge 0 \\ -x & \text{otherwise} \end{cases}$
$\left( \frac{1}{2} \right)^n$
$\int_0^1 x^2 \, \mathrm{d}x = \tfrac{1}{3}$
$A = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}$
$\vec{v} \cdot \vec{w} \le \lVert v \rVert \lVert w \rVert$
$\mathcal{O}(n \log n)$ from session import files, needs, run
# Ten LaTeX formulas, each as pandoc writes it in Typst, then the converted file compiled as a check.
needs("pandoc", "typst")
files("maths/maths.tex")
run("""
pandoc maths.tex -t typst
pandoc maths.tex -t typst -o maths.typ && typst compile maths.typ
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ pandoc maths.tex -t typst
$frac(a + b, 2)$
$root(3, x)$
$x in bb(R)$
$hat(theta) = "arg max"_theta L\(theta\)$
$\|x\|= cases(delim: "{", x & upright("if ") x gt.eq 0, - x & upright("otherwise"))$
$(1 / 2)^n$
$integral_0^1 x^2 thin upright(d) x = 1 / 3$
$A = mat(delim: "(", 1, 2; 3, 4)$
$arrow(v) dot.op arrow(w) lt.eq lr(bar.v.double v bar.v.double) lr(bar.v.double w bar.v.double)$
$cal(O)\(n log n\)$
$ pandoc maths.tex -t typst -o maths.typ && typst compile maths.typ
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 maths_session.py
All ten compiled. Most need nothing more, and a few are worth a look before you keep them:
| LaTeX | pandoc’s Typst | Look at |
|---|---|---|
\frac{a+b}{2} |
frac(a + b, 2) |
nothing; the same fraction |
\sqrt[3]{x} |
root(3, x) |
nothing |
x \in \mathbb{R} |
x in bb(R) |
nothing |
\operatorname{arg\,max}_\theta |
"arg max"_theta |
an upright text operator; check where the subscript sits |
\begin{cases} … \end{cases} |
cases(delim: "{", …) |
\text{if } became upright("if ") |
\lVert v \rVert |
lr(bar.v.double v bar.v.double) |
correct but long; norm(v) reads better |
L(\theta) |
L\(theta\) |
escaped brackets, which print as plain brackets |
When a maths-heavy file must keep its LaTeX syntax for a while, MiTeX, a package on Typst Universe described as
“LaTeX support for Typst, powered by Rust and WASM”, renders LaTeX maths inside a Typst document with #mi(...) and
#mitex(...).
Trusting a formula because it compiled
Wrong: compiling the converted maths and moving on. Right: compare each formula on the page with the LaTeX PDF, at least once, because a formula can compile and mean something else, for example when brackets group differently.
Checkpoint
Check: Converting maths
Word and Markdown keep the structure, not the LaTeX
A .docx file is a ZIP package of XML parts, defined by the ECMA-376 standard. pandoc’s manual says that maths in a
Word file is written as OMML: Office Math Markup Language, the XML in which Word files store equations, as m:oMath elements, so that they stay editable in a word processor., Word’s own maths markup. This session writes the note as a Word file with citations
formatted by pandoc’s --citeproc, lists what the file holds, reads it back as Markdown, and then turns a Markdown
file of lecture notes into a PDF through Typst:
---
title: Rain gauges on flat roofs
author: Meera Pillai
---
# What we measured
Two gauges logged the rain on two roofs for two weeks [@rao2025gauges].
Each daily reading $x_i$ was added up, so the total of a roof is $\sum_{i=1}^{14} x_i$.
| Roof | Rain (mm) |
|---------|----------:|
| Block A | 83.0 |
| Block B | 76.5 |
# What to do next
- Read both gauges at the same time each day.
- Write *missing* when a reading cannot be taken. from session import files, needs, run
# The note as a Word file, what the file holds, and the Word file read back as Markdown; then Markdown notes turned
# into a PDF through Typst, with citations formatted by pandoc's citeproc.
needs("pandoc", "typst", "pdftotext")
files("note/note.tex", "note/refs.bib", "md/notes.md", "docx_parts.py")
run("""
pandoc note.tex --citeproc --bibliography=refs.bib --number-sections -o note.docx
python3 docx_parts.py note.docx
pandoc note.docx -t markdown --wrap=none
pandoc notes.md --citeproc --bibliography=refs.bib --pdf-engine=typst -o notes.pdf
pdfinfo notes.pdf | grep -E 'Creator|Tagged|Pages'
pdftotext notes.pdf - | grep 'Rao'
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ pandoc note.tex --citeproc --bibliography=refs.bib --number-sections -o note.docx
$ python3 docx_parts.py note.docx
16 parts, among them: word/document.xml, word/numbering.xml, word/styles.xml, word/footnotes.xml, word/comments.xml, word/settings.xml, word/fontTable.xml, word/webSettings.xml
paragraph styles: Author 1, Bibliography 1, BodyText 1, Compact 6, FirstParagraph 5, Heading1 2, TableCaption 1, Title 1
equations (m:oMath): 3
$ pandoc note.docx -t markdown --wrap=none
# 1 Method
Two gauges logged the rain for *two weeks*[^1] (Rao and Kulkarni 2025). The daily mean of roof $r$ is
$${\bar{x}}_{r} = \frac{1}{n}\sum_{i = 1}^{n}x_{r,i}$$
and Table [1](#_8ddf680cae075dc657fb546cd9f3f5f778cab5e) lists the totals.
- Block A faces east.
- Block B faces west.
# 2 Result
-------------------------------------------------------------
Roof Rain (mm)
------------------------------ ------------------------------
Block A 83.0
Block B 76.5
-------------------------------------------------------------
: Totals for two weeks
Equation [\[eq:mean\]](#_908f492889e3ae7c115bf8154b176a12b022e3a) gives ${\bar{x}}_{A} = 5.93$ mm a day, as Section [1](#_09ee8443ab55a3f30fdd88acf82bf7f547c29d1) explains.
Rao, Asha, and Dev Kulkarni. 2025. "Low-Cost Rain Gauges on Flat Roofs." *Journal of Example Hydrology* 12: 40--52. <https://doi.org/10.5555/jeh.2025.040>.
[^1]: Readings were taken at 8 a.m.
$ pandoc notes.md --citeproc --bibliography=refs.bib --pdf-engine=typst -o notes.pdf
$ pdfinfo notes.pdf | grep -E 'Creator|Tagged|Pages'
Creator: Typst 0.15.1
Tagged: yes
Pages: 1
$ pdftotext notes.pdf - | grep 'Rao'
Two gauges logged the rain on two roofs for two weeks (Rao and Kulkarni 2025). Each daily
Rao, Asha, and Dev Kulkarni. 2025. “Low-Cost Rain Gauges on Flat Roofs.” Journal of Example
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 word_md_session.py
The Word file had 16 parts and Word’s own paragraph styles, Title, Author, Heading1 for the two sections and
TableCaption for the caption, and its three formulas were m:oMath equations. Read back as Markdown, the structure
had survived, and so had the faults: the reference to the equation still read [eq:mean], and the citation had become
(Rao and Kulkarni 2025), because citeproc formats in the Chicago author-date style unless it is given a style.
--pdf-engine=typst made a one-page PDF whose pdfinfo said Creator: Typst 0.15.1 and Tagged: yes.
| Route | What survived | What to fix by hand |
|---|---|---|
| LaTeX to Word | headings as Heading 1, the table, the footnote, maths as Word equations, citations formatted | the citation style, references made by \eqref |
| Word to Markdown | headings, lists, the table, maths as LaTeX maths | links to Word bookmarks in place of labels |
| Markdown to PDF through Typst | headings, the table, maths, citations, a tagged PDF | nothing in these notes; Markdown itself has no numbered references |
MySmartCoPilot’s Markdown to Word and Markdown to PDF tools do the last two routes in your browser, for notes that need no numbering.
Markdown, HTML & Text to Word (DOCX) Turn Markdown notes into an editable Word file with real heading styles.Checkpoint
Check: Word and Markdown routes
HTML from LaTeX and from Typst keeps different things
A web page needs HTML, and both systems can write it. For LaTeX the tool is TeX4ht; its CTAN entry calls make4ht a
simple build system for it, but make4ht stopped here because a module of the LuaXML package was missing from this
lesson’s TinyTeX, so the session uses TeX4ht’s older driver, htlatex, which needs nothing more. Typst has an HTML export of its own, which its reference says is
experimental, needs --features html and should not be used for production. The session converts the corrected note
both ways and counts what each HTML file kept:
"""Counts what an HTML file kept of a document: headings, MathML formulas, images, inline styles and style blocks.
python3 html_count.py note.html
The counts come from the tags in the file, read with Python's HTML parser.
"""
import sys
from html.parser import HTMLParser
class Counter(HTMLParser):
def __init__(self):
super().__init__()
self.headings, self.maths, self.images, self.styles, self.blocks, self.title = [], 0, [], 0, 0, ""
self._in = None
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if "style" in attrs:
self.styles += 1
if tag in ("h1", "h2", "h3", "h4"):
self._in = tag
self.headings.append([tag, ""])
elif tag == "title":
self._in = "title"
elif tag == "math":
self.maths += 1
elif tag == "img":
self.images.append(attrs.get("src", ""))
elif tag == "style":
self.blocks += 1
def handle_endtag(self, tag):
if tag == self._in:
self._in = None
def handle_data(self, data):
if self._in == "title":
self.title += data
elif self._in:
self.headings[-1][1] += data
with open(sys.argv[1], encoding="utf-8", errors="replace") as file:
counter = Counter()
counter.feed(file.read())
print(f"title: {counter.title.strip()}")
for tag, text in counter.headings:
print(f"{tag}: {' '.join(text.split())}")
print(f"MathML formulas: {counter.maths}, images: {', '.join(counter.images) or 'none'}")
print(f"style attributes: {counter.styles}, style blocks: {counter.blocks}") from session import files, needs, run
# HTML from both sources. For note.tex, after a latexmk build that writes the .bbl it needs: make4ht, which stops
# because a module of LuaXML is not installed in this TinyTeX, then TeX4ht's older driver htlatex. For the converted
# and corrected note: Typst's experimental HTML export. html_count.py then counts what each HTML file kept.
needs("pdflatex", "latexmk", "htlatex", "typst")
files("note/note.tex", "note/refs.bib", "note/main.typ", "note/body-fixed.typ=body.typ", "html_count.py")
run("""
latexmk -pdf -silent note.tex > /dev/null 2>&1
!make4ht note.tex 'mathml' > make4ht.log 2>&1
grep -o "module 'luaxml[a-z-]*' not found" make4ht.log
htlatex note.tex 'xhtml,mathml' > htlatex.log 2>&1
ls note*.html note*.svg note.css
python3 html_count.py note.html
typst compile --features html --format html main.typ note-typst.html 2>&1 | grep '^warning'
python3 html_count.py note-typst.html
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ latexmk -pdf -silent note.tex > /dev/null 2>&1 $ make4ht note.tex 'mathml' > make4ht.log 2>&1 [exit status 1] $ grep -o "module 'luaxml[a-z-]*' not found" make4ht.log module 'luaxml-domobject' not found $ htlatex note.tex 'xhtml,mathml' > htlatex.log 2>&1 $ ls note*.html note*.svg note.css note.css note.html note0x.svg note2.html $ python3 html_count.py note.html title: Rain on two roofs h1: Rain on two roofs h3: 1 Method h3: 2 Result h3: References MathML formulas: 3, images: note0x.svg style attributes: 11, style blocks: 0 $ typst compile --features html --format html main.typ note-typst.html 2>&1 | grep '^warning' warning: html export is under active development and incomplete warning: page set rule was ignored during HTML export warning: align was ignored during HTML export warning: align was ignored during HTML export warning: rect was ignored during HTML export $ python3 html_count.py note-typst.html title: Rain on two roofs h2: 1 Method h2: 2 Result h2: References MathML formulas: 3, images: none style attributes: 2, style blocks: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 html_session.py
| Part of the note | TeX4ht (htlatex) | Typst HTML export |
|---|---|---|
| Title | an h1 and the <title> |
the <title> only; the centred title block was ignored |
| Sections | h3 headings |
h2 headings |
| Formulas | 3 in MathML: The W3C's markup language for mathematics, which records a formula's structure, such as which part is a fraction or a power, so that browsers can lay it out and assistive technology can read it. More in “Maths fonts, symbol lookup and accessible equations” | 3 in MathML |
| The Block A drawing | note0x.svg, an image |
dropped: rect was ignored during HTML export |
| Styling | a note.css file and 11 style attributes |
1 style block and 2 style attributes |
Typst listed what it ignored as warnings: the page settings, both align calls and the rect. TeX4ht kept the drawing
as an SVG image. Its style attributes matter for any site whose security policy forbids inline styles, which must
strip them or move them to classes.
Checkpoint
Check: HTML from both systems
A conversion is checked by its counts and its words
Compiling proves only that the new file is valid. Two checks prove that it says what the old one said. Count the parts
that both should have, here with grep on note.tex and, for Typst, with typst eval and query(), which its
reference describes as finding elements in the document; then compare the same sentences of both PDFs again after the
fixes. This session compares pandoc’s body with the corrected one, then runs both checks:
= Method
<sec:method>
Two gauges logged the rain for #emph[two weeks]#footnote[Readings were
taken at 8 a.m.] @rao2025gauges. The daily mean of roof $r$ is
$ macron(x)_r = 1 / n sum_(i = 1)^n x_(r\,i) $<eq:mean> and
@tab:totals lists the totals.
- Block A faces east.
- Block B faces west.
= Result
<result>
#figure(
align(center)[#table(
columns: 2,
align: (left,right,),
table.header([Roof], [Rain (mm)],),
table.hline(),
[Block A], [83.0],
[Block B], [76.5],
)]
, caption: [Totals for two weeks]
, kind: table
)
<tab:totals>
@eq:mean gives $macron(x)_A = 5.93$ mm a
day, as @sec:method explains.
// The TikZ picture of note.tex, which pandoc skipped, drawn again in Typst.
#rect(width: 2cm, height: 1cm, inset: 0pt)[#align(center + horizon)[Block A]] from session import files, needs, run
# The converted body against the corrected one, then the corrected note checked: the numbered headings, display
# equations and tables of each source and document counted, and the same sentences of the two PDFs compared again.
needs("pandoc", "typst", "pdflatex", "latexmk", "pdftotext", "git")
files("note/note.tex", "note/refs.bib", "note/main.typ", "note/body-fixed.typ")
run("""
pandoc note.tex -t typst -o body.typ
!git diff --no-index -U0 body.typ body-fixed.typ
cp body-fixed.typ body.typ && typst compile main.typ note-typst.pdf
grep -c -E '^.section' note.tex
grep -c -E 'begin.equation' note.tex
grep -c -E 'begin.table' note.tex
typst eval 'query(heading.where(numbering: "1")).len()' --in main.typ
typst eval 'query(math.equation.where(block: true)).len()' --in main.typ
typst eval 'query(figure.where(kind: table)).len()' --in main.typ
latexmk -pdf -silent note.tex > /dev/null 2>&1
pdftotext note.pdf latex.txt && pdftotext note-typst.pdf typst.txt
grep -E 'daily mean|lists the totals|explains' latex.txt typst.txt
grep -c 'Block A' latex.txt typst.txt
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ pandoc note.tex -t typst -o body.typ $ git diff --no-index -U0 body.typ body-fixed.typ diff --git a/body.typ b/body-fixed.typ index 4acbeaa..b7d9cfd 100644 --- a/body.typ +++ b/body-fixed.typ @@ -6 +6 @@ $ macron(x)_r = 1 / n sum_(i = 1)^n x_(r\,i) $<eq:mean> and -Table~@tab:totals lists the totals. +@tab:totals lists the totals. @@ -28,2 +28,5 @@ Table~@tab:totals lists the totals. -Equation~#link(<eq:mean>)[\[eq:mean\]] gives $macron(x)_A = 5.93$ mm a -day, as Section~@sec:method explains. +@eq:mean gives $macron(x)_A = 5.93$ mm a +day, as @sec:method explains. + +// The TikZ picture of note.tex, which pandoc skipped, drawn again in Typst. +#rect(width: 2cm, height: 1cm, inset: 0pt)[#align(center + horizon)[Block A]] [exit status 1] $ cp body-fixed.typ body.typ && typst compile main.typ note-typst.pdf $ grep -c -E '^.section' note.tex 2 $ grep -c -E 'begin.equation' note.tex 1 $ grep -c -E 'begin.table' note.tex 1 $ typst eval 'query(heading.where(numbering: "1")).len()' --in main.typ 2 $ typst eval 'query(math.equation.where(block: true)).len()' --in main.typ 1 $ typst eval 'query(figure.where(kind: table)).len()' --in main.typ 1 $ latexmk -pdf -silent note.tex > /dev/null 2>&1 $ pdftotext note.pdf latex.txt && pdftotext note-typst.pdf typst.txt $ grep -E 'daily mean|lists the totals|explains' latex.txt typst.txt latex.txt:Two gauges logged the rain for two weeks 1 [1]. The daily mean of roof r is latex.txt:and Table 1 lists the totals. latex.txt:Equation (1) gives x̄A = 5.93 mm a day, as Section 1 explains. typst.txt:Two gauges logged the rain for two weeks1 [1]. The daily mean of roof 𝑟 is typst.txt:and Table 1 lists the totals. typst.txt:Equation 1 gives 𝑥̄𝐴 = 5.93 mm a day, as Section 1 explains. $ grep -c 'Block A' latex.txt typst.txt latex.txt:3 typst.txt:3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The fixes were two hunks: Table~ and Section~ removed before their references, @eq:mean in place of the link,
and the Block A box drawn again with rect. Both sides then counted 2 numbered sections, 1 display equation and 1
table, the sentences read Table 1, Equation 1 and Section 1, and Block A appeared 3 times in each text. Follow
the checks in order:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- It compiles, so check it. main.typ with pandoc's body.typ compiled without an error. That says only that the markup is valid Typst, not that it says what note.tex said.
- Count what both should have. grep counted 2 sections, 1 equation and 1 table in note.tex; typst eval with query() counted 2 numbered headings, 1 display equation and 1 table figure. Equal counts rule out a lost section or table.
- Compare the words a reader sees. The same three sentences of both PDFs showed what the counts missed: and Table Table 1, Equation [eq:mean] for Equation (1), and Section Section 1. Block A appeared 3 times in the LaTeX text and 2 times in the Typst text.
- Fix the source, not the PDF. body-fixed.typ removes Table~ and Section~ before two references, because Typst adds those words itself, writes @eq:mean in place of the link, and draws the Block A box again with rect.
- Build and compare again. The second build gave and Table 1, Equation 1 and Section 1, and Block A 3 times in both texts. Only intended differences remain, such as the style of the bibliography.
- What is left is expected. LaTeX printed (1) for the equation where Typst printed 1, and the maths letters were extracted as r and 𝑟. Each remaining difference has a reason you can name, which is the test of a finished conversion.
What remains is expected and nameable: LaTeX’s \eqref printed (1) where Typst’s reference printed 1, the maths
letters were extracted as r and 𝑟, which Unicode’s NFKC: A Unicode normalisation form that replaces compatibility variants, such as mathematical italic letters, superscript digits and ligatures, with their plain characters, so that text compares and searches as people type it. More in “Inline and display maths” form turns into the same letter, and
the two bibliographies follow different styles.
Key idea
A conversion is finished when its counts match and every difference left in the text has a reason you can name: compiling only proves that the new file is valid.
Checkpoint
Check: Checking a conversion
Practise: list what a conversion lost
The sentence checks above compare by eye. A short function does it for every word of two extracted texts, and with normalising on it ignores the maths letters that differ only in their code points. The sample tests are made from this lesson’s note, and the first hint is open:
Exercise · Medium · Python
List the words a conversion lost or added
After a conversion, the text of the new PDF should hold the same words as the old one. Write word_changes(before, after, normalise=False) in wordcheck.py. before and after are texts that pdftotext extracted from the two PDFs. It returns a pair of lists, (missing, extra):
- the words are the pieces of each text between runs of whitespace, punctuation included, as in
(1)ortotals.; missingholds the words ofbeforethatafterlacks, andextrathe words ofafterthatbeforelacks, counting repeats: threeBlockbefore and two after leave oneBlockmissing;- both lists are sorted;
- with
normalise=True, every word is first put in Unicode's NFKC form, which turns mathematical italic letters such as𝑟, the letters Typst's PDFs hold for maths, into the plainrthat pdfLaTeX's PDFs hold.
Two texts with the same words in any order give ([], []).
Starter code · wordcheck.py
def word_changes(before, after, normalise=False):
"""Return (missing, extra): the sorted words that `after` lacks and the sorted words it has in addition."""
# Replace this line: count the words of each text, then subtract the counts both ways.
return [], [] The sample tests · test_wordcheck.py
from wordcheck import word_changes
def test_doubled_word():
"""finds the word the conversion doubled"""
assert word_changes("and Table 1 lists the totals.", "and Table Table 1 lists the totals.") == ([], ["Table"])
def test_reference_as_text():
"""finds an equation number that became a label's name"""
before = "Equation (1) gives 5.93 mm a day."
after = "Equation [eq:mean] gives 5.93 mm a day."
assert word_changes(before, after) == (["(1)"], ["[eq:mean]"])
def test_repeats_counted():
"""counts repeated words, so a drawing's lost label shows"""
before = "Block A faces east.\nBlock A 83.0\nBlock A"
after = "Block A faces east.\nBlock A 83.0"
assert word_changes(before, after) == (["A", "Block"], [])
def test_order_and_spaces():
"""ignores the order of words and the kind and amount of whitespace"""
assert word_changes("mean of roof r", "roof r\nmean of") == ([], [])
def test_maths_letters():
"""treats a mathematical italic letter as a different word unless asked to normalise"""
before = "The daily mean of roof r is"
after = "The daily mean of roof \U0001d45f is"
assert word_changes(before, after) == (["r"], ["\U0001d45f"])
assert word_changes(before, after, normalise=True) == ([], [])
def test_sorted_lists():
"""returns both lists sorted"""
assert word_changes("b a c", "d c e") == (["a", "b"], ["d", "e"])
def test_empty_texts():
"""gives two empty lists for two empty texts"""
assert word_changes("", "") == ([], []) A hint
text.split() gives the words, and collections.Counter counts them. One Counter minus another keeps only the positive differences, so before_counts - after_counts is what is missing. unicodedata.normalize("NFKC", word) does the normalising.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The self-check below covers the six ideas, starting with the one you have answered least well, and explains every choice as soon as you make it:
Practice that adapts to you
Practise this lesson
16 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): What does pandoc keep and lose when it converts a LaTeX file to Typst?
pandoc reads the LaTeX into its own document tree and writes Typst from the tree, so it keeps what the tree can hold:
headings with their labels, paragraphs, emphasis, lists, footnotes, tables, maths and citations. It applies custom
macros instead of keeping them, so \mean{x} arrived as macron(x). It drops what the tree cannot hold: in this
lesson’s note it skipped \maketitle, \centering, a TikZ picture and \bibliographystyle, and said so only with
--verbose. It also writes things that compile and read wrongly: Table~\ref{…} became “Table Table 1”, because a
Typst reference prints its own word, and \eqref became a link whose text was the label’s name. So a converted file is
a draft: compile it, count its headings, equations and tables against the original, and compare the text of both PDFs.
Two harder questions build on this: a thesis chapter that must move to Typst with all its references working, and a lab manual needed as a PDF, a Word file and a web page. Sketch an answer to each, then check it against its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
You must move one 30-page chapter of a LaTeX thesis, with 40 cross-references, 12 equations, 6 tables and 3 TikZ figures, to Typst this week, and the rest of the thesis follows next term. How do you convert it so that every reference and number still works, and how do you prove it?
-
Mid to senior
A department publishes its lab manual as a PDF for printing, a Word file that teaching assistants edit, and a web page that must be accessible. The manual has equations, tables and cross-references. Choose the source format and the conversions, and say what you check after every change.
Key takeaways
- pandoc reads a document into its own tree and writes the tree again, so whatever the tree cannot hold, such as a TikZ picture, is dropped, and custom macros arrive expanded.
- Converted to Typst, the note compiled but printed “Table Table 1”, “Section Section 1” and “Equation [eq:mean]”, and
had lost its drawing;
--verbosenamed only the skipped commands. - Maths converts formula by formula into Typst’s own syntax; check each formula on the page once, and use MiTeX while a file must keep LaTeX maths.
- Word files keep headings, tables and maths as Word’s own styles and equations, citeproc formats citations in Chicago
author-date unless given a style, and Markdown reaches a tagged PDF through
--pdf-engine=typst. - TeX4ht kept the drawing as SVG and wrote inline styles; Typst’s experimental HTML export dropped the drawing and the title block and said so in its warnings.
- A conversion is checked by counting its parts on both sides and comparing the text of both PDFs until every difference left has a reason.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does pandoc convert LaTeX to Typst?
A reader parses the input into pandoc's own document tree and a writer writes Typst from the tree, so whatever the tree cannot hold, such as a TikZ picture, is dropped.
-
What happens to a custom macro such as \mean{x} in a pandoc conversion?
It is expanded, not kept: the Typst file has macron(x), and its reader never learns that the macro existed.
-
Why did the converted note print Table Table 1?
Typst's reference @tab:totals prints the word Table itself, so the word that the LaTeX source typed before \ref doubled.
-
What did \eqref become in the conversion, and what replaces it?
A link whose text was the label's name, printed as Equation [eq:mean]; in Typst write @eq:mean.
-
Why convert only the body and write the Typst frame yourself?
pandoc's whole-document template left the headings unnumbered and the build stopped on a reference; a main.typ of your own keeps numbering, captions, fonts and the title, and a later conversion changes none of them.
-
Which pandoc option shows what the reader skipped?
--verbose, which named \maketitle, \centering, the tikzpicture and \bibliographystyle; the doubled words and the wrong reference compiled without a message.
-
How does pandoc store maths and citations in a Word file?
Maths as OMML, Word's own maths markup, in m:oMath equations; --citeproc formats citations in the Chicago author-date style unless it is given another.
-
How do you prove that a conversion lost nothing?
Count the parts on both sides, such as sections, equations and tables (with typst eval and query() for Typst), then compare the text of both PDFs until every difference left has a reason.
Terms in this lesson
- document tree
- pandoc's own representation of a document: blocks such as headings, paragraphs and tables, holding inlines such as words, emphasis, maths and citations. Every reader builds it and every writer works from it.
- OMML
- Office Math Markup Language, the XML in which Word files store equations, as m:oMath elements, so that they stay editable in a word processor.
References
- Pandoc user’s guide (readers, writers, extensions, --track-changes, --pdf-engine) (pandoc)
- Pandoc 3.12 release notes (pandoc)
- Typst guide for LaTeX users (converting with Pandoc) (Typst)
- Typst reference: HTML export (Typst)
- Typst reference: introspection and query (Typst)
- mitex: LaTeX support for Typst (Typst Universe)
- make4ht: a build system for TeX4ht (CTAN)
- TeX4ht: LaTeX to HTML and other formats (CTAN)
- ECMA-376: Office Open XML file formats (Ecma International)
- Typst web app plans (conversion from LaTeX, Word and Markdown) (Typst)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress