Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

Print is a feature: laying out A4 QR sheets in the browser with jsPDF

Our product is a live quiz that people join by scanning a QR code on their table. Which means that before any of the real time machinery matters, somebody in a pub has to walk around with a stack of printed cards and put

Our product is a live quiz that people join by scanning a QR code on their table. Which means that before any of the real time machinery matters, somebody in a pub has to walk around with a stack of printed cards and put one on each table.

For a long time the app generated the codes and then left that person on their own. One PNG download per table. Sixteen tables, sixteen downloads, then a fight with a word processor to get them onto pages without a code straddling the fold.

The fix was not more product. It was a PDF.

Millimetres, not pixels

The first thing that changes when you generate a print artifact is your unit. jsPDF will happily work in millimetres, and once you do, the layout code stops being a guess about DPI and starts being arithmetic about paper:

const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
const PAGE_MARGIN_MM = 15

const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })

A grid of codes is then a division. Given a layout of cols by rows:

const printableWidth = A4_WIDTH_MM - PAGE_MARGIN_MM * 2
const printableHeight = A4_HEIGHT_MM - PAGE_MARGIN_MM * 2

const cellWidth = printableWidth / cols
const cellHeight = printableHeight / rows

const cellPadding = 4
const labelHeight = 8 // mm reserved for the table label
const qrSize = Math.min(cellWidth, cellHeight - labelHeight) - cellPadding * 2

That Math.min is the whole trick for a square image in a non-square cell. The QR code is as big as it can be without exceeding either dimension, and the label gets its reserved strip subtracted from the height before the min, not after, so a label never pushes a code off the bottom of its cell.

Placing item i is then modular arithmetic against the page capacity:

const perPageCount = cols * rows
const posOnPage = i % perPageCount

if (posOnPage === 0 && i !== 0) doc.addPage()

const col = posOnPage % cols
const row = Math.floor(posOnPage / cols)

const cellX = PAGE_MARGIN_MM + col * cellWidth
const cellY = PAGE_MARGIN_MM + row * cellHeight

// centre the square horizontally in its cell
const qrX = cellX + (cellWidth - qrSize) / 2
const qrY = cellY + cellPadding

doc.addImage(table.qr_code_url, 'PNG', qrX, qrY, qrSize, qrSize)

The posOnPage === 0 && i !== 0 guard is the kind of thing that is obvious once written and produces a blank first page every single time it is forgotten.

Labels scale with density, because a table number set at 10pt in a nine-per-page grid overlaps its neighbour:

doc.setFontSize(perPage === '9' ? 7 : perPage === '6' ? 8 : 10)
doc.setTextColor(40, 40, 40)
doc.text(table.table_number, cellX + cellWidth / 2, labelY + 4, {
  align: 'center',
  maxWidth: cellWidth - cellPadding * 2,
})

The preview cannot disagree with the PDF

The dialog shows a little page preview before you commit to printing. The temptation is to hand draw that preview in CSS to look roughly right. We drive it from the same layout object the PDF generator uses:

const LAYOUTS = {
  '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' },
}
<div style={{
  display: 'grid',
  gridTemplateColumns: `repeat(${LAYOUTS[perPage].cols}, 1fr)`,
  gridTemplateRows: `repeat(${LAYOUTS[perPage].rows}, 1fr)`,
}}>

CSS grid and a jsPDF page are completely different rendering models, but they are both taking cols and rows from one record. Adding a twelve per page option is a single line, and it is impossible to add it to the generator and forget the preview.

The page count shown under the dropdown is the same idea, one Math.ceil rather than a second opinion:

{Math.ceil(activeTables.length / parseInt(perPage))} pages

Load the library only when somebody prints

jsPDF is not small, and the overwhelming majority of page views never generate a PDF. So it is a dynamic import inside the click handler, not a module level one:

const { jsPDF } = await import('jspdf')

Same for the QR library itself. qrcode ships a browser build that bundlers pick up automatically, so both the dashboard's table codes and the public tool run entirely client side.

Name the file like a human

doc.save(`QR_Codes_${activeTables.length}_tables_${perPage}pp_${totalPages}pages.pdf`)

This costs nothing and it is the difference between a downloads folder you can navigate and one full of document (3).pdf. The person doing this is doing it once a week, sometimes for two venues, and the filename is the only thing that will tell them which is which in a month.

Nothing is uploaded, and you can check

We put a version of this on the public site as a free tool, and the interesting property is what is absent from it. There is no backend. The URL you type never leaves your machine, which is a much better answer to "what do you do with what I put in here" than a privacy policy is.

You can verify that claim rather than trust it. Open the QR code generator, open your browser's Network tab first, type any address, generate the code, and then hit "A4 sheet of four". Watch the request list. A chunk of JavaScript loads and then nothing goes out. The PDF is assembled in the tab and handed to the download manager.

If you want to see the same thing wired into an actual product, the QR code joining feature page explains what happens on the player's side after they scan one, including what the printed card does when the venue WiFi is having a bad night.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.