<?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: Accreditly</title>
    <description>The latest articles on DEV Community by Accreditly (@accreditly).</description>
    <link>https://dev.to/accreditly</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%2F1045142%2Fa534ea30-7b1e-4199-b7e1-87688d0761db.png</url>
      <title>DEV Community: Accreditly</title>
      <link>https://dev.to/accreditly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/accreditly"/>
    <language>en</language>
    <item>
      <title>Screenshot Every Page of a Website from Its Sitemap with One Node.js Script</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:23:08 +0000</pubDate>
      <link>https://dev.to/accreditly/screenshot-every-page-of-a-website-from-its-sitemap-with-one-nodejs-script-51ad</link>
      <guid>https://dev.to/accreditly/screenshot-every-page-of-a-website-from-its-sitemap-with-one-nodejs-script-51ad</guid>
      <description>&lt;p&gt;You're about to relaunch a site and someone asks for a screenshot of every page as it stands today. Or a client wants to sign off 140 pages of copy, or legal wants a dated record of what the pricing page said. Doing it by hand means opening each URL, waiting for the lazy images, closing the cookie banner and saving the file under a name you'll still understand next month. Two minutes a page adds up to most of a working day for a 200-page site.&lt;/p&gt;

&lt;p&gt;So I wrote one Node.js script that reads the sitemap, captures every page at desktop and mobile widths in real Chrome, and leaves a folder of named PNGs, a CSV manifest and a contact sheet. Last run: 38 full-page captures in 35 seconds, no failures.&lt;/p&gt;

&lt;p&gt;Disclosure: I run &lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt;, and the script uses its Screenshot API for the captures. This is a cross-post of &lt;a href="https://html2img.com/articles/screenshot-every-page-of-a-website/" rel="noopener noreferrer"&gt;the full guide on the HTML to Image blog&lt;/a&gt;, which is the canonical version and goes further into webhooks, costs and compliance archives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18 or later, for the built-in &lt;code&gt;fetch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An &lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; API key. The free credits are enough to try this on a handful of pages.&lt;/li&gt;
&lt;li&gt;A site with a sitemap, or a text file of URLs from a crawl.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @html2img/client
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Find the sitemap
&lt;/h2&gt;

&lt;p&gt;Most sites declare their sitemap in &lt;code&gt;robots.txt&lt;/code&gt;, so check there first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://example.com/robots.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^sitemap:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If nothing comes back, try &lt;code&gt;/sitemap.xml&lt;/code&gt;, then &lt;code&gt;/sitemap_index.xml&lt;/code&gt; (Yoast and Rank Math on WordPress), then &lt;code&gt;/wp-sitemap.xml&lt;/code&gt; (WordPress core since 5.5). Big sites use a sitemap index that points at child sitemaps, some of them gzipped. The script follows all of that, so you only ever pass the top-level URL.&lt;/p&gt;

&lt;p&gt;One caveat: a sitemap is the list the CMS chooses to publish. Orphaned landing pages and anything marked &lt;code&gt;noindex&lt;/code&gt; won't be in it. For a migration archive, cross-check against a crawl and feed the script a text file instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The script
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;capture-site.mjs&lt;/code&gt;. It's long-ish because it handles the boring parts properly: sitemap indexes, &lt;code&gt;.gz&lt;/code&gt; files, duplicates, a credit check before spending anything, four captures at a time, retries for errors that can recover, and the reports at the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// capture-site.mjs&lt;/span&gt;
&lt;span class="c1"&gt;// node capture-site.mjs https://example.com/sitemap.xml [--dry-run]&lt;/span&gt;
&lt;span class="c1"&gt;// node capture-site.mjs urls.txt&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gunzipSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:zlib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ServerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&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;SOURCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;DRY_RUN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--dry-run&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;OUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shots&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;CONCURRENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;MAX_ATTEMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// One credit per page per variant.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VARIANTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desktop&lt;/span&gt;&lt;span class="dl"&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;1440&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mobile&lt;/span&gt;&lt;span class="dl"&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;390&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// { name: 'pdf', format: 'pdf' }, // A4 document with a real text layer&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Sent with every capture.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CAPTURE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;fullpage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;css&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`#onetrust-banner-sdk, .onetrust-pc-dark-filter, #CybotCookiebotDialog,
        .qc-cmp2-container, .osano-cm-window, #cookie-law-info-bar,
        .intercom-frame, #crisp-chatbox { display: none !important; }`&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;SKIP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;pdf|jpe&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;g|png|gif|webp|svg|zip|mp4|xml&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&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;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DRY_RUN&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Every &amp;lt;loc&amp;gt; in a sitemap, following sitemap indexes and .gz files.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readSitemap&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="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;())&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;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&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;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; returned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;body&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="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x1f&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;body&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="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x8b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gunzipSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&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;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;locs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;loc&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;!&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="sr"&gt;CDATA&lt;/span&gt;&lt;span class="se"&gt;\[)?(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;?)(?:\]\]&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)?\s&lt;/span&gt;&lt;span class="sr"&gt;*&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;loc&amp;gt;/g&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&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;m&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;m&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;amp|apos|quot|lt|gt&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&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="na"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;apos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})[&lt;/span&gt;&lt;span class="nx"&gt;e&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="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;sitemapindex&lt;/span&gt;&lt;span class="se"&gt;[\s&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xml&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;locs&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;children&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locs&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;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;readSitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seen&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;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Same site only, no files, no duplicates.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bare&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&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;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^www&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onlyHost&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;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &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;raw&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;urls&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;u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// header rows, blanks&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;onlyHost&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;bare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;bare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onlyHost&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&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;SKIP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// "/blog/launch-notes/" -&amp;gt; "blog__launch-notes"&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;multiSite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+|&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+$/g&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;part&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^-|-$/g&lt;/span&gt;&lt;span class="p"&gt;,&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__&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="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;150&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home&lt;/span&gt;&lt;span class="dl"&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;multiSite&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;bare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&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;slug&lt;/span&gt;&lt;span class="p"&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;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;creditsLeft&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;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.html2img.com/api/me&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="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;},&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Key check failed with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;credits_remaining&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;retryable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TimeoutError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ServerError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ConnectionError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page_load_timeout&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. One capture, retried on errors that are worth a second attempt.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variant&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;variant&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;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&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;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;for &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;attempt&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="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&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;shot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;CAPTURE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shot&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shot&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&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="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`download returned &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;status&lt;/span&gt;&lt;span class="p"&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&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;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&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;ext&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&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;OUT&lt;/span&gt;&lt;span class="p"&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&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;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&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;arrayBuffer&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&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;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;retryable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS&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;row&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 4. A fixed number of captures in flight at once.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&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;rows&lt;/span&gt; &lt;span class="o"&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;stopped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;()&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;stopped&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="k"&gt;try&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;row&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;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&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;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;stopped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Stopping early: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&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;stopped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONCURRENCY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;lane&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;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 5. A manifest for spreadsheets and an index.html for people.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;writeReports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&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;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;variant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image_url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&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;cell&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;""&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;csv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rows&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;r&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;cols&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;c&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;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&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;line&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;line&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;cell&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&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;OUT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/manifest.csv`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csv&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;esc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&amp;lt;&amp;gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;amp;#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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="s2"&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;cards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.png&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;a href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;&amp;lt;img src="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;" loading="lazy"&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; · &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;&amp;lt;/a&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&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;OUT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/index.html`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;title&amp;gt;Site capture&amp;lt;/title&amp;gt;
&amp;lt;style&amp;gt;body{font:14px system-ui;margin:24px;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:20px}
a{color:#334155;text-decoration:none}img{width:100%;height:320px;object-fit:cover;object-position:top;border:1px solid #e2e6ec;border-radius:8px}
span{display:block;margin-top:6px;word-break:break-all}&amp;lt;/style&amp;gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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;fromSitemap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SOURCE&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;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fromSitemap&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readSitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SOURCE&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SOURCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&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;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fromSitemap&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SOURCE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;multiSite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pages&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;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;bare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&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="mi"&gt;1&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;DRY_RUN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;page&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;VARIANTS&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;variant&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="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variant&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;credits&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;creditsLeft&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&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;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; pages x &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VARIANTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; variants = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; credits. Balance: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&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;credits&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not enough credits for the whole run. Trim the list or top up first.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;VARIANTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&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;OUT&lt;/span&gt;&lt;span class="p"&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;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;rows&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;runAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeReports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&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;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&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;skipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Saved &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, failed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, skipped &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Open &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;OUT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/index.html`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;css&lt;/code&gt; string is doing quiet but important work. The renderer arrives as a first-time visitor with no cookies, so every page shows its consent banner. Injecting &lt;code&gt;display: none !important&lt;/code&gt; for the common consent providers and chat widgets takes them out before the shot. If a site uses something else, find the banner's ID in dev tools and add it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Dry run first, then capture
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; prints the URLs the script would capture. It doesn't need a key and spends nothing, so use it to filter out tag archives, paginated listings and anything else you don't need a picture of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node capture-site.mjs https://html2img.com/sitemap.xml &lt;span class="nt"&gt;--dry-run&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'/tools/'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; urls.txt
node capture-site.mjs urls.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the real run I mentioned at the top, against the free tools section of my own site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;19 pages x 2 variants = 38 credits. Balance: 100.
1/38 ok desktop https://html2img.com/docs/mcp/tools/
2/38 ok mobile https://html2img.com/docs/mcp/tools/
3/38 ok desktop https://html2img.com/tools/
4/38 ok mobile https://html2img.com/tools/
...
38/38 ok desktop https://html2img.com/tools/youtube-thumbnail/
Saved 38, failed 0, skipped 0. Open shots/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spot the first line. My lazy &lt;code&gt;grep&lt;/code&gt; also matched &lt;code&gt;/docs/mcp/tools/&lt;/code&gt;, which I never meant to include. That's exactly why the dry run exists: &lt;code&gt;grep '^https://html2img.com/tools/'&lt;/code&gt; would have kept it out.&lt;/p&gt;

&lt;p&gt;The desktop captures came back 1,440 pixels wide and between 3,737 and 10,016 pixels tall. Here's the &lt;code&gt;index.html&lt;/code&gt; the run produced, each card cropped to the top of its page:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa9ozucxa7mtvn5nl2ss.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa9ozucxa7mtvn5nl2ss.png" alt="The index.html contact sheet from a real run against html2img.com: desktop and mobile captures of the tools index, the Open Graph Image Generator, the Invoice Image Generator and the Website Screenshot Tool, each captioned with its variant and URL" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the script does when things go wrong
&lt;/h2&gt;

&lt;p&gt;Not every page cooperates, so the retry logic only retries what a second attempt can fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;504 timeouts, 500s, slow-loading sites and dropped connections&lt;/strong&gt; get two more attempts, 3 then 6 seconds apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead links, SSL failures and sites that block automated visitors&lt;/strong&gt; (&lt;code&gt;url_not_found&lt;/code&gt;, &lt;code&gt;url_unreachable&lt;/code&gt;, &lt;code&gt;connection_refused&lt;/code&gt;) are logged in &lt;code&gt;manifest.csv&lt;/code&gt; and skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running out of credits&lt;/strong&gt; stops every lane, still writes the reports, and tells you how many captures were skipped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To re-run only the failures, pull them out of the manifest and run them in a fresh folder so the first run's reports stay intact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'","'&lt;/span&gt; &lt;span class="s1"&gt;'$3 == "failed" { print substr($1, 2) }'&lt;/span&gt; shots/manifest.csv | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; retry.txt
&lt;span class="nb"&gt;mkdir &lt;/span&gt;retry &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;retry &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node ../capture-site.mjs ../retry.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep &lt;code&gt;CONCURRENCY&lt;/code&gt; low for sites you don't own. Every capture is a real page load against somebody's production server.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few limits worth planning around
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public pages only.&lt;/strong&gt; The renderer has no cookies or headers, so anything behind a login captures as the sign-in page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infinite scroll has no bottom.&lt;/strong&gt; Cap the feed with injected CSS such as &lt;code&gt;main.feed { max-height: 12000px; overflow: hidden; }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed accordions stay closed.&lt;/strong&gt; Nobody clicks during a capture, so open hidden panels with CSS if they matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One credit per page per width.&lt;/strong&gt; Desktop plus mobile doubles the cost, and the PDF variant adds a third.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where I'd use this
&lt;/h2&gt;

&lt;p&gt;Before a migration, so you have the old site on disk the day the DNS switches. For client sign-off, where one contact sheet beats 200 links. And for compliance, where the commented-out &lt;code&gt;pdf&lt;/code&gt; variant gives you searchable A4 documents in a dated folder.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://html2img.com/articles/screenshot-every-page-of-a-website/" rel="noopener noreferrer"&gt;full guide on the HTML to Image blog&lt;/a&gt; also covers handing long captures to a webhook instead of waiting on them, a bash version for short URL lists, and what a full-site capture costs at different sizes.&lt;/p&gt;

&lt;p&gt;How are you archiving sites before a relaunch at the moment? Browser extension, Screaming Frog, something homegrown? Let me know in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>node</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Tested html2canvas, html-to-image and modern-screenshot on the Same Card</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Fri, 09 Oct 2026 20:10:14 +0000</pubDate>
      <link>https://dev.to/accreditly/i-tested-html2canvas-html-to-image-and-modern-screenshot-on-the-same-card-4424</link>
      <guid>https://dev.to/accreditly/i-tested-html2canvas-html-to-image-and-modern-screenshot-on-the-same-card-4424</guid>
      <description>&lt;p&gt;You need a "download as image" button, a shareable stats card, or a PNG of a chart, and the first three npm results are html2canvas, html-to-image and modern-screenshot. Between them they were downloaded about 120 million times last month. They do not work the same way, and the differences only show up once your component uses a web font, a photo from another domain, or any CSS written in the last five years.&lt;/p&gt;

&lt;p&gt;So I ran all three against the same card, in Chromium and in WebKit (the engine behind Safari), with each library's defaults, and rendered the same markup on a server for comparison. Every image below is real output.&lt;/p&gt;

&lt;p&gt;One disclosure before the results: I run &lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt;, a rendering API, which has no connection to the html-to-image npm package despite the name. The API only turns up at the end, as the server-side comparison, and the three libraries are judged on their own output.&lt;/p&gt;

&lt;p&gt;This is a cross-post of &lt;a href="https://html2img.com/articles/html2canvas-vs-html-to-image/" rel="noopener noreferrer"&gt;the full write-up on the HTML to Image blog&lt;/a&gt;, which is the canonical version if you want to link to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test card
&lt;/h2&gt;

&lt;p&gt;The component is a 760×420 "year in review" card of the kind apps let you download and share. Each part of it exercises something a capture library has to get right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fonts:&lt;/strong&gt; Fraunces for the headline and Manrope for everything else, loaded from Google Fonts with a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cover photo from another origin&lt;/strong&gt;, which is how most apps serve user and product images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A frosted badge&lt;/strong&gt; over the photo, using &lt;code&gt;backdrop-filter: blur()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gradient headline&lt;/strong&gt;, using &lt;code&gt;background-clip: text&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A donut chart&lt;/strong&gt; drawn with &lt;code&gt;conic-gradient&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An inline SVG sparkline.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rotated stamp&lt;/strong&gt; with &lt;code&gt;filter: drop-shadow()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the card as Chrome renders it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flwj6qe5b7mv0lixibj9b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flwj6qe5b7mv0lixibj9b.png" alt="The test card as the browser renders it: a cover photo of a latte beside a stack of receipts with a frosted 2026 IN REVIEW pill in the corner, the headline Your year in coffee in a serif with an orange to purple gradient, the line Northgate Coffee · member since 2022, an orange donut chart reading 68%, the number 412 labelled cups this year, a purple sparkline and a green TOP 5% stamp tilted with a soft green glow" width="799" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The capture code is each library's documented one-liner, run on the same node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;html2canvas&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html2canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toPng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html-to-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;domToPng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;modern-screenshot&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;node&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frame&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;fromHtml2canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;html2canvas&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;useCORS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="s1"&gt;image/png&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;fromHtmlToImage&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;toPng&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fromModernScreenshot&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;domToPng&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The versions tested were html2canvas 1.4.1, which is still its latest release and dates from January 2022, html-to-image 1.11.13 from February 2025, and modern-screenshot 4.7.0 from April 2026. The browsers were Playwright's Chromium 153 and WebKit 26.6 builds, at a device pixel ratio of 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to turn the DOM into pixels
&lt;/h2&gt;

&lt;p&gt;The results make sense once you know there are only two techniques here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;html2canvas repaints the page itself.&lt;/strong&gt; It walks the DOM, reads each element's computed styles, and draws every box, border, gradient and line of text onto a canvas with its own code. Layout comes from the browser, but painting does not, so any CSS feature html2canvas has not implemented is skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;html-to-image and modern-screenshot let the browser paint.&lt;/strong&gt; They clone the node, copy the computed styles onto the clone, embed every font and image as a data URL, and wrap the result in an SVG &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt;. The browser then draws that SVG onto a canvas with its real rendering engine, so modern CSS comes through. The catch is the embedding step: every font file and image has to be readable by JavaScript, which puts CORS in charge of what ends up in your PNG.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results in Chromium
&lt;/h2&gt;

&lt;p&gt;With the fonts fixed (more on that in a moment), this is what each library produced, next to the same markup rendered by Chrome on a server:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0btdteqdxxvmj0bepwcj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0btdteqdxxvmj0bepwcj.png" alt="Four captures of the test card in Chromium. html2canvas 1.4.1 with useCORS true: the photo is present but the headline is a solid orange to purple bar, the donut is missing leaving only the 68% label, the stamp has no glow and the badge is not frosted, captioned Gradient text, conic-gradient, drop-shadow and backdrop blur lost. html-to-image 1.11.13 and modern-screenshot 4.7.0: both match the browser render, captioned Matches Chrome, once the font link has crossorigin. Server render through the HTML to Image API: identical to the browser, captioned Same markup, rendered by Chrome on a server" width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;html2canvas lost four of the seven features. The headline came out as a solid gradient bar with no text, because &lt;code&gt;background-clip: text&lt;/code&gt; is not implemented, so it painted the background and never cut the letters out of it. The donut vanished with &lt;code&gt;conic-gradient&lt;/code&gt;. The stamp lost its glow because &lt;code&gt;filter&lt;/code&gt; is ignored, and the badge lost its frosting because &lt;code&gt;backdrop-filter&lt;/code&gt; is too. It is also the only library that skips cross-origin images by default: without &lt;code&gt;useCORS: true&lt;/code&gt;, the cover photo is simply left blank.&lt;/p&gt;

&lt;p&gt;It was fast, though. Without the photo a capture took about 100 ms; with it, 0.7 to 0.9 seconds.&lt;/p&gt;

&lt;p&gt;html-to-image and modern-screenshot both matched the on-screen render. html-to-image's first capture took 1.3 seconds while it fetched and inlined the fonts and the photo, and a second capture on the same page took 52 ms because those were cached. modern-screenshot took 0.6 to 0.7 seconds every time.&lt;/p&gt;

&lt;p&gt;That is the good news. The next three sections are what it took to get there, and what happened when the conditions were less friendly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: web fonts from a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag
&lt;/h2&gt;

&lt;p&gt;On the first run, the two foreignObject libraries produced a card in the wrong fonts. The badge, the "cups this year" label and the stamp all wrapped onto two lines, because the fallback font was wider than Manrope:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshm8cpu1b9a90ubqh24k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshm8cpu1b9a90ubqh24k.png" alt="Two html-to-image captures in Chromium. With a plain Google Fonts link tag, the card renders in fallback fonts and the badge, the cups this year label and the TOP 5% stamp all wrap onto two lines, captioned SecurityError reading cssRules: fallback fonts, labels wrap. With crossorigin anonymous added to the link, the fonts are embedded and the card matches the page, captioned Fonts embedded, matches the page" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To embed a font, these libraries need the &lt;code&gt;@font-face&lt;/code&gt; rules, and a script cannot read the rules of a stylesheet from another origin unless the stylesheet was fetched in CORS mode. html-to-image says so in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error while reading CSS rules from https://fonts.googleapis.com/css2?family=Fraunces...
SecurityError: Failed to read the 'cssRules' property from 'CSSStyleSheet': Cannot access rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;modern-screenshot hit the same wall without logging anything. Google Fonts sends &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;, so the fix is one attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt;
  &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,700&amp;amp;family=Manrope:wght@500;700;800&amp;amp;display=swap"&lt;/span&gt;
  &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt;
  &lt;span class="na"&gt;crossorigin=&lt;/span&gt;&lt;span class="s"&gt;"anonymous"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Self-hosting the font files on your own origin works too. html2canvas is not affected by this one, because it draws text onto the canvas with whatever fonts the page has already loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the first capture in Safari's engine
&lt;/h2&gt;

&lt;p&gt;WebKit is where the libraries separated. html-to-image's first &lt;code&gt;toPng()&lt;/code&gt; call returned the card without its cover photo, and a second call on the same page, a moment later, included it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4x2fe2a6x6xmy3gwjdv2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4x2fe2a6x6xmy3gwjdv2.png" alt="Two html-to-image captures in WebKit. The first toPng call shows the card with an empty white area where the cover photo should be, captioned Cover photo missing. The second call on the same page shows the photo and the frosted badge, captioned Cover photo present" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It happened in both test runs, with and without the font fix, and nothing in the markup changed between the two calls. modern-screenshot was correct on the first call in WebKit every time. html2canvas behaved the same in both browsers, which in this case means the same four missing features.&lt;/p&gt;

&lt;p&gt;If you stay on html-to-image, capturing twice and keeping the second result worked in every WebKit run here. It also doubles the work of every capture in Safari.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: images from a host without CORS headers
&lt;/h2&gt;

&lt;p&gt;The cover photo in the runs above came from a host that sends &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;. Plenty do not: an S3 bucket only sends it once someone adds a CORS configuration, and many image hosts never do. So I moved the same photo to a host that sends no CORS headers and ran everything again:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hlkaq1nabsl7ekg87dj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hlkaq1nabsl7ekg87dj.png" alt="Four captures of the card with the cover photo served from a host without CORS headers, in Chromium. html2canvas with useCORS true: the photo area is blank and the headline is still a gradient bar, captioned Photo dropped, no error. html-to-image: no image at all, shown as a panel reading toPng() rejected, captioned Promise rejected with a bare error Event. modern-screenshot: the card renders correctly except the photo area is blank, captioned Photo swapped for a transparent pixel, no error. Server render through the HTML to Image API: the full card with the photo, captioned CORS does not apply: the photo loads like any page image" width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each library failed differently, and only one of them told you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;html2canvas&lt;/strong&gt; dropped the photo, with or without &lt;code&gt;useCORS&lt;/code&gt;, and resolved normally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;html-to-image&lt;/strong&gt; rejected the whole capture. The rejection was a DOM &lt;code&gt;Event&lt;/code&gt; with the type &lt;code&gt;error&lt;/code&gt;, not an &lt;code&gt;Error&lt;/code&gt;, so &lt;code&gt;err.message&lt;/code&gt; is undefined and a typical logger records nothing useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;modern-screenshot&lt;/strong&gt; replaced the photo with its default placeholder, a transparent 1×1 GIF, and resolved normally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wherever you capture client-side, wrap the call so a failure says what it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureCard&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toPng&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A failed image load rejects with a DOM Event, not an Error.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Event&lt;/span&gt; &lt;span class="p"&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;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; event while loading an image`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Card capture failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both foreignObject libraries can also put a stand-in in place of a broken image: &lt;code&gt;imagePlaceholder&lt;/code&gt; in html-to-image, and &lt;code&gt;fetch.placeholderImage&lt;/code&gt; in modern-screenshot. That keeps the capture from failing, but the photo is still missing. The real fixes are on the server: add CORS headers to the image host, proxy images through your own origin, or render somewhere CORS does not apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard
&lt;/h2&gt;

&lt;p&gt;Defaults, the same card, both browsers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;html2canvas 1.4.1&lt;/th&gt;
&lt;th&gt;html-to-image 1.11.13&lt;/th&gt;
&lt;th&gt;modern-screenshot 4.7.0&lt;/th&gt;
&lt;th&gt;Server render&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web fonts from a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;crossorigin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;crossorigin&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-origin image, host sends CORS&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;useCORS: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-origin image, no CORS headers&lt;/td&gt;
&lt;td&gt;Dropped silently&lt;/td&gt;
&lt;td&gt;Capture rejects&lt;/td&gt;
&lt;td&gt;Transparent pixel&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;background-clip: text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Solid bar&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;conic-gradient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;filter: drop-shadow()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backdrop-filter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First call in WebKit&lt;/td&gt;
&lt;td&gt;Same as later calls&lt;/td&gt;
&lt;td&gt;Photo missing&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capture time, Chromium, with photo&lt;/td&gt;
&lt;td&gt;0.7 to 0.9 s&lt;/td&gt;
&lt;td&gt;1.3 s, then 0.05 s&lt;/td&gt;
&lt;td&gt;0.6 to 0.7 s&lt;/td&gt;
&lt;td&gt;About 2 s including network&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are choosing a client-side library today, modern-screenshot was the most dependable of the three in this test. html-to-image is close behind once you deal with the font link and the first Safari capture. html2canvas has not had a release since January 2022 and only suits components built from flat colours, borders and plain text.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the browser is the wrong place to render
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the image should come from the visitor's browser. That is the right call for a download button: the user is looking at the card, the libraries are free, and nothing leaves the device.&lt;/p&gt;

&lt;p&gt;It is the wrong call when the output has to be the same for everyone. The PNG a client-side library produces depends on the visitor's browser, their installed fonts, and whether every image host on the page sends CORS headers. Server-side, all three of those stop being variables. Render on a server when the image is a share card or an Open Graph image, goes &lt;a href="https://html2img.com/articles/images-in-email-render-everywhere/" rel="noopener noreferrer"&gt;into an email&lt;/a&gt;, is generated without anyone's browser open (a cron job, a webhook, &lt;a href="https://html2img.com/articles/how-to-generate-signed-digital-certificates-at-scale/" rel="noopener noreferrer"&gt;a batch of certificates&lt;/a&gt;), or uses images from hosts you do not control.&lt;/p&gt;

&lt;p&gt;The server-side version of the card is the same HTML and CSS sent to an API. In Node, with the official client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cardHtml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the card's markup with its &amp;lt;style&amp;gt; and font &amp;lt;link&amp;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;808&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="mi"&gt;468&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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="c1"&gt;// hosted PNG, 2x by default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the card is a React component, &lt;code&gt;renderToStaticMarkup&lt;/code&gt; from &lt;code&gt;react-dom/server&lt;/code&gt; gives you &lt;code&gt;cardHtml&lt;/code&gt; from the same props the page uses, so the shared image and the on-screen card cannot drift apart. And if the thing you want is a component on a live page, the &lt;a href="https://html2img.com/screenshot-api/" rel="noopener noreferrer"&gt;Screenshot API&lt;/a&gt; can &lt;a href="https://html2img.com/articles/screenshot-single-element-from-url/" rel="noopener noreferrer"&gt;capture a single element from a URL&lt;/a&gt; by its CSS selector, which is the server-side equivalent of passing a DOM node to one of these libraries.&lt;/p&gt;

&lt;p&gt;The quickest way to see how one of your own components behaves is to paste its markup into the free &lt;a href="https://html2img.com/tools/html-to-image/" rel="noopener noreferrer"&gt;HTML to Image Converter&lt;/a&gt; and compare the result with what your library of choice gives you. If your cards end up as link previews, &lt;a href="https://html2img.com/articles/satori-css-limits/" rel="noopener noreferrer"&gt;Satori's CSS limits&lt;/a&gt; are worth knowing too: it is another engine that reimplements CSS instead of using a browser, with a similar list of gaps.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://html2img.com/articles/html2canvas-vs-html-to-image/" rel="noopener noreferrer"&gt;full write-up lives on the HTML to Image blog&lt;/a&gt;, with the comparison images at full size.&lt;/p&gt;

&lt;p&gt;Which of the three are you running in production, and has Safari's first capture caught you out yet? Share your experience in the comments below.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>css</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Invoice PDFs from one API call: what I sent, what came back, and how I checked it was a real PDF</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:46:44 +0000</pubDate>
      <link>https://dev.to/accreditly/invoice-pdfs-from-one-api-call-what-i-sent-what-came-back-and-how-i-checked-it-was-a-real-pdf-5f9j</link>
      <guid>https://dev.to/accreditly/invoice-pdfs-from-one-api-call-what-i-sent-what-came-back-and-how-i-checked-it-was-a-real-pdf-5f9j</guid>
      <description>&lt;p&gt;Every billing system I have worked on has had the same weak spot. The data model is fine, the totals are right, the emails go out. Then someone in accounts opens the attached invoice, tries to copy the invoice number into their ledger, and finds it is a picture.&lt;/p&gt;

&lt;p&gt;That happens because generating the file is the part nobody wants to own. You either bolt on a PDF library that fights your CSS, or you run headless Chrome in production and discover what a month-end batch does to memory. I have done both. This time I wanted the invoice PDF to be one HTTP call from a queued job, and I wanted to prove the result was a real PDF before a customer saw it.&lt;/p&gt;

&lt;p&gt;I wrote up the full setup, including the receipt variant and the Laravel job, in &lt;a href="https://html2img.com/articles/invoice-pdf-api/" rel="noopener noreferrer"&gt;Invoice PDF API: Generate Invoice and Receipt PDFs from HTML&lt;/a&gt; on html2img.com. This post is the shorter, first-person version: what I sent, what came back, and the checks I ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An HTML to Image API key (the free tier is enough to follow along)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt;, and &lt;code&gt;poppler-utils&lt;/code&gt; for &lt;code&gt;pdfinfo&lt;/code&gt;, &lt;code&gt;pdffonts&lt;/code&gt; and &lt;code&gt;pdftotext&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Node 18+ if you want to run the receipt example&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: send the invoice as JSON and ask for a PDF
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://html2img.com/templates/invoice-image" rel="noopener noreferrer"&gt;invoice image template&lt;/a&gt; takes the invoice as plain JSON. There is no HTML to write. The trick I did not know until recently is that every template accepts a &lt;code&gt;format&lt;/code&gt; key, and setting it to &lt;code&gt;pdf&lt;/code&gt; swaps the PNG for an A4 document.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://app.html2img.com/api/v1/templates/invoice-image &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: &lt;/span&gt;&lt;span class="nv"&gt;$HTML2IMG_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "invoice_number": "INV-2026-0912",
    "issue_date": "16 Sep 2026",
    "due_date": "16 Oct 2026",
    "business_name": "Fieldgate Studio Ltd",
    "business_address": "4 Watergate Row\nChester CH1 2LE\nUnited Kingdom",
    "client_name": "Northgate Coffee Ltd",
    "client_address": "18 Bold Street\nLiverpool L1 4DS\nUnited Kingdom",
    "items": [
      {"description": "Online ordering system, phase 2", "quantity": "1", "unit_price": "$6,400.00", "amount": "$6,400.00"},
      {"description": "Menu photography (half day)", "quantity": "2", "unit_price": "$550.00", "amount": "$1,100.00"},
      {"description": "Hosting and monitoring, Q4", "quantity": "3", "unit_price": "$120.00", "amount": "$360.00"},
      {"description": "Support retainer (hours)", "quantity": "8", "unit_price": "$95.00", "amount": "$760.00"}
    ],
    "subtotal": "$8,620.00",
    "tax_label": "VAT (20%)",
    "tax_amount": "$1,724.00",
    "total": "$10,344.00",
    "notes": "Payment due within 30 days by bank transfer.",
    "format": "pdf"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that every amount is a string. The template renders what you give it and does no arithmetic, which I think is the right call. My app already knows how to format money for the customer; the last thing I want is an API rounding differently to my ledger.&lt;/p&gt;

&lt;p&gt;The response is a JSON envelope with a &lt;code&gt;url&lt;/code&gt; that ends in &lt;code&gt;.pdf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"968de823-85ed-4e79-904d-96168ab85240"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invoice-image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"credits_remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.html2img.com/image-1789562462319-296970.pdf"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the same request rendered as a PNG so you can see the layout. &lt;a href="https://i.html2img.com/image-1789562462319-296970.pdf" rel="noopener noreferrer"&gt;The actual PDF is here&lt;/a&gt; if you want to open it and highlight the text.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rps1ckrjwlwq6dpt7yx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rps1ckrjwlwq6dpt7yx.png" alt="Rendered invoice from Fieldgate Studio Ltd to Northgate Coffee Ltd with four line items, VAT and a total of $10,344.00" width="800" height="1132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: check it is a document, not a screenshot
&lt;/h2&gt;

&lt;p&gt;This is the step I would not skip. Plenty of "HTML to PDF" services hand you a PNG inside a PDF wrapper, and you only find out when a customer cannot search it. Three commands settle it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s2"&gt;"https://i.html2img.com/image-1789562462319-296970.pdf"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; invoice.pdf

pdfinfo invoice.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Pages|Page size|Producer"&lt;/span&gt;
pdffonts invoice.pdf
pdftotext &lt;span class="nt"&gt;-layout&lt;/span&gt; invoice.pdf - | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I got back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Producer:  Skia/PDF m131
Pages:     1
Page size: 595.92 x 841.92 pts (A4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pdffonts&lt;/code&gt; listed the template's Open Sans weights as embedded, subsetted TrueType. &lt;code&gt;pdftotext&lt;/code&gt; printed the parties, every line item, the VAT row and the total, in reading order. So it is a Chromium-rendered vector PDF with a proper text layer, and the number in accounts can copy is actually copyable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the receipt, twice from one function
&lt;/h2&gt;

&lt;p&gt;Receipts are the same idea with a smaller payload, and in practice I render them twice: a PNG for the confirmation email body and a PDF for the customer to file for expenses. One function, one argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.html2img.com/api/v1/templates/receipt-image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;business_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Northgate Coffee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;order_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&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;l&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountFormatted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="na"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtotalFormatted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;shipping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shippingFormatted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;tax_amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxFormatted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalFormatted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;thank_you_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Thanks for your order. Keep this receipt for your records.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;format&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Receipt render failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pngUrl&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;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&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;pdfUrl&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;renderReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjr3hy30c0mmqb1tvrtj2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjr3hy30c0mmqb1tvrtj2.png" alt="Rendered receipt for order NG-20481: three items, subtotal, shipping, tax and a total of $26.40" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two renders is two credits. That is still less than the cost of one browser process sitting idle on a server all month, which is what it replaced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: when the template is not your brand, send your own HTML
&lt;/h2&gt;

&lt;p&gt;The template covers the common case. When it does not, the &lt;code&gt;/api/html&lt;/code&gt; endpoint takes your own markup with the same &lt;code&gt;format&lt;/code&gt; key. From Laravel that is a Blade view and one &lt;code&gt;Http&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoices.pdf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'invoice'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-API-Key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.html2img.key'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://app.html2img.com/api/html'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'html'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pdf'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things I learned about the stylesheet, because a PDF paginates and an image does not:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The PDF uses your &lt;strong&gt;screen&lt;/strong&gt; CSS. &lt;code&gt;@media print&lt;/code&gt; rules are not applied. Everything the document needs goes in the normal styles.&lt;/li&gt;
&lt;li&gt;Pages are A4 portrait and content reflows to the page width. Design the invoice to flow, not to a fixed 1240px canvas.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;break-inside: avoid&lt;/code&gt; on &lt;code&gt;tr&lt;/code&gt; and on the totals block works. &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; does not repeat on page two, so if your invoices run long, repeat the header row yourself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To check the pagination under load I pushed a 34-line invoice through. It came back as &lt;a href="https://i.html2img.com/image-1789562522446-692802.pdf" rel="noopener noreferrer"&gt;two clean A4 pages&lt;/a&gt; with no row split across the break and the totals kept together at the top of page two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: put it in a queued job and keep the bytes
&lt;/h2&gt;

&lt;p&gt;The one design rule I would push on anyone doing this: do not return the CDN URL to the customer. Render in a queued job when the invoice is finalised, download the PDF into your own storage keyed by invoice number, and attach from there. Free-tier CDN files expire after seven days, and on any plan the invoice needs to still exist when an auditor asks in five years.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RenderInvoicePdf&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ShouldQueue&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$tries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;withHeaders&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'X-API-Key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.html2img.key'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://app.html2img.com/api/v1/templates/invoice-image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toTemplatePayload&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="s1"&gt;'format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'pdf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"invoices/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Http&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'pdf_path'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nc"&gt;Mail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;client_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvoiceIssued&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;invoice&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are generating thousands at month end, pass a &lt;code&gt;webhook_url&lt;/code&gt; in the request instead and let the callback drive the storage step rather than holding a worker on each render.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;p&gt;The invoice PDF was never the hard part; owning a renderer was. One extra key on a request I was already making, three commands to prove the output is real, and a job that keeps the bytes. The longer version with the receipt reference, the page-break stylesheet and the full Laravel setup is in &lt;a href="https://html2img.com/articles/invoice-pdf-api/" rel="noopener noreferrer"&gt;the html2img.com article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;How are you generating invoices today? If you are still on wkhtmltopdf or dompdf, I would be interested to hear what is keeping you there. Drop it in the comments.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>laravel</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Turn a JSON File of Testimonials into Quote Card Images with One Node.js Script</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:05:10 +0000</pubDate>
      <link>https://dev.to/accreditly/turn-a-json-file-of-testimonials-into-quote-card-images-with-one-nodejs-script-hfh</link>
      <guid>https://dev.to/accreditly/turn-a-json-file-of-testimonials-into-quote-card-images-with-one-nodejs-script-hfh</guid>
      <description>&lt;p&gt;You have a folder of customer testimonials, or a spreadsheet of pull quotes, or a transcript with the best lines highlighted. What you do not have is forty quote card images, because someone has to open Figma forty times to make them.&lt;/p&gt;

&lt;p&gt;This tutorial turns a JSON file of quotes into a folder of branded PNGs with one Node.js script and an HTML to Image API. If you want the longer version, with a custom-HTML layout and the reasoning behind the design choices, take a look at the full article on &lt;a href="https://html2img.com/articles/quote-card-api/" rel="noopener noreferrer"&gt;generating quote cards from an API&lt;/a&gt;. This post is the short, runnable path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18 or later (we use the built-in &lt;code&gt;fetch&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; API key. Sign-up takes a minute and the free tier is enough to run everything here.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;testimonials.json&lt;/code&gt; file. Any shape works as long as each entry has the quote text and who said it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the one I'm using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"We replaced a designer-in-the-loop process with one API call. The cards went out the same afternoon the quotes came in, and nobody could tell they were not hand made."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Priya Raman"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Head of Product, Fieldline"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Onboarding used to take our team a week. It now takes an afternoon."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tomas Vale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Operations Lead, Harbourline"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Render one card by hand
&lt;/h2&gt;

&lt;p&gt;Before writing the loop, make one request so you know what comes back. The quote card template takes the quote and the attribution as JSON and returns a hosted PNG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://app.html2img.com/api/v1/templates/quote-card &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: &lt;/span&gt;&lt;span class="nv"&gt;$HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "quote": "We replaced a designer-in-the-loop process with one API call. The cards went out the same afternoon the quotes came in, and nobody could tell they were not hand made.",
    "attribution_name": "Priya Raman",
    "attribution_role": "Head of Product, Fieldline",
    "brand_name": "Fieldline",
    "background_color": "#FAF7F0",
    "accent_color": "#2563EB"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quote-card"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"credits_remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.html2img.com/image-1789228709438-533456.png"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the image at that URL looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qxzvajkim0blvkpe86q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qxzvajkim0blvkpe86q.png" alt="A square off-white quote card with a 30-word testimonial in large dark type, a blue rule, the attribution Priya Raman, Head of Product, Fieldline, and the brand name FIELDLINE at the foot" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things to notice. The type has been sized to the quote automatically, so a ten-word quote comes back bigger rather than sitting in a sea of empty space. And the single &lt;code&gt;accent_color&lt;/code&gt; value is the only branding you have to think about; it shows up as the rule and the brand name and nowhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Write the loop
&lt;/h2&gt;

&lt;p&gt;Now the script. It reads the JSON, renders one card per entry, and writes the resulting URL back onto each record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// make-cards.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&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;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&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;testimonials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;testimonials.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;testimonials&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.html2img.com/api/v1/templates/quote-card&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attribution_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attribution_role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;brand_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fieldline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;background_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#FAF7F0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;accent_color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#2563EB&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&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;data&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="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits_remaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; credits left)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;testimonials.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;testimonials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_key node make-cards.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important design choice is writing &lt;code&gt;card_url&lt;/code&gt; back into the source file. That makes &lt;code&gt;testimonials.json&lt;/code&gt; the single source of truth: the marketing site reads the URL for the testimonial wall, the social scheduler reads it for the post, and re-running the script only renders entries that are missing a URL if you add a one-line &lt;code&gt;if (t.card_url) continue;&lt;/code&gt; at the top of the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Download the files (optional)
&lt;/h2&gt;

&lt;p&gt;The hosted URL is usually all you need, because it goes straight into a CMS field or a scheduling tool. If you want local files as well, for a pitch deck or a case study PDF, add a download after each render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createWriteStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:stream/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;download&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="nx"&gt;path&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;res&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;fetch&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createWriteStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// inside the loop, after t.card_url = data.url;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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="s2"&gt;`cards/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template renders at 2x, so each file is 2400x2400 and around 1 to 1.5 MB. That is what you want for a retina social feed. For something lighter, render your own markup through the HTML endpoint at the default DPI instead; the full article shows a 1200x630 dark testimonial layout that comes back under 200 KB:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi6lxt8sk3e5ijharok07.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi6lxt8sk3e5ijharok07.png" alt="A dark navy 1200x630 quote card with a blue quotation mark, a white serif testimonial, a blue monogram avatar reading PR and the attribution Priya Raman, Head of Product, Fieldline" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keep the batch honest
&lt;/h2&gt;

&lt;p&gt;A few lines that stop a batch from going wrong quietly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watch the credit count.&lt;/strong&gt; Every response carries &lt;code&gt;credits_remaining&lt;/code&gt;, so break out of the loop when it hits zero rather than collecting a string of 402s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log validation errors, don't throw.&lt;/strong&gt; A 422 comes with an &lt;code&gt;errors&lt;/code&gt; object naming the field. The script above prints it and moves on, so one bad entry does not stop the other thirty-nine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a webhook for large batches.&lt;/strong&gt; For hundreds of cards, pass a &lt;code&gt;webhook_url&lt;/code&gt; with each request and let the API post the finished URL back to you instead of holding the connections open one at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the whole pipeline: a JSON file in, a JSON file with image URLs out, and a folder of PNGs if you want them. The &lt;a href="https://html2img.com/articles/quote-card-api/" rel="noopener noreferrer"&gt;full write-up&lt;/a&gt; goes further into building your own card layout in HTML, sizing the type to the quote length yourself, and producing the square, link-preview and Story formats from the same data.&lt;/p&gt;

&lt;p&gt;How are you making quote and testimonial graphics at the moment? Still by hand, or have you automated it another way? Let me know in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How HTML to PDF Conversion Actually Works: Rendering Engines, Text Objects and Fonts</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:31:49 +0000</pubDate>
      <link>https://dev.to/accreditly/how-html-to-pdf-conversion-actually-works-rendering-engines-text-objects-and-fonts-103j</link>
      <guid>https://dev.to/accreditly/how-html-to-pdf-conversion-actually-works-rendering-engines-text-objects-and-fonts-103j</guid>
      <description>&lt;p&gt;Two PDFs can look identical on screen and be completely different files. In one you can select a line, search for an invoice number and print it sharp at 300 dpi. The other is a photograph of a page: nothing to select, nothing to find, and every zoom step makes the text softer. Both came from HTML. Both came from something calling itself an HTML to PDF converter. The difference is what happened in the last few milliseconds of the render.&lt;/p&gt;

&lt;p&gt;The full write-up, with the file internals and the font-embedding detail, is on the HTML to Image blog as &lt;a href="https://html2img.com/articles/how-html-to-pdf-conversion-works/" rel="noopener noreferrer"&gt;How HTML to PDF conversion actually works&lt;/a&gt;. This is the short version: the pipeline, what "selectable text" really means, where fonts go, and three commands that tell you in seconds which kind of PDF you have been handed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same pipeline, different last step
&lt;/h2&gt;

&lt;p&gt;A browser-based converter, whether that is headless Chrome on your own box, a Puppeteer script or a hosted API, runs exactly the pipeline it uses to put a page on your monitor. Parse HTML into a DOM. Resolve CSS into computed styles. Lay out every box in CSS pixels and break text into lines. Paint the result into a display list: fill this rect, stroke this border, draw these glyphs here.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9fzmone23xpwpwfh3gr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd9fzmone23xpwpwfh3gr.png" alt="The render pipeline: parse, style, layout and paint are identical for PNG and PDF; only the final backend differs"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fork is stage five, when that display list is replayed into a backend. A raster backend replays it into pixels and writes a PNG. Chromium's PDF backend, part of the Skia graphics library, replays the same commands into PDF operators and writes a document. The commands are identical. What differs is what the backend is allowed to keep: text stays text, paths stay paths, fonts get subset and embedded, and only &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; content is stored as pixels.&lt;/p&gt;

&lt;p&gt;That is also why a Chrome PDF matches a Chrome PNG so closely, and why the two most common failures, missing fonts and rasterised pages, both happen at stage five.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pixels in, points out
&lt;/h2&gt;

&lt;p&gt;CSS measures in pixels at 96 per inch. PDF measures in points at 72 per inch. So every CSS pixel becomes 0.75 pt, and an A4 page is 794 by 1123 CSS pixels in layout and roughly 595 by 842 pt in the file. You can see the arithmetic in &lt;code&gt;pdfinfo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfinfo invoice.pdf
&lt;span class="go"&gt;Creator:         Chromium
Producer:        Skia/PDF m131
Tagged:          yes
Pages:           1
Page size:       595.92 x 841.92 pts (A4)
File size:       69467 bytes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unit change explains a rule that surprises people: DPI does nothing to a PDF. Raising the device pixel ratio makes a screenshot sharper because it adds pixels. A vector page has no pixels to add, so a 1x render and a 4x render of the same HTML produce identical vector content.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a text layer actually is
&lt;/h2&gt;

&lt;p&gt;People say a PDF "has a text layer" as if it were a transparent sheet over an image. That is how OCR repairs a scan, and it is where the phrase comes from, but a properly generated PDF has no such thing. The text is not a layer on the page. The text is the page.&lt;/p&gt;

&lt;p&gt;Inside a page's content stream you find text objects: select a font, set a position, draw a run of glyph IDs. Those IDs are indices into the embedded font, and on their own they mean nothing to a search box. What makes the text selectable is a second structure, the ToUnicode CMap, which maps each glyph ID back to the character it was drawn from. If it is missing, the text renders and scales perfectly but cannot be copied, and &lt;code&gt;pdftotext&lt;/code&gt; returns garbage.&lt;/p&gt;

&lt;p&gt;Run it on the invoice and every word comes back in reading order, including a word drawn with an inline &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; element, because Skia turns SVG text into the same text objects as HTML text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdftotext &lt;span class="nt"&gt;-layout&lt;/span&gt; invoice.pdf -
&lt;span class="gp"&gt;Northgate Coffee, invoice #&lt;/span&gt;1042
&lt;span class="go"&gt;Issued 3 September 2026. Payment due within 30 days.

 ITEM                              QTY   PRICE
&lt;/span&gt;&lt;span class="gp"&gt; Ethiopian Yirgacheffe, 1 kg       4     $&lt;/span&gt;72.00
&lt;span class="gp"&gt; Filter papers, box of 100         2     $&lt;/span&gt;9.50
&lt;span class="gp"&gt; Total                                   $&lt;/span&gt;81.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How fonts get inside the file
&lt;/h2&gt;

&lt;p&gt;A PDF that names a font without carrying it is at the mercy of whichever machine opens it, and the line lengths shift with every substitution. So the backend embeds fonts, and to keep files small it subsets them: only the glyphs actually drawn go in. The six-letter prefix on the name is the subset tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdffonts invoice.pdf
&lt;span class="go"&gt;name                              type          emb sub uni
--------------------------------- ------------- --- --- ---
AAAAAA+ManropeExtraLight_800wght  Type 3        yes yes yes
BAAAAA+ManropeExtraLight_500wght  Type 3        yes yes yes
FAAAAA+JetBrainsMono-Medium       CID TrueType  yes yes yes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the three right-hand columns. &lt;code&gt;emb&lt;/code&gt; is whether the font data is in the file, &lt;code&gt;sub&lt;/code&gt; whether it was subset, &lt;code&gt;uni&lt;/code&gt; whether a ToUnicode map exists. You want &lt;code&gt;yes yes yes&lt;/code&gt; on every row.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; column has a story in it. Both faces came from Google Fonts. JetBrains Mono was served as a static file and went in as &lt;code&gt;CID TrueType&lt;/code&gt;, the original outlines, the format every PDF tool likes. Manrope was served as a variable font, and Chrome embedded each instance as &lt;code&gt;Type 3&lt;/code&gt;, where every glyph is its own little drawing procedure. Still vector, still selectable, and every mainstream viewer handles it, but PDF/A validators and some accessibility checkers are unhappy about Type 3. If your documents have to pass those, serve static font files instead of variable ones. Nothing else changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vector where it can, raster where it must
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikayn6cdiqdoku6xzg64.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikayn6cdiqdoku6xzg64.png" alt="Side by side at 700% zoom: a vector text object stays crisp while a rasterised page dissolves into pixels"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Text, borders, gradients, shadows and inline SVG all go in as geometry. &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; go in as pixels at whatever resolution they arrived, which &lt;code&gt;pdfimages -list&lt;/code&gt; shows you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfimages &lt;span class="nt"&gt;-list&lt;/span&gt; invoice.pdf
&lt;span class="go"&gt;page num type  width height  size ratio
   1   0 image    64    64    98B  0.8%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One small logo, 98 bytes. That is what a healthy document's image list looks like. If you are printing, supply bitmaps at two or three times their CSS size, or supply them as SVG and let them go in as paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screenshot in a wrapper
&lt;/h2&gt;

&lt;p&gt;Now the file from the first paragraph. It comes from tools with no PDF backend at all: they screenshot the page and write the bitmap into a PDF container, one image per page. The three commands expose it instantly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdffonts wrapped.pdf
&lt;span class="go"&gt;name   type   encoding   emb sub uni
------ ------ ---------- --- --- ---

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfimages &lt;span class="nt"&gt;-list&lt;/span&gt; wrapped.pdf
&lt;span class="go"&gt;page num type  width height  size ratio
   1   0 image  1654  2339   1.9M   38%

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdftotext wrapped.pdf -
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No fonts, because there is no text. One page-sized image per page. Nothing from &lt;code&gt;pdftotext&lt;/code&gt;. The file is often ten to thirty times larger, and it gets worse exactly where PDFs matter, which is printing: 1654 by 2339 is 200 ppi on A4, and body text goes soft on a laser printer.&lt;/p&gt;

&lt;p&gt;So "can I select the text" is the single best acceptance test for a converter. Not "does it look right", which every tool passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three-command check
&lt;/h2&gt;

&lt;p&gt;All three ship with Poppler (&lt;code&gt;apt install poppler-utils&lt;/code&gt;, &lt;code&gt;brew install poppler&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdffonts  output.pdf          &lt;span class="c"&gt;# every row: emb yes, sub yes, uni yes&lt;/span&gt;
pdfimages &lt;span class="nt"&gt;-list&lt;/span&gt; output.pdf    &lt;span class="c"&gt;# one entry per real picture, no page-sized bitmaps&lt;/span&gt;
pdftotext output.pdf - | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="c"&gt;# your content, in reading order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty &lt;code&gt;pdffonts&lt;/code&gt; means a screenshot in a wrapper. &lt;code&gt;emb: no&lt;/code&gt; means the document looks different on every machine. &lt;code&gt;uni: no&lt;/code&gt; means text that renders but cannot be searched. A page-sized image in &lt;code&gt;pdfimages&lt;/code&gt; means the page was rasterised.&lt;/p&gt;

&lt;p&gt;If you want to try it on a real file, the &lt;a href="https://html2img.com/tools/html-to-pdf" rel="noopener noreferrer"&gt;HTML to PDF converter&lt;/a&gt; returns an A4 PDF from the same Skia backend described above, and from code it is one field on an image request to the &lt;a href="https://html2img.com/html-to-pdf/" rel="noopener noreferrer"&gt;HTML to PDF API&lt;/a&gt;: add &lt;code&gt;"format": "pdf"&lt;/code&gt; and the response &lt;code&gt;url&lt;/code&gt; points at a &lt;code&gt;.pdf&lt;/code&gt;. The longer version on the blog covers screen versus print CSS, webfont timing, pagination and what actually drives file size: &lt;a href="https://html2img.com/articles/how-html-to-pdf-conversion-works/" rel="noopener noreferrer"&gt;How HTML to PDF conversion actually works&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What is the worst PDF you have been handed by a converter, and did &lt;code&gt;pdffonts&lt;/code&gt; come back empty? Share it in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pdf</category>
      <category>html</category>
      <category>programming</category>
    </item>
    <item>
      <title>Convert HTML to PDF in Node.js With One API Call</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 26 Aug 2026 08:12:03 +0000</pubDate>
      <link>https://dev.to/accreditly/convert-html-to-pdf-in-nodejs-with-one-api-call-2fe4</link>
      <guid>https://dev.to/accreditly/convert-html-to-pdf-in-nodejs-with-one-api-call-2fe4</guid>
      <description>&lt;p&gt;You have an invoice, a receipt or a report that already exists as HTML and CSS, and you need a Node.js process to turn it into a PDF. Not a person pressing print, a process: a payment webhook, a queue worker, a cron job, an API route. The usual advice is Puppeteer, which means shipping a copy of Chrome with your application, pinning its version, feeding it fonts and watching its memory. That is a lot of infrastructure to own for a document.&lt;/p&gt;

&lt;p&gt;The alternative is one HTTP request. You send the markup with &lt;code&gt;format: "pdf"&lt;/code&gt; and get back a URL to an A4 vector PDF: selectable text, embedded webfonts, automatic pagination. This guide does that in Node.js with the official &lt;code&gt;@html2img/client&lt;/code&gt; package, then covers what a real integration needs on top: a template, downloading and attaching the file, fixed-width layouts, error handling and long documents.&lt;/p&gt;

&lt;p&gt;This is the dev.to edition of a guide that lives on the HTML to Image blog as &lt;a href="https://html2img.com/articles/html-to-pdf-nodejs/" rel="noopener noreferrer"&gt;Convert HTML to PDF in Node.js With One API Call&lt;/a&gt;. The code is the same in both places; the original is where the linked parameter docs are kept current.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one request gives you
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://html2img.com/html-to-pdf" rel="noopener noreferrer"&gt;HTML to PDF API&lt;/a&gt; is the same endpoint as image rendering with one extra parameter. Your HTML is rendered in current Chrome and returned as a real PDF rather than a screenshot wrapped in a PDF shell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text is selectable and searchable, so the document works with copy and paste, search and screen readers.&lt;/li&gt;
&lt;li&gt;Webfonts are embedded. Google Fonts, Adobe Fonts and self-hosted &lt;code&gt;@font-face&lt;/code&gt; all load server-side and travel with the file.&lt;/li&gt;
&lt;li&gt;Text, borders, shapes and gradients are vector, so they stay sharp at any zoom. Only &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; elements and canvases are embedded as pixels.&lt;/li&gt;
&lt;li&gt;Long content paginates automatically across as many A4 pages as it needs.&lt;/li&gt;
&lt;li&gt;Background colours, gradients and images are included by default.&lt;/li&gt;
&lt;li&gt;The response &lt;code&gt;url&lt;/code&gt; points at a &lt;code&gt;.pdf&lt;/code&gt; on the CDN, served as &lt;code&gt;application/pdf&lt;/code&gt;. A PDF costs the same single credit as an image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things to know before you write any markup. Pages are A4 portrait, with no US Letter, landscape or custom sizes, and there are no header and footer slots. And the document is rendered with your normal screen CSS: &lt;code&gt;@media print&lt;/code&gt; rules are not applied, so everything the PDF needs belongs in your standard styles. The &lt;a href="https://html2img.com/docs/parameters/format" rel="noopener noreferrer"&gt;format parameter docs&lt;/a&gt; cover the full behaviour, including which sizing parameters are ignored in PDF mode (&lt;code&gt;width&lt;/code&gt;, &lt;code&gt;height&lt;/code&gt;, &lt;code&gt;dpi&lt;/code&gt;, &lt;code&gt;fullpage&lt;/code&gt; and &lt;code&gt;selector&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18 or newer, for the built-in global &lt;code&gt;fetch&lt;/code&gt;. Bun and Deno work too.&lt;/li&gt;
&lt;li&gt;An HTML to Image API key from the &lt;a href="https://app.html2img.com/register" rel="noopener noreferrer"&gt;dashboard&lt;/a&gt;. Every account starts with free credits and no card is needed.&lt;/li&gt;
&lt;li&gt;Some HTML you want as a PDF. The guide builds an invoice, but a receipt, report or certificate follows the same path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: install the client and store your key
&lt;/h2&gt;

&lt;p&gt;The official package has no runtime dependencies and is built on the standard &lt;code&gt;fetch&lt;/code&gt; API, so it runs unchanged in Node.js 18 or newer, Bun, Deno and serverless runtimes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @html2img/client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get an API key from the dashboard and put it in the environment, without a public prefix. This is a server-side client: the key spends credits, so it never goes anywhere near browser code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-api-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://html2img.com/docs/authentication" rel="noopener noreferrer"&gt;authentication docs&lt;/a&gt; cover rotating keys and checking a key with &lt;code&gt;GET /api/me&lt;/code&gt;, which does not consume a credit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: render your first PDF
&lt;/h2&gt;

&lt;p&gt;The whole conversion is one call. Set &lt;code&gt;format&lt;/code&gt; to &lt;code&gt;'pdf'&lt;/code&gt; and the response carries the URL of the finished document.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// render-pdf.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;
  &amp;lt;body style="font-family: sans-serif; padding: 48px"&amp;gt;
    &amp;lt;h1&amp;gt;Invoice #1042&amp;lt;/h1&amp;gt;
    &amp;lt;p&amp;gt;Due within 30 days.&amp;lt;/p&amp;gt;
  &amp;lt;/body&amp;gt;
&amp;lt;/html&amp;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="s1"&gt;pdf&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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="c1"&gt;// https://i.html2img.com/....pdf&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;creditsRemaining&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// credits left on the account&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node --env-file=.env render-pdf.mjs&lt;/code&gt; on Node 20.6 or newer, or load the &lt;code&gt;.env&lt;/code&gt; file with &lt;code&gt;dotenv&lt;/code&gt; on older versions. The response is a typed &lt;code&gt;RenderResponse&lt;/code&gt; with &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;creditsRemaining&lt;/code&gt;, &lt;code&gt;expiresAt&lt;/code&gt; and &lt;code&gt;raw&lt;/code&gt;, which holds the full decoded JSON if you want the envelope as the API sent it.&lt;/p&gt;

&lt;p&gt;That is a working PDF. Everything from here is about making it a document you would put in front of a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: build the document from a template
&lt;/h2&gt;

&lt;p&gt;Keep the HTML in a function that takes your data and returns a complete document. Three rules make the output behave:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write a flowing layout, not a fixed one. The A4 page is roughly 794 CSS pixels wide and content reflows to that width, so use percentages and flex rather than a hard &lt;code&gt;width: 1200px&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Load fonts with a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag or &lt;code&gt;@import&lt;/code&gt; in the head. They are fetched at render time and embedded in the PDF.&lt;/li&gt;
&lt;li&gt;Escape every value you interpolate. A customer called &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; should not become a script.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// invoice.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;&amp;amp;&amp;lt;&amp;gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;#39;&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;char&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;money&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&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="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&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;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
    &amp;lt;tr&amp;gt;
      &amp;lt;td&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;td class="num"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;td class="num"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
      &amp;lt;td class="num"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/td&amp;gt;
    &amp;lt;/tr&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&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;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang="en"&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset="utf-8"&amp;gt;
  &amp;lt;link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&amp;amp;display=swap" rel="stylesheet"&amp;gt;
  &amp;lt;style&amp;gt;
    body { margin: 0; padding: 56px; font-family: 'Inter', sans-serif; font-size: 14px; color: #0f172a; }
    header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
    h1 { margin: 0 0 4px; font-size: 28px; }
    .muted { color: #64748b; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { padding: 0 0 8px; border-bottom: 2px solid #e2e8f0; text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
    td { padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
    .num { text-align: right; }
    tr { break-inside: avoid; }
    .total { display: flex; justify-content: flex-end; gap: 32px; margin-top: 24px; font-size: 18px; font-weight: 700; }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;header&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;h1&amp;gt;Invoice &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h1&amp;gt;
      &amp;lt;div class="muted"&amp;gt;Issued &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;middot; Due &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;
      &amp;lt;span class="muted"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
    &amp;lt;/div&amp;gt;
  &amp;lt;/header&amp;gt;

  &amp;lt;div&amp;gt;&amp;lt;span class="muted"&amp;gt;Billed to&amp;lt;/span&amp;gt;&amp;lt;br&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/strong&amp;gt;&amp;lt;/div&amp;gt;

  &amp;lt;table&amp;gt;
    &amp;lt;thead&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;Description&amp;lt;/th&amp;gt;&amp;lt;th class="num"&amp;gt;Qty&amp;lt;/th&amp;gt;&amp;lt;th class="num"&amp;gt;Unit&amp;lt;/th&amp;gt;&amp;lt;th class="num"&amp;gt;Amount&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/thead&amp;gt;
    &amp;lt;tbody&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/tbody&amp;gt;
  &amp;lt;/table&amp;gt;

  &amp;lt;div class="total"&amp;gt;&amp;lt;span&amp;gt;Total due&amp;lt;/span&amp;gt;&amp;lt;span&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;money&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;`&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 &lt;code&gt;tr { break-inside: avoid; }&lt;/code&gt; line is the one people forget. Without it, a line item that lands on a page boundary is cut in half, with its description at the foot of one page and its amount at the top of the next. Automatic pagination fills pages; it has no opinion about what belongs together, and you supply that opinion in CSS. &lt;a href="https://html2img.com/articles/html-to-pdf-page-breaks/" rel="noopener noreferrer"&gt;How to control page breaks in HTML to PDF output&lt;/a&gt; covers the full set of fragmentation properties.&lt;/p&gt;

&lt;p&gt;Because the renderer is real Chrome, the layout CSS you already use works: grid, flexbox, custom properties, &lt;code&gt;gap&lt;/code&gt;, modern selectors. &lt;a href="https://html2img.com/articles/html-to-pdf-css-support/" rel="noopener noreferrer"&gt;Can I use it in a PDF?&lt;/a&gt; compares that support against the other PDF engines if you are moving a template across from one of them.&lt;/p&gt;

&lt;p&gt;Now render it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// send-invoice.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoiceHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./invoice.mjs&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&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;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INV-1042&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;26 August 2026&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;due&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;25 September 2026&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Northgate Coffee&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;billing@northgate.example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Riverside Bakery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;items&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="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Wholesale beans, 5kg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Delivery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&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="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&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="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&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="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to see what the markup will look like before spending a credit, paste it into the free &lt;a href="https://html2img.com/tools/html-to-pdf" rel="noopener noreferrer"&gt;HTML to PDF converter&lt;/a&gt;. It runs the same renderer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: download the file, attach it, or keep the URL
&lt;/h2&gt;

&lt;p&gt;The response carries a hosted URL rather than the bytes, which is usually what you want: store it against the order and link to it from the account page. When you need the file itself, for an email attachment or your own storage, fetch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&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;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&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="s1"&gt;pdf&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;file&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&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="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="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`invoices/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf`&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same buffer goes straight into a mail attachment. With Nodemailer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accounts@riverside.example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Invoice &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your invoice is attached.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;attachments&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="na"&gt;filename&lt;/span&gt;&lt;span class="p"&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;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&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="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/pdf&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail to build in: on the free tier, renders expire and &lt;code&gt;response.expiresAt&lt;/code&gt; tells you when. On paid plans it is &lt;code&gt;null&lt;/code&gt; and the file is hosted permanently. If a free-tier document needs to outlive that window, download a copy as above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: serve it from an API route
&lt;/h2&gt;

&lt;p&gt;In a long-running Node server, build the client once at module scope and reuse it. Return the URL rather than proxying the bytes: the CDN serves the file better than your process will.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Html2imgError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;invoiceHtml&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./invoice.mjs&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/invoices/:id/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&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;invoice&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;loadInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&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;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&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="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;render&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Html2imgError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape works in a Next.js route handler, a Nuxt server route or a Lambda handler. The &lt;a href="https://html2img.com/integrations/javascript" rel="noopener noreferrer"&gt;JavaScript integration guide&lt;/a&gt; has each of those written out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed-width layouts: scale the design to the page
&lt;/h2&gt;

&lt;p&gt;Some documents are not flowing text. A certificate, a wide comparison table or a dashboard export is a fixed-width design, and if it is wider than the A4 page a plain PDF render crops it at the right edge. The &lt;code&gt;scale_to_fit&lt;/code&gt; parameter fixes that: the renderer lays the content out at its natural width, then scales the whole layout down to fit the page. The scaling is vector, so text stays selectable and sharp, and any trailing blank page is trimmed.&lt;/p&gt;

&lt;p&gt;As of version 2.0.0, &lt;code&gt;scale_to_fit&lt;/code&gt; is not on the SDK's option types, so send that one request with plain &lt;code&gt;fetch&lt;/code&gt;. This is also the whole API without a dependency: one POST, one header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.html2img.com/api/html&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;certificateHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attendee&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="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scale_to_fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="nx"&gt;_000&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Render failed with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave &lt;code&gt;scale_to_fit&lt;/code&gt; off for the invoice above. A document written to flow reads better reflowed to the page width at full size than scaled down. The &lt;a href="https://html2img.com/docs/parameters/scale-to-fit" rel="noopener noreferrer"&gt;scale_to_fit docs&lt;/a&gt; explain the measuring step and when it applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle the errors that will actually happen
&lt;/h2&gt;

&lt;p&gt;Every failed request rejects with an &lt;code&gt;Html2imgError&lt;/code&gt;, and the useful cases have their own subclass. The distinction that matters is between failures that are worth retrying and failures that are not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Html2img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Html2imgError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@html2img/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&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;render&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;invoiceHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&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="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveInvoiceUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;render&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 400 or 422: the request is wrong, retrying will not help&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rejected fields:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;InsufficientCreditsError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 402: nothing will render until the account is topped up&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Out of credits (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;creditsRemaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; left)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 504: the render exceeded the 30 second budget, resend with a webhook&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Html2imgError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 5xx or a connection failure: retry with backoff&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errorCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client deliberately does not retry on its own. A 5xx or a dropped connection is worth a second attempt; a 4xx is not, and a retry loop around a validation error just spends credits on the same bad request. If you want a retry policy, pass your own &lt;code&gt;fetch&lt;/code&gt; in the constructor options and put the backoff there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Long reports: take the render off the request
&lt;/h2&gt;

&lt;p&gt;A synchronous render has a 30 second budget and the client waits 35 by default. An invoice is nowhere near that. A 40-page report with large tables can be, and several serverless platforms cut a function off well before either limit. For those, pass a &lt;code&gt;webhookUrl&lt;/code&gt;. The API answers immediately with a &lt;code&gt;processing&lt;/code&gt; status and the render id, then POSTs the finished URL to your endpoint when the document is ready.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;monthlyReportHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&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="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;webhookUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-app.example.com/hooks/html2img?token=shared-secret&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isProcessing&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your endpoint receives a JSON body with &lt;code&gt;status&lt;/code&gt;, the &lt;code&gt;url&lt;/code&gt; on success or an &lt;code&gt;error&lt;/code&gt; message on failure, and a &lt;code&gt;log_id&lt;/code&gt; that matches the &lt;code&gt;id&lt;/code&gt; from the initial response, which is how you tie the callback back to the report. Return a 200 within a few seconds and do the rest asynchronously. Delivery is attempted once and the API sends no custom headers, so put a secret in the URL as above. The &lt;a href="https://html2img.com/docs/parameters/webhook-url" rel="noopener noreferrer"&gt;webhook_url docs&lt;/a&gt; have the exact payloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you are no longer maintaining
&lt;/h2&gt;

&lt;p&gt;The list of things this integration does not contain is the point of it. There is no Chrome binary in the deploy and no &lt;code&gt;puppeteer-core&lt;/code&gt; and &lt;code&gt;@sparticuz/chromium&lt;/code&gt; pair to keep in step every time Chrome ships a major version. There is no font manifest to curate so that a customer's name does not render as hollow rectangles. There is no memory tuning, no crash recovery and no cold-start budget spent booting a browser. &lt;a href="https://html2img.com/articles/puppeteer-html-to-pdf-serverless/" rel="noopener noreferrer"&gt;Why HTML to PDF with Puppeteer keeps breaking on serverless&lt;/a&gt; goes through each of those failures in detail if you are currently living with them.&lt;/p&gt;

&lt;p&gt;The trade is stated plainly on the product page: the PDF is built for parity with the image renderer, so it looks exactly like the PNG of the same input would. If your project needs US Letter, landscape pages, running headers and footers or print stylesheets, a dedicated PDF service is the better fit, and &lt;a href="https://html2img.com/articles/generate-pdf-from-html/" rel="noopener noreferrer"&gt;The complete guide to generating PDFs from HTML&lt;/a&gt; compares every route, including the ones that count against us. For the invoice, receipt, certificate or report that already exists as HTML in your application, the whole job is the one call at the top of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;p&gt;You installed &lt;code&gt;@html2img/client&lt;/code&gt;, sent an HTML string with &lt;code&gt;format: 'pdf'&lt;/code&gt; and got back a URL to an A4 vector PDF with selectable text and embedded fonts. From there you built the document from a template with &lt;code&gt;break-inside: avoid&lt;/code&gt; on the rows, downloaded the file for an email attachment, put the render behind an Express route, scaled a fixed-width design onto the page with &lt;code&gt;scale_to_fit&lt;/code&gt;, handled the errors that are worth retrying separately from the ones that are not, and moved long reports onto a webhook. Nothing in that list installs a browser.&lt;/p&gt;

&lt;p&gt;The full guide, with the parameter docs it links to, is at &lt;a href="https://html2img.com/articles/html-to-pdf-nodejs/" rel="noopener noreferrer"&gt;Convert HTML to PDF in Node.js With One API Call&lt;/a&gt; on the HTML to Image blog.&lt;/p&gt;

&lt;p&gt;How are you generating PDFs from Node at the moment? If you are still running Puppeteer for it, what does it cost you to keep it working? Share your setup in the comments.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Tested 7 HTML to PDF APIs Against the Same Awkward Invoice</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sun, 23 Aug 2026 10:55:41 +0000</pubDate>
      <link>https://dev.to/accreditly/i-tested-7-html-to-pdf-apis-against-the-same-awkward-invoice-8nb</link>
      <guid>https://dev.to/accreditly/i-tested-7-html-to-pdf-apis-against-the-same-awkward-invoice-8nb</guid>
      <description>&lt;p&gt;Every "best HTML to PDF API" list you will find is written by a vendor. So is this one: I run &lt;a href="https://html2img.com/html-to-pdf" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt;, and it is one of the seven services below. The difference is that I am telling you in the second sentence, and every service gets judged against the same fixed document instead of a feature grid copied from marketing pages.&lt;/p&gt;

&lt;p&gt;This is a cross-post of &lt;a href="https://html2img.com/articles/best-html-to-pdf-api/" rel="noopener noreferrer"&gt;the full comparison on the HTML to Image blog&lt;/a&gt;, which is the canonical version if you want to link to it.&lt;/p&gt;

&lt;p&gt;"Best" is the wrong question anyway. The right question is: best for &lt;em&gt;which document, at which volume&lt;/em&gt;? A one-page receipt and a 40-page annual report with running headers are different jobs, and the service that wins one loses the other. So I built one deliberately awkward invoice, worked out exactly what it demands from a rendering engine, and put seven services in front of it: HTML to Image, DocRaptor, PDFShift, Api2Pdf, PDFCrowd, PDFMonkey, and self-hosted Gotenberg as the benchmark for doing it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invoice that does the judging
&lt;/h2&gt;

&lt;p&gt;The test document is a two-page supplier invoice: 38 line items, a totals block, small print. Ordinary on the surface, and booby-trapped underneath. Every feature in it was chosen because at least one engine in this market handles it badly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw4ouvgydj3811mqr3yz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpw4ouvgydj3811mqr3yz.png" alt="Page one of the test invoice rendered by the HTML to Image API: a flexbox header with an SVG logo, a CSS grid billing block, and the start of a 38-row line item table in the Inter webfont" width="800" height="1131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what it quietly demands:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A webfont.&lt;/strong&gt; The whole document is set in Inter, loaded from Google Fonts. The engine has to fetch it, render with it, and embed it in the PDF, or the text layer falls back to something ugly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexbox.&lt;/strong&gt; The header is a flex row. Trivial in a browser, fatal in anything built on 2015-era WebKit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS Grid.&lt;/strong&gt; The billing block is a three-column grid. This one matters because one major commercial engine still does not support Grid at all. More on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom properties.&lt;/strong&gt; Brand colours live in &lt;code&gt;:root&lt;/code&gt; variables. Legacy engines resolve none of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A table that crosses the page boundary.&lt;/strong&gt; 38 rows will not fit on one A4 page, so pagination behaviour is on show, including whether &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; repeats on page two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;break-inside: avoid&lt;/code&gt;.&lt;/strong&gt; Rows and the totals block must not split across pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page numbers via &lt;code&gt;@page&lt;/code&gt; margin boxes.&lt;/strong&gt; The stylesheet asks for &lt;code&gt;"Page " counter(page) " of " counter(pages)&lt;/code&gt; in the bottom-right margin of every page. Historically this separated the CSS Paged Media engines from the browser engines. In 2026 the answer is more interesting than that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript.&lt;/strong&gt; The subtotal, tax and total are computed by a script at render time. An engine that does not execute JavaScript prints an invoice with dashes where the money should be.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trap CSS, in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;A4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18mm&lt;/span&gt; &lt;span class="m"&gt;16mm&lt;/span&gt; &lt;span class="m"&gt;22mm&lt;/span&gt; &lt;span class="m"&gt;16mm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="err"&gt;@bottom-right&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Page "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s1"&gt;" of "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;@bottom-left&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Fieldgate Studio Ltd · INV-2026-0847"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;tbody&lt;/span&gt; &lt;span class="nt"&gt;tr&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.totals&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;break-inside&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;avoid&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;If a service renders this invoice correctly, it will handle the overwhelming majority of transactional documents: receipts, statements, packing slips, certificates, simple reports. If it fails, you now know exactly where.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the judging works
&lt;/h2&gt;

&lt;p&gt;Full disclosure of method, because this is where vendor lists usually go quiet.&lt;/p&gt;

&lt;p&gt;I ran the invoice through my own API and inspected the output with &lt;code&gt;pdfinfo&lt;/code&gt;, &lt;code&gt;pdffonts&lt;/code&gt; and &lt;code&gt;pdftotext&lt;/code&gt;. Those results are reported below as tested facts. For the other six services, I did not create six accounts and pretend to run a neutral lab. Instead, each service is judged on the engine it runs, that engine's documented CSS support, and its published pricing, all checked in August 2026. Engines are honest in a way marketing pages are not: if a service renders with Prince, Prince's documented behaviour is its behaviour. If it renders with Chromium, the invoice behaves the way it behaves in Chrome.&lt;/p&gt;

&lt;p&gt;One tested finding is worth pulling out before the scorecard, because most advice online has not caught up with it. The classic rule was that Chromium-based PDF generation cannot do CSS page numbers, and that you needed Prince, WeasyPrint, or a vendor's header and footer templates to get them. That rule died in November 2024: Chromium 131 shipped support for &lt;code&gt;@page&lt;/code&gt; margin boxes, including &lt;code&gt;counter(page)&lt;/code&gt; and &lt;code&gt;counter(pages)&lt;/code&gt;, and the &lt;a href="https://developer.chrome.com/release-notes/131" rel="noopener noreferrer"&gt;Chrome 131 release notes&lt;/a&gt; document it. My test render came back stamped &lt;code&gt;Skia/PDF m131&lt;/code&gt;, and both pages carried a correct "Page 1 of 2" and "Page 2 of 2" in the bottom margin, from plain CSS, with no vendor-specific footer syntax. Whether &lt;em&gt;your&lt;/em&gt; provider gives you this depends entirely on which Chromium build it has pinned. It is now a question worth asking every vendor on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Flexbox + Grid&lt;/th&gt;
&lt;th&gt;CSS page numbers&lt;/th&gt;
&lt;th&gt;Runs JS&lt;/th&gt;
&lt;th&gt;Starting price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML to Image&lt;/td&gt;
&lt;td&gt;Current Chrome&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, tested&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$9/mo, 1,000 renders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DocRaptor&lt;/td&gt;
&lt;td&gt;Prince&lt;/td&gt;
&lt;td&gt;Flexbox yes, Grid no&lt;/td&gt;
&lt;td&gt;Yes, full Paged Media&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;$15/mo, 125 docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFShift&lt;/td&gt;
&lt;td&gt;Chromium&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via header/footer templates&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$24/mo, 2,500 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Api2Pdf&lt;/td&gt;
&lt;td&gt;Chrome, wkhtmltopdf or LibreOffice&lt;/td&gt;
&lt;td&gt;Yes on the Chrome route&lt;/td&gt;
&lt;td&gt;Via header/footer options&lt;/td&gt;
&lt;td&gt;Yes on the Chrome route&lt;/td&gt;
&lt;td&gt;$1/mo + usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFCrowd&lt;/td&gt;
&lt;td&gt;Chromium-based converters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via header/footer parameters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;$23/mo, 1,000 credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFMonkey&lt;/td&gt;
&lt;td&gt;Chromium&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Template-driven workflow&lt;/td&gt;
&lt;td&gt;Yes, on paid plans&lt;/td&gt;
&lt;td&gt;€5/mo, 300 docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gotenberg (self-hosted)&lt;/td&gt;
&lt;td&gt;Chromium + LibreOffice&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via header/footer files&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Free, plus your server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this post is each service against the invoice, strengths and failures both.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML to Image
&lt;/h2&gt;

&lt;p&gt;Mine, so read this section with the scepticism it deserves. Here is what the API did with the test document, verified rather than claimed.&lt;/p&gt;

&lt;p&gt;The invoice came back as a two-page A4 vector PDF, 230KB, with Inter embedded as subsetted fonts and a real text layer throughout: every line item, and the JavaScript-computed totals, are selectable and searchable in the output. The &lt;code&gt;break-inside&lt;/code&gt; rules held, so no row split across the boundary and the totals block arrived intact on page two. The &lt;code&gt;@page&lt;/code&gt; margin boxes rendered, which honestly surprised me too, because my own comparison pages have been telling people the service does not do page numbers. The renderer is current Chrome, Chrome is now past 131, so &lt;code&gt;counter(page)&lt;/code&gt; simply works.&lt;/p&gt;

&lt;p&gt;Two failures, stated plainly. First, the &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; did not repeat on page two; the 38-row table just continues. That is a known Chromium PDF behaviour and the fix is repeating the header yourself, not a parameter. Second, the scope is deliberately narrow: A4 portrait only, no US Letter, no landscape, no watermarking, no encryption, no merging, and your &lt;code&gt;@media print&lt;/code&gt; styles are ignored because rendering uses screen CSS. The whole PDF surface is one &lt;code&gt;format&lt;/code&gt; parameter on the same endpoint that renders PNGs.&lt;/p&gt;

&lt;p&gt;What you get in exchange for that narrowness: one credit per render regardless of document size, output hosted on a CDN URL that stays live on paid plans, the same API key rendering both images and PDFs, and pricing that starts at $9 for 1,000 renders and reaches 100,000 for $300. If your documents are transactional and A4, this is the cheap, boring option. If they are not, one of the next five services is your answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  DocRaptor
&lt;/h2&gt;

&lt;p&gt;DocRaptor is the only service here that does not render with a browser engine, and that single fact decides everything about it. It runs &lt;a href="https://www.princexml.com/" rel="noopener noreferrer"&gt;Prince&lt;/a&gt;, the commercial CSS Paged Media engine, and Prince remains the best print typographer in the business: real headers and footers, page counters, footnotes, cross-references, PDF bookmarks, mixed page sizes within one document, and tagged, accessible output meeting WCAG 2.0, Section 508 and ISO-14289. DocRaptor holds SOC2 and HIPAA compliance on top. For a regulated 60-page financial statement, nothing else on this list is a serious rival.&lt;/p&gt;

&lt;p&gt;Against this invoice, though, Prince trips on trap number three. Prince's own documentation is upfront that it does not support CSS Grid, so the three-column billing block would need rewriting as flexbox or floats before this invoice renders correctly. JavaScript support exists but is Prince's own engine rather than a browser, so scripts that lean on browser APIs need testing rather than trusting. If your templates were written for Chrome, budget for a porting pass.&lt;/p&gt;

&lt;p&gt;Two more honest observations. DocRaptor's trial policy is the best in the market: test documents are watermarked but unlimited and free forever, so you can develop against it indefinitely without paying. And its pricing is the steepest here by a distance: &lt;a href="https://docraptor.com/plans" rel="noopener noreferrer"&gt;$15 a month covers 125 documents&lt;/a&gt;, $149 covers 5,000, and 40,000 costs $1,000. You are paying for Prince, and for many document types Prince is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFShift
&lt;/h2&gt;

&lt;p&gt;PDFShift is the straightforward one: a French-run, Chromium-based converter that has been doing exactly this job for years without trying to become a platform. The invoice renders correctly because current Chromium renders it correctly, and PDFShift layers on page-level controls: page sizes including Letter and custom dimensions, landscape, encryption, watermarks, custom headers and footers with page numbers, and delivery straight to your own S3 bucket. Asynchronous mode and parallel processing are there for batch work.&lt;/p&gt;

&lt;p&gt;The pricing model needs one careful read: credits are metered by output size, one credit per 5MB, so a 14MB catalogue costs three credits while this 230KB invoice costs one. The free tier is a genuine 50 conversions a month, the published Boost plan is $24 for 2,500 credits with overage at $0.03, and the ladder above that is not published, so high-volume teams are quoting rather than reading a price list. For a team that wants Chromium fidelity plus real PDF options at a sane price, PDFShift is the safe default in the middle of this market.&lt;/p&gt;

&lt;h2&gt;
  
  
  Api2Pdf
&lt;/h2&gt;

&lt;p&gt;Api2Pdf does something rare: it tells you precisely what it runs. Headless Chrome, wkhtmltopdf and LibreOffice are all surfaced as explicit choices, alongside merging, barcodes and thumbnails. That candour cuts both ways. The Chrome route renders the invoice as well as any Chromium service here. The wkhtmltopdf route fails it comprehensively, because &lt;a href="https://github.com/wkhtmltopdf/wkhtmltopdf" rel="noopener noreferrer"&gt;wkhtmltopdf is an archived project&lt;/a&gt; rendering with a WebKit build that predates flexbox-era CSS; it is still offered for legacy compatibility, not for new work. Pick the Chrome engine and never look back.&lt;/p&gt;

&lt;p&gt;The LibreOffice engine is the genuinely differentiating piece: Api2Pdf converts Word, Excel and PowerPoint files to PDF, which nothing else on this list attempts. And the &lt;a href="https://www.api2pdf.com/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; is pay-as-you-go taken seriously: $1 a month base, $0.001 per MB of bandwidth, $0.00019551 per second of compute. At this invoice's real weight of 230KB and roughly two seconds of render time, 2,500 invoices a month costs about $2.60. That is not a typo, and it makes Api2Pdf the cheapest metered option in the market by an order of magnitude. The trade is forecastability and the absence of a hosting layer: you get bytes back, and storage is your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFCrowd
&lt;/h2&gt;

&lt;p&gt;PDFCrowd has been converting HTML since 2009 and its API reads like it: over one hundred configuration parameters covering page size, margins, headers and footers with page numbers, page break control, watermarking and password protection, with official SDKs for Python, PHP, Node.js, Java, .NET, Ruby and Go. The current converter versions are Chromium-based, so the invoice's layout, fonts, custom properties and JavaScript all render correctly, and the header and footer parameters supply the page numbers.&lt;/p&gt;

&lt;p&gt;The things to check before committing are operational rather than rendering. Credits are metered at one per 0.5MB of output, the tightest size metering here, so a 2MB report costs four credits and plan capacity is smaller than the document counts suggest. Plans carry per-minute rate limits and concurrency allowances that step up with price, which matters for bursty invoice runs on billing day. Published pricing starts at $23 for 1,000 credits and $61 covers 10,000. If you want maximum knobs from a vendor that has outlived most of its competitors, this is that vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFMonkey
&lt;/h2&gt;

&lt;p&gt;PDFMonkey is shaped differently from everything above, and comparing it fairly means saying so. You do not POST HTML per render. Templates live in PDFMonkey's dashboard, written in HTML and CSS with Liquid placeholders, and each API call sends the JSON data to merge into one. For an invoice pipeline where the accounts team occasionally edits the template and developers only ever send &lt;code&gt;{"invoice_number": "INV-2026-0847", ...}&lt;/code&gt;, that split is genuinely the right architecture, and it is why PDFMonkey keeps winning small SaaS teams.&lt;/p&gt;

&lt;p&gt;The engine is Chromium, so the invoice's CSS and JavaScript are safe, with one asterisk: the free plan cannot load external images, fonts or scripts at all, so trap number one, the Google-hosted webfont, fails until you pay. &lt;a href="https://www.pdfmonkey.io/pricing" rel="noopener noreferrer"&gt;Pricing&lt;/a&gt; is the friendliest entry point on this list, €5 a month for 300 documents and €15 for 3,000, but read the retention column before storing invoice URLs anywhere: documents are deleted after one day on Starter and seven days on Pro, with unlimited retention only from the €60 Pro+ plan. If your app links to the PDF later, that detail is a production incident waiting in the pricing table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotenberg, if you would rather run it yourself
&lt;/h2&gt;

&lt;p&gt;Every managed service above competes with one alternative: a Docker container on your own infrastructure. &lt;a href="https://github.com/gotenberg/gotenberg" rel="noopener noreferrer"&gt;Gotenberg&lt;/a&gt; is the best version of that idea, an MIT-licensed API wrapping Chromium and LibreOffice, so the invoice renders exactly as it does on any current-Chromium service, header and footer files included, for a licence cost of nothing.&lt;/p&gt;

&lt;p&gt;The cost is not nothing, it has just moved. You now own a browser in production: memory limits, zombie processes, concurrency under a billing-day burst, and the Chromium upgrade treadmill, which, as the margin-box story above shows, is also how you receive new CSS features. A small VM at $20 to $40 a month is the visible line item; the engineer who answers when rendering falls over at 2am is the real one. Gotenberg is the right choice when data genuinely cannot leave your infrastructure, and a self-imposed operations tax the rest of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the same invoice costs at three volumes
&lt;/h2&gt;

&lt;p&gt;Monthly prices for rendering the 230KB invoice, at published August 2026 rates. Api2Pdf figures are estimates from its published per-MB and per-second rates at the invoice's measured weight and a two-second render; size-metered services assume the document stays under their credit threshold, which at 230KB it comfortably does.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Volume per month&lt;/th&gt;
&lt;th&gt;250 invoices&lt;/th&gt;
&lt;th&gt;2,500 invoices&lt;/th&gt;
&lt;th&gt;25,000 invoices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML to Image&lt;/td&gt;
&lt;td&gt;$9&lt;/td&gt;
&lt;td&gt;$25&lt;/td&gt;
&lt;td&gt;$120&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DocRaptor&lt;/td&gt;
&lt;td&gt;$29&lt;/td&gt;
&lt;td&gt;$149&lt;/td&gt;
&lt;td&gt;$1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFShift&lt;/td&gt;
&lt;td&gt;$24&lt;/td&gt;
&lt;td&gt;$24&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Api2Pdf&lt;/td&gt;
&lt;td&gt;~$1.20&lt;/td&gt;
&lt;td&gt;~$2.60&lt;/td&gt;
&lt;td&gt;~$17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFCrowd&lt;/td&gt;
&lt;td&gt;$23&lt;/td&gt;
&lt;td&gt;$61&lt;/td&gt;
&lt;td&gt;Not published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDFMonkey&lt;/td&gt;
&lt;td&gt;€5&lt;/td&gt;
&lt;td&gt;€15&lt;/td&gt;
&lt;td&gt;€180–300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gotenberg&lt;/td&gt;
&lt;td&gt;Your server&lt;/td&gt;
&lt;td&gt;Your server&lt;/td&gt;
&lt;td&gt;Your server, plus real ops work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things jump out of that table. Api2Pdf's metered model is untouchable on raw price if you can live with usage billing and bring your own storage. DocRaptor costs ten to forty times the Chromium services at every volume, which is the price of Prince and worth paying only when you need what only Prince does. And two of seven vendors stop publishing prices exactly where volume gets interesting, which tells you something about how those negotiations go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick
&lt;/h2&gt;

&lt;p&gt;Match the service to the document, not to the adjectives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactional A4 documents at volume, images from the same key&lt;/strong&gt;: HTML to Image. One credit each, hosted URLs, $9 to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Print-grade typography, accessibility tagging, or compliance requirements&lt;/strong&gt;: DocRaptor, and rewrite your Grid layouts first. Nothing else here produces tagged PDFs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chromium fidelity plus page-level control&lt;/strong&gt;, Letter and landscape, watermarks, encryption: PDFShift for the cleaner API, PDFCrowd for the deepest parameter surface and official SDKs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Office documents in the pipeline, or the lowest possible bill&lt;/strong&gt;: Api2Pdf on its Chrome engine, with your own storage behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates edited by non-developers, data merged by API&lt;/strong&gt;: PDFMonkey, on a paid plan, with the retention column read twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data that cannot leave your infrastructure&lt;/strong&gt;: Gotenberg, priced honestly to include the engineer.&lt;/p&gt;

&lt;p&gt;Whatever this or any other vendor's list says: every service here has a free tier, free test documents or a trial. The only test that outranks mine is your own document through two or three of them. The &lt;a href="https://html2img.com/articles/best-html-to-pdf-api/" rel="noopener noreferrer"&gt;full comparison, with the deeper engine notes, lives on the HTML to Image blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which HTML to PDF service are you running in production, and has it bitten you? Share your experience in the comments below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>pdf</category>
      <category>api</category>
      <category>programming</category>
    </item>
    <item>
      <title>Can I use it in a PDF? CSS support across HTML to PDF engines</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 20 Aug 2026 20:02:13 +0000</pubDate>
      <link>https://dev.to/accreditly/can-i-use-it-in-a-pdf-css-support-across-html-to-pdf-engines-4n53</link>
      <guid>https://dev.to/accreditly/can-i-use-it-in-a-pdf-css-support-across-html-to-pdf-engines-4n53</guid>
      <description>&lt;p&gt;Your template renders perfectly in the browser, then falls apart in the PDF: the grid collapses, the footer vanishes and &lt;code&gt;counter(page)&lt;/code&gt; prints nothing. Your CSS is fine. The problem is that "HTML to PDF" is not one renderer. It is four different engines with four different ideas about which parts of CSS exist.&lt;/p&gt;

&lt;p&gt;Browsers have caniuse.com. PDF engines have changelogs, forum threads and folklore. So we built the missing table. The full write-up lives on the HTML to Image blog as &lt;a href="https://html2img.com/articles/html-to-pdf-css-support/" rel="noopener noreferrer"&gt;Can I use it in a PDF? CSS support across HTML to PDF engines&lt;/a&gt;; this is the condensed version with the rows that bite most often.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four engines
&lt;/h2&gt;

&lt;p&gt;Nearly every converter you will meet is one of these under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome headless&lt;/strong&gt;: Puppeteer, Playwright, Gotenberg and most hosted APIs. Browser-grade CSS, and since Chrome 131 (November 2024) proper &lt;code&gt;@page&lt;/code&gt; margin boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wkhtmltopdf&lt;/strong&gt;: Qt WebKit frozen around 2012. Archived in January 2023, last release June 2020, and carrying an unpatched 9.8 SSRF vulnerability (CVE-2022-35583).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WeasyPrint&lt;/strong&gt;: a Python layout engine built for pagination. No JavaScript, excellent paged media.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prince&lt;/strong&gt;: the commercial reference implementation for print CSS, with its own engine and its own JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The support table
&lt;/h2&gt;

&lt;p&gt;Checked against Chrome 131 or later, wkhtmltopdf 0.12.6, WeasyPrint 69 and Prince 16, in August 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Chrome headless&lt;/th&gt;
&lt;th&gt;wkhtmltopdf&lt;/th&gt;
&lt;th&gt;WeasyPrint&lt;/th&gt;
&lt;th&gt;Prince&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flexbox&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Broken (2009 draft syntax)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grid&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, gaps remain&lt;/td&gt;
&lt;td&gt;Partial, no fragmentation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom properties &lt;code&gt;var()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calc()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Unreliable&lt;/td&gt;
&lt;td&gt;Yes, added 2025&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@page&lt;/code&gt; size and margins&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;CLI flags instead&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@page&lt;/code&gt; margin boxes&lt;/td&gt;
&lt;td&gt;Yes, since 131&lt;/td&gt;
&lt;td&gt;No, CLI headers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;counter(page)&lt;/code&gt; / &lt;code&gt;counter(pages)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Margin boxes only&lt;/td&gt;
&lt;td&gt;No, &lt;code&gt;[page]&lt;/code&gt; tokens&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named pages (&lt;code&gt;page: cover&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;break-before&lt;/code&gt; / &lt;code&gt;after&lt;/code&gt; / &lt;code&gt;inside&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Legacy &lt;code&gt;page-break-*&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named strings (&lt;code&gt;string-set&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target-counter()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;leader()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footnotes (&lt;code&gt;float: footnote&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs JavaScript&lt;/td&gt;
&lt;td&gt;Yes, full V8&lt;/td&gt;
&lt;td&gt;2012-era engine&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Own engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shape of it: Chromium wins everything a browser cares about and loses everything print specific beyond margin boxes. wkhtmltopdf loses everything. The two dedicated print engines are the only ones with the generated content features long documents need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The row that changed recently: headers and footers
&lt;/h2&gt;

&lt;p&gt;Until late 2024 Chromium ignored &lt;code&gt;@page&lt;/code&gt; margin boxes entirely, which is why every Puppeteer tutorial reaches for &lt;code&gt;headerTemplate&lt;/code&gt; and &lt;code&gt;footerTemplate&lt;/code&gt;, a clunky parallel HTML system with its own font quirks. From Chrome 131 it is just CSS, in all 16 margin regions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;@top-center&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Northgate Coffee · Statement"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9pt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;@bottom-right&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Page "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s1"&gt;" of "&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9pt&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch: in Chromium, &lt;code&gt;counter(page)&lt;/code&gt; only resolves inside margin boxes. Drop it into body content and you get nothing. WeasyPrint and Prince render it anywhere.&lt;/p&gt;

&lt;p&gt;What Chromium still cannot do is pull document content into the header. The classic "current chapter title in the running head" pattern needs named strings, which only the print engines implement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;string-set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chapter&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@page&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;@top-right&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chapter&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rows Chromium does not have at all
&lt;/h2&gt;

&lt;p&gt;A print table of contents needs the page number of a target element, plus the dotted line leading to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.toc&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;leader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;target-counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;page&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;&lt;code&gt;target-counter()&lt;/code&gt; and &lt;code&gt;leader()&lt;/code&gt; work in WeasyPrint and Prince and nowhere else. The same goes for footnotes via &lt;code&gt;float: footnote&lt;/code&gt;. If you are locked into Chromium and need these, Paged.js polyfills them in JavaScript before printing, at the cost of a heavier render and another dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas hiding inside "Yes"
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;WeasyPrint only gained grid in v62 (April 2024) and &lt;code&gt;calc()&lt;/code&gt; in the 2025 releases. An older install following a newer tutorial fails in confusing ways, so pin and check your version.&lt;/li&gt;
&lt;li&gt;Prince 16's grid cannot fragment across pages yet. Use grid for components, not page-spanning layout.&lt;/li&gt;
&lt;li&gt;wkhtmltopdf's flexbox is the abandoned 2009 &lt;code&gt;-webkit-box&lt;/code&gt; draft. Modern &lt;code&gt;display: flex&lt;/code&gt; markup silently renders as stacked blocks.&lt;/li&gt;
&lt;li&gt;Fragmentation is where most real bugs live. Chrome, WeasyPrint and Prince honour &lt;code&gt;break-inside: avoid&lt;/code&gt; and repeat &lt;code&gt;thead&lt;/code&gt; across pages; wkhtmltopdf only understands the legacy &lt;code&gt;page-break-*&lt;/code&gt; spellings, and its table splitting is fragile even then.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Picking an engine
&lt;/h2&gt;

&lt;p&gt;For invoices, statements and reports: Chromium, now that it has real headers and page numbers. For books, contracts and anything with a table of contents or cross references: WeasyPrint for free, Prince when there is budget. And if you are still on wkhtmltopdf, the table above is the migration argument in one screen: an archived engine, a critical CVE and a decade of missing CSS.&lt;/p&gt;

&lt;p&gt;The full version, covering &lt;code&gt;@page&lt;/code&gt; setup, named pages, fragmentation control and the JavaScript story, is on the blog: &lt;a href="https://html2img.com/articles/html-to-pdf-css-support/" rel="noopener noreferrer"&gt;CSS support across HTML to PDF engines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which engine are you running, and what is the worst CSS surprise it has given you? Share it in the comments below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>pdf</category>
      <category>html</category>
    </item>
    <item>
      <title>How to Generate Images in n8n (Social Cards, Certificates and PDF Reports)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Tue, 18 Aug 2026 19:42:07 +0000</pubDate>
      <link>https://dev.to/accreditly/how-to-generate-images-in-n8n-social-cards-certificates-and-pdf-reports-h24</link>
      <guid>https://dev.to/accreditly/how-to-generate-images-in-n8n-social-cards-certificates-and-pdf-reports-h24</guid>
      <description>&lt;p&gt;Your n8n workflow pulls the data, formats the message and posts it to Slack, then stops dead at the visual. The share card still gets made by hand, the certificate waits for someone to export it, and the Monday report goes out as a wall of numbers.&lt;/p&gt;

&lt;p&gt;You can fix all three with n8n's stock HTTP Request node and an HTML to Image API. No community node, no headless browser to maintain, and it works the same on n8n Cloud and self-hosted. This tutorial builds three workflows: social cards from an RSS feed, certificates from a form submission, and a weekly PDF report on a schedule.&lt;/p&gt;

&lt;p&gt;This is a cross-post: the original lives on the HTML to Image blog as &lt;a href="https://html2img.com/articles/n8n-image-generation/" rel="noopener noreferrer"&gt;How to Generate Images in n8n: Automate Social Cards, Certificates and Reports&lt;/a&gt;, where you'll also find the template gallery and API docs referenced throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An n8n instance, either Cloud or self-hosted&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://html2img.com/" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; API key (the free tier gives you 25 renders a month, which covers everything here)&lt;/li&gt;
&lt;li&gt;Somewhere to deliver the output: a Slack workspace and a Gmail account in these examples&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the API fits into n8n
&lt;/h2&gt;

&lt;p&gt;Two endpoints do all the work.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST https://app.html2img.com/api/v1/templates/{slug}&lt;/code&gt; renders one of 25 named templates. You send a small JSON payload (a title, a name, some line items) and get back a finished image at known dimensions. No HTML anywhere in your workflow.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST https://app.html2img.com/api/html&lt;/code&gt; renders raw HTML you supply, in real Chrome. Grid, flexbox, custom fonts and inline JavaScript all behave the way they do in your browser.&lt;/p&gt;

&lt;p&gt;Both respond with the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://i.html2img.com/abc123.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"credits_remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;973&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;url&lt;/code&gt; is a permanent CDN link, which is what makes this pleasant in n8n: no binary data moves between nodes. You pass a short string to Slack or into an email and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Create the credential
&lt;/h2&gt;

&lt;p&gt;Copy your API key from the dashboard, then in any HTTP Request node:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set &lt;strong&gt;Authentication&lt;/strong&gt; to &lt;strong&gt;Generic Credential Type&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Set &lt;strong&gt;Generic Auth Type&lt;/strong&gt; to &lt;strong&gt;Header Auth&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Create a credential with &lt;strong&gt;Name&lt;/strong&gt; &lt;code&gt;X-API-Key&lt;/code&gt; and &lt;strong&gt;Value&lt;/strong&gt; set to your key&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save it once, reuse it in every workflow below. The key never appears in node parameters or workflow exports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Social cards from an RSS feed
&lt;/h2&gt;

&lt;p&gt;The workflow is three nodes: &lt;strong&gt;RSS Feed Trigger → HTTP Request → Slack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Point the RSS Feed Trigger at your blog's feed. Each new post arrives with &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;link&lt;/code&gt; and &lt;code&gt;creator&lt;/code&gt; fields.&lt;/p&gt;

&lt;p&gt;Configure the HTTP Request node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Method&lt;/strong&gt;: POST&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL&lt;/strong&gt;: &lt;code&gt;https://app.html2img.com/api/v1/templates/open-graph-image&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: the Header Auth credential from Step 1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Body Content Type&lt;/strong&gt;: JSON, with &lt;strong&gt;Specify Body&lt;/strong&gt; set to &lt;strong&gt;Using JSON&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON.stringify($json.title)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fresh on the blog"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there are no quotes around the expression. &lt;code&gt;JSON.stringify()&lt;/code&gt; adds them itself and escapes whatever is inside. If you write &lt;code&gt;"title": "{{ $json.title }}"&lt;/code&gt; instead, the workflow runs fine for weeks and then breaks the first time a post title contains a double quote. Let &lt;code&gt;stringify&lt;/code&gt; build the value and that failure mode disappears. It's a habit worth applying to every hand-built JSON body in n8n.&lt;/p&gt;

&lt;p&gt;Finally, the Slack node posts the article link plus &lt;code&gt;{{ $json.url }}&lt;/code&gt; from the HTTP Request output. Slack unfurls the CDN URL into the card. Swap Slack for LinkedIn, X or Buffer and nothing upstream changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Certificates from a form submission
&lt;/h2&gt;

&lt;p&gt;Templates need no markup, but certificates usually want your own design. That's the raw HTML endpoint's job, and the clean pattern in n8n is: build the whole API payload in a Code node, then hand it to the HTTP Request untouched.&lt;/p&gt;

&lt;p&gt;The workflow: &lt;strong&gt;n8n Form Trigger → Code → HTTP Request → Gmail&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's the Code node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Full name&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;course&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Course&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;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&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="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&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;certId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NC-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;
&amp;lt;style&amp;gt;
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&amp;amp;family=JetBrains+Mono:wght@500&amp;amp;display=swap');
  body { width: 1200px; height: 850px; margin: 0; box-sizing: border-box;
         padding: 56px; background: #ffffff; font-family: 'Manrope', sans-serif;
         color: #0e1521; }
  .frame { height: 100%; border: 2px solid #2563eb; border-radius: 12px;
           box-sizing: border-box; padding: 64px; text-align: center;
           display: flex; flex-direction: column; justify-content: space-between; }
  .eyebrow { font-family: 'JetBrains Mono', monospace; font-size: 15px;
             letter-spacing: 0.2em; color: #2563eb; }
  h1 { font-size: 44px; font-weight: 800; margin: 12px 0 0; }
  .name { font-size: 56px; font-weight: 800; color: #2563eb; margin: 8px 0; }
  .body { font-size: 20px; color: #6b7585; line-height: 1.6; }
  .meta { display: flex; justify-content: space-between; align-items: flex-end;
          font-family: 'JetBrains Mono', monospace; font-size: 14px; color: #6b7585; }
  .sig { border-top: 1px solid #0e1521; padding-top: 8px; width: 220px; }
&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;&amp;lt;div class="frame"&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;div class="eyebrow"&amp;gt;CERTIFICATE OF COMPLETION&amp;lt;/div&amp;gt;
    &amp;lt;h1&amp;gt;Northgate Coffee Academy&amp;lt;/h1&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;p class="body"&amp;gt;This certifies that&amp;lt;/p&amp;gt;
    &amp;lt;div class="name"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;
    &amp;lt;p class="body"&amp;gt;has successfully completed&amp;lt;br&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;course&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/strong&amp;gt;&amp;lt;br&amp;gt;on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
  &amp;lt;/div&amp;gt;
  &amp;lt;div class="meta"&amp;gt;
    &amp;lt;div class="sig"&amp;gt;Course Director&amp;lt;/div&amp;gt;
    &amp;lt;div&amp;gt;Verify: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;certId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`&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="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;html&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;1200&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="mi"&gt;850&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&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;Template literals drop the attendee's name straight into the markup, and the node returns the complete API payload as its output item. That makes the HTTP Request trivial: POST to &lt;code&gt;https://app.html2img.com/api/html&lt;/code&gt;, &lt;strong&gt;Specify Body&lt;/strong&gt; set to &lt;strong&gt;Using JSON&lt;/strong&gt;, and the entire body is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON.stringify($json)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No escaping the quotes, newlines and backticks buried in 40 lines of HTML. The Code node built a clean object; &lt;code&gt;stringify&lt;/code&gt; serialises it correctly by definition.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dpi: 2&lt;/code&gt; renders at double pixel density so the certificate stays sharp on retina screens and survives printing, at the cost of roughly double the render time.&lt;/p&gt;

&lt;p&gt;The Gmail node then sends an HTML email with the certificate inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Congratulations! Your certificate is below.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{ $json.url }}"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Certificate of completion"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"max-width:100%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the certificate is a PNG on a CDN rather than styled HTML in the email body, it renders identically in Outlook, Gmail and Apple Mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: A weekly report PDF on a schedule
&lt;/h2&gt;

&lt;p&gt;Same pattern as the certificate, two changes: a Schedule Trigger at the front and &lt;code&gt;format: "pdf"&lt;/code&gt; in the payload.&lt;/p&gt;

&lt;p&gt;The workflow: &lt;strong&gt;Schedule Trigger → Google Sheets → Code → HTTP Request → Slack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Set the trigger to Monday at 08:00 and have the Google Sheets node return your metrics rows. The Code node turns them into a report, with a bar chart made of plain divs. CSS is a perfectly good charting library when real Chrome is your renderer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&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;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&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;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;rows&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;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&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;bars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
  &amp;lt;div class="bar-row"&amp;gt;
    &amp;lt;span class="label"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;week&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
    &amp;lt;div class="track"&amp;gt;&amp;lt;div class="bar" style="width:&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;span class="val"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/span&amp;gt;
  &amp;lt;/div&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;
&amp;lt;style&amp;gt;
  @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&amp;amp;display=swap');
  body { font-family: 'Manrope', sans-serif; color: #0e1521; padding: 48px; }
  h1 { font-size: 28px; margin: 0 0 4px; }
  .sub { color: #6b7585; margin: 0 0 32px; }
  .kpis { display: flex; gap: 16px; margin-bottom: 40px; }
  .kpi { flex: 1; border: 1px solid #e2e6ec; border-radius: 10px; padding: 20px; }
  .kpi b { display: block; font-size: 30px; }
  .kpi span { color: #6b7585; font-size: 14px; }
  .bar-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; }
  .label { width: 90px; font-size: 13px; color: #6b7585; }
  .track { flex: 1; background: #eff4ff; border-radius: 4px; }
  .bar { height: 18px; background: #2563eb; border-radius: 4px; }
  .val { width: 50px; font-size: 13px; text-align: right; }
&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;h1&amp;gt;Weekly growth report&amp;lt;/h1&amp;gt;
  &amp;lt;p class="sub"&amp;gt;Generated &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
  &amp;lt;div class="kpis"&amp;gt;
    &amp;lt;div class="kpi"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signups&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;span&amp;gt;Signups this week&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;div class="kpi"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;span&amp;gt;Revenue this week&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;div class="kpi"&amp;gt;&amp;lt;b&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/b&amp;gt;&amp;lt;span&amp;gt;Weeks tracked&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;
  &amp;lt;/div&amp;gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bars&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`&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="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;html&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="s1"&gt;pdf&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HTTP Request is identical to Step 3, body &lt;code&gt;{{ JSON.stringify($json) }}&lt;/code&gt;. The difference is &lt;code&gt;format: "pdf"&lt;/code&gt;: the API returns an A4 portrait PDF with selectable text and embedded fonts, and long content paginates automatically. Width and height are ignored in PDF mode, so leave them out.&lt;/p&gt;

&lt;p&gt;The Slack node posts the returned &lt;code&gt;url&lt;/code&gt;. It ends in &lt;code&gt;.pdf&lt;/code&gt; and opens straight in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Renders are synchronous within a 30 second budget.&lt;/strong&gt; For heavy renders, pass a &lt;code&gt;webhook_url&lt;/code&gt; in the payload and the API responds immediately, then POSTs the finished file's URL to a second n8n workflow that starts with a Webhook node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't re-render what hasn't changed.&lt;/strong&gt; Every render costs a credit and every URL is permanent, so store the URL back in your sheet or database on first render and reuse it. An IF node checking whether the URL column is populated is usually enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn on Retry On Fail&lt;/strong&gt; in each HTTP Request node's settings. Validation errors come back as a 422 with a &lt;code&gt;details&lt;/code&gt; object naming the bad field, and n8n shows the response body in the execution log.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch &lt;code&gt;credits_remaining&lt;/code&gt;&lt;/strong&gt; in every response. An IF node that pings you when it drops below a threshold means a busy month never silently stops your certificates going out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;You've built three workflows, and they're really one pattern: a trigger, an optional Code node that assembles a payload, and an HTTP Request that returns a CDN URL. Templates cover the no-code cases, raw HTML covers your own designs, and &lt;code&gt;{{ JSON.stringify($json) }}&lt;/code&gt; keeps the whole thing escape-proof. The full version of this guide, with a rendered example of the certificate and the workflow diagrams, is on the HTML to Image blog: &lt;a href="https://html2img.com/articles/n8n-image-generation/" rel="noopener noreferrer"&gt;How to Generate Images in n8n&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Are you generating images or PDFs from your n8n workflows already? Share your setup in the comments below.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Convert Markdown to an Image (Without Screenshotting Your Editor)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Sat, 15 Aug 2026 07:51:21 +0000</pubDate>
      <link>https://dev.to/accreditly/how-to-convert-markdown-to-an-image-without-screenshotting-your-editor-3l9o</link>
      <guid>https://dev.to/accreditly/how-to-convert-markdown-to-an-image-without-screenshotting-your-editor-3l9o</guid>
      <description>&lt;p&gt;You write release notes, changelogs and comparison tables in Markdown, and then you need to post them somewhere that refuses to render it. Slack collapses your table into a line of pipes. X and LinkedIn strip every heading and bullet to flat text. Email clients show your readers literal asterisks.&lt;/p&gt;

&lt;p&gt;So you open the preview pane and take a screenshot. It works, in the way that holding a door shut works instead of fixing the lock. We've just published a fuller guide on &lt;a href="https://html2img.com/articles/markdown-to-image/" rel="noopener noreferrer"&gt;converting Markdown to an image&lt;/a&gt; over on HTML to Image, and this post covers the working version for JavaScript developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's wrong with a screenshot
&lt;/h2&gt;

&lt;p&gt;A screenshot carries everything you didn't want along with the content. Your editor theme comes with it, so a dark-mode preview lands in a light-mode inbox looking like a ransom note. The resolution is whatever your display happens to be, so text that was crisp on your laptop turns soft on a retina screen. The crop is hand-drawn, so there's a sliver of scrollbar down one edge.&lt;/p&gt;

&lt;p&gt;And it's manual. If the notes change, you screenshot again. Fine once; wrong as a workflow.&lt;/p&gt;

&lt;p&gt;The better approach: convert the Markdown to HTML, wrap it in a clean stylesheet, and render it in a real browser at a fixed width. The formatting survives because it's no longer formatting. It's pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node 18 or newer (for built-in &lt;code&gt;fetch&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;marked&lt;/code&gt; package: &lt;code&gt;npm install marked&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An API key for a rendering service (&lt;a href="https://html2img.com/docs" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; has a free tier; any HTML-to-PNG renderer with a full-page option works the same way)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Parse the Markdown
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;marked&lt;/code&gt; handles GitHub-flavoured Markdown out of the box, so tables, strikethrough and fenced code blocks all convert properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marked&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;markdown&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;release-notes.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&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;fragment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gfm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get back is a bare HTML fragment with no opinion about its own appearance. That's the next step's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Wrap it in a document stylesheet
&lt;/h2&gt;

&lt;p&gt;The parser gives you semantics; this gives you the look. It's deliberately close to how GitHub renders Markdown, because that's what people expect a rendered document to look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stylesheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  body { margin: 0; padding: 48px 56px;
    font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 17px; line-height: 1.6; color: #1f2328; }
  h2 { font-size: 30px; margin: 0 0 16px; padding-bottom: 10px;
    border-bottom: 1px solid #d1d9e0; }
  table { border-collapse: collapse; width: 100%; margin: 0 0 18px; }
  th, td { border: 1px solid #d1d9e0; padding: 9px 14px; text-align: left; }
  th { background: #f6f8fa; }
  blockquote { margin: 0 0 18px; padding: 4px 18px;
    border-left: 4px solid #d1d9e0; color: #59636e; }
  code { font-family: ui-monospace, Menlo, Consolas, monospace;
    font-size: 0.9em; background: #f0f1f3; padding: 2px 6px; border-radius: 5px; }
  pre { background: #f6f8fa; border: 1px solid #d1d9e0;
    border-radius: 8px; padding: 16px 18px; }
  pre code { background: none; padding: 0; font-size: 15px; }
`&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;!doctype html&amp;gt;
&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;meta charset="utf-8"&amp;gt;&amp;lt;style&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stylesheet&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap the font stack for your brand face and tint the borders if you want it on-brand. The structure doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Render it
&lt;/h2&gt;

&lt;p&gt;One POST. The two parameters that matter are &lt;code&gt;width&lt;/code&gt;, which sets the line length (1,000px reads well for a document), and &lt;code&gt;fullpage: true&lt;/code&gt;, which makes the capture height follow the content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://app.html2img.com/api/html&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML2IMG_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;html&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;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullpage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full-page part is what separates this from screenshotting. A three-line note produces a small image and a forty-line changelog produces a tall one, from the same code, with nothing cropped at the bottom.&lt;/p&gt;

&lt;p&gt;Here's a release note that started life as thirty lines of Markdown and came out of exactly this script:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6b3hvhmkeywh7f1spyv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6b3hvhmkeywh7f1spyv.png" alt="A rendered release note as a PNG: heading, bold bullets, a table with inline code, a blockquote and a code block all intact" width="800" height="729"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bold lead-ins, the inline code, the table columns, the blockquote and the fenced block all survived. Post that image anywhere and it looks the same everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pays off
&lt;/h2&gt;

&lt;p&gt;Release notes as social posts are the obvious one: the structure survives on platforms that would flatten it to a paragraph. Tables in Slack are the daily one. Email is the sneaky one, because clients disagree about nearly every layout feature, and a PNG looks identical in Gmail, Outlook and Apple Mail since there's nothing left to interpret.&lt;/p&gt;

&lt;p&gt;And because it's one HTTP call, it automates: run it in CI when a release tags, or in a cron that turns the week's merged PRs into a Friday summary card. The Markdown was already being written. Now it travels.&lt;/p&gt;

&lt;p&gt;For the browser-based version of this (paste Markdown, get a PNG, no code), plus Python and PHP implementations and the full stylesheet, the &lt;a href="https://html2img.com/articles/markdown-to-image/" rel="noopener noreferrer"&gt;complete guide is here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you post rendered Markdown anywhere regularly, or are you still screenshotting the preview pane? Share your setup in the comments.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why your Open Graph image doesn't show up when you share a link</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Wed, 12 Aug 2026 17:26:06 +0000</pubDate>
      <link>https://dev.to/accreditly/why-your-open-graph-image-doesnt-show-up-when-you-share-a-link-4h41</link>
      <guid>https://dev.to/accreditly/why-your-open-graph-image-doesnt-show-up-when-you-share-a-link-4h41</guid>
      <description>&lt;p&gt;You paste a link into Slack and the preview comes back as a bare grey rectangle. Or the title shows and the image does not. Or LinkedIn serves artwork from three deploys ago. You open devtools, the tags are right there, and the card is still wrong.&lt;/p&gt;

&lt;p&gt;The reason is nearly always the same one. The thing reading your page is not a browser. A social crawler makes one anonymous request, parses the HTML that came back over the wire, fetches the image, and leaves. No session, no JavaScript engine, no patience. We have published the full version of this with the platform specifics on the HTML to Image blog, in &lt;a href="https://html2img.com/articles/og-image-not-showing/" rel="noopener noreferrer"&gt;Why your Open Graph image doesn't show up when you share a link&lt;/a&gt;, and this post walks through the eight causes and how to tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when someone shares your URL
&lt;/h2&gt;

&lt;p&gt;Four steps, and each fails independently:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The platform requests your URL with a crawler user agent, following redirects.&lt;/li&gt;
&lt;li&gt;It parses the served HTML for &lt;code&gt;og:&lt;/code&gt; and &lt;code&gt;twitter:&lt;/code&gt; tags.&lt;/li&gt;
&lt;li&gt;It resolves &lt;code&gt;og:image&lt;/code&gt; to an absolute URL and fetches it, often reading only enough of the file to get its dimensions.&lt;/li&gt;
&lt;li&gt;It builds a card using its own rules about which tag wins, how to crop, and how much of the title to keep.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Working out which step broke is most of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The tags are in devtools but not in the response
&lt;/h2&gt;

&lt;p&gt;The most common cause by a distance. If your head is set client-side, a Vue app assigning meta on mount, a React app using a client-only helmet component, a CMS script patching the head after load, the crawler sees none of it. Devtools shows the DOM after your JavaScript has run. The crawler reads the document as served.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa1uur4bo6lucic9wzdx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa1uur4bo6lucic9wzdx.png" alt="Side by side comparison of a page head in devtools with three Open Graph tags present, and the same page as served to a crawler with no Open Graph tags at all" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One command tells you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; https://example.com/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'og:\|twitter:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output means the tags have to move into server-rendered HTML. In Next.js that is the &lt;code&gt;metadata&lt;/code&gt; export or &lt;code&gt;generateMetadata&lt;/code&gt; in a server component, not anything set in &lt;code&gt;useEffect&lt;/code&gt;. In Nuxt it is &lt;code&gt;useSeoMeta&lt;/code&gt; on a server-rendered route. In a static site generator it is the head partial at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;og:image&lt;/code&gt; is a relative path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Valid HTML, useless to a crawler. The protocol asks for an absolute URL including the scheme, and several platforms drop the image rather than guess your host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same problem with protocol-relative &lt;code&gt;//&lt;/code&gt; URLs, and with anything still pointing at &lt;code&gt;localhost&lt;/code&gt; because the base URL comes from an environment variable nobody set in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The image is not reachable by an anonymous bot
&lt;/h2&gt;

&lt;p&gt;The image fetch is as anonymous as the page fetch. Hotlink protection that wants a same-origin referer, Cloudflare Bot Fight Mode on the asset path, a &lt;code&gt;robots.txt&lt;/code&gt; rule over the image directory, an expired signed CDN URL, or a private bucket object will all kill it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/images/og/pricing.png | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want a &lt;code&gt;200&lt;/code&gt; and an image content type. A &lt;code&gt;403&lt;/code&gt; for curl is a &lt;code&gt;403&lt;/code&gt; for Facebook.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. No &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Seeing your URL for the first time, a platform has no idea how big the image is, so it downloads and measures it before laying out the card. Several will render the card without the image rather than wait, then get it right on the second or third share. Your launch post has already been seen by then.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"image/png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing plans for Northgate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The image is the wrong size, ratio or format
&lt;/h2&gt;

&lt;p&gt;1200 by 630 renders cleanly everywhere. Below 600 by 315, Facebook and LinkedIn downgrade to a small square thumbnail beside the text. Over 8MB, the image is dropped. SVG is not read as an &lt;code&gt;og:image&lt;/code&gt; anywhere that matters, and WebP support is patchy enough that PNG or JPEG remains the safe answer.&lt;/p&gt;

&lt;p&gt;Then the crop. Everything centre-crops toward roughly 1.91:1 and square unfurls keep only the middle band, so a logo or headline near an edge is a logo or headline you sometimes lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;twitter:card&lt;/code&gt; is missing so X renders the small card
&lt;/h2&gt;

&lt;p&gt;X falls back to &lt;code&gt;og:&lt;/code&gt; tags for the title, description and image, which is why people skip the &lt;code&gt;twitter:&lt;/code&gt; namespace. The one tag you cannot skip is the card type. Without it you get &lt;code&gt;summary&lt;/code&gt;, a small square thumbnail beside the text, instead of the full-width image card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. The URL you tested is not the URL people share
&lt;/h2&gt;

&lt;p&gt;Tags are read from the final URL after redirects, and what ends up in the wild is rarely the clean URL you tested. Campaign parameters, a trailing slash difference, a locale prefix, an AMP variant or an &lt;code&gt;http&lt;/code&gt; to &lt;code&gt;https&lt;/code&gt; hop can each resolve to a document with different tags. Test the canonical URL, then test the exact string your campaign is about to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Facebook is still showing a card you fixed last week
&lt;/h2&gt;

&lt;p&gt;Meta caches the first scrape hard and can hold it for days. Deploying a fix does not invalidate it, so run the URL through Meta's Sharing Debugger once and use Scrape Again. This one matters mostly because it makes the other seven harder to diagnose. If a live fetch shows correct tags and Facebook shows the old card, the cache is stale rather than your fix being wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the lot in one pass
&lt;/h2&gt;

&lt;p&gt;The official debuggers have thinned out. Meta's needs a Facebook login and only covers Facebook. X removed the rendered preview from its Card Validator, so it now tells you a card is valid without showing you the card. Neither says anything about Slack, Discord or WhatsApp.&lt;/p&gt;

&lt;p&gt;We built the &lt;a href="https://html2img.com/tools/open-graph-checker" rel="noopener noreferrer"&gt;Open Graph Checker&lt;/a&gt; to run the whole sequence in one request: fetch as a crawler without executing JavaScript, follow redirects and read tags from the final URL, resolve relative image URLs, then read the image's real dimensions, type and file size from the file rather than from what the tags claim. It renders the card as Facebook, X, LinkedIn, Slack, Discord and WhatsApp would each build it, scores the page, and prints a corrected tag block. No login, and it reads live rather than from a cache.&lt;/p&gt;

&lt;p&gt;The failures it separates fastest are the ones that look identical from outside: tags missing because they are client-side, an image URL that resolves but returns a &lt;code&gt;403&lt;/code&gt;, an image that is present but 400 pixels wide, and a page that redirects somewhere with different tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  The block you are aiming for
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"article"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/pricing"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:site_name"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Northgate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing that scales with your traffic"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Per-render pricing with no seat fees, and a free tier that covers a small site."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"image/png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing plans for Northgate"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing that scales with your traffic"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Per-render pricing with no seat fees, and a free tier that covers a small site."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/images/og/pricing.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Titles under about 60 characters and descriptions under about 155, or they truncate in most placements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping it happening again
&lt;/h2&gt;

&lt;p&gt;Share metadata rots quietly. It breaks during framework migrations, CMS moves and route refactors, and nothing in your test suite notices because the page still returns a 200. A crude assertion over your important URLs catches most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;url &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/ &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/pricing &lt;span class="se"&gt;\&lt;/span&gt;
  https://example.com/docs
&lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;html&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'property="og:image"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ok   &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it in CI after deploy, and put your top twenty URLs through a checker after any migration, while the traffic that would have hit those broken cards is still recoverable.&lt;/p&gt;

&lt;p&gt;The longer version, including how to generate the card image itself when the answer turns out to be that you never had one, is on the &lt;a href="https://html2img.com/articles/og-image-not-showing/" rel="noopener noreferrer"&gt;HTML to Image blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which of the eight has bitten you? Mine is client-side tags, twice on the same project.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>seo</category>
      <category>html</category>
    </item>
    <item>
      <title>Generate Open Graph images in GitHub Actions (no headless browser required)</title>
      <dc:creator>Accreditly</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:21:26 +0000</pubDate>
      <link>https://dev.to/accreditly/generate-open-graph-images-in-github-actions-no-headless-browser-required-2153</link>
      <guid>https://dev.to/accreditly/generate-open-graph-images-in-github-actions-no-headless-browser-required-2153</guid>
      <description>&lt;p&gt;You can render Open Graph images, pull request screenshots and release cards from a GitHub workflow without installing a single browser on the runner. No Puppeteer, no &lt;code&gt;apt-get install&lt;/code&gt; block for Chrome's dependencies, no cache step for a 300 MB browser download. One workflow step does the rendering remotely and hands you back a file.&lt;/p&gt;

&lt;p&gt;The step is &lt;code&gt;html2img/action&lt;/code&gt;, the official GitHub Action for the &lt;a href="https://html2img.com" rel="noopener noreferrer"&gt;HTML to Image API&lt;/a&gt;. We have published a full write-up of the workflow patterns in &lt;a href="https://html2img.com/articles/generate-og-images-github-actions/" rel="noopener noreferrer"&gt;Generate Open Graph Images in GitHub Actions&lt;/a&gt;, and this tutorial walks you through getting from nothing to committed OG images and PR screenshots, step by step.&lt;/p&gt;

&lt;p&gt;Why bother moving the render out of the runner at all? Because a GitHub-hosted runner is a fresh machine every run. Anything a headless browser needs gets installed every time or cached with &lt;code&gt;actions/cache&lt;/code&gt;, and both routes add minutes and new ways to fail. An API call takes a couple of seconds whatever the page contains, and fonts, emoji and CSS support stop being your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A GitHub repository with Actions enabled.&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://html2img.com" rel="noopener noreferrer"&gt;HTML to Image&lt;/a&gt; account. The free tier comes with 50 credits and does not ask for a card. One credit is one render.&lt;/li&gt;
&lt;li&gt;Your API key from the dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Add your API key as a secret
&lt;/h2&gt;

&lt;p&gt;In your repository, go to Settings, then Secrets and variables, then Actions, and create a repository secret named &lt;code&gt;HTML2IMG_API_KEY&lt;/code&gt; with your key as the value. The action masks the key in logs, but it still belongs in a secret rather than in the workflow file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Render your first image
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/render.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Render&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;workflow_dispatch&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;html2img/action@v1&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HTML2IMG_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;div&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;style="font:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;700&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;72px&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;system-ui;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;padding:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;80px"&amp;gt;Hello&amp;lt;/div&amp;gt;'&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630&lt;/span&gt;
          &lt;span class="na"&gt;output-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello.png&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Rendered ${{ steps.render.outputs.url }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it from the Actions tab. The step writes &lt;code&gt;hello.png&lt;/code&gt; into the workspace and sets three outputs you can use in later steps: &lt;code&gt;url&lt;/code&gt; (the hosted render), &lt;code&gt;path&lt;/code&gt; (where the file landed) and &lt;code&gt;skipped&lt;/code&gt; (more on that in a moment).&lt;/p&gt;

&lt;p&gt;You give the action exactly one source per step: &lt;code&gt;html&lt;/code&gt; for inline markup, &lt;code&gt;html-file&lt;/code&gt; for a file in your repository, &lt;code&gt;url&lt;/code&gt; to screenshot a live page, or &lt;code&gt;template&lt;/code&gt; plus &lt;code&gt;variables&lt;/code&gt; to render a named template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Generate OG images for your blog posts
&lt;/h2&gt;

&lt;p&gt;Here is the real workflow: every time posts change on &lt;code&gt;main&lt;/code&gt;, find the ones with no social card yet, render them, and commit the PNGs. The example paths are Astro's, but only the paths are. Point it at &lt;code&gt;content/posts&lt;/code&gt; and it is a Hugo workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OG images&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/content/blog/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;find-posts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;slugs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.find.outputs.slugs }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;find&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;slugs=$(for file in src/content/blog/*.md; do&lt;/span&gt;
            &lt;span class="s"&gt;slug=$(basename "$file" .md)&lt;/span&gt;
            &lt;span class="s"&gt;if [ ! -f "public/og/$slug.png" ]; then printf '%s\n' "$slug"; fi&lt;/span&gt;
          &lt;span class="s"&gt;done | jq -R . | jq -sc .)&lt;/span&gt;
          &lt;span class="s"&gt;echo "slugs=$slugs" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;

  &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;find-posts&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;needs.find-posts.outputs.slugs != '[]'&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJSON(needs.find-posts.outputs.slugs) }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build the card markup&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;title=$(sed -n 's/^title: *//p' "src/content/blog/${{ matrix.slug }}.md" | head -1 | tr -d '"')&lt;/span&gt;
          &lt;span class="s"&gt;mkdir -p build&lt;/span&gt;
          &lt;span class="s"&gt;cat &amp;gt; build/card.html &amp;lt;&amp;lt;HTML&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;meta charset="utf-8"&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;div style="display:flex;align-items:center;width:1200px;height:630px;&lt;/span&gt;
                      &lt;span class="s"&gt;box-sizing:border-box;padding:80px;background:#0f172a;&lt;/span&gt;
                      &lt;span class="s"&gt;color:#fff;font:700 68px/1.15 system-ui,sans-serif"&amp;gt;&lt;/span&gt;
            &lt;span class="s"&gt;$title&lt;/span&gt;
          &lt;span class="s"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
          &lt;span class="s"&gt;HTML&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;html2img/action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HTML2IMG_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;html-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build/card.html&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630&lt;/span&gt;
          &lt;span class="na"&gt;output-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/og/${{ matrix.slug }}.png&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;og-${{ matrix.slug }}&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/og/&lt;/span&gt;

  &lt;span class="na"&gt;commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;render&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/download-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;og-*&lt;/span&gt;
          &lt;span class="na"&gt;merge-multiple&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public/og&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name 'github-actions[bot]'&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email '41898282+github-actions[bot]@users.noreply.github.com'&lt;/span&gt;
          &lt;span class="s"&gt;git add public/og&lt;/span&gt;
          &lt;span class="s"&gt;if git diff --cached --quiet; then&lt;/span&gt;
            &lt;span class="s"&gt;echo 'No new cards.'&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;git commit -m 'Add Open Graph images'&lt;/span&gt;
            &lt;span class="s"&gt;git push&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Committing the images matters, and it is not just tidiness. On the free plan a hosted render is kept for 7 days. An OG image referenced from a meta tag has to exist for as long as the post does, so the PNG belongs in your repository or your deploy output, not hotlinked from a CDN you do not control.&lt;/p&gt;

&lt;p&gt;The card markup above is deliberately minimal so the workflow stays readable. In a real project you would design something better, or skip HTML altogether and use a named template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Screenshot the pull request preview
&lt;/h2&gt;

&lt;p&gt;If your host deploys a preview per pull request, you can screenshot it and post the image as a comment that updates on every push. Reviewers see the change without leaving the PR.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Preview screenshot&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;screenshot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.pull_request.head.repo.full_name == github.repository&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for the preview to answer&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;preview&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;url="https://deploy-preview-${{ github.event.pull_request.number }}--example.netlify.app"&lt;/span&gt;
          &lt;span class="s"&gt;for _ in $(seq 1 30); do&lt;/span&gt;
            &lt;span class="s"&gt;if curl -fsS -o /dev/null "$url"; then&lt;/span&gt;
              &lt;span class="s"&gt;echo "url=$url" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
              &lt;span class="s"&gt;exit 0&lt;/span&gt;
            &lt;span class="s"&gt;fi&lt;/span&gt;
            &lt;span class="s"&gt;sleep 10&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;html2img/action@v1&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shot&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.HTML2IMG_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.preview.outputs.url }}&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1280&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800&lt;/span&gt;
          &lt;span class="na"&gt;wait-for-selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt;
          &lt;span class="na"&gt;css&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.cookie-banner,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.chat-widget&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;display:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;!important&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;peter-evans/create-or-update-comment@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;issue-number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.number }}&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;Preview of ${{ github.event.pull_request.head.sha }}:&lt;/span&gt;

            &lt;span class="s"&gt;![Preview screenshot](${{ steps.shot.outputs.url }})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two inputs are doing the heavy lifting. &lt;code&gt;wait-for-selector&lt;/code&gt; holds the capture until your content actually exists in the DOM, and returns the moment it does, which beats guessing a fixed delay. &lt;code&gt;css&lt;/code&gt; injects styles over the page's own, and since the page's styles usually win on specificity, that &lt;code&gt;!important&lt;/code&gt; is not optional. It is the cleanest way to hide a cookie banner before the shot.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;if:&lt;/code&gt; guard. GitHub does not expose secrets to workflows triggered by pull requests from forks, so this recipe is for branches on your own repository. That is a platform security decision, and a sensible one, but it is better to know before you wonder why fork PRs get no screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that save your credits
&lt;/h2&gt;

&lt;p&gt;First, caching. When &lt;code&gt;output-path&lt;/code&gt; is set, the action hashes the resolved inputs and writes the digest next to the file as &lt;code&gt;&amp;lt;output-path&amp;gt;.html2img-hash&lt;/code&gt;. If the file and a matching digest already exist, the API is never called and the &lt;code&gt;skipped&lt;/code&gt; output is &lt;code&gt;true&lt;/code&gt;. Commit both files, as the OG workflow does, and re-runs cost nothing. CI re-runs identical work all day long, so this default is what keeps 50 free credits meaning 50 images rather than 50 workflow runs.&lt;/p&gt;

&lt;p&gt;Second, DPI. For HTML sources the API's &lt;code&gt;dpi&lt;/code&gt; default is 2, so a 1200 by 630 card comes back as a 2400 by 1260 file. That is the right call for retina screens, but if anything downstream asserts exact pixel dimensions, set &lt;code&gt;dpi: 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;The same step renders named templates (&lt;code&gt;template: open-graph-image&lt;/code&gt; with a &lt;code&gt;variables&lt;/code&gt; JSON object), which is how you attach a social card to every release without maintaining any markup, and it takes &lt;code&gt;format: pdf&lt;/code&gt; if the artefact you need is a document rather than an image. The &lt;a href="https://html2img.com/articles/generate-og-images-github-actions/" rel="noopener noreferrer"&gt;full article&lt;/a&gt; covers the release workflow, the PDF options and the parameter combinations the action refuses to send, and the &lt;a href="https://github.com/html2img/action" rel="noopener noreferrer"&gt;action's README&lt;/a&gt; has the complete inputs table.&lt;/p&gt;

&lt;p&gt;In this tutorial you have set up an API key as a secret, rendered a first image from a workflow, wired up committed OG images for a blog and posted preview screenshots on pull requests, all without a browser anywhere near your CI.&lt;/p&gt;

&lt;p&gt;Are you generating OG images at build time, in CI, or on demand at request time? Share your setup in the comments below.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>github</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
