PDFPipe

Formatting and locale / Numbers and money

Decimal and thousands separators, and the factor of a thousand

The comma and the point swap roles between conventions, so the same string of characters is two amounts a thousand apart depending on who is reading it.

The defect

Write 1,250 and you have written one thousand two hundred and fifty in one convention and one and a quarter in another. Write 1.250 and you have written exactly the opposite pair. There is no reading of the characters that resolves it, because both readings are correct somewhere, and a reader who has only ever seen one convention will not notice there was a question. On an invoice this is a factor of a thousand, and the two most common failures are opposite: a system that formats with the document's own convention regardless of the reader, and a system that formats with the reader's convention while the printed currency code says something else.

What it does to the document

A separator changes the width of a figure, so a column tuned to fit under one convention can be a character wider under another, and a group separator that is a space will break a figure across a line unless it is non-breaking. Both effects land in the amount column, which is the column with the least spare width in most documents.

The decisions

Reasons rather than a description of the code. Formatting rules are data that changes, so most of these are about taking the rule from a maintained source rather than encoding today's answer.

  • Format with an internationalisation library that takes a locale, rather than by string manipulation. The rules are data and they change, and hand-rolled formatting encodes today's assumption permanently.
  • Decide deliberately whose convention the document uses: the issuer's, the recipient's, or the currency's. All three are defensible and only one can be true, so write the decision down.
  • Use a group separator at all. An unseparated seven-digit figure is harder to read in every convention, and it is where transcription errors come from.
  • Print the currency code alongside any amount that could be read under two conventions. A code is unambiguous where a symbol and a separator are not.
  • Never mix conventions in one document. Two figures on the same page formatted differently is worse than either convention used consistently.
  • Use a non-breaking separator where the convention uses a space as the group separator, or the figure will break across a line and become two numbers.

In code

The formatting happens before the markup exists, so this is mostly code rather than CSS. The wrong version is kept in a comment where the wrong version is the thing people write.

js
// Format with a library that takes a locale. The rules are data.
const amount = 4180.5;

// Issuer's convention, stated explicitly rather than defaulted.
const asIssued = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
}).format(amount);           // "4.180,50 €"

// Recipient's convention, for the same amount and currency.
const asRead = new Intl.NumberFormat("en-GB", {
  style: "currency",
  currency: "EUR",
}).format(amount);           // "€4,180.50"

// Whichever you choose, print the currency code somewhere on the
// document so the figure cannot be read under the other convention.

// Not this: string manipulation encodes one convention forever.
// const bad = amount.toFixed(2).replace(".", ",");

What people do instead

Formatting the numbers for the recipient and leaving the rest of the document in the issuer's convention. The result is a document that reads correctly figure by figure and is internally inconsistent, and the inconsistency is the thing that makes a reader query it.

How to find out rather than assume

Render the same document under both conventions and lay them side by side. Confirm every figure changed, not just the ones in the totals block, and confirm no figure wrapped. Then check the currency code appears at least once somewhere a reader will see it.

Where the API sits in this

Nowhere, and that is worth saying plainly. This API renders HTML to PDF. By the time markup reaches it, every value in it is already a string that somebody's code produced, so there is no locale option on the render because there would be nothing for one to do. What the render controls is the page: the size, the margins, and whether the formatted value still fits the column it landed in. That is why each of these pages has a paragraph about the layout consequence, because that is the half a document adds to the general problem.

Frequently asked

Should the document use the sender's conventions or the reader's?

That is a decision rather than a fact, and the only wrong answer is not making it. The issuer's conventions are defensible for a document that is a record of what the issuer did. The recipient's are defensible for a document meant to be acted on. What fails is a document that mixes them, because a reader who sees two conventions on one page cannot tell which one any given figure is in.

Is it enough to set the document language?

No. A language attribute helps with hyphenation, with the assistive technology reading order and with anything that needs to know what language the text is in, and it changes none of the values. A date formatted as 03/04/2026 is exactly as ambiguous in a document declared as English as in one declared as anything else. The values are formatted upstream.

Why does this belong in a document reference rather than a general one?

Because a document cannot be re-rendered by its reader. On a screen a wrong format is annoying and a user can often change a setting. On paper the format is final, the layout was tuned to the string lengths of one language, and a value that grew by a few characters can push a table onto another page. The formatting problem and the pagination problem are the same problem here.

Related formatting topics

The defects that travel together, then the rest of the same group.

Values are formatted by your code before they reach the markup, so every fix on this page is upstream of the render. What the render decides is whether the result still fits.