AboutBlogMediaTags

Jupyter to PDF: A Measured Fidelity Benchmark of nbconvert's Three Export Paths

Runcell Team,

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…UseBecause
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 endnone of themAll 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 webpdfLaTeX dropped 4 of 8 Unicode markers with the stock template’s fonts
Styled DataFrames, or anything whose table lives only in HTML--to webpdfThe LaTeX route printed <pandas.io.formats.style.Styler at 0x…> instead of the table
SVG output--to webpdfThe 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 distributiona 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

RouteCommandWhat renders the page
LaTeXnbconvert --to pdfnbconvert writes .tex, xelatex typesets it
webpdfnbconvert --to webpdfnbconvert writes HTML, Playwright’s Chromium prints it
HTML + printnbconvert --to html, then print that fileThe 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:

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-dataframe24/2410/2410/24
unicode4/88/88/8
html-repr0/11/11/1
svg-outputdid not run1/11/1
ansi-traceback1/11/11/1
latex-math2/22/22/2
long-code-line3/31/31/3
long-stdout3/33/33/3
matplotlib-figure1/11/11/1

Where the run carrying each sentinel sits

Probe--to pdf--to webpdf--to html + print
wide-dataframe24/24 inside0/24 inside0/24 inside
unicode4/8 inside7/8 inside7/8 inside
html-repr0/2 inside0/2 inside0/2 inside
svg-output—2/2 inside2/2 inside
ansi-traceback1/1 inside1/1 inside1/1 inside
latex-math1/2 inside1/2 inside1/2 inside
long-code-line3/3 inside, 3 off-edge1/3 inside1/3 inside
long-stdout3/3 inside3/3 inside3/3 inside
matplotlib-figure1/2 inside1/2 inside1/2 inside

Totals over the eight probes every route completed:

Metric--to pdf--to webpdf--to html + print
sentinels kept382727
sentinels possible434343
total bytes181,077490,549490,549
total seconds23.7257.0648.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 = 2

The 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 1

All 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\linewidth

A 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 1

All 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=True

display.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 ￿ 1

One 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:

√ (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> 1

A 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:


Size and time

Over the same eight probes:

Metric--to pdf--to webpdf--to html + print
total bytes181,077490,549490,549
total seconds23.7257.0648.89

Per-probe conversion seconds, execution excluded:

Probe--to pdf--to webpdf--to html + print
wide-dataframe2.965.735.52
unicode2.965.845.59
html-repr2.905.606.78
svg-output—5.535.78
ansi-traceback2.985.675.47
latex-math2.918.218.86
long-code-line3.0010.545.89
long-stdout2.949.955.40
matplotlib-figure3.075.525.38

Per-probe output size:

Probe--to pdf--to webpdf--to html + print
wide-dataframe21,587 (1p)71,746 (1p)71,746 (1p)
unicode19,354 (1p)65,777 (1p)65,777 (1p)
html-repr22,332 (1p)81,891 (1p)81,891 (1p)
svg-output—56,013 (1p)56,013 (1p)
ansi-traceback21,503 (1p)55,665 (1p)55,665 (1p)
latex-math22,219 (1p)57,492 (1p)57,492 (1p)
long-code-line17,589 (1p)45,221 (1p)45,221 (1p)
long-stdout24,706 (9p)52,254 (9p)52,254 (9p)
matplotlib-figure31,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.29s

Three 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.7

Blocking 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.7

nbconvert’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 ~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 rsfs

The 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 chromium

Execute 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/html

For 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)], ) df

Sentinels — 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:

ComponentVersion
nbconvert7.16.6
notebook7.4.5
ipykernel7.3.0
nbclient0.11.0
Chromium (Playwright)153.0.8010.12
playwright1.63.0
xelatexXeTeX 3.141592653-2.6-0.999997 (TeX Live 2025 BasicTeX)
pandas2.3.2
matplotlib3.10.6
pypdf6.1.1
Python3.14.2
PlatformDarwin 25.5.0 arm64

Limits of this measurement

Stated plainly, because they bound what the numbers can be used for:


© Runcell.RSS