← Blog
How it works

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.