← Blog
How it works AegisPDF

The euro a standard PDF font will not draw

“The currency symbol is missing from my generated invoice PDF and nothing reports an error.”

An invoice goes out and the total reads 1.234,00. It should read €1.234,00. Nothing in the generator logged an error, the PDF opens cleanly, and the pound and dollar invoices produced by the same code on the same day are all correct.

The usual guesses are a string that lost its prefix, a template branch that skipped the currency, or an encoding problem somewhere between the database and the renderer. On a PDF written in one of the standard fonts, there is a fourth possibility, and it is the one that leaves the least evidence: the euro reached the file intact and the file still draws nothing, because the font was never asked a question it could answer.

The same page twice

Eight one-page PDFs, written by hand so that nothing but the one line under test differs, with a ninth added at the end. Each draws text in Helvetica at 36pt. Helvetica is not embedded, which is the normal case for the fourteen standard fonts: the file names the font and the reader supplies it.

Each file was opened in three PDF engines: poppler 24.02.0, MuPDF 1.28.1 and pdf.js 6.4.299, the engine behind Firefox's viewer. Each engine uses its own stand-in for Helvetica, so the ink counts differ a little between them. What matters is where they are zero.

The first five draw a single byte. Byte 80 is the euro in WinAnsiEncoding. Byte A3 is the pound, and it is the control, because the pound sits at the same code in both of the encodings in play. Rendered at 150 dpi in grayscale, counting pixels darker than mid-grey:

file /Encoding in the font dictionary byte drawn extracted text dark pixels (poppler / MuPDF / pdf.js)
A absent 80 nothing usable 0 / 0 / 0
B /WinAnsiEncoding 80 € (U+20AC) 769 / 811 / 814
C /Differences [128 /Euro] 80 € (U+20AC) 769 / 811 / 814
D absent A3 £ (U+00A3) 689 / 785 / 760
E /WinAnsiEncoding A3 £ (U+00A3) 689 / 785 / 760

File D is the important row. With no /Encoding entry at all, the pound draws. The euro, in a file that differs from D by one byte, draws nothing in any of the three. Not a box, not a question mark. Poppler and pdf.js extract it as the empty string. MuPDF extracts U+FFFD, the replacement character, which is the only trace of it anywhere.

The last three files put the byte in front of an amount. The columns give the first pixel column with ink:

file /Encoding bytes drawn extracted text first ink (poppler / MuPDF / pdf.js)
F absent 80 + 1.234,00 1.234,00 27 / 50 / 50
G /WinAnsiEncoding 80 + 1.234,00 €1.234,00 20 / 21 / 21
H absent 1.234,00 1.234,00 27 / 29 / 29

In poppler, F and H render to the identical image. Same MD5, same every pixel. A PDF that asks for a euro and a PDF that never mentions one are the same page.

MuPDF and pdf.js do one thing differently: they keep the euro's place. F starts 21 pixels to the right of H in both, which at 36pt is a blank 278/1000 of an em wide. That is exactly the width of a space in Helvetica. So in those two readers the total reads 1.234,00, with a space where the currency should be.

That is why this failure survives review. A missing glyph that drew a box would be caught by anyone glancing at the invoice. This draws nothing or a space. The line is not broken and the layout is not disturbed. It reads as a number someone deliberately chose not to prefix.

Why the pound and not the euro

A Type 1 font dictionary with no /Encoding entry uses the font's own built-in encoding. For the standard fonts that is AdobeStandardEncoding, which the metrics files state outright: Helvetica's AFM carries the line EncodingScheme AdobeStandardEncoding. pdffonts agrees, reporting the encoding of files A and D as Standard.

AdobeStandardEncoding has a code for dollar (octal 044), cent (0242), sterling (0243), yen (0245), florin (0246) and the generic currency sign (0250). It has no code for the euro. Not an unused code, not a code that maps to a missing glyph: the character table in Annex D of the PDF specification, ISO 32000-1, leaves the euro's Standard column empty. WinAnsiEncoding does have one, at octal 200, which is decimal 128, which is the byte these files draw.

The specification also says how that happened. Its note on the table records that the euro was added to the Adobe standard Latin character set in PDF 1.3, and was given a code in WinAnsiEncoding and in PDFDocEncoding. StandardEncoding was not changed.

So the difference between an invoice that shows a euro and one that does not is a single token in the font dictionary. Of the currency symbols these fonts carry at all, the euro is the only one their built-in encoding cannot reach.

What the font says about its own euro

The standard fonts do know the name. Adobe's metrics for Helvetica, version 002.000, with a creation date of 1 May 1997, list it among the 315 characters:

C -1 ; WX 556 ; N Euro ; B 0 0 0 0 ;

Both numbers in that line are worth reading against Adobe's own specification for the format. On the character code, the spec says characters not encoded "follow the encoded characters and are identified by character codes of -1", and defines the C key as the "decimal value of default character code (-1 if not encoded)". So the font's metrics declare, in the file, that this glyph has no code in the font's own encoding. That is the same fact pdffonts reports from the other direction.

On the bounding box, the spec says that if a character "makes no marks on the page (for example, the space character), this field reads B 0 0 0 0". In Helvetica's 315 characters, exactly two lines carry that bounding box. One is space. The other is Euro. The same holds in every face of Helvetica, Times and Courier: two glyphs each, the space and the euro.

Across the twelve Latin standard fonts the Euro entry is identical in shape, unencoded and with no marks, varying only in width: 556 in the Helvetica family, 500 in the Times family, 600 in the Courier family. Of the fourteen standard fonts the only one whose metrics give the euro both a code and a real bounding box is Symbol, where it sits at code 160 with a bounding box of 20 -12 714 685. ZapfDingbats has no euro at all. Symbol is not a font anyone sets an invoice in.

The substitute faces readers actually reach for are in a different position. Nimbus Sans, the URW face shipped in the standard Ghostscript font set as a metric-compatible stand-in for Helvetica, carries a drawable euro at the same 556 width:

C -1 ; WX 556 ; N Euro ; B 2 -22 543 709 ;

Still C -1, still unreachable through the font's built-in encoding, but with an outline waiting behind the name. That is the shape of the whole problem. The ink exists. The route to it does not, until the file asks for it by name.

The fix is one line, in two places

Declare the encoding in the font dictionary:

/Encoding /WinAnsiEncoding

That is file B. A library may already be doing it: pdf-lib, for one, selects WinAnsi for every standard font except Symbol and ZapfDingbats and writes Encoding: 'WinAnsiEncoding' into the dictionary. Check which encoding yours selects rather than assuming. A /Differences array naming /Euro at the code you are writing works equally well, and is file C above, which drew the same ink as B in all three engines.

The second place is the byte. /WinAnsiEncoding means the string you write has to be WinAnsi, so the euro goes in as the single byte 80. A UTF-8 euro is three bytes, and a ninth file written to check this extracts as € in all three engines, three characters rather than one. That failure leaves visible evidence, so it is the easier of the two to catch.

The quieter version of the same mistake is treating WinAnsi as Latin-1. The two agree on every code except 80 to 9F, and the euro is at 80. The Dart pdf package, at version 3.13.1, shows the pattern: its standard fonts declare /WinAnsiEncoding, but text is encoded with Latin-1, and a built-in font reports any character above U+00FF as unsupported. The euro is U+20AC, so it never reaches the byte 80 the dictionary has room for. That library at least says so: one invoicing project found every amount on its German invoices logging "Unable to find a font to draw "€"", and fixed it by embedding a fallback font.

If the invoice carries anything beyond the Latin set, a currency such as the rupee or the lira, or a customer name with characters outside WinAnsi, then neither fix is enough and the font has to be embedded and subset. The standard fourteen exist so that a file need not carry its fonts, and the cost of that is a character repertoire someone else froze.

What was and was not checked

Every encoding and metrics claim above comes from files fetched and read here: the Adobe metrics files, Adobe's AFM specification and ISO 32000-1 Annex D. Every number in the two tables is output from a command run here. The files under test were written by hand for this post and are listed in the sources note.

Three engines were tested: poppler, MuPDF and pdf.js. They agree that the euro is not drawn and disagree about what goes in its place, so the ink counts and the gap are each engine's own behaviour. A reader whose Helvetica substitute has no euro outline would not draw file B either. Acrobat, Chrome's built-in viewer, macOS Preview and the mobile viewers were not tested.

This post is also about non-embedded standard fonts only. A PDF with an embedded, subset font has a different failure mode, where the glyph is missing from the subset rather than from the encoding, and the symptoms are not the same.

Where AegisPDF fits

AegisPDF edits PDFs in the browser, and its text editing, part of Pro, runs into the near side of this. When you change the string of an existing text run, it has to decide whether the run's current font can carry the new characters. It does not guess. The test is whether every character you typed was already present in that run:

let keep_font = new_text.chars().all(|c| old_text.contains(c));

If it was, the original font is kept and the typeface is preserved exactly. If it was not, the run is recreated in the closest standard face, which the code comments explain is "because the run's subset-embedded font may lack the new glyphs". The result carries a refonted flag, and the editor says so in as many words: "The run’s original font lacked some of the new characters, so it was set in a standard font (Helvetica/Times/Courier)."

Typing a euro into a total that did not already contain one is exactly the case that fails that test, so the question in this post is the one that path has to answer. It answers it the way file B does. AegisPDF creates the standard font through PDFium, the PDF library it is built on, and in the PDFium version AegisPDF ships the new font dictionary is written with /Encoding /WinAnsiEncoding. Each character you type is then mapped through that encoding, so a euro goes in as the byte 80. Both halves of the fix are there. This was read in the source, not run end to end, and whether the euro is then drawn still depends on the viewer's Helvetica, as in the tables above.

What the design gets right is refusing to assume. A font that has not been observed to carry a character is not trusted to carry it, and the user is told when the substitution happened. The failure this post is about is the opposite posture: a file that names a font, asks it for a character it was never given a code for, and reports nothing.