LaTeX & Typst (papers, theses, resumes and slides) Module 12 – Automation, collaboration, conversion and publishing
Collaboration, version control and tracked changes
Keep LaTeX and Typst in Git with diffs that show sentences, mark changes between drafts with latexdiff, merge Word tracked changes, keep notes out of PDFs.
What you will learn
- Organise LaTeX and Typst sources in Git with one sentence per line, so that diffs and merges show changed sentences
- Generate a PDF that marks every change between two LaTeX drafts with latexdiff, and check it
- Compare two Typst drafts through the text a reader sees
- Merge a co-author's tracked changes in a Word file back into the source with pandoc
- Remove notes for co-authors from the final PDF without editing the source, and choose an online editor by its features
Before you start
What you’ll learn · Before you start
What you will learn
- Organise LaTeX and Typst sources in Git with one sentence per line, so that diffs and merges show changed sentences
- Generate a PDF that marks every change between two LaTeX drafts with latexdiff, and check it
- Compare two Typst drafts through the text a reader sees
- Merge a co-author's tracked changes in a Word file back into the source with pandoc
- Remove notes for co-authors from the final PDF without editing the source, and choose an online editor by its features
Before you start
On this page
Two co-authors who each change one sentence of the same paragraph should not have to merge their work by hand. In this lesson’s session they did have to, because the paragraph was a single line of the file; with one sentence per line, Git merged the same two edits by itself.
Below, two people write a short report together. You will lay its source out so that Git shows and merges sentences, keep the files that builds write out of the repository, give a supervisor a PDF with every change between two drafts marked, compare Typst drafts, bring a Word user’s tracked changes back into the source, and keep notes for co-authors out of the final PDF. Every session was recorded with Git 2.54, TeX Live 2026, Typst 0.15.1 and pandoc 3.12.
One sentence per line lets Git merge two people’s changes
Git compares files line by line. A diff lists the lines that changed, and a merge combines two branches’ changes as
long as they touch different lines; when both change the same lines, the result is a merge conflict: What Git reports when two branches changed the same lines of a file, or lines next to each other: it stops and writes both versions between conflict markers for a person to resolve.. In this session two co-authors change one paragraph of three sentences: Ravi, on
the branch ravi, the last sentence, and Meera, on main, the first. two_edits.sh makes both commits, and the
merge is recorded three times, with the paragraph on one line, with one sentence per line, and with Ravi’s change moved
to the second sentence, next to Meera’s:
merge/two_edits.sh
# two_edits.sh FOLDER OLD1 NEW1 OLD2 NEW2
# In FOLDER, commit method.tex, then change it on two branches, as two co-authors would:
# on the branch ravi, OLD1 becomes NEW1;
# on main, OLD2 becomes NEW2.
# It stops on main, ready for git merge ravi, and stops at the first command that fails.
set -e
cd "$1"
git init -q -b main .
git add method.tex
git commit -q -m 'Method, first draft'
git switch -q -c ravi
python3 ../edit.py method.tex "$2" "$3"
git commit -q -am 'Mark missing readings'
git switch -q main
python3 ../edit.py method.tex "$4" "$5"
git commit -q -am 'Two weeks of data' edit.py
"""Replaces one piece of text in a file, the way a co-author changes one sentence in an editor.
python3 edit.py FILE OLD NEW
The first OLD in FILE becomes NEW, and the file is saved. It stops with exit status 1, and changes nothing, when FILE
does not contain OLD, so a session never goes on as if an edit had been made.
"""
import sys
from pathlib import Path
path, old, new = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
text = path.read_text(encoding="utf-8")
if old not in text:
sys.exit(f"{path}: no {old!r} in the file")
path.write_text(text.replace(old, new, 1), encoding="utf-8") from session import files, needs, run
# Two co-authors change one paragraph on two branches, and Git merges them: first with the paragraph on one line,
# then with one sentence per line, then with the two changes in neighbouring sentences.
needs("git", "python3")
files("edit.py", "merge/two_edits.sh", "merge/paragraph.tex=paragraph/method.tex",
"merge/sentences.tex=sentences/method.tex", "merge/sentences.tex=neighbours/method.tex")
run("""
# 1. The whole paragraph on one line; Ravi changes the last sentence, Meera the first.
sh two_edits.sh paragraph 'left blank.' 'left blank and marked as missing.' 'for a week.' 'for two weeks.'
!cd paragraph && git merge ravi -m 'Merge ravi'
cat paragraph/method.tex
# 2. One sentence per line, the same two changes.
sh two_edits.sh sentences 'left blank.' 'left blank and marked as missing.' 'for a week.' 'for two weeks.'
cd sentences && git merge ravi -m 'Merge ravi'
cat sentences/method.tex
# 3. One sentence per line, but Ravi changes the second sentence, next to Meera's first.
sh two_edits.sh neighbours 'at 8 a.m.' 'at 8 a.m. and 6 p.m.' 'for a week.' 'for two weeks.'
!cd neighbours && git merge ravi -m 'Merge ravi'
cat neighbours/method.tex
""") Output
# 1. The whole paragraph on one line; Ravi changes the last sentence, Meera the first. $ sh two_edits.sh paragraph 'left blank.' 'left blank and marked as missing.' 'for a week.' 'for two weeks.' $ cd paragraph && git merge ravi -m 'Merge ravi' Auto-merging method.tex CONFLICT (content): Merge conflict in method.tex Automatic merge failed; fix conflicts and then commit the result. [exit status 1] $ cat paragraph/method.tex <<<<<<< HEAD Two gauges logged the rain for two weeks. They were read at 8 a.m. every day. A reading that could not be taken was left blank. ======= Two gauges logged the rain for a week. They were read at 8 a.m. every day. A reading that could not be taken was left blank and marked as missing. >>>>>>> ravi # 2. One sentence per line, the same two changes. $ sh two_edits.sh sentences 'left blank.' 'left blank and marked as missing.' 'for a week.' 'for two weeks.' $ cd sentences && git merge ravi -m 'Merge ravi' Auto-merging method.tex Merge made by the 'ort' strategy. method.tex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) $ cat sentences/method.tex Two gauges logged the rain for two weeks. They were read at 8 a.m. every day. A reading that could not be taken was left blank and marked as missing. # 3. One sentence per line, but Ravi changes the second sentence, next to Meera's first. $ sh two_edits.sh neighbours 'at 8 a.m.' 'at 8 a.m. and 6 p.m.' 'for a week.' 'for two weeks.' $ cd neighbours && git merge ravi -m 'Merge ravi' Auto-merging method.tex CONFLICT (content): Merge conflict in method.tex Automatic merge failed; fix conflicts and then commit the result. [exit status 1] $ cat neighbours/method.tex <<<<<<< HEAD Two gauges logged the rain for two weeks. They were read at 8 a.m. every day. ======= Two gauges logged the rain for a week. They were read at 8 a.m. and 6 p.m. every day. >>>>>>> ravi A reading that could not be taken was left blank.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 merge_session.py
With the paragraph on one line, both branches had changed line 1, so Git stopped with CONFLICT (content) and wrote the
whole paragraph twice between conflict markers. With one sentence per line, the changes sat on lines 1 and 3, and Git
answered Merge made by the 'ort' strategy with both sentences edited. Changes to neighbouring lines conflict too, as
the third merge shows, but its markers held two sentences instead of a paragraph. Step through the merges:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The same three sentences. Both files hold the method paragraph of three sentences. On the left it is one line of the file; on the right each sentence has a line of its own.
- Ravi changes the last sentence. On his branch, Ravi adds and marked as missing to the last sentence. On the left that changes line 1, the only line; on the right it changes line 3.
- Meera changes the first sentence. On main, Meera changes for a week to for two weeks. That is line 1 in both files.
- One line changed twice. Git merges by lines. On the left both branches changed line 1, so Git cannot choose: it stopped with CONFLICT (content) and wrote both versions of the whole paragraph between conflict markers.
- Two different lines. On the right the branches changed lines 1 and 3, with line 2 untouched between them, so Git took both changes: Merge made by the ort strategy, and the file holds both sentences as edited.
- Neighbouring lines still collide. In the third recorded merge Ravi changed line 2 instead. Changes to neighbouring lines conflict too, but the markers held only the two changed sentences, not the paragraph.
The same layout makes diffs readable. This session commits two drafts of report.tex, each sentence on its own line,
and asks Git what changed; then it builds the report and asks what Git sees in the folder:
draft 1 · drafts/v1.tex
\documentclass{article}
\begin{document}
\section{Method}
Two gauges logged the rain on the roofs of Block~A and Block~B for a week.
The gauges were read at 8 a.m. every day.
A reading that could not be taken was left blank.
\section{Result}
Block~A received 41.5\,mm and Block~B 38.0\,mm, a difference of 3.5\,mm.
\end{document} draft 2 · drafts/v2.tex
\documentclass{article}
\begin{document}
\section{Method}
Two gauges logged the rain on the roofs of Block~A and Block~B for two weeks.
The gauges were read at 8 a.m. every day.
A reading that could not be taken was left blank and marked as missing.
\section{Result}
Block~A received 83.0\,mm and Block~B 76.5\,mm, a difference of 6.5\,mm.
The gauges agree to within their stated error.
\end{document} from session import files, needs, run
# The two drafts of report.tex as two commits: what git diff shows, with and without the tex diff driver of
# .gitattributes and with --word-diff; then a build, and what git status says before and after a .gitignore.
needs("git", "pdflatex", "latexmk")
files("drafts/v1.tex=report.tex", "drafts/gitignore.txt")
run("""
git init -q -b main . && git add report.tex && git commit -q -m 'Draft 1'
""")
files("drafts/v2.tex=v2.tex")
run("""
mv v2.tex report.tex
git diff --stat
git diff -U0
echo '*.tex diff=tex' > .gitattributes
git diff -U0
git diff --word-diff -U0
git add .gitattributes report.tex && git commit -q -m 'Draft 2'
latexmk -pdf -silent report.tex > /dev/null 2>&1
git status --short
cp gitignore.txt .gitignore && rm gitignore.txt
git status --short
git check-ignore -v report.log report.pdf
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ git init -q -b main . && git add report.tex && git commit -q -m 'Draft 1'
$ mv v2.tex report.tex
$ git diff --stat
report.tex | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
$ git diff -U0
diff --git a/report.tex b/report.tex
index f1f6bfe..e334a5a 100644
--- a/report.tex
+++ b/report.tex
@@ -4 +4 @@
-Two gauges logged the rain on the roofs of Block~A and Block~B for a week.
+Two gauges logged the rain on the roofs of Block~A and Block~B for two weeks.
@@ -6 +6 @@ The gauges were read at 8 a.m. every day.
-A reading that could not be taken was left blank.
+A reading that could not be taken was left blank and marked as missing.
@@ -8 +8,2 @@ A reading that could not be taken was left blank.
-Block~A received 41.5\,mm and Block~B 38.0\,mm, a difference of 3.5\,mm.
+Block~A received 83.0\,mm and Block~B 76.5\,mm, a difference of 6.5\,mm.
+The gauges agree to within their stated error.
$ echo '*.tex diff=tex' > .gitattributes
$ git diff -U0
diff --git a/report.tex b/report.tex
index f1f6bfe..e334a5a 100644
--- a/report.tex
+++ b/report.tex
@@ -4 +4 @@ \section{Method}
-Two gauges logged the rain on the roofs of Block~A and Block~B for a week.
+Two gauges logged the rain on the roofs of Block~A and Block~B for two weeks.
@@ -6 +6 @@ \section{Method}
-A reading that could not be taken was left blank.
+A reading that could not be taken was left blank and marked as missing.
@@ -8 +8,2 @@ \section{Result}
-Block~A received 41.5\,mm and Block~B 38.0\,mm, a difference of 3.5\,mm.
+Block~A received 83.0\,mm and Block~B 76.5\,mm, a difference of 6.5\,mm.
+The gauges agree to within their stated error.
$ git diff --word-diff -U0
diff --git a/report.tex b/report.tex
index f1f6bfe..e334a5a 100644
--- a/report.tex
+++ b/report.tex
@@ -4 +4 @@ \section{Method}
Two gauges logged the rain on the roofs of Block~A and Block~B for [-a week-]{+two weeks+}.
@@ -6 +6 @@ \section{Method}
A reading that could not be taken was left blank {+and marked as missing+}.
@@ -8 +8,2 @@ \section{Result}
Block~A received [-41-]{+83+}.[-5-]{+0+}\,mm and Block~B [-38-]{+76+}.[-0-]{+5+}\,mm, a difference of [-3-]{+6+}.5\,mm.
{+The gauges agree to within their stated error.+}
$ git add .gitattributes report.tex && git commit -q -m 'Draft 2'
$ latexmk -pdf -silent report.tex > /dev/null 2>&1
$ git status --short
?? gitignore.txt
?? report.aux
?? report.fdb_latexmk
?? report.fls
?? report.log
?? report.pdf
$ cp gitignore.txt .gitignore && rm gitignore.txt
$ git status --short
?? .gitignore
$ git check-ignore -v report.log report.pdf
.gitignore:7:*.log report.log
.gitignore:12:*.pdf report.pdf
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 git_session.py
Each changed sentence showed as one changed line. The line after @@ is the header of a hunk: One block of changed lines in a diff, introduced by a header line that starts with @@ and gives where the block starts in the old and in the new file.: by default Git took the nearest
line that starts with a letter, a sentence that tells you nothing, but with *.tex diff=tex in .gitattributes,
one of the built-in patterns the gitattributes manual lists, it named the section, \section{Method}. --word-diff
showed the changed words inside each line, [-a week-]{+two weeks+}; with the tex pattern on, it split numbers at
the full stop, so 41.5 became two changes.
Commit what you write, never what the build writes
After the build, git status --short listed five files that nobody wrote: report.aux, report.fdb_latexmk,
report.fls, report.log and the PDF, besides the gitignore.txt the session had copied in. Committed, they would change on every build and conflict on every merge. With a .gitignore that lists
them, only the .gitignore itself was left to add, and git check-ignore -v named the line that matched each file:
# Save as .gitignore: files that LaTeX, BibTeX, latexmk and the editors write, never the sources.
*.aux
*.bbl
*.blg
*.fdb_latexmk
*.fls
*.log
*.out
*.synctex.gz
*.toc
# The PDFs are built from the sources; CI keeps them as artifacts.
*.pdf Hard wrapping at 80 columns
Wrong: an editor that rewraps a paragraph at a fixed width, so that one changed word moves the line breaks of every line after it and the diff shows the whole paragraph. Right: one sentence per line, and let the editor wrap long lines on screen without changing the file.
Checkpoint
Check: One sentence per line in Git
latexdiff marks every change between two drafts
A supervisor who reads PDFs does not want a diff of markup. latexdiff, whose CTAN entry calls it a Perl script for
visual mark up and revision of significant differences between two LaTeX files, writes a third .tex file in which
every deletion and addition is marked; pdflatex then builds it like any other file. This session takes the last
commit but one from Git, compares it with the current file, builds the result and checks the PDF:
from session import files, needs, run
# Draft 1 and draft 2 of report.tex as two commits; latexdiff marks what changed between the last commit but one and
# the file as it is now, pdflatex builds the marked file, and changes.py lists the coloured text of its PDF.
# latexdiff-vc, which comes with latexdiff, does the same from the commit's name; its temporary file is named after
# the process, which the page shows as [process id].
needs("git", "pdflatex", "latexdiff", "mutool")
files("drafts/v1.tex=report.tex", "changes.py")
run("""
git init -q -b main . && git add report.tex && git commit -q -m 'Draft 1'
""")
files("drafts/v2.tex=v2.tex")
run("""
mv v2.tex report.tex && git commit -q -am 'Draft 2'
git show HEAD~1:report.tex > old.tex
latexdiff old.tex report.tex > diff.tex
sed -n '/^.begin{document}/,$p' diff.tex
pdflatex -interaction=nonstopmode -halt-on-error diff.tex > /dev/null
python3 changes.py diff.pdf
latexdiff-vc --git -r HEAD~1 report.tex
""", mask=[(r"oldtmp-\d+", "oldtmp-[process id]")], env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ git init -q -b main . && git add report.tex && git commit -q -m 'Draft 1'
$ mv v2.tex report.tex && git commit -q -am 'Draft 2'
$ git show HEAD~1:report.tex > old.tex
$ latexdiff old.tex report.tex > diff.tex
$ sed -n '/^.begin{document}/,$p' diff.tex
\begin{document}
\section{Method}
Two gauges logged the rain on the roofs of Block~A and Block~B for \DIFdelbegin \DIFdel{a week}\DIFdelend \DIFaddbegin \DIFadd{two weeks}\DIFaddend .
The gauges were read at 8 a.m. every day.
A reading that could not be taken was left blank \DIFaddbegin \DIFadd{and marked as missing}\DIFaddend .
\section{Result}
Block~A received \DIFdelbegin \DIFdel{41.5}\DIFdelend \DIFaddbegin \DIFadd{83.0}\DIFaddend \,mm and Block~B \DIFdelbegin \DIFdel{38.0}\DIFdelend \DIFaddbegin \DIFadd{76.5}\DIFaddend \,mm, a difference of \DIFdelbegin \DIFdel{3.5}\DIFdelend \DIFaddbegin \DIFadd{6.5}\DIFaddend \,mm\DIFaddbegin \DIFadd{.
The gauges agree to within their stated error}\DIFaddend .
\end{document}
$ pdflatex -interaction=nonstopmode -halt-on-error diff.tex > /dev/null
$ python3 changes.py diff.pdf
deleted a week
added two weeks
added and marked as missing
deleted 41.5
added 83.0
deleted 38.0
added 76.5
deleted 3.5
added 6.5
added . The gauges agree to within their stated error
$ latexdiff-vc --git -r HEAD~1 report.tex
Working on report.tex
patching file report.tex
Running: latexdiff "report-oldtmp-[process id].tex" "report.tex" > "report-diffHEAD~1.tex"
Generated difference file report-diffHEAD~1.tex
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 latexdiff_session.py
In diff.tex, a week became \DIFdelbegin \DIFdel{a week}\DIFdelend and two weeks the matching \DIFadd. latexdiff
compares words, not lines, so each changed number was marked alone, and the full stop of the old last sentence moved
into the added text. Follow the session’s steps:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The old draft from Git. git show HEAD~1:report.tex prints the file as the last commit but one held it, and the session saves it as old.tex. Any commit, tag or branch name works the same way.
- latexdiff compares the words. latexdiff reads old.tex and the current report.tex and compares them word by word, not line by line, so a changed number inside a sentence is marked alone.
- A new .tex file with the markup. It writes diff.tex: the new text with each deletion wrapped in \DIFdel and each addition in \DIFadd, and a preamble that loads ulem and color to draw them.
- An ordinary build. pdflatex builds diff.tex like any other file. Your own report.tex and its PDF are not touched.
- Colours a co-author can read. In diff.pdf added words are blue with a wavy line under them and deleted words red and struck through, in the default style called UNDERLINE.
- Check the PDF itself. changes.py reads the colour of every character with MuPDF and lists the runs: deleted a week, added two weeks and eight more. A change that should be there and is not sends you back to latexdiff.
A PDF with marks should be checked like any other output. changes.py reads every character’s colour with MuPDF, and
listed four deleted runs and six added ones, a week deleted and two weeks added first. latexdiff-vc, installed
with latexdiff, does the Git step itself: latexdiff-vc --git -r HEAD~1 report.tex wrote report-diffHEAD~1.tex. For
a project of several files, --flatten reads \input and \include files into the comparison.
"""Lists the marked text of a latexdiff PDF: blue text is what was added, red text what was deleted.
python3 changes.py diff.pdf
MuPDF's structured text (mutool draw -F stext) gives every character of the PDF with its colour. Neighbouring
characters of the same colour become one run, and a black character ends a run. latexdiff's default style draws the
wavy line under an addition with characters too (each one a ":" in blue), so those are skipped.
"""
import subprocess
import sys
import xml.etree.ElementTree as ET
KINDS = {"#0000ff": "added", "#ff0000": "deleted"}
stext = subprocess.run(["mutool", "draw", "-q", "-F", "stext", "-o", "-", sys.argv[1]], capture_output=True,
check=True).stdout
runs = []
for line in ET.fromstring(stext).iter("line"):
for char in line.iter("char"):
kind, text = KINDS.get(char.get("color")), char.get("c")
if kind == "added" and text == ":":
continue
if kind is None:
if runs and runs[-1][0] is not None:
runs.append((None, ""))
elif runs and runs[-1][0] == kind:
runs[-1] = (kind, runs[-1][1] + text)
else:
runs.append((kind, text))
if runs and runs[-1][0] is not None:
runs[-1] = (runs[-1][0], runs[-1][1] + " ")
for kind, text in runs:
if kind and text.strip():
print(f"{kind:8} {' '.join(text.split())}") A marked-up PDF is a working copy
latexdiff’s default style draws its marks with the ulem package, and the LaTeX Project’s tagging status list marks ulem, like todonotes, as currently incompatible with tagged PDF. Send the marked PDF to co-authors, and the clean build to the journal or the thesis office.
Checkpoint
Check: Marked-up PDFs with latexdiff
A Typst draft is compared through the text a reader sees
Typst has no latexdiff. Its source is plain text, so the Git diffs above work the same way, but a co-author who reads
PDFs needs the change in the words of the page. This session builds the two drafts of the report in Typst, extracts
their text with pdftotext and compares the two texts word by word with git diff --no-index, which compares two files
outside any repository:
draft 1 · drafts/v1.typ
#set heading(numbering: "1")
= Method
Two gauges logged the rain on the roofs of Block A and Block B for a week.
The gauges were read at 8 a.m. every day.
A reading that could not be taken was left blank.
= Result
Block A received 41.5 mm and Block B 38.0 mm, a difference of 3.5 mm. draft 2 · drafts/v2.typ
#set heading(numbering: "1")
= Method
Two gauges logged the rain on the roofs of Block A and Block B for two weeks.
The gauges were read at 8 a.m. every day.
A reading that could not be taken was left blank and marked as missing.
= Result
Block A received 83.0 mm and Block B 76.5 mm, a difference of 6.5 mm.
The gauges agree to within their stated error. from session import files, needs, run
# Typst has no latexdiff. The two drafts are compiled, pdftotext extracts what a reader sees, and git diff compares
# the two texts word by word (--no-index: two files, no repository).
needs("typst", "pdftotext", "git")
files("drafts/v1.typ", "drafts/v2.typ")
run("""
typst compile v1.typ && typst compile v2.typ
pdftotext v1.pdf v1.txt && pdftotext v2.pdf v2.txt
!git diff --no-index --word-diff -U0 v1.txt v2.txt
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ typst compile v1.typ && typst compile v2.typ
$ pdftotext v1.pdf v1.txt && pdftotext v2.pdf v2.txt
$ git diff --no-index --word-diff -U0 v1.txt v2.txt
diff --git a/v1.txt b/v2.txt
index aab0e2e..9a22d1b 100644
--- a/v1.txt
+++ b/v2.txt
@@ -2,2 +2,2 @@
Two gauges logged the rain on the roofs of Block A and Block B for [-a week.-]{+two weeks.+} The gauges were
read at 8 a.m. every day. A reading that could not be taken was left [-blank.-]{+blank and marked as missing.+}
@@ -6 +6,2 @@ Two gauges logged the rain on the roofs of Block A and Block B for a week. The g
Block A received [-41.5-]{+83.0+} mm and Block B [-38.0-]{+76.5+} mm, a difference of [-3.5-]{+6.5+} mm. {+The gauges agree to within+}
{+their stated error.+}
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typst_compare_session.py
The comparison found the same six changes as latexdiff, [-a week.-]{+two weeks.+} first, and ended with exit status
1, which git diff --no-index uses when the files differ. The extracted text follows the PDF’s line breaks, not the
source’s, so one changed sentence can show as two lines, as the last addition did. MySmartCoPilot’s Text Diff shows the
same comparison side by side, and its PDF Compare tool also marks the areas of each page that look different:
Checkpoint
Check: Comparing Typst drafts
A co-author’s tracked changes come back through pandoc
Some co-authors work only in a word processor, with tracked change: An insertion or a deletion that a word processor records with its author instead of making it silently, so that others can see it and accept or reject it.. A .docx file is a ZIP package of XML parts, so
Git can store it but not show what changed. ECMA-376, the Office Open XML standard, stores each tracked change in the
document’s XML: inserted text in a w:ins element, deleted text in a w:del element, each with its author. This
session writes the report as a Word file, lets coauthor_edits.py make three tracked changes as an invented co-author
would, and reads the file back with pandoc:
from session import files, needs, run
# report.tex goes to a co-author as a Word file. coauthor_edits.py plays the co-author, who makes three tracked
# changes; pandoc then reads them back, first with every change shown, then with the changes accepted (its default).
needs("pandoc", "python3")
files("drafts/v2.tex=report.tex", "coauthor_edits.py")
run("""
pandoc report.tex -o report.docx
python3 coauthor_edits.py report.docx report-ravi.docx
pandoc report-ravi.docx --track-changes=all -t markdown --wrap=none
pandoc report-ravi.docx -t markdown --wrap=none
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ pandoc report.tex -o report.docx
$ python3 coauthor_edits.py report.docx report-ravi.docx
report-ravi.docx: 3 tracked changes by Ravi Kumar
$ pandoc report-ravi.docx --track-changes=all -t markdown --wrap=none
# Method
Two gauges logged the rain on the roofs of Block A and Block B for [two weeks]{.deletion author="Ravi Kumar"}[14 days]{.insertion author="Ravi Kumar"}. The gauges were read at 8 a.m. every day. A reading that could not be taken was left blank and marked as missing. [Missing readings were not estimated.]{.insertion author="Ravi Kumar"}
# Result
Block A received 83.0 mm and Block B 76.5 mm, a difference of 6.5 mm. [The gauges agree to within their stated error.]{.deletion author="Ravi Kumar"}
$ pandoc report-ravi.docx -t markdown --wrap=none
# Method
Two gauges logged the rain on the roofs of Block A and Block B for 14 days. The gauges were read at 8 a.m. every day. A reading that could not be taken was left blank and marked as missing. Missing readings were not estimated.
# Result
Block A received 83.0 mm and Block B 76.5 mm, a difference of 6.5 mm.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 word_session.py
Pandoc’s manual describes --track-changes with three values. all kept every change as a span with its kind and
author, [two weeks]{.deletion author="Ravi Kumar"}[14 days]{.insertion author="Ravi Kumar"}, a list to work through;
the default, accept, printed the text with the three changes made; reject gives the text without them. Step
through the round trip:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A Word file for Ravi. pandoc report.tex -o report.docx turns the sections into Heading 1 paragraphs and the sentences into body text. The .tex file stays the primary copy.
- Ravi edits with tracking on. Ravi changes two weeks to 14 days, adds a sentence and deletes another. Each change is stored in the file with his name, as w:del and w:ins elements.
- The file comes back. Ravi returns report-ravi.docx. Opened in Word it shows his three changes; to Git it is a ZIP file, so a diff of it says nothing useful.
- Every change, with its author. pandoc --track-changes=all writes each change as a span with its kind and author, such as [14 days]{.insertion author="Ravi Kumar"}, a list you can work through.
- The changes accepted. Without the option, pandoc accepts every change, its default, and prints the text as Ravi left it. With reject it prints the text as it was.
- You apply them to the source. You make the changes you agree with in report.tex and commit them. The LaTeX markup, such as \,mm and the labels, never went through Word, so nothing of it is lost.
The .tex file stays the primary copy: The one set of source files that every other format of a document is made from and that every edit goes into. Copies in other formats are outputs: they are rebuilt from it and never edited by hand.: you make the changes you agree with in report.tex and commit them, so the
LaTeX markup that never went through Word, such as \,mm and the labels, is never lost.
Key idea
Every comparison works on the text it can read: Git on source lines, latexdiff on LaTeX words, pdftotext on the words of a page, and pandoc on the changes stored inside a Word file.
Checkpoint
Check: Tracked changes from Word
Notes for co-authors stay in the source, out of the final PDF
A question for a co-author is easiest to answer where it belongs, in the text. todonotes, which its CTAN entry says
lets you mark things to do later, draws a note in the margin or, with inline, in the text. In Typst a short
function does the same, and sys.inputs, which reads values given with --input on the command line, turns the notes
off for the final build:
notes/notes.tex
% notes.tex: notes for co-authors with todonotes. A build that passes the option disable to todonotes leaves them out.
\documentclass{article}
\usepackage{todonotes}
\begin{document}
\section{Method}
Two gauges logged the rain for two weeks.\todo{Ravi: add the gauge model.}
They were read at 8 a.m. every day.
\todo[inline]{Meera: say who read them on Sundays.}
\end{document} notes/notes.typ
// notes.typ: notes for co-authors. typst compile notes.typ shows them;
// typst compile --input final=yes notes.typ leaves them out.
#let final = sys.inputs.at("final", default: "no") == "yes"
#let note(who, body) = if not final {
box(fill: yellow.lighten(60%), inset: 2pt)[*#who:* #body]
}
#set heading(numbering: "1")
= Method
Two gauges logged the rain for two weeks. #note("Ravi")[Add the gauge model.]
They were read at 8 a.m. every day.
#note("Meera")[Say who read them on Sundays.] from session import files, needs, run
# The same notes in both systems: a working build that shows them, and a final build that leaves them out, checked
# by counting the co-authors' names in the text pdftotext extracts.
needs("pdflatex", "typst", "pdftotext")
files("notes/notes.tex", "notes/notes.typ")
run(r"""
pdflatex -interaction=nonstopmode -halt-on-error notes.tex > /dev/null
pdftotext notes.pdf - | grep -c -E 'Ravi|Meera'
pdflatex -interaction=nonstopmode -halt-on-error -jobname=notes-final '\PassOptionsToPackage{disable}{todonotes}\input{notes}' > /dev/null
!pdftotext notes-final.pdf - | grep -c -E 'Ravi|Meera'
typst compile notes.typ
pdftotext notes.pdf - | grep -c -E 'Ravi|Meera'
typst compile --input final=yes notes.typ notes-final.pdf
!pdftotext notes-final.pdf - | grep -c -E 'Ravi|Meera'
""", env={"SOURCE_DATE_EPOCH": "1767225600"}) Output
$ pdflatex -interaction=nonstopmode -halt-on-error notes.tex > /dev/null
$ pdftotext notes.pdf - | grep -c -E 'Ravi|Meera'
2
$ pdflatex -interaction=nonstopmode -halt-on-error -jobname=notes-final '\PassOptionsToPackage{disable}{todonotes}\input{notes}' > /dev/null
$ pdftotext notes-final.pdf - | grep -c -E 'Ravi|Meera'
0
[exit status 1]
$ typst compile notes.typ
$ pdftotext notes.pdf - | grep -c -E 'Ravi|Meera'
2
$ typst compile --input final=yes notes.typ notes-final.pdf
$ pdftotext notes-final.pdf - | grep -c -E 'Ravi|Meera'
0
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 notes_session.py
grep -c counted the co-authors’ names in the extracted text. The working builds held 2; the final builds, pdflatex
with \PassOptionsToPackage{disable}{todonotes} given before the file and Typst with --input final=yes, held 0, and
grep ended with exit status 1 because it found nothing. Neither final build needed a change to the source, so a
note that nobody remembered to delete still stays out of the final PDF, and the same grep can fail a CI job when it
finds one.
Name the person in the note
Start every note with the name of the person who should answer it, as both files do. A search for Ravi: then finds
every open question for Ravi, in the source and in the working PDF.
Checkpoint
Check: Notes out of the final PDF
The online editor’s plan decides history, tracked changes and Git
Some groups write in the browser. Overleaf edits LaTeX and the Typst web app edits Typst, and what each offers depends on the plan. Overleaf’s plans page lists these differences:
| Feature | Without a subscription | With a subscription |
|---|---|---|
| Co-authors | 1 collaborator per project | 10 per project, or unlimited |
| History | The previous 24 hours only | Complete, every version restorable |
| Tracked changes | Not included | Included |
| Git and other tools | No integrations | Git, GitHub, Dropbox, Zotero, Mendeley and ReadCube |
The Typst web app’s plans page lists these:
| Feature | Without a subscription | With a subscription |
|---|---|---|
| Co-authors | Share projects and work on them together | The same, and invitations by email |
| Comments | Not listed | Threaded comments, marked resolved |
| Git | Not listed | Experimental sync with GitHub or GitLab |
| Storage | 200 MB, 100 files per project | 2 GB, 1,000 files per project |
So without a subscription, an Overleaf project has one collaborator and a day of history, and no tracked changes; the Typst app lets a group edit together but lists comments and Git sync only with its subscription.
Companies and projects named here
MySmartCoPilot is independent of Overleaf, Typst, the Git project and Ecma International. The plan features and formats described here are quoted from each one’s own pages and standards, cited at the end of the lesson.
A Git repository with the latexdiff and pdftotext comparisons of this lesson keeps full history and gives marked PDFs whatever editor each co-author uses, so the plans decide only what the browser itself can do.
Choose by the feature, then check the page
Write down the two or three features your group cannot do without, such as history older than a day or Git, then read the plans page itself before you choose: plans change, and the page is the only source that is always current.
Checkpoint
Check: Choosing an online editor
Practise: one sentence per line
Breaking a paragraph into sentences is the one step of this lesson that you would otherwise do by hand. Write it once, against sample tests made from this lesson’s report; the first hint is open:
Exercise · Medium · Python
Put one sentence on each line of a paragraph
Git compares and merges files line by line, so a paragraph kept as one sentence per line shows exactly which sentences changed. Write one_per_line(paragraph) in sentences.py. Given a paragraph of LaTeX or Typst text on one line, it returns the same text with a line break after each sentence:
- a sentence ends at
.,?or!that is followed by a space and then an upper-case letter, a digit or a backslash (the start of a command such as\textbf); - the space after the end of a sentence becomes one line break, and nothing else in the text changes;
- these words end with a full stop but do not end a sentence:
e.g.,i.e.,cf.,vs.,al.(as inet al.),Fig.,Eq.,Sec.,Dr.andProf., and so does a single capital letter with a full stop, an initial such as theA.ofA. Rao; - a full stop inside a number, as in
41.5, is never followed by a space, so it ends nothing.
Spaces at the start and the end of the paragraph are removed.
Starter code · sentences.py
def one_per_line(paragraph):
"""Return the paragraph with each sentence on a line of its own."""
# Replace this line: find each sentence end and put a line break in place of the space after it.
return paragraph.strip() The sample tests · test_sentences.py
from sentences import one_per_line
def test_three_sentences():
"""breaks a paragraph of three sentences into three lines"""
text = "Two gauges logged the rain for a week. They were read at 8 a.m. every day. A reading that could not be taken was left blank."
assert one_per_line(text) == (
"Two gauges logged the rain for a week.\n"
"They were read at 8 a.m. every day.\n"
"A reading that could not be taken was left blank."
)
def test_abbreviations():
"""keeps e.g. and Fig. inside their sentences"""
text = "Two gauges, e.g. the ones on Block A, logged the rain. Fig. 2 shows them."
assert one_per_line(text) == "Two gauges, e.g. the ones on Block A, logged the rain.\nFig. 2 shows them."
def test_initials_and_et_al():
"""keeps initials and et al. inside their sentences"""
text = "The method of A. Rao and D. Kulkarni was used. Rao et al. tested it on 12 roofs. It worked."
assert one_per_line(text) == (
"The method of A. Rao and D. Kulkarni was used.\n"
"Rao et al. tested it on 12 roofs.\n"
"It worked."
)
def test_questions_numbers_and_commands():
"""ends sentences at ? and !, before a digit or a command, and never inside a number"""
text = "Block A received 41.5 mm. Why less in Block B? 38.0 mm is low! \\textbf{Block B} was shaded."
assert one_per_line(text) == (
"Block A received 41.5 mm.\n"
"Why less in Block B?\n"
"38.0 mm is low!\n"
"\\textbf{Block B} was shaded."
)
def test_lower_case_after_full_stop():
"""does not break before a word in lower case"""
assert one_per_line("They were read at 8 a.m. every day.") == "They were read at 8 a.m. every day."
def test_one_sentence_and_spaces():
"""returns a single sentence unchanged, without its outer spaces"""
assert one_per_line(" The totals are in Table~\\ref{tab:totals}. ") == "The totals are in Table~\\ref{tab:totals}." A hint
Walk through the text with re.finditer(r"[.?!] +(?=[A-Z0-9\\])", text): each match is a possible sentence end. Look at the word just before the match (the characters after the last space before it) and skip the match when that word, with its full stop, is one of the abbreviations or a single capital letter.
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.
Last, a short self-check on the six ideas of the lesson. Questions on what you know least come first, and every choice you pick is explained on the spot:
Practice that adapts to you
Practise this lesson
14 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): Why keep one sentence per line in a LaTeX or Typst file that lives in Git?
Because Git diffs and merges by lines. With one sentence per line, a diff shows which sentences changed, and two co-authors who change different sentences of a paragraph get a clean merge. With a whole paragraph on one line, any change marks the paragraph as changed, and two changes anywhere in it conflict. In this lesson’s session, the same two edits to the first and last sentence of a paragraph conflicted when the paragraph was one line and merged by themselves with one sentence per line; edits to neighbouring sentences still conflicted, but only over those two lines. Neither LaTeX nor Typst cares where a line ends inside a paragraph, so the PDF stays the same, and the editor can still wrap long lines on screen.
Two harder questions come from this lesson: a thesis committee that wants the changes between two versions, and a group whose members write in three different tools. Plan your opening move for each before you read its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Your thesis committee wants a PDF that marks every change between the version they read at the last meeting and the revised one, a 140-page LaTeX thesis in twelve chapter files. How do you produce it, check it, and keep it apart from the PDF you submit?
-
Mid to senior
Four authors write one paper: two in LaTeX in Git, one in an online LaTeX editor, and one who only uses a word processor with tracked changes. Set up the way you work so that no change is lost, every change can be traced to its author, and the submitted PDF has no notes in it.
Key takeaways
- Git diffs and merges by lines, so one sentence per line turned a paragraph-wide conflict into a clean merge, and
*.tex diff=texmade each hunk name its section. - Commit the sources and a
.gitignore, never the .aux, .log, .bbl or PDF files that every build writes again. - latexdiff writes a
.texfile with every change marked, which pdflatex builds into a PDF a co-author can read; check the PDF’s marks, aschanges.pydid by colour. - Typst drafts are compared through their extracted text, word by word, with
git diff --no-index --word-diff. - pandoc’s
--track-changes=alllists a Word co-author’s changes with their authors, and the.texfile stays the primary copy in which you apply them. - Notes for co-authors stay out of the final PDF when the final build turns them off, todonotes with
disableand Typst throughsys.inputs, and the plans pages say which online editor gives history, tracked changes and Git.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why keep one sentence per line in a source file under Git?
Git diffs and merges by lines, so co-authors' edits to different sentences merge by themselves, while the same edits to a paragraph kept on one line conflicted.
-
How do you make Git's hunk headers name the LaTeX section?
Add *.tex diff=tex to .gitattributes, one of Git's built-in patterns; the header then showed \section{Method}.
-
Which files belong in a LaTeX project's .gitignore?
Everything a build writes again: .aux, .log, .fls, .fdb_latexmk, .bbl and the PDF. git check-ignore -v names the line that matched each file.
-
What does latexdiff produce?
A third .tex file in which every deletion and addition between two drafts is marked word by word, which pdflatex builds into a PDF; latexdiff-vc --git -r HEAD~1 takes the old draft from Git.
-
How do you compare two Typst drafts the way a reader sees them?
Extract the text of both PDFs with pdftotext and compare it word by word with git diff --no-index --word-diff.
-
How does pandoc read a Word co-author's tracked changes?
--track-changes=all keeps each insertion and deletion as a span with its author; accept, the default, applies them, and reject drops them.
-
Why does the .tex file stay the primary copy when a co-author edits in Word?
You apply the changes you agree with in report.tex, so the LaTeX markup that never went through Word, such as \,mm and the labels, is never lost.
-
How do you keep notes for co-authors out of the final PDF without editing the source?
Pass todonotes the disable option from the command line, with \PassOptionsToPackage{disable}{todonotes}; in Typst, read --input final=yes from sys.inputs.
Terms in this lesson
- hunk
- One block of changed lines in a diff, introduced by a header line that starts with @@ and gives where the block starts in the old and in the new file.
- merge conflict
- What Git reports when two branches changed the same lines of a file, or lines next to each other: it stops and writes both versions between conflict markers for a person to resolve.
- primary copy
- The one set of source files that every other format of a document is made from and that every edit goes into. Copies in other formats are outputs: they are rebuilt from it and never edited by hand.
- tracked change
- An insertion or a deletion that a word processor records with its author instead of making it silently, so that others can see it and accept or reject it.
References
- gitattributes: defining a custom hunk-header (the built-in tex pattern) (Git)
- git-diff: --word-diff and --no-index (Git)
- gitignore: specifies intentionally untracked files to ignore (Git)
- latexdiff: determine and mark up significant differences between LaTeX files (CTAN)
- todonotes: marking things to do in a LaTeX document (CTAN)
- Tagging status of LaTeX packages and classes (The LaTeX Project)
- Typst reference: sys (sys.inputs) (Typst)
- Pandoc user’s guide: --track-changes (pandoc)
- ECMA-376: Office Open XML file formats (Ecma International)
- Overleaf plans and features (Overleaf)
- Typst web app plans (Typst)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress