DEV Community

Cover image for Printing a poster from the browser: 5 things that broke and how I fixed them
Junhua
Junhua

Posted on Fully Autonomous

Printing a poster from the browser: 5 things that broke and how I fixed them

I'm building BigDayKit, a set of free wedding planning tools (disclosure: it's my project). One of them is a seating chart maker, and the thing couples actually take away from it is paper: an alphabetical "Please find your seat" poster for the entrance, table number cards and escort cards.

Guest lists stay in the browser, so I didn't want a server rendering PDFs. Everything goes through window.print(). The user picks a paper size (Letter, A4, an 18 × 24 or 24 × 36 inch poster, 5 × 7 cards), clicks Print, and saves a PDF for the print shop.

1. The paper size was wrong

The app and the printout are two different layouts. The printout lives in its own container that is hidden on screen and is the only thing visible on paper:

.print-only { display: none; }

@media print {
  .app { display: none; }
  .print-only { display: block; }
}
Enter fullscreen mode Exit fullscreen mode

The paper size has to change with the user's choice, so instead of a fixed @page rule in the stylesheet I write one into a <style> element every time the printout is built:

const PAPER = { letter: [8.5, 11], a4: [8.27, 11.69], p18: [18, 24], p24: [24, 36] }; // inches

function setPage(paper, marginIn) {
  const [w, h] = PAPER[paper];
  let style = document.getElementById('page-style');
  if (!style) {
    style = document.createElement('style');
    style.id = 'page-style';
    document.head.appendChild(style);
  }
  style.textContent = `@page { size: ${w}in ${h}in; margin: ${marginIn}in; }`;
}
Enter fullscreen mode Exit fullscreen mode

Chrome and Edge pick this up, and the print dialog opens on the right size. Safari didn't in my testing (Safari 27.0.1 on macOS): it printed on whatever paper was selected in its own dialog. I couldn't find a way around that, so the app now tells Safari users which size to choose in the dialog (Paper Size, then Manage Custom Sizes) or suggests Chrome for posters. Browser sniffing is used only to decide whether to show that hint.

2. Chrome printed the date, title and URL on my poster

By default Chrome adds a header and footer: the date, the page title and the page's address. On a wedding poster that's not a good look, and you can't ask every couple to find the "Headers and footers" checkbox.

Those headers and footers are printed inside the page margin. If the page has no margin, there's nowhere to put them:

setPage(paper, 0); // no page margin, so no room for Chrome's header and footer
Enter fullscreen mode Exit fullscreen mode

The poster still needs white space around it, so the margin moves inside the content. The printout is a single block exactly one page in size, with the margin as padding:

const [w] = PAPER[paper];
poster.style.boxSizing = 'border-box';
poster.style.width = w + 'in';
poster.style.padding = marginIn + 'in';
Enter fullscreen mode Exit fullscreen mode

There's one trade-off. If a guest list is too long to fit on one page even at the smallest readable size, the chart has to flow onto a second page. In that case I give the margin back to @page, because padding on a block only shows at the top of the first page and the bottom of the last, so the pages in between would run to the edge of the paper. The header and footer come back with the margin. That only happens with very long lists on small paper.

3. In Safari, the Print button did nothing

Chrome opened the print dialog. Safari silently ignored the click.

My first version did some asynchronous work before calling print(), something like this:

// Simplified: this did nothing in Safari for me
printButton.addEventListener('click', () => {
  requestAnimationFrame(() => {
    buildPrintArea();
    window.print();
  });
});
Enter fullscreen mode Exit fullscreen mode

My guess is that Safari only honors print() while it is still handling the user's click, but I haven't found that documented. What I can tell you is what worked in my tests in Safari 27.0.1 on macOS: doing all the work synchronously and calling print() in the same handler:

printButton.addEventListener('click', () => {
  buildPrintArea(); // render the chart, size the names, write @page: all synchronous
  window.print();   // still inside the click handler
});

// Cmd+P / Ctrl+P skips the button, so rebuild right before the browser prints
window.addEventListener('beforeprint', buildPrintArea);
Enter fullscreen mode Exit fullscreen mode

That means everything buildPrintArea() needs has to be ready before the click: fonts loaded, data in memory, no fetches. In this app that's already true, because there is no server to fetch from.

4. Names didn't fit, or looked tiny

A seating poster for 40 guests and one for 200 guests need very different font sizes, and couples expect the poster to look full, not like a list in the corner. So the printout sizes itself: it finds the largest font at which every name fits on the page.

Measure at real size. The print area is display: none on screen, and you can't measure something that isn't laid out. While sizing, I show it off-screen at its real width (CSS uses 96 px per inch):

.print-only.measuring {
  display: block !important;
  position: absolute;
  left: -100000px;
  visibility: hidden;
}
Enter fullscreen mode Exit fullscreen mode

Search instead of guessing. For each column count worth trying, a binary search finds the largest font size that fits. First with every name on one line; only if that's impossible are long names allowed to wrap:

const PX_PER_IN = 96, PX_PER_PT = 96 / 72;

function fitNames(poster, cols, rows, pageHeightIn, minPt, maxPt, columnCounts) {
  const maxH = pageHeightIn * PX_PER_IN - 4;

  const fits = (px) => {
    poster.style.fontSize = px + 'px';
    if (poster.scrollHeight > maxH) return false;
    if (poster.classList.contains('nowrap')) {
      for (const row of rows) if (row.scrollWidth > row.clientWidth + 1) return false;
    }
    return true;
  };

  const search = () => {
    let best = { px: 0, cols: columnCounts[0] };
    for (const c of columnCounts) {
      cols.style.columnCount = c;
      let lo = minPt * PX_PER_PT, hi = maxPt * PX_PER_PT;
      if (!fits(lo)) continue;
      if (fits(hi)) lo = hi;
      else for (let i = 0; i < 12; i++) {
        const mid = (lo + hi) / 2;
        if (fits(mid)) lo = mid; else hi = mid;
      }
      if (lo > best.px) best = { px: lo, cols: c };
    }
    return best;
  };

  poster.classList.add('nowrap');      // every name on one line
  let best = search();
  if (!best.px) {                      // too many names: let long ones wrap
    poster.classList.remove('nowrap');
    best = search();
  }
  return best;                         // px 0 means it won't fit on one page
}
Enter fullscreen mode Exit fullscreen mode

Each paper size has its own range (much larger on a 24 × 36 poster than on Letter) and its own column counts to try. Twelve halvings is plenty of precision for a font size. On a 24 × 36 poster with fairly long test names, 40 guests came out at about 55 pt in two columns and 200 guests at about 22 pt in four.

Multi-column layouts also need a couple of break rules, or a name ends up split across two columns and a letter heading sits alone at the bottom of a column:

.row { break-inside: avoid; }
.letter-heading { break-after: avoid; } /* keep "M" with the first M name */
Enter fullscreen mode Exit fullscreen mode

5. My automated tests produced blank PDFs

I wanted tests that check the actual printed output, not just the screen. Playwright can switch a page to print media and save a real PDF at the CSS page size:

await page.evaluate(() => { window.print = () => {}; }); // don't open the dialog in tests
await page.selectOption('#paper', 'p24');
await page.click('#print');
await page.emulateMedia({ media: 'print' });
await page.pdf({ path: 'poster-24x36.pdf', preferCSSPageSize: true, printBackground: true });
Enter fullscreen mode Exit fullscreen mode

Then pdfinfo poster-24x36.pdf should report one page of 1728 × 2592 points (24 × 36 inches at 72 points per inch), and the test can check the font size the fitting step chose.

The first PDFs came out blank. I had an afterprint handler that cleared the print area to keep the DOM tidy, and generating the PDF triggered it before the content was captured. Removing the cleanup fixed it. The print area is hidden on screen anyway, so there was no reason to empty it.

If you've printed posters or labels from a web app and found other traps, I'd like to hear about them in the comments.

An AI assistant drafted this article from the app's source code and test results; I reviewed and edited it. The code samples are simplified from the app's source.

Top comments (0)