PDFPipe

Formatting and locale / Names, addresses and identifiers

Tax and registration identifiers, and the validator that rejects them

Registration numbers whose length, character set and check rules differ by country, printed and validated by systems that assume the shape of the first country they supported.

The defect

A tax identifier is a country-specific string. Length varies, whether it contains letters varies, whether there is a check digit and how it is computed varies, and whether the country prefix is part of the value or displayed separately varies. Two failures follow. A validator written against one country's shape rejects valid identifiers from another, which blocks real customers. And a display that strips or adds a prefix inconsistently produces a document where the identifier does not match the one in the counterparty's system, which is what a machine matches on. Neither failure is visible to the person who wrote the code, because their own country's identifiers pass.

What it does to the document

Identifiers vary in length between countries by enough to matter in a header block sized for the domestic case. Give the field room for the longest identifier you accept and set it not to wrap, because a wrapped identifier is retyped with a space in it.

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.

  • Validate against a per-country rule set from a maintained source, and never against a single pattern applied to everything.
  • Where you have no rule for a country, accept the value and store it rather than rejecting it. A false rejection costs a customer; a permissive field costs a later correction.
  • Store and display consistently, including whether the country prefix is part of the value, because the counterparty's system matches on the exact string.
  • Set identifiers in a face where similar characters are distinguishable, and keep them unbreakable, for the same reason as bank details.
  • Label the field with the name used in the destination country, because a reader is checking it against a document of their own that uses that name.
  • Do not compute or assert a check digit rule you have not verified. Rejecting a valid identifier because of a wrong assumption is the expensive direction of this error.

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
// Per-country rules, from a maintained source. Never one pattern for all.
function validateTaxId(countryCode, value) {
  const rule = TAX_ID_RULES[countryCode];
  // No rule for this country: accept and store rather than reject.
  if (!rule) return { ok: true, note: "unvalidated" };
  return rule.test(value);
}

// Store and display the same string the counterparty will match on,
// including whether the country prefix is part of the value.
const stored = "GB123456789";

/* Display like any transcribed identifier:
   .tax-id {
     font-family: "IBM Plex Mono", ui-monospace, monospace;
     white-space: nowrap;
     letter-spacing: 0.02em;
   }
   Label it with the name used where the reader is, not the internal one. */

What people do instead

Writing one regular expression against the domestic format and applying it to every country. It passes every test the author can think of, because the author's examples are all domestic, and it silently blocks customers from everywhere else.

How to find out rather than assume

Put a real identifier from each country you operate in through the validator and onto a rendered document. Every one should be accepted, printed unbroken, and byte-identical to what the counterparty has on their side.

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.