<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Paw Alarm</title>
    <description>The latest articles on DEV Community by Paw Alarm (@pawalarm).</description>
    <link>https://dev.to/pawalarm</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4165972%2F8738af21-7402-4474-a332-c833f467580e.png</url>
      <title>DEV Community: Paw Alarm</title>
      <link>https://dev.to/pawalarm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pawalarm"/>
    <language>en</language>
    <item>
      <title>Generating print-ready A4 PDFs in the browser (and the Safari bug that blanked my images)</title>
      <dc:creator>Paw Alarm</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:33:35 +0000</pubDate>
      <link>https://dev.to/pawalarm/generating-print-ready-a4-pdfs-in-the-browser-and-the-safari-bug-that-blanked-my-images-3ep9</link>
      <guid>https://dev.to/pawalarm/generating-print-ready-a4-pdfs-in-the-browser-and-the-safari-bug-that-blanked-my-images-3ep9</guid>
      <description>&lt;h1&gt;
  
  
  Building a Print-Ready Lost Pet Poster Generator in the Browser
&lt;/h1&gt;

&lt;p&gt;PawAlarm is a lost-pet alert service I’m building. When a dog or cat goes missing, PawAlarm turns the owner's alert into geo-targeted Facebook and Instagram ads shown to people around the area where the pet was last seen.&lt;/p&gt;

&lt;p&gt;One of the free tools inside PawAlarm is much simpler: a lost-pet poster generator. You enter the pet's name, upload a photo, add a description and the last-seen area, and get a print-ready A4 PDF containing a QR code. There’s no account required. I built it because when a pet goes missing, people shouldn't have to fight with Canva or Word before they can start putting posters up.&lt;/p&gt;

&lt;p&gt;There were a few constraints that made this more interesting than simply putting some HTML on a page: it needed to work well on phones, produce something that looks good from an ordinary home printer, support 9 languages across 13 locales, and generate the final PDF entirely in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;PawAlarm uses Next.js with the App Router and &lt;code&gt;next-intl&lt;/code&gt;. Locale-prefixed routes live under &lt;code&gt;app/[locale]&lt;/code&gt;, while the poster wizard itself lives in &lt;code&gt;components/start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actual poster is a React component, &lt;code&gt;poster-artwork.tsx&lt;/code&gt;. Rather than treating it like a normal responsive UI, I lay it out as a fixed A4-sized canvas in pixels. The browser ultimately has to rasterize the DOM anyway, so working in a fixed pixel coordinate system makes positioning and fitting predictable. The physical A4 dimensions only really matter at the final PDF step, where the rendered image is mapped onto a 210 × 297 mm page.&lt;/p&gt;

&lt;p&gt;The generation pipeline is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Poster form
    ↓
React poster DOM
    ↓
html-to-image canvas
    ↓
Composite canvas
    ↓
JPEG
    ↓
jsPDF
    ↓
A4 PDF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the poster itself as React/HTML was useful because the preview and final document could share essentially the same rendering logic. The difficult part was making sure that what looked correct in the browser survived the trip from DOM to canvas to PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fitting Text in 9 Languages
&lt;/h2&gt;

&lt;p&gt;A fixed-size poster and translated text are not particularly good friends.&lt;/p&gt;

&lt;p&gt;A heading that fits comfortably in English might be substantially longer in German or Finnish. Pet names are another variable I can't control. Something like &lt;code&gt;MAX&lt;/code&gt; is trivial; a name like &lt;code&gt;CHARLIE-BROWN&lt;/code&gt; can suddenly push a heading outside the space that was designed for it. Addresses are even less predictable once street names and locality information are included.&lt;/p&gt;

&lt;p&gt;The design can't simply grow vertically because the output still needs to fit exactly onto one A4 page.&lt;/p&gt;

&lt;p&gt;The solution is deliberately simple. Elements that need fitting get &lt;code&gt;data-fit&lt;/code&gt; attributes, and before generating the poster I run a fitting pass that progressively reduces their font size until their content fits.&lt;/p&gt;

&lt;p&gt;The core idea in &lt;code&gt;resolveFitSize()&lt;/code&gt; in &lt;code&gt;poster-fit.ts&lt;/code&gt; is essentially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveFitSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;startSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;minSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;startSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;minSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fontSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two slightly different cases.&lt;/p&gt;

&lt;p&gt;Some elements, such as headings and pet names, need &lt;strong&gt;width fitting&lt;/strong&gt;. They're kept on one line and reduced until &lt;code&gt;scrollWidth &amp;lt;= clientWidth&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Other elements, particularly addresses, need &lt;strong&gt;box fitting&lt;/strong&gt;. They can wrap, but the resulting text still needs to stay within both the available width and height.&lt;/p&gt;

&lt;p&gt;A good example of the problem was a last-seen location such as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Winterthurerstrasse 537, 8051 Zürich&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At the normal font size, a string like that could wrap awkwardly or push outside the allocated area. After the fitting pass, the font size is reduced just enough to preserve the layout without making every address unnecessarily small.&lt;/p&gt;

&lt;p&gt;I considered handling this entirely with CSS using &lt;code&gt;clamp()&lt;/code&gt;, responsive units or container queries. The problem is that those techniques react to the size of the container, not necessarily to whether an arbitrary piece of rendered text actually fits inside it.&lt;/p&gt;

&lt;p&gt;Here I care about the final rendered dimensions. Measuring &lt;code&gt;scrollWidth&lt;/code&gt;, &lt;code&gt;scrollHeight&lt;/code&gt;, &lt;code&gt;clientWidth&lt;/code&gt; and &lt;code&gt;clientHeight&lt;/code&gt; gives me a boring but deterministic answer: does this exact text, using this exact font and translation, fit?&lt;/p&gt;

&lt;p&gt;For a document that's about to become a PDF, that predictability is more useful than having a particularly elegant CSS solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating the QR Code
&lt;/h2&gt;

&lt;p&gt;Every finished poster includes a QR code pointing to the corresponding lost-pet case.&lt;/p&gt;

&lt;p&gt;The QR itself is generated with the &lt;code&gt;qrcode&lt;/code&gt; package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toDataURL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qrcode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;560&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The import is dynamic because QR generation isn't needed for the initial application bundle.&lt;/p&gt;

&lt;p&gt;There's also an important distinction between the preview and the downloaded poster.&lt;/p&gt;

&lt;p&gt;The preview displays a placeholder QR code. The real case URL is created immediately before the PDF is downloaded, and the QR code in the exported poster is generated from that URL.&lt;/p&gt;

&lt;p&gt;That prevents an important class of bug: generating a nice-looking poster whose QR code points to placeholder or stale content.&lt;/p&gt;

&lt;p&gt;I kept the QR code comfortably larger than the theoretical minimum rather than trying to save a few millimetres of space. On an A4 poster it ends up at roughly 30–35 mm square, which gives it plenty of margin for normal home-printer quality and for posters that aren't perfectly flat when someone scans them.&lt;/p&gt;

&lt;p&gt;I tested scanning from both iPhone and Android devices using their normal camera apps. The important test wasn't whether it scanned from a pristine PDF on a monitor, but whether it remained easy to scan after being printed on ordinary A4 paper.&lt;/p&gt;

&lt;p&gt;For error correction I use the library's normal QR generation behaviour rather than pushing the density unnecessarily high. The URL is relatively short, and keeping the resulting QR visually simple helps scanning.&lt;/p&gt;

&lt;h2&gt;
  
  
  DOM to PDF, Entirely in the Browser
&lt;/h2&gt;

&lt;p&gt;There is no server-side PDF renderer involved.&lt;/p&gt;

&lt;p&gt;For the final export, &lt;code&gt;html-to-image&lt;/code&gt; renders the poster DOM to a canvas using a pixel ratio of 3. That canvas is exported as a JPEG at &lt;code&gt;0.95&lt;/code&gt; quality and then placed into a PDF using &lt;code&gt;jsPDF&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Conceptually, the final step looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pixelRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;jsPDF&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;orientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;portrait&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;JPEG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;297&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lost-pet-poster.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advantage is that everything happens locally in the browser. There's no PDF-generation endpoint to maintain and no need to send the rendered poster to another service just to turn it into a PDF.&lt;/p&gt;

&lt;p&gt;The main compromise is that the resulting poster is rasterised. The text isn't real vector PDF text anymore, so resolution matters. Rendering at &lt;code&gt;pixelRatio: 3&lt;/code&gt; gives the raster enough detail for A4 printing without producing unnecessarily enormous files.&lt;/p&gt;

&lt;p&gt;In practice the PDFs are usually around the low single-digit megabyte range, depending mostly on the uploaded pet photo. That's larger than a carefully constructed vector PDF would be, but still perfectly reasonable for something the user downloads once and prints.&lt;/p&gt;

&lt;p&gt;More importantly, printed text, photos and the QR code remain sharp enough on a normal home printer. I didn't see much value in chasing professional prepress quality for a document whose actual destination is usually a home printer, notice board, shop window or lamppost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Safari/WebKit &lt;code&gt;foreignObject&lt;/code&gt; Bug
&lt;/h2&gt;

&lt;p&gt;This was probably the strangest problem in the implementation.&lt;/p&gt;

&lt;p&gt;The poster looked correct in Chrome. On iOS, however, generated posters could contain all of the text while the pet photo and QR code were simply blank.&lt;/p&gt;

&lt;p&gt;That immediately made the bug confusing because the DOM itself was correct. The poster looked fine on screen. It was specifically the rasterized output that was broken.&lt;/p&gt;

&lt;p&gt;The underlying problem was the way &lt;code&gt;html-to-image&lt;/code&gt; renders DOM content. Part of the process goes through an SVG &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt;. WebKit could render the HTML inside it while failing to reliably paint the raster &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;

&lt;p&gt;That meant the poster couldn't simply be snapshotted as one DOM tree.&lt;/p&gt;

&lt;p&gt;The workaround was to treat images separately.&lt;/p&gt;

&lt;p&gt;Before taking the snapshot, I measure every image that needs special handling and preserve its position, dimensions, rotation and border radius. I then generate a transparent snapshot without relying on those images being correctly rasterised by WebKit.&lt;/p&gt;

&lt;p&gt;The final canvas is built in layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;white A4 background
        ↓
"under" images
(pet photo)
        ↓
DOM snapshot
(text + artwork)
        ↓
"over" images
(QR code)
        ↓
final canvas
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant part of &lt;code&gt;elementToA4Pdf&lt;/code&gt; is conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pdfLayer&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;under&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Capture the DOM separately from the raster images.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;pixelRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transparent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLImageElement&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. White page&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Pet photo and other background images&lt;/span&gt;
&lt;span class="nf"&gt;drawImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;under&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 3. DOM/text snapshot&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 4. QR and foreground images&lt;/span&gt;
&lt;span class="nf"&gt;drawImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layer&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real implementation has more bookkeeping around scaling, transforms and clipping, but that's the important part of the workaround.&lt;/p&gt;

&lt;p&gt;The distinction between &lt;code&gt;under&lt;/code&gt; and &lt;code&gt;over&lt;/code&gt; images matters. The pet photograph belongs behind parts of the poster artwork, while the QR code needs to remain cleanly visible above it.&lt;/p&gt;

&lt;p&gt;Finding the problem took longer than implementing the eventual fix. I initially treated it like an image-loading or CORS problem because missing images in a canvas export usually point in that direction. Once it became clear that the same DOM and image URLs worked in Chromium but failed specifically during the WebKit snapshot, I could isolate the &lt;code&gt;foreignObject&lt;/code&gt; rendering path.&lt;/p&gt;

&lt;p&gt;The fix itself was fairly straightforward after that. Isolating the browser-specific rendering behaviour was the time-consuming part.&lt;/p&gt;

&lt;p&gt;I now treat iOS Safari as a separate target for this feature rather than assuming that a successful Chrome export proves the PDF pipeline works. Whenever the poster rendering changes materially, I generate an actual PDF on an iPhone and check the image, text and QR output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;There are three main test files around the poster flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;poster-fit.test.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;poster-pdf.test.ts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;poster-show-step.locales.test.tsx&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;poster-fit.test.ts&lt;/code&gt; covers the text-fitting behaviour: whether overflowing elements step down correctly, whether the minimum font size is respected, and whether width-only and box-fitting elements use the appropriate dimensions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;poster-pdf.test.ts&lt;/code&gt; focuses on the export pipeline. It verifies the expected A4 dimensions and generation options, and covers the image-layer behaviour that prevents the Safari issue from being accidentally reintroduced.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;poster-show-step.locales.test.tsx&lt;/code&gt; renders the poster flow across the supported locales. This is useful because localization isn't just a translation concern here. A translation can be semantically perfect and still break a fixed physical layout.&lt;/p&gt;

&lt;p&gt;The automated tests catch the deterministic parts, but I still use manual visual checks for the final output. The poster is tested in Chromium and Safari, including an actual iPhone, and periodically printed on A4 paper.&lt;/p&gt;

&lt;p&gt;That last check is important. Browser screenshots don't tell you whether the smallest text is comfortable to read on paper, whether the photo has enough contrast after printing, or whether someone can point their phone at the QR code from a realistic distance.&lt;/p&gt;

&lt;p&gt;For something designed to end up on paper, the printer is ultimately part of the test environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;The biggest architectural question is raster versus vector output.&lt;/p&gt;

&lt;p&gt;Generating the entire poster as an image makes browser rendering relatively straightforward, but a proper vector PDF would give sharper typography and potentially smaller files. It would also avoid depending quite so heavily on browser-specific DOM rasterisation behaviour.&lt;/p&gt;

&lt;p&gt;If I were building a substantially more complicated poster system, I'd seriously consider generating the PDF directly rather than converting HTML into an image first.&lt;/p&gt;

&lt;p&gt;That said, HTML has one major advantage: the poster is just another React component. Styling it, previewing it and iterating on the design is much faster than working with low-level PDF drawing primitives.&lt;/p&gt;

&lt;p&gt;Server-side rendering is another trade-off. A server-controlled browser or PDF renderer would give much more deterministic output across devices. But it would also introduce an entire rendering service for what is currently a completely client-side feature.&lt;/p&gt;

&lt;p&gt;For this use case, I'd probably still keep generation on the client. The Safari workaround is ugly, but the operational simplicity is worth quite a lot.&lt;/p&gt;

&lt;p&gt;The other thing I'd think about earlier is the physical environment in which the poster will be used.&lt;/p&gt;

&lt;p&gt;A lost-pet poster isn't a webpage printed onto paper. Someone might see it from several metres away, in poor light, through a shop window, or while walking past it. The pet photo, name and basic call to action matter much more than squeezing another paragraph of information onto the page.&lt;/p&gt;

&lt;p&gt;That affects accessibility too. Contrast, type size, hierarchy and QR-code placement aren't just visual design decisions when the final interface is a sheet of paper hanging outdoors.&lt;/p&gt;

&lt;p&gt;That was probably the main lesson from building this feature: generating a PDF is easy. Generating one from arbitrary user content, in multiple languages, across browsers, that still looks right after someone sticks it to a lamppost is where most of the engineering ended up.&lt;/p&gt;

&lt;p&gt;PawAlarm, the lost-pet alert service I'm building, is at &lt;a href="https://www.pawalarm.com/en" rel="noopener noreferrer"&gt;https://www.pawalarm.com/en&lt;/a&gt; – the poster generator is one free feature of it. Disclosure: the alert service itself is paid.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
