DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We localise prices for 58 countries from a header, and both our PDF exporters are hard-coded to A4

PubTrivia prints things. Players join by scanning the code on their table, so a venue's first task is to produce a card per table, print it once and reuse it every week. There are two pieces of code that make the printable sheet: one in the dashboard that exports every table's code at once, and a free one on the marketing site that will do it for any web address at all, with no sign-up and no request leaving the browser.

Both of them begin like this:

// A4 dimensions in mm
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
const PAGE_MARGIN_MM = 15
Enter fullscreen mode Exit fullscreen mode
const doc = new jsPDF({
    orientation: 'portrait',
    unit: 'mm',
    format: 'a4',
})
Enter fullscreen mode Exit fullscreen mode

Elsewhere in the same repository there is a module whose whole job is to notice where the visitor is:

return currencyForCountry(headerList.get('x-vercel-ip-country'))
Enter fullscreen mode Exit fullscreen mode

That map holds 58 countries and 24 currencies, so the pricing page quotes a venue in Mexico in pesos and a venue in Sweden in krona. The same two-letter code, available in the same request, would also answer a question nobody asked it: what size is the paper in that country.

The numbers, because they decide how bad it is

A4 is 210 by 297mm. US Letter, officially ANSI A, is 8.5 by 11 inches, which is 215.9 by 279.4mm, and it is the usual office size in the United States, Canada and the Philippines, with Mexico and Panama mostly using US sizes too. Our country map contains the US, Canada, Mexico, Panama and Puerto Rico.

So an A4 sheet against Letter is 5.9mm narrower and 17.6mm taller. Width is not the problem. Height is: printed at actual size, the bottom 17.6mm falls off the page, and with 15mm margins and a 4-up grid, the thing living in that strip is the label under the bottom row of codes. Printed with "scale to fit", which most drivers default to, everything shrinks to 279.4 / 297, or about 94%, and comes out with an uneven bottom margin.

Neither outcome breaks a quiz night. A QR code at 94% of 82mm still scans from across a table, and the label is the table number, which the person cutting up the sheet already knows. That is exactly why this sat there: the failure is cosmetic, it happens in somebody else's printer dialog, and nobody files a bug that says "my table cards are 6% small".

What the exporter actually does with the space

The dashboard version offers five layouts and derives everything else:

const LAYOUTS: Record<string, LayoutConfig> = {
    '1': { cols: 1, rows: 1, label: '1 per page' },
    '2': { cols: 1, rows: 2, label: '2 per page' },
    '4': { cols: 2, rows: 2, label: '4 per page (recommended)' },
    '6': { cols: 2, rows: 3, label: '6 per page' },
    '9': { cols: 3, rows: 3, label: '9 per page' },
}
Enter fullscreen mode Exit fullscreen mode
const cellWidth = printablWidth / cols
const cellHeight = printableHeight / rows
const cellPadding = 4
const labelHeight = 8 // mm reserved for label text
const qrSize = Math.min(cellWidth, cellHeight - labelHeight) - cellPadding * 2
Enter fullscreen mode Exit fullscreen mode

(Yes, printablWidth. It has been there since the first version and it is spelled that way in both places it is used, which is the only reason it still compiles.)

Because the size is derived rather than fixed, swapping the page format really is a two-constant change. With a 15mm margin, 4-up on A4 gives an 82mm code: width is the binding constraint, so the cell is 90mm wide and the code is that minus two lots of 4mm padding. The same layout on Letter gives about 85mm, because Letter is the wider sheet. The sheet that loses height gains code size, which is a pleasing result and entirely an accident. Everything downstream, including the font size ladder, follows the cell:

doc.setFontSize(perPage === '9' ? 7 : perPage === '6' ? 8 : 10)
Enter fullscreen mode Exit fullscreen mode

Three font sizes, chosen so that nine codes on a page still have a readable number under them. This is the part of the file I would keep unchanged in any rewrite: the layout is a function of the paper, so the paper is the only thing a new size has to change.

The two things I would copy from this exporter

First, jsPDF is imported at the moment the button is pressed, not at the top of the file:

const { jsPDF } = await import('jspdf')
Enter fullscreen mode Exit fullscreen mode

The dashboard page that renders the button is loaded by every host before every quiz. The PDF library is loaded by the subset of them who are printing table cards this week, which is close to all of them once and almost none of them again.

Second, there is no network call in the export, and that is a schema decision rather than a clever bit of client code. Each table row carries its code as a data URL:

doc.addImage(table.qr_code_url, 'PNG', qrX, qrY, qrSize, qrSize)
Enter fullscreen mode Exit fullscreen mode

The images are already in the page's props, so exporting 50 tables is 50 addImage calls and no requests. The free tool on the marketing site reaches the same place from the other direction: it generates the code in the browser with qrcode, so the address you type is never sent anywhere, which is a promise the page makes and the absence of a backend keeps.

The filename is built from what the file contains, too, which costs one line and saves a support thread:

doc.save(`QR_Codes_${activeTables.length}_tables_${perPage}pp_${totalPages}page${totalPages !== 1 ? 's' : ''}.pdf`)
Enter fullscreen mode Exit fullscreen mode

Why the fix is not just adding a dropdown

The obvious change is a page size selector next to the layout selector. I do not think that is the right one, or at least not the first one.

A venue owner printing table cards does not want a choice between ISO 216 and ANSI A. They want the sheet to come out of the printer that is in the room. The country code is already in the request, the paper size follows from it for every country we quote a price in, and the sensible default is the one the request implies, with an override for the minority who are printing for somewhere else. Which is exactly the pattern the pricing page already uses, including the part where the guess is coarse and the override is cheap.

The general shape, and the reason this is worth 1,000 words about paper: we had already decided that locale is something you infer and offer rather than ask about. Then we wrote a feature that produces a physical object and never asked the same question. Internationalisation work tends to stop at currency, language and date format, because those are the three a framework hands you. The list is longer than that, and paper is on it, along with address formats, phone number shapes and which day a week starts on.

Our own audit is now one question per output: for everything this app produces that leaves the screen, which of its properties are facts about the user's country rather than facts about us? The prices got that treatment a month ago. The PDFs are getting it this week.

Top comments (0)