A PDF table has no cells
“Our invoice and credit-note PDFs cut every line description over 50 characters to 47 characters plus "...", so the full text is never printed and a customer cannot tell two similar lines apart on the document they keep for their books.”
An invoice table in a PDF looks like a table. It has a header, aligned columns, rows that line up, money flush right. Open the file and none of that is in it. There is a sequence of instructions that set a font and then paint glyphs at coordinates, and the table is an impression those coordinates make on a reader.
This matters because every property you expect from a table is something the generator computed once and then threw away. A column does not contain its text; the text is simply near where the column heading was drawn. A row does not grow to fit; a row is however far the generator chose to move the cursor down. A page does not end; it has a rectangle that readers mostly respect. Nothing in the file objects when any of these go wrong, and nothing downstream can reconstruct what was meant.
Two invoice generators fixed this in October, in unrelated codebases and different languages.
hardcount#1228, a Go billing system, cut
every invoice line description over 50 characters to 47 plus ...; its fix,
PR #1263, also fixed totals that overlapped
large amounts. toolbelt#246, a TypeScript tool
built on jsPDF, wrapped the first row of each page in the column header's font, so its description
ran into the next column. Different bugs, the same root: each is a job the table would have done if
the file had one.
Below are three ways that goes wrong, each measured here, and then the part that surprised me: what text extraction makes of the result.
Measuring in one font and drawing in another
Every PDF library ships a function that breaks a string into lines that fit a width. The question worth asking is where it gets the font from, because the width of a string is meaningless without one.
In jsPDF 4.2.1, it is ambient. splitTextToSize begins:
var fsize = options.fontSize || this.internal.getFontSize(),
and looks the face up through getFont, which falls back the same way:
fontName =
fontName !== undefined ? fontName : fonts[activeFontKey].fontName;
fontStyle =
fontStyle !== undefined ? fontStyle : fonts[activeFontKey].fontStyle;
So called plainly, it measures with whatever font happens to be set on the document at that moment. gopdf 0.38.4 says the same thing in its own doc comment, with no hedging:
// SplitTextWithOption splits a text into multiple lines based on the current font size of the document.
"The current font size of the document" is a global. A table generator sets the header font to draw
the heading row, then starts the row loop. If it wraps the first description before setting the row
font back, it measures in the heading's font and draws in the body's. Later rows look fine, because
the previous row left the font correct. That is toolbelt#246 exactly: splitTextToSize ran while the
header's 8.5pt bold was still set, and the 10.5pt normal row font was set one line later. The fix in
PR #268 moves that one line, plus a test that
records the font size every time the wrapper is called.
Here is what that costs, in toolbelt's layout but re-measured rather than copied (the issue's figures are for Noto Sans; these are Liberation Sans). One description of exactly 120 characters, a description column 258pt wide, a heading at 8.5pt bold and body text at 10.5pt normal, measured three times in three unrelated libraries:
| jsPDF 4.2.1 | ReportLab 5.1.0 | gopdf 0.38.4 | |
|---|---|---|---|
| Full string at 8.5pt bold | 498.28pt | 498.28pt | 497.71pt |
| Full string at 10.5pt normal | 569.67pt | 569.67pt | 569.31pt |
| Lines when wrapped at 8.5pt bold | 2 | 2 | 2 |
| Lines when wrapped at 10.5pt normal | 3 | 3 | 3 |
| First line of the 8.5pt wrap, drawn at 10.5pt | 283.06pt | 283.06pt | 282.88pt |
jsPDF and ReportLab agree to the hundredth of a point, which is what you would hope: both read the advance widths out of the same font file. gopdf is about half a point adrift over a 570pt string, roughly a tenth of one per cent, from its own scaling arithmetic.
The measurement in the heading font comes out at 87.5% of the real width. Measured there, the first line fits; drawn, it is 283.06pt in a 258pt column, so it ends 25.06pt past the column edge. Nothing clips it. There is no box.
Worth noting what ReportLab's equivalent looks like:
def stringWidth(text, fontName, fontSize, encoding='utf8'):
The font and the size are required positional arguments. You cannot forget to set them, because there is nothing to set. You can still pass the wrong font, but then the mistake is written in the call where a reviewer can see it, not hidden in whatever state the previous line left behind.
The row that does not grow and the page that does not end
The wrong measurement has a second consequence, and it is the one that moves text onto other text. The wrap said two lines. The real answer is three. At a 13pt line height, the row needs 39pt and the generator reserved 26pt.
Thirteen points of shortfall then flows into the page-break check, because that check asks whether
the row fits in what is left of the page. With the cursor 82pt from the bottom of the page and a
56pt bottom margin: 82 - 26 = 56, which still clears the margin, so no page break. Had the row
been measured correctly, 82 - 39 = 43 would have failed, and the row would have moved to the next
page. It does not. The three baselines land at 72pt, 59pt and 46pt. The third is 10pt below the
bottom margin and 6pt above the footer's baseline, so the last line of the description is drawn into
the footer.
hardcount's fix names this step explicitly: the row grows with its wrapped lines, and "the page-break check and the 'header kept with the first row' check use the row's real height". The same gap exists one level up, for whole blocks: an open Chainvoice issue works out, from the layout arithmetic, that with ten line items the totals box reaches past the footer line, because the only page-break check runs before each item and never asks about the totals.
That is the ordinary case. The page edge is not a boundary either. Drawing a line at y = -20 in a
page whose MediaBox is [0, 0, 612, 792] puts it outside the page, and the glyphs still go in the
content stream, intact. Here are the text-positioning operators from that page, as written, with the
font and string operands trimmed:
BT 1 0 0 1 56 72 Tm ... ET
BT 1 0 0 1 56 59 Tm ... ET
BT 1 0 0 1 56 46 Tm ... ET
BT 1 0 0 1 56 40 Tm ... ET
BT 1 0 0 1 56 -20 Tm ... ET
The last number in each Tm is the baseline's y: the three description lines, the footer at 40, and
the off-page line at -20.
So what happens to text that is not on the page? It depends who you ask. pdftotext -layout does
not return it. pypdf does. One reader treats the page rectangle as a filter and the other treats it
as advice, and both are defensible, because the file does not say.
Cutting a string is not hiding it
The other way generators handle a description too long for its column is to cut it: anything over 50 characters becomes the first 47 plus an ellipsis. That was hardcount's invoice and credit-note PDFs until October, and the issue says why it mattered there: usage lines differ late in the string, so "the customer cannot tell two similar lines apart", on "the documents a customer keeps for their books". It looks tidy and it is the most destructive option available, because it happens before the glyphs are written.
I built that file with two generators and read it with two extractors. The tail of the description,
production support, including on-call hours in June 2026., is in none of the four outputs. Not
hidden, not clipped, not off the page. The characters were never written, so there is nothing to
recover: no selection, no search, no copy and paste, no downstream parser, no screen reader. What the
document shows is:
DESCRIPTION AMOUNT
Monthly retainer for backend API development, c... $12,500.00
and what the document contains is the same 50 characters. The invoice is now the only copy of a number whose explanation has been discarded.
What the extractors make of the overlap
This is the part I did not expect, and it is the clearest statement of the problem.
Take the overlapping file, the one where the first line was wrapped in the heading font and drawn at
10.5pt. The description's first line ends at x = 339.06. The amount, $12,500.00, is 52.55pt wide
and right-aligned at x = 380, so it starts at x = 327.45. The two overlap by 11.61pt of shared
horizontal space.
Ask pdftotext -layout what the row says:
DESCRIPTION AMOUNT
Monthly retainer for backend API development, code reviews$12,500.00
and production support, including on-call hours in June 2026.
reviews$12,500.00. No space, no separator, no column. The extractor reconstructs columns from
x-coordinates, which is the only information it has, and where the glyphs overlap the boundary it
infers is wrong. Both generators produce this, so it is not an artefact of one writer.
Now ask two other readers the same question. Plain pdftotext without -layout does not glue them.
pypdf does not glue them either; it returns the description and the amount as separate runs. Two
tools, three ways of asking, two different answers about which column a string belongs to, from
identical bytes, and no cell anywhere to appeal to.
It gets worse for anyone hoping to test for this. Normalised to single spaces, pypdf returns exactly the same text for the overlapping file and for the correctly laid out one:
DESCRIPTION AMOUNT Monthly retainer for backend API development, code reviews and production support, including on-call hours in June 2026. $12,500.00
The broken document and the correct document extract identically. A test that renders a row and asserts on the extracted string passes on both, so the string alone cannot see this class of bug. A 120-character test case is worth having, but it catches truncation, not collision.
What can see it is the geometry. hardcount's tests read each text run back with its font, size and
measured width, then assert that no run is drawn over another and every description sits whole
inside its column. toolbelt's test checks the cause as well: it records the font at every
splitTextToSize call and fails if any description was measured at anything but 10.5pt normal, then
checks that the description ends left of the Rate column.
Both fail on the old code. Neither asks the extracted text what the table meant, because the text
does not know.
And the correct file has its own surprise. Because the description wraps onto three lines and the amount sits beside the first, reading the layout-preserving output line by line and joining it gives:
Monthly retainer for backend API development, code $12,500.00 reviews and production support,
including on-call hours in June 2026.
The amount is spliced into the middle of the description. That is the correctly rendered invoice. A parser taking the row as a line of text gets an amount inside a sentence, which is the input that breaks the money-matching patterns we looked at in an amount in PDF text has no edges.
What the generator is actually responsible for
Four separate obligations, none of them enforced by the format:
- Measure in the font you draw with. If the wrapper takes the font from document state, set the state first. If it takes arguments, pass them.
- Size the row from the wrap, not the other way round. The line count is an output of measurement and an input to row height. Reversing them is the 13pt.
- Make the page-break check use the real row height. It is the same number, and using the reserved one is how a row gets drawn into the footer.
- Never cut a string to make it fit. Wrap it, or grow the column, or put it on its own line. Truncation at render time deletes the data.
What strikes me is that these are independent. A generator can get the measurement right and the page break wrong, or wrap properly and still truncate. They fail separately because the format offers no single place to get them right. In a format with cells, three of the four would not be decisions at all.
What this post covers, and what it does not
Everything measured here was run on 9 November 2026 on one x86_64 Linux machine, with jsPDF 4.2.1
under Node 24.19.0, ReportLab 5.1.0 and pypdf 7.0.0 under Python 3.12.3, gopdf 0.38.4 under Go 1.24.7,
and Poppler 24.02.0's pdftotext. The same figures came out of an earlier run of independent scripts
on different Node and Python versions. Those are behaviours of those builds, not of PDF generators in
general.
All widths are Liberation Sans 2.1.5 Regular and Bold at 8.5pt and 10.5pt, in one layout: a 258pt description column, an amount right-aligned at x = 380, a 13pt line height, a 56pt bottom margin. The point-for-point agreement between jsPDF and ReportLab is agreement about one font file. A different font changes every number in the table, and the shape of the result is what carries over, not the figures.
The two line-wrapping functions were read in the source of the versions actually installed and run,
and the gopdf doc comment and the jsPDF fallbacks are quoted from those files. I did not quote
ISO 32000-1 anywhere, so there is no claim here about what the PDF standard requires, only about what
these libraries and these two extractors did. The disagreement between pdftotext and pypdf over text outside the
MediaBox is reported as measured behaviour, and this post does not say which is correct.
One more aside, outside the post's argument. hardcount's fix wraps with gopdf's
SplitTextWithWordWrap plus a guard, because "gopdf loops forever when a character is wider than the
line". Measured here: at 10.5pt a capital M is 8.75pt wide, and asking it to wrap MMMM into 4.37pt
had not produced a result after ten seconds. In the source, the strict-break path decrements the loop
index while the loop increments it, with the line buffer still empty. A column width derived from a
page size or a user setting can reach zero, so it is worth a floor.
No count of affected projects or reports appears anywhere in this post, and none should be inferred.