Jupyter to PDF: A Measured Fidelity Benchmark of nbconvert's Three Export Paths
Jupyter to PDF: A Measured Fidelity Benchmark of nbconvert’s Three Export Paths
jupyter nbconvert --to pdf and jupyter nbconvert --to webpdf both produce a PDF of
your notebook. They do not produce the same PDF, and the differences are not cosmetic —
each route silently discards things the other keeps.
This page measures that. Nine notebooks were built, each pressing on a single thing that is hard to carry through a PDF pipeline. Each was executed once, and the same executed file was converted by three routes: 27 conversion attempts, 26 of which produced a PDF. Every PDF’s text layer was then searched for marker strings a correct conversion has to contain.
The short version: the two families fail in opposite directions. The LaTeX route was the only one that kept all 24 columns of a wide DataFrame — and the only one that dropped Greek letters, CJK and emoji, and reduced a styled DataFrame to an object repr. The browser routes kept those characters and rendered that table, and pushed the right-hand end of a too-wide table off the page without a mark.
A long line of source code is the case where they all lose: every route ran the tail of a 218-character line off the right edge. LaTeX keeps it in the text layer, so it is still there if you select and copy; the browser routes do not.
The decision table
| If your notebooks contain… | Use | Because |
|---|---|---|
| Wide DataFrames you need every column of | --to pdf (LaTeX) | It kept 24/24 column names; both browser routes kept 10 |
| Long source lines you need to read to the end | none of them | All three push the tail of a 218-character line off the page. LaTeX at least keeps it in the file’s text layer, so it survives copy-and-paste; the browser routes drop it entirely. Wrap the line in your source |
| Non-ASCII text — CJK, Greek, emoji | --to webpdf | LaTeX dropped 4 of 8 Unicode markers with the stock template’s fonts |
| Styled DataFrames, or anything whose table lives only in HTML | --to webpdf | The LaTeX route printed <pandas.io.formats.style.Styler at 0x…> instead of the table |
| SVG output | --to webpdf | The LaTeX route needs Inkscape installed and did not run at all without it |
| Nothing unusual, and you want small files fast | --to pdf (LaTeX) | 2.7× smaller, about twice as fast — after the install cost below |
| …and you would rather not install a TeX distribution | a browser-based converter | Nothing to install. Expect browser-style clipping on wide content, so check a wide table before trusting it |
If you want the commands rather than the measurements, the nbconvert tutorial covers those, and nbconvert PDF export errors covers conversions that fail outright rather than degrade quietly.
What was measured, and how
The three routes
| Route | Command | What renders the page |
|---|---|---|
| LaTeX | nbconvert --to pdf | nbconvert writes .tex, xelatex typesets it |
| webpdf | nbconvert --to webpdf | nbconvert writes HTML, Playwright’s Chromium prints it |
| HTML + print | nbconvert --to html, then print that file | The same Playwright Chromium, driven directly |
The third route is what people do by hand: export HTML, open it, print to PDF. It uses
the same browser build as --to webpdf, and neither asks for a paper size, so both get
Chromium’s default — a difference between those two columns would point at nbconvert’s
HTML template and CSS rather than at the renderer or the page geometry.
Getting that right mattered more than it sounds. An earlier version of this benchmark
passed format="A4" on the HTML-print route while --to webpdf used the default US
Letter. That alone made the two columns disagree on the wide table and on pagination —
which are precisely the results page width decides. With both on the same paper, the two
browser routes agree on every probe: same sentinel scores, same page counts, same output
size to the byte, and identical text layers.
The probes
Nine notebooks, one difficulty each: a 24-column DataFrame, a 218-character source line, Unicode (Greek, CJK, maths symbols, emoji), a matplotlib figure, a real uncaught traceback, a styled DataFrame, display maths, an inline SVG, and 400 lines of stdout. The source of all nine is at the bottom of this page.
The scoring, and why it is trustworthy
Each probe carries sentinels: strings a correct PDF must contain. The score is how many survive into the PDF’s text layer, extracted with pypdf.
What makes the measurement mean what it claims is that output sentinels are assembled at runtime and never appear in the cell source:
print('FIG' + 'TEXTPROBE')If FIGTEXTPROBE turns up in the PDF, it got there through the cell’s output. Written
literally in the source, finding it would only prove the source code was rendered — which
every route does — and the table would be measuring the wrong thing. The generator checks
this when it writes the notebooks, and the runner checks it again on the executed
notebooks; a leak aborts the run rather than printing a number.
This is not a hypothetical worry. An earlier draft of this benchmark did write the markers literally into the source, and its figure column then credited text drawn inside a PNG with surviving into the text layer — which cannot happen, and is what exposed the mistake. That draft’s output is not published here, so take the anecdote as the reason for the check rather than as a result; the check itself runs on every execution and is what the numbers in this article depend on.
Presence is not visibility
A string can sit in a PDF’s text layer and still be off the visible page, so extraction alone cannot prove a column is readable. Each sentinel is therefore also located: pypdf’s text visitor gives the text matrix and the current transformation matrix, and their composite translation is compared against the page’s crop box.
Read that check narrowly, because it is weaker than it sounds. What gets located is the origin of the text run containing the sentinel, not the sentinel’s own bounding box. The two ways it can be wrong point in opposite directions:
- A non-zero count can be too generous. A run can start inside the crop box and extend
past the right edge, and a sentinel late in that run still counts as inside. So a
non-zero number rules out a run positioned entirely off the page; it does not prove
every character of a long run is on it. This is not a theoretical worry: it is exactly
what the LaTeX route does to
long-code-line, and reading only theinsidecount had that result backwards. The table therefore also carries an off-edge flag — the run starts on the page but is wide enough to leave it — estimated from the run’s length and font size. Crude, and meant to prompt a look rather than to measure. - A zero says very little. It happens when the visitor splits a sentinel across two chunks — which is why the table reads 0/24 for the browser routes on a probe where extraction found ten columns — but also when the string is genuinely absent, or when its run does start outside the crop box. A zero on its own distinguishes none of these.
Because the optimistic direction is the one that could mislead, the check is used below only to exclude the wholly-off-page case, never as proof of legibility.
Results
Fidelity: sentinels kept
| Probe | --to pdf | --to webpdf | --to html + print |
|---|---|---|---|
wide-dataframe | 24/24 | 10/24 | 10/24 |
unicode | 4/8 | 8/8 | 8/8 |
html-repr | 0/1 | 1/1 | 1/1 |
svg-output | did not run | 1/1 | 1/1 |
ansi-traceback | 1/1 | 1/1 | 1/1 |
latex-math | 2/2 | 2/2 | 2/2 |
long-code-line | 3/3 | 1/3 | 1/3 |
long-stdout | 3/3 | 3/3 | 3/3 |
matplotlib-figure | 1/1 | 1/1 | 1/1 |
Where the run carrying each sentinel sits
| Probe | --to pdf | --to webpdf | --to html + print |
|---|---|---|---|
wide-dataframe | 24/24 inside | 0/24 inside | 0/24 inside |
unicode | 4/8 inside | 7/8 inside | 7/8 inside |
html-repr | 0/2 inside | 0/2 inside | 0/2 inside |
svg-output | — | 2/2 inside | 2/2 inside |
ansi-traceback | 1/1 inside | 1/1 inside | 1/1 inside |
latex-math | 1/2 inside | 1/2 inside | 1/2 inside |
long-code-line | 3/3 inside, 3 off-edge | 1/3 inside | 1/3 inside |
long-stdout | 3/3 inside | 3/3 inside | 3/3 inside |
matplotlib-figure | 1/2 inside | 1/2 inside | 1/2 inside |
Totals over the eight probes every route completed:
| Metric | --to pdf | --to webpdf | --to html + print |
|---|---|---|---|
| sentinels kept | 38 | 27 | 27 |
| sentinels possible | 43 | 43 | 43 |
| total bytes | 181,077 | 490,549 | 490,549 |
| total seconds | 23.72 | 57.06 | 48.89 |
Do not read those totals as a ranking. They are dominated by one probe: the wide DataFrame is worth 24 of the 43 points. The per-probe table is the useful one, because which route is better depends entirely on what your notebooks contain.
Anything wider than the page gets cut
Two probes. On the first the routes differ; on the second they all lose.
A 24-column DataFrame
A DataFrame displayed in a notebook produces two representations, and the executed
notebook carries both:
wide-dataframe:
execute_result ['text/html', 'text/plain']The pipelines pick different ones. Exporting the same executed notebook both ways and counting the markers that only one representation can produce:
--to html : '<table' occurrences = 1, Verbatim environments = 0
--to latex: '<table' occurrences = 0, Verbatim environments = 2The browser routes take text/html — a real <table>. Twenty-four columns is wider
than a printed page, and a PDF has no horizontal scrollbar. From the --to webpdf output:
Wide DataFrame
A frame with 24 columns, printed as the cell result.
import pandas as pd
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 10_000)
df = pd.DataFrame(
[[f'r{r}c{c}' for c in range(24)] for r in range(3)],
columns=[f'COL{c:02d}' for c in range(24)],
)
df
COL00COL01COL02COL03COL04COL05COL06COL07COL08COL09
0 r0c0 r0c1 r0c2 r0c3 r0c4 r0c5 r0c6 r0c7 r0c8 r0c9
1 r1c0 r1c1 r1c2 r1c3 r1c4 r1c5 r1c6 r1c7 r1c8 r1c9
2 r2c0 r2c1 r2c2 r2c3 r2c4 r2c5 r2c6 r2c7 r2c8 r2c9
In [1]:
Out[1]:COL10 through COL23 are gone. Not truncated with an ellipsis — gone, with no mark on
the page that anything is missing. The geometry check found no drawn chunk containing them
either. Both browser routes lose exactly the same fourteen columns.
The LaTeX route takes text/plain and puts it in a Verbatim block:
wide-dataframe-executed
September 21, 2026
1 Wide DataF rame
A frame with 24 columns, printed as the cell result.
[1]: import pandas as pd
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 10_000)
df = pd.DataFrame(
[[f'r{r}c{c}' for c in range(24)] for r in range(3)],
columns=[f'COL{c:02d}' for c in range(24)],
)
df
[1]: COL00 COL01 COL02 COL03 COL04 COL05 COL06 COL07 COL08 COL09 COL10 COL11
COL12 COL13 COL14 COL15 COL16 COL17 COL18 COL19 COL20 COL21 COL22
COL23
0 r0c0 r0c1 r0c2 r0c3 r0c4 r0c5 r0c6 r0c7 r0c8 r0c9 r0c10 r0c11
r0c12 r0c13 r0c14 r0c15 r0c16 r0c17 r0c18 r0c19 r0c20 r0c21 r0c22
r0c23
1 r1c0 r1c1 r1c2 r1c3 r1c4 r1c5 r1c6 r1c7 r1c8 r1c9 r1c10 r1c11
r1c12 r1c13 r1c14 r1c15 r1c16 r1c17 r1c18 r1c19 r1c20 r1c21 r1c22
r1c23
2 r2c0 r2c1 r2c2 r2c3 r2c4 r2c5 r2c6 r2c7 r2c8 r2c9 r2c10 r2c11
r2c12 r2c13 r2c14 r2c15 r2c16 r2c17 r2c18 r2c19 r2c20 r2c21 r2c22
r2c23
1All 24 names are present, and every one of them sits in a run that starts inside the crop box — which, with the visible line wrapping above, is what you would expect if nothing is running off the edge. It is a win on completeness, not on looks: what you get is a wrapped text dump, not a table.
The wrapping is worth attributing correctly. It is not pandas — the notebook’s
text/plain value is four long lines, because the probe sets
pd.set_option('display.width', 10_000):
text/plain: 4 lines, longest 159 chars
COL00 COL01 COL02 COL03 COL04 COL05 COL06 COL07 COL08 COL09 COL10 COL11 COL12 COL13 COL14 COL15 COL16 COL17 C…
0 r0c0 r0c1 r0c2 r0c3 r0c4 r0c5 r0c6 r0c7 r0c8 r0c9 r0c10 r0c11 r0c12 r0c13 r0c14 r0c15 r0c16 r0c17 r…
1 r1c0 r1c1 r1c2 r1c3 r1c4 r1c5 r1c6 r1c7 r1c8 r1c9 r1c10 r1c11 r1c12 r1c13 r1c14 r1c15 r1c16 r1c17 r…
2 r2c0 r2c1 r2c2 r2c3 r2c4 r2c5 r2c6 r2c7 r2c8 r2c9 r2c10 r2c11 r2c12 r2c13 r2c14 r2c15 r2c16 r2c17 r…It is nbconvert’s LaTeX template, which configures fancyvrb to break long verbatim
lines. From the generated .tex:
50: \usepackage{fancyvrb} % verbatim replacement that allows latex
300: % For linebreaks inside Verbatim environment from package fancyvrb.
356: \def\FancyVerbFormatLine ##1{\hsize\linewidthA 218-character source line
The same shape, on source code rather than output — and the one case where the LaTeX route
does not come out ahead. The line carries markers at 0-based offsets 8, 109 and 208. From
--to webpdf:
Long source line
One source line of 218 characters, with markers at its start, middle and end.
TAIL = 'MARKERSTART_________________________________________________________
print(len(TAIL))
209
In [1]:The line stops mid-underscore at the page edge. MARKERMID and MARKEREND are not in the
PDF at all. From --to pdf:
long-code-line-executed
September 21, 2026
1 Long source line
One source line of 218 characters, with markers at its start, middle and end.
[1]: TAIL =␣
↪'MARKERSTART__________________________________________________________________________________________MARKERMID__________________________________________________________________________________________MARKEREND'
print(len(TAIL))
209
1All three markers are present, and the extraction score says 3/3. That score is misleading, and this probe is the reason the geometry column exists.
nbconvert’s LaTeX template does configure fancyvrb to break long verbatim lines, and you
can see it fire once — the ␣ and ↪ are its break and continuation marks, after
TAIL =. But the continuation line is then 209 characters of unbroken monospace. Measuring
where it is actually drawn:
cropbox width: 612 points
x= 76.0 len= 4 est_right= 102.2 on page 'TAIL'
x= 95.8 len= 209 est_right= 1463.8 OFF PAGE 'MARKERSTART_____________________________'
x= 76.0 len= 5 est_right= 108.7 on page 'print'
x= 127.5 len= 7 est_right= 173.3 on page '(TAIL))'One run, starting inside the page at x≈96, reaching an estimated x≈1464 on a 612-point
page. MARKERMID and MARKEREND are in the file but not on the paper.
So the honest reading of that row is: all three routes lose the tail of a long source line off the right edge. The difference is only that LaTeX leaves it in the text layer — recoverable by selecting and copying, invisible when read or printed — while the browser routes discard it. If you need long lines legible, the fix is in your notebook, not in the exporter: break the line.
What you can do about it
The two pandas options do different jobs here, which is easy to get backwards. Varying each while holding the other fixed, on the same frame:
width=80 max_columns=None text/plain lines=14 text/plain has COL23=True html <th> count=28 html has COL23=True
width=80 max_columns=6 text/plain lines=6 text/plain has COL23=True html <th> count=11 html has COL23=True
width=10000 max_columns=None text/plain lines=4 text/plain has COL23=True html <th> count=28 html has COL23=True
width=10000 max_columns=6 text/plain lines=6 text/plain has COL23=True html <th> count=11 html has COL23=Truedisplay.width changes the text/plain line count and leaves the HTML untouched, so it
is a lever on the LaTeX route only. display.max_columns changes both — 28 header
cells down to 11 — so lowering it truncates the HTML table instead of letting it run off
the edge. Note the COL23=True on every row: pandas truncates from the middle and keeps
the last columns, so what you lose to max_columns is not the same set you lose to the
page edge. Beyond that the levers for the browser routes are CSS, reshaping the frame, or
exporting to HTML and leaving it in a format
where a table is allowed to be wider than a page.
Unicode: the LaTeX route drops characters, and says so
The LaTeX route kept 4 of 8 Unicode sentinels. The four ASCII tags (GREEKPROBE,
CJKPROBE, MATHPROBE, EMOJIPROBE) all arrived; all four glyph strings — αβγ,
数据分析, ∑, 🧪 — did not. The lines reached the page and the characters were removed
from them.
Extraction alone would be weak evidence: a glyph can be drawn as a blank box and still sit in the text layer. So the xelatex log was checked too:
Missing character: There is no α (U+03B1) in font [lmmono10-regular]:!
Missing character: There is no β (U+03B2) in font [lmmono10-regular]:!
Missing character: There is no γ (U+03B3) in font [lmmono10-regular]:!
Missing character: There is no 数 (U+6570) in font [lmmono10-regular]:!
Missing character: There is no 据 (U+636E) in font [lmmono10-regular]:!
Missing character: There is no 分 (U+5206) in font [lmmono10-regular]:!
Missing character: There is no 析 (U+6790) in font [lmmono10-regular]:!
Missing character: There is no ∑ (U+2211) in font [lmmono10-regular]:!
Missing character: There is no ≈ (U+2248) in font [lmmono10-regular]:!
Missing character: There is no 🧪 (U+1F9EA) in font [lmmono10-regular]:!Ten warnings, one per character. Nine of them are the characters behind the four failed
sentinels (αβγ is three, 数据分析 is four, plus ∑ and 🧪); the tenth is ≈, which
the same line printed but which no sentinel scored.
Something is left in their place. The LaTeX PDF’s text layer extracts as:
unicode-executed
September 21, 2026
1 Unicode coverage
Greek, CJK, maths and an emoji, printed from a cell.
[1]: print('GREEK' + 'PROBE', '\u03b1\u03b2\u03b3')
print('CJK' + 'PROBE', '\u6570\u636e\u5206\u6790')
print('MATH' + 'PROBE', '\u2211 \u221a \u2248')
print('EMOJI' + 'PROBE', '\U0001f9ea')
GREEKPROBE
CJKPROBE
MATHPROBE √
EMOJIPROBE
1One placeholder per dropped character — U+FFFF, which is what pypdf emits for a glyph it
cannot map back to a character, not Unicode’s U+FFFD replacement character. The precise
statement is that the character is neither displayed as itself nor recoverable from the
file; whether a reader sees a blank or a box depends on the font’s .notdef glyph, which
this does not determine.
Two things worth being exact about:
- This is a font problem, not a LaTeX problem. The font named in every warning is
lmmono10-regular, the monospace face nbconvert’s default template uses for cell output. XeTeX can typeset all of these given a font that has them; the stock template does not point it at one. - The build still succeeded. Exit code 0, a PDF was written, the losses appear only in the log. A CI job that treats “a PDF exists” as a pass will not notice.
√ (U+221A), printed by the same line as ∑ and ≈, is not in the warning list and
survives — visible between two replacement characters above. Coverage is per character, so
“does LaTeX handle my notebook’s Unicode” has no yes-or-no answer.
Scope: the eight sentinels are what was scored. √ and ≈ were checked afterwards
against this extract and the log; no claim is made about characters neither probe emitted.
HTML-only output: the LaTeX route prints the object, not the table
The html-repr probe displays a Styler — df.style.set_caption(...).background_gradient().
Its text/plain representation is not the table; it is an object repr. The LaTeX route
takes text/plain, so that is what lands on the page:
html-repr-executed
September 21, 2026
1 Rich HTML output
A styled DataF rame. Its text/plain representation is just an object repr, so only the HTML carries
the table.
[1]: import pandas as pd
col = 'HTMLCOL' + 'PROBE'
cap = 'HTMLCAPTION' + 'PROBE'
df = pd.DataFrame({col: [ 1, 2, 3], 'other': [ 4, 5, 6]})
df.style.set_caption(cap).background_gradient()
[1]: <pandas.io.formats.style.Styler at 0x1145506e0>
1A bare <pandas.io.formats.style.Styler at 0x…> line — no column name, no caption, no
data, and nothing to indicate a table was lost. (The hex address is a memory address and
differs on every run; it carries no meaning.) Both browser routes carried the column name
and the caption into their PDFs’ text layers. Whether the gradient shading survived was not
checked: nothing here inspects colour, only text.
This was measured for a pandas Styler and nothing else. Other outputs whose useful
representation is HTML — Plotly figures, ipywidgets, IPython.display.HTML — plausibly
behave the same way through a text/plain-preferring exporter, but they were not tested
here, so treat that as an expectation rather than a result.
SVG: the LaTeX route needs Inkscape
--to pdf on the SVG probe produced no PDF at all:
FileNotFoundError: [Errno 2] No such file or directory: 'inkscape'nbconvert’s LaTeX pipeline converts SVG outputs to PDF by shelling out to Inkscape. No Inkscape, no conversion — and the failure takes down the whole notebook, not just that cell.
Both browser routes handled it. The text drawn inside the graphic (SVGINNERPROBE) is in
the text layer and inside the crop box, so that text was not rasterised. That is a
statement about the text, not proof that every part of the SVG stayed vector.
What all three routes got right
So the table above is not read as “everything is broken” — with the long-line result from the previous section excluded, since all three failed that one:
- Tracebacks. The message of a real uncaught
ValueErrorsurvived in all three. The probe scores the message, not every line of the formatted traceback. - Maths. Display maths in markdown and an
IPython.display.Mathoutput: the surrounding text survived in all three. - Long output. 400 printed lines kept the first, middle and last markers everywhere. All three paginated rather than truncated, at 9 pages each.
- Matplotlib figures. Exactly one embedded raster image in each of the three PDFs. The text drawn inside the figure is absent from all three, which is correct: it is pixels.
Size and time
Over the same eight probes:
| Metric | --to pdf | --to webpdf | --to html + print |
|---|---|---|---|
| total bytes | 181,077 | 490,549 | 490,549 |
| total seconds | 23.72 | 57.06 | 48.89 |
Per-probe conversion seconds, execution excluded:
| Probe | --to pdf | --to webpdf | --to html + print |
|---|---|---|---|
wide-dataframe | 2.96 | 5.73 | 5.52 |
unicode | 2.96 | 5.84 | 5.59 |
html-repr | 2.90 | 5.60 | 6.78 |
svg-output | — | 5.53 | 5.78 |
ansi-traceback | 2.98 | 5.67 | 5.47 |
latex-math | 2.91 | 8.21 | 8.86 |
long-code-line | 3.00 | 10.54 | 5.89 |
long-stdout | 2.94 | 9.95 | 5.40 |
matplotlib-figure | 3.07 | 5.52 | 5.38 |
Per-probe output size:
| Probe | --to pdf | --to webpdf | --to html + print |
|---|---|---|---|
wide-dataframe | 21,587 (1p) | 71,746 (1p) | 71,746 (1p) |
unicode | 19,354 (1p) | 65,777 (1p) | 65,777 (1p) |
html-repr | 22,332 (1p) | 81,891 (1p) | 81,891 (1p) |
svg-output | — | 56,013 (1p) | 56,013 (1p) |
ansi-traceback | 21,503 (1p) | 55,665 (1p) | 55,665 (1p) |
latex-math | 22,219 (1p) | 57,492 (1p) | 57,492 (1p) |
long-code-line | 17,589 (1p) | 45,221 (1p) | 45,221 (1p) |
long-stdout | 24,706 (9p) | 52,254 (9p) | 52,254 (9p) |
matplotlib-figure | 31,787 (1p) | 60,503 (1p) | 60,503 (1p) |
The LaTeX route is about 2.7× smaller. It is also faster, by roughly a factor of two — but that one deserves a caveat rather than a decimal point.
The browser timings are noisy enough to be worth distrusting, and the evidence for
that is in the next section rather than in this table. Running the same --to webpdf
command three times in a row on one notebook gave 10.14 s, 5.72 s and 5.39 s. A column of
per-probe browser timings built from single samples cannot be read more finely than that
spread allows, and the totals inherit it. The LaTeX repeats over the same notebook were
3.13, 3.09 and 3.06.
So: LaTeX is faster here, by something in the region of a factor of two, and that is as precise as this data gets.
Where the browser routes’ five seconds actually go
The obvious guess is that starting a browser is expensive. That guess is wrong, and it is worth walking through because the real answer is actionable.
A bare Chromium launch, blank page, print to PDF:
launch + blank page + pdf: 0.27s
launch + blank page + pdf: 0.28s
launch + blank page + pdf: 0.29sThree tenths of a second, not five. The nbconvert subprocesses themselves are not the answer either:
--to html [0.83, 0.82, 0.83]
--to latex [0.89, 0.89, 0.91]
--to pdf [3.13, 3.09, 3.06]
--to webpdf [10.14, 5.72, 5.39]Writing the HTML costs about 0.8 s. Printing a blank page costs about 0.3 s.
--to webpdf costs about 5.5 s in a typical run — and note the 10.14 in that first
column, one sample of the same command that took 5.72 and 5.39 immediately afterwards.
Whatever accounts for the gap, it is not the exporter.
Taking the same exported HTML file and varying only what the browser waits for:
wait_until=domcontentloaded [1.91, 2.05]
wait_until=load [3.37, 3.46]
wait_until=networkidle [4.25, 4.36]
networkidle, http requests aborted: [0.81, 0.83]
external requests made by the page: 7
https://cdnjs.cloudflare.com/ajax/libs/require.js/2.1.10/require.min.js
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-AMS_CHTML-full,Safe
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/config/TeX-AMS_CHTML-full.js?V=2.7.7
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/config/Safe.js?V=2.7.7
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/extensions/Safe.js?V=2.7.7
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/jax/output/CommonHTML/fonts/TeX/fontdata.js?V=2.7.7Blocking outbound HTTP takes the same page from roughly 4.3 s to 0.8 s. The page is waiting on the network, and this is what it fetches:
external requests made by the page: 7
https://cdnjs.cloudflare.com/ajax/libs/require.js/2.1.10/require.min.js
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/latest.js?config=TeX-AMS_CHTML-full,Safe
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-AMS_CHTML-full,Safe
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/config/TeX-AMS_CHTML-full.js?V=2.7.7
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/config/Safe.js?V=2.7.7
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/extensions/Safe.js?V=2.7.7
https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/jax/output/CommonHTML/fonts/TeX/fontdata.js?V=2.7.7nbconvert’s HTML template pulls MathJax and RequireJS from a CDN — and the file used here is the traceback probe, which contains no maths at all.
The obvious inference is that this accounts for the browser routes’ runtime and their run-to-run spread generally. That is a hypothesis, not something measured: one route and one file were instrumented, and blocking requests removes downloading and executing together.
Be careful how far this generalises. Only one route and one file were instrumented:
the --to html output of ansi-traceback, printed through Playwright. --to webpdf was
timed but not traced, so the claim below is about the HTML-print route on that page. The
two routes produce near-identical output everywhere else in this benchmark, which makes it
likely they behave the same here, but likely is not measured.
What the numbers do support:
- That route reaches for a CDN on a notebook with no maths in it. The LaTeX route was not instrumented for requests; what can be said is that it needs no browser and its timings show no comparable floor.
- The delta is remote-dependent work, not pure transfer time. Aborting the requests prevents both downloading MathJax and running it, so the ~3.5 seconds it removes covers both. The experiment does not separate them.
- It is a latency dependency, not a hard requirement. With every HTTP request aborted the conversion still finished, in about 0.8 s, and produced a PDF. On a slow connection this route waits; it did not fail without one. Whether a genuinely air-gapped machine behaves like an aborted request was not tested.
- Most of that runtime is not work on your notebook, so these timings say little about how the engines scale with notebook size.
latex-mathis the slowest probe on the HTML-print route (8.86 s), which is consistent with MathJax having something to typeset once loaded. It is not the slowest on--to webpdf, wherelong-code-line(10.54 s) andlong-stdout(9.95 s) came out higher — and given the spread above, that ordering is not worth interpreting.
That ~4.3 → 0.8 s comparison is a two-condition test on one machine, one route and one file, holding everything but the request blocking fixed. It locates the time in remote-dependent work; it does not separate transfer from execution, and the absolute numbers depend on your connection.
The install cost of the LaTeX route
Those numbers are the steady state. Getting there is the expensive part: on a machine with BasicTeX the LaTeX route could not convert a single one of the nine notebooks until its dependencies were filled in.
Resolving them one missing file at a time took ten packages:
tcolorbox environ trimspaces pdfcol adjustbox
collectbox titling enumitem soul rsfsThe user TeX tree went from 4 KB to 1,048 KB. Nine installed cleanly with
tlmgr --usermode install, which needs no administrator password.
The tenth is a trap worth knowing about. tcolorbox from the TeX Live 2025 final
repository fails on a TeX Live 2025 BasicTeX install:
! Undefined control sequence.
l.76 \NewStructureName\NewStructureName is a LaTeX kernel macro newer than the format BasicTeX ships
(LaTeX2e <2024-11-01>) — the package is newer than the kernel underneath it. Pointing
tlmgr at the 2024 repository is refused outright because it does not support a 2025
installation, so tcolorbox 6.4.1 had to be unpacked from the TeX Live 2024 archive into the
user tree by hand. That cleared it.
Two things follow. First, the browser routes have no equivalent cost: pip install "nbconvert[webpdf]" plus playwright install chromium. Second, if you would rather not own
a TeX installation at all, a browser-based converter
needs nothing installed locally — with the caveat from the decision table, that a
browser-rendered PDF is the family that clips wide content, so check a wide table before
trusting it for one.
This was measured on one machine with one minimal TeX install. A full TeX Live pays none of it.
The specific error messages, tcolorbox.sty not found among them, are catalogued in
nbconvert PDF export errors. If your failure is
ModuleNotFoundError rather than a LaTeX error, it is an environment problem and
the kernel failure matrix is the faster read.
One more thing, if you are putting this in CI
The third route was originally driven through system Chrome’s --print-to-pdf. On this
machine it writes the PDF and then does not exit:
=== mode: --headless ===
pdf written after : 5 s
process ended after: 45 s, exit code 124 (124 = killed by timeout)
pdf size : 65819 bytes
pdf parses : yes, 1 page(s)
expected content : present (TRACEBACKPROBE)
=== mode: --headless=old ===
pdf written after : 4 s
process ended after: 45 s, exit code 124 (124 = killed by timeout)
pdf size : 65819 bytes
pdf parses : yes, 1 page(s)
expected content : present (TRACEBACKPROBE)A non-empty file appeared within a few seconds in both modes; both processes then sat there until the 45-second timeout killed them. The parse check runs after that timeout, so what it establishes is that the file Chrome left behind is a real PDF containing the notebook’s output — not that it was already complete at the moment it first became non-empty.
That distinction matters for the workaround. Interactively the hang is survivable. In a
pipeline the step looks stuck rather than finished, and the obvious fix — “kill it once the
file exists” — is the one thing this test cannot justify, because it never checked the file
at that moment. If you take that route, parse the PDF after killing Chrome and fail the
step when it does not parse. Driving the same Chromium through Playwright’s page.pdf()
avoids the question entirely by exiting normally, which is why the benchmark does that.
One machine, one Chrome build (153.0.8010.52, macOS 25.5.0); it may not reproduce on yours.
If you rely on --print-to-pdf, wrap it in a timeout — and then open the file and check
it, rather than treating its existence as success. A file can be there and still be
half-written when the timeout fires; this test says nothing about the moment it first
appeared, only about what survived the kill.
Re-running this
The harness lives in this site’s content repository, which is not public, so what follows is the reproduction path: the nine probe cells and the commands. Everything the numbers depend on is here.
python3 -m venv venv && ./venv/bin/pip install \
"nbconvert[webpdf]==7.16.6" "notebook==7.4.5" "pandas==2.3.2" \
"matplotlib==3.10.6" "pypdf==6.1.1"
./venv/bin/python -m playwright install chromiumExecute each probe once, then convert that same executed file three ways:
NB=wide-dataframe
./venv/bin/python -m nbconvert --to notebook --execute --allow-errors \
"$NB.ipynb" --output "$NB-executed.ipynb" --output-dir work
./venv/bin/python -m nbconvert --to pdf "work/$NB-executed.ipynb" --output-dir out/latex
./venv/bin/python -m nbconvert --to webpdf "work/$NB-executed.ipynb" --output-dir out/webpdf
./venv/bin/python -m nbconvert --to html "work/$NB-executed.ipynb" --output-dir out/htmlFor the third route, print that HTML with the same Chromium rather than system Chrome, for the reason in the previous section:
from playwright.sync_api import sync_playwright
# These five calls mirror nbconvert 7.16.6's own WebPDFExporter exactly. Every one of
# them matters: print media emulation changes layout, and omitting `format=` keeps both
# routes on the same paper. Passing A4 here, and skipping the media emulation, each
# confounded an earlier version of this benchmark.
with sync_playwright() as pw:
browser = pw.chromium.launch()
page = browser.new_page()
page.emulate_media(media="print")
page.wait_for_timeout(100)
page.goto(f"file://{html_path}", wait_until="networkidle")
page.wait_for_timeout(100)
page.pdf(path=pdf_path, print_background=True)
browser.close()Then score each PDF. Extraction gives the sentinel count:
from pypdf import PdfReader
text = "\n".join(p.extract_text() or "" for p in PdfReader(pdf_path).pages)
# A PDF's text layer breaks lines wherever the layout did, so normalise before searching
# or correct output scores as a loss.
flat = text.replace("\n", "").replace("\u00ad", "").replace("\u200b", "").replace(" ", "")
kept = [s for s in sentinels if s.replace(" ", "") in flat]And geometry gives the off-edge flag, which is what stops that count being read as
“visible” — the long-code-line result depends entirely on it:
page = PdfReader(pdf_path).pages[0]
box = [float(v) for v in page.cropbox]
runs = []
def visit(text, cm, tm, font, size):
t = text.strip()
if not t:
return
# origin, mapped into page space
x = tm[4] * cm[0] + tm[5] * cm[2] + cm[4]
# horizontal scale of tm x cm, so the font size is in page units too
scale_x = abs(tm[0] * cm[0] + tm[1] * cm[2]) or 1.0
runs.append((x, len(t) * float(size) * scale_x * 0.6, t))
page.extract_text(visitor_text=visit)
for x, width, t in runs:
if x <= box[2] and x + width > box[2]:
print("starts on the page and runs off it:", t[:40])The * 0.6 is a monospace advance estimate. It flags runs worth looking at; it does not
measure them.
The nine probes
Each probe is given in full — its markdown cell as well as its code cell, since the maths
probe scores a sentinel that lives in the markdown. The run records each probe’s own facts
alongside its results — long-code-line, for instance, is stored as
source_longest_line_chars: 218 with MARKERSTART, MARKERMID and MARKEREND at
0-based offsets 8, 109 and 208 — so the descriptions here are checkable against the data
rather than only against the generator.
wide-dataframe
Markdown cell:
# Wide DataFrame
A frame with 24 columns, printed as the cell result.Code cell:
import pandas as pd
pd.set_option('display.max_columns', None)
pd.set_option('display.width', 10_000)
df = pd.DataFrame(
[[f'r{r}c{c}' for c in range(24)] for r in range(3)],
columns=[f'COL{c:02d}' for c in range(24)],
)
dfSentinels — scored, from output: COL00, COL01, COL02, COL03, COL04, COL05, COL06, COL07, COL08, COL09, COL10, COL11, COL12, COL13, COL14, COL15, COL16, COL17, COL18, COL19, COL20, COL21, COL22, COL23.
unicode
Markdown cell:
# Unicode coverage
Greek, CJK, maths and an emoji, printed from a cell.Code cell:
print('GREEK' + 'PROBE', '\u03b1\u03b2\u03b3')
print('CJK' + 'PROBE', '\u6570\u636e\u5206\u6790')
print('MATH' + 'PROBE', '\u2211 \u221a \u2248')
print('EMOJI' + 'PROBE', '\U0001f9ea')Sentinels — scored, from output: GREEKPROBE, αβγ, CJKPROBE, 数据分析, MATHPROBE, ∑, EMOJIPROBE, 🧪.
long-code-line
Markdown cell:
# Long source line
One source line of 218 characters, with markers at its start, middle and end.Code cell:
TAIL = 'MARKERSTART__________________________________________________________________________________________MARKERMID__________________________________________________________________________________________MARKEREND'
print(len(TAIL))Sentinels — scored, from source: MARKERSTART, MARKERMID, MARKEREND.
html-repr
Markdown cell:
# Rich HTML output
A styled DataFrame. Its text/plain representation is just an object repr, so only the HTML carries the table.Code cell:
import pandas as pd
col = 'HTMLCOL' + 'PROBE'
cap = 'HTMLCAPTION' + 'PROBE'
df = pd.DataFrame({col: [1, 2, 3], 'other': [4, 5, 6]})
df.style.set_caption(cap).background_gradient()Sentinels — scored, from output: HTMLCOLPROBE; reported, not scored: HTMLCAPTIONPROBE.
svg-output
Markdown cell:
# SVG output
An inline SVG display plus a text marker.Code cell:
from IPython.display import SVG, display
inner = 'SVGINNER' + 'PROBE'
print('SVGTEXT' + 'PROBE')
display(SVG(
'<svg xmlns="http://www.w3.org/2000/svg" width="140" height="60">'
'<rect width="140" height="60" fill="#4477aa"/>'
f'<text x="8" y="35" fill="white">{inner}</text></svg>'
))Sentinels — scored, from output: SVGTEXTPROBE; reported, not scored: SVGINNERPROBE.
ansi-traceback
Markdown cell:
# Traceback with ANSI colour
A real uncaught exception, kept by `--allow-errors`.Code cell:
def failing():
raise ValueError('TRACEBACK' + 'PROBE')
failing()Sentinels — scored, from output: TRACEBACKPROBE.
latex-math
Markdown cell:
# Maths
Display maths in markdown:
$$ \int_0^1 x^2 dx = \frac{1}{3} $$
MATHMDPROBE follows the equation.Code cell:
from IPython.display import Math
print('MATHOUT' + 'PROBE')
Math(r'E = mc^2')Sentinels — scored, from output: MATHOUTPROBE; scored, from source: MATHMDPROBE.
long-stdout
Markdown cell:
# Long stdout
400 printed lines, with markers at both ends and the middle.Code cell:
for i in range(400):
if i == 0:
print('STDOUTFIRST' + 'PROBE')
elif i == 200:
print('STDOUTMID' + 'PROBE')
elif i == 399:
print('STDOUTLAST' + 'PROBE')
else:
print(f'line {i:03d} of output')Sentinels — scored, from output: STDOUTFIRSTPROBE, STDOUTMIDPROBE, STDOUTLASTPROBE.
matplotlib-figure
Markdown cell:
# Matplotlib figure
A PNG figure plus a text marker from the same cell.Code cell:
%matplotlib inline
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(4, 2.5))
ax.plot([0, 1, 2, 3], [0, 1, 4, 9])
ax.set_title('FIG' + 'TITLEPROBE')
print('FIG' + 'TEXTPROBE')
plt.show()Sentinels — scored, from output: FIGTEXTPROBE; reported, not scored: FIGTITLEPROBE.
Versions these numbers came from, as recorded by the run itself:
| Component | Version |
|---|---|
| nbconvert | 7.16.6 |
| notebook | 7.4.5 |
| ipykernel | 7.3.0 |
| nbclient | 0.11.0 |
| Chromium (Playwright) | 153.0.8010.12 |
| playwright | 1.63.0 |
| xelatex | XeTeX 3.141592653-2.6-0.999997 (TeX Live 2025 BasicTeX) |
| pandas | 2.3.2 |
| matplotlib | 3.10.6 |
| pypdf | 6.1.1 |
| Python | 3.14.2 |
| Platform | Darwin 25.5.0 arm64 |
Limits of this measurement
Stated plainly, because they bound what the numbers can be used for:
- Extraction is not eyesight. Nothing here renders a page and looks at it. A string in the text layer is strong evidence it is in the file; the Unicode result was additionally cross-checked against the xelatex log, and the other rows were not cross-checked that way.
- The geometry check locates runs, not sentinels. It reports where the run containing
a sentinel starts, and flags runs wide enough to leave the page. The off-edge flag is an
estimate from character count and font size, reliable for the monospace blocks it was
built for and not for proportional text. A zero
insidecan mean the sentinel was split across visitor chunks, that its run starts off the page, or that it is simply absent — the missing LaTeX Unicode sentinels are the last of those. - Timings are single samples and visibly noisy. Running one
--to webpdfcommand three times in a row gave 10.14 s, 5.72 s and 5.39 s. Treat the speed comparison as “roughly twice” and nothing finer. - The probes are worst cases, not an average notebook. On a notebook with no wide frames, no long lines, no non-ASCII and no HTML-only output, all three routes kept every sentinel. That is a statement about those sentinels, not a claim that the pages look alike — nothing here compares layout, typography or colour.
- The LaTeX column reflects nbconvert’s stock template. A custom template with a Unicode-capable monospace font would change the Unicode row.
text/htmlversustext/plainis the underlying variable in two of the probes where the routes disagreed —wide-dataframeandhtml-repr. The others have separate causes: font coverage (unicode) and a missing Inkscape (svg-output).long-code-lineis not a disagreement at all once geometry is taken into account; all three clip it. Where the MIME choice applies, it is a property of nbconvert’s exporters, not of xelatex or Chromium.
Related reading
- Jupyter nbconvert: Convert Notebooks to HTML, PDF & Python — the commands and flags themselves.
- nbconvert PDF Export Errors: A Complete Troubleshooting Reference — for conversions that fail loudly rather than degrade quietly.
- ModuleNotFoundError in Jupyter: A Reproduced Kernel Failure Matrix — when the notebook will not execute in the first place.
- What Is .ipynb_checkpoints? — worth clearing before a batch export, so you do not convert stale snapshots.