<?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: Casper Tran</title>
    <description>The latest articles on DEV Community by Casper Tran (@casper_tran_59c6a8b1e1f3d).</description>
    <link>https://dev.to/casper_tran_59c6a8b1e1f3d</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%2F4232116%2F713471b1-5322-4164-a5df-70301a5320fc.jpg</url>
      <title>DEV Community: Casper Tran</title>
      <link>https://dev.to/casper_tran_59c6a8b1e1f3d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/casper_tran_59c6a8b1e1f3d"/>
    <language>en</language>
    <item>
      <title>Rebuilding a Tailwind v4 theme from an existing site</title>
      <dc:creator>Casper Tran</dc:creator>
      <pubDate>Sun, 08 Nov 2026 05:54:16 +0000</pubDate>
      <link>https://dev.to/casper_tran_59c6a8b1e1f3d/rebuilding-a-tailwind-v4-theme-from-an-existing-site-49fl</link>
      <guid>https://dev.to/casper_tran_59c6a8b1e1f3d/rebuilding-a-tailwind-v4-theme-from-an-existing-site-49fl</guid>
      <description>&lt;p&gt;A client sends a link: "make the new app feel like our site". The site isn't built with Tailwind, the original designer is gone, and there's no style guide. You need a theme.&lt;/p&gt;

&lt;p&gt;Here is how I go from a live page to a Tailwind CSS v4 &lt;code&gt;@theme&lt;/code&gt; block, first by hand, then the shortcut I ended up building.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Find the colors and give them jobs
&lt;/h2&gt;

&lt;p&gt;A flat list of every hex code on the page is easy to get (Chrome DevTools &amp;gt; More tools &amp;gt; &lt;strong&gt;CSS Overview&lt;/strong&gt;) and not very useful. What you want is roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;primary&lt;/strong&gt;: the fill of the main buttons. Inspect the hero CTA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;background&lt;/strong&gt; and &lt;strong&gt;surface&lt;/strong&gt;: the page, and the cards or panels on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;text&lt;/strong&gt; and &lt;strong&gt;muted&lt;/strong&gt;: body copy and captions. Check the contrast while you're there; DevTools shows it in the color picker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;border&lt;/strong&gt;: whatever the dividers and input outlines use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Naming by role is what lets you write &lt;code&gt;bg-primary&lt;/code&gt; and &lt;code&gt;text-muted&lt;/code&gt; later instead of &lt;code&gt;bg-[#1d4ed8]&lt;/code&gt; everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Find the fonts that actually render
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;font-family&lt;/code&gt; declaration is a wish list. To see what the browser drew, inspect a heading, open &lt;strong&gt;Computed&lt;/strong&gt;, and scroll to &lt;strong&gt;Rendered Fonts&lt;/strong&gt;. Do it for a heading, body text, a button and any code. If a font is commercial, pick a free stand-in now (Google Fonts has a close match for most).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Write down the type scale with its details
&lt;/h2&gt;

&lt;p&gt;For each heading level and body size, note size, weight, line height and letter spacing. Tight tracking on big headlines is a big part of how a site feels, and it's the first thing that gets lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Put it in &lt;code&gt;@theme&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Tailwind v4 reads theme variables from CSS, so there's no config file. Order matters: the font &lt;code&gt;@import&lt;/code&gt; has to come first, then Tailwind, then the theme.&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;@import&lt;/span&gt; &lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;800&amp;amp;display=swap"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#1d4ed8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#65a30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#0f172a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#64748b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#e2e8f0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--font-sans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Manrope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ui-sans-serif&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-6xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-6xl--line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.02&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-6xl--letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-2.7px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-6xl--font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--radius-md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&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;Two things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--text-6xl--line-height&lt;/code&gt;, &lt;code&gt;--letter-spacing&lt;/code&gt; and &lt;code&gt;--font-weight&lt;/code&gt; ride along with the size, so &lt;code&gt;text-6xl&lt;/code&gt; alone gives you the full headline style.&lt;/li&gt;
&lt;li&gt;Reusing Tailwind's names (&lt;code&gt;text-6xl&lt;/code&gt;, &lt;code&gt;radius-md&lt;/code&gt;) replaces the defaults. That's usually what you want for sizes; for colors, role names avoid clobbering &lt;code&gt;blue-700&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The shortcut
&lt;/h2&gt;

&lt;p&gt;Doing this by hand takes me 20 to 30 minutes per site, and I kept doing it, so I made a browser extension, &lt;a href="https://stylasso.pixlasso.com/website-to-tailwind-theme?src=devto" rel="noopener noreferrer"&gt;Stylasso&lt;/a&gt;, that does steps 1 to 4 from the tab you're on. It picks roles from where colors are painted (button fills and the logo for primary, the most used body color for text), reads the rendered fonts and the type scale, and exports a &lt;code&gt;@theme&lt;/code&gt; block like the one above. Those values are its output for a demo site, trimmed to fit this post.&lt;/p&gt;

&lt;p&gt;It also writes CSS variables, JSON and a DESIGN.md for AI coding tools; those are free. Since it runs in your own browser, it works on pages you're signed in to and on localhost.&lt;/p&gt;

&lt;p&gt;I made it, so I'm biased; if you try it on a site and it gets the primary color wrong, I'd like to know which one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with help from an AI assistant. The steps and the example output were checked against the real tool.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>css</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Converting WebP to JPG inside a Chrome extension: OffscreenCanvas in an MV3 offscreen document</title>
      <dc:creator>Casper Tran</dc:creator>
      <pubDate>Sat, 07 Nov 2026 12:53:46 +0000</pubDate>
      <link>https://dev.to/casper_tran_59c6a8b1e1f3d/converting-webp-to-jpg-inside-a-chrome-extension-offscreencanvas-in-an-mv3-offscreen-document-35kb</link>
      <guid>https://dev.to/casper_tran_59c6a8b1e1f3d/converting-webp-to-jpg-inside-a-chrome-extension-offscreencanvas-in-an-mv3-offscreen-document-35kb</guid>
      <description>&lt;p&gt;Chrome, Edge and most sites now hand you WebP or AVIF when you right-click an image and pick "Save image as". Renaming &lt;code&gt;photo.webp&lt;/code&gt; to &lt;code&gt;photo.jpg&lt;/code&gt; changes the label and nothing else: the bytes are still WebP, and the next print shop, marketplace upload form or old image editor rejects it.&lt;/p&gt;

&lt;p&gt;I make PixLasso, a Chrome and Edge extension that saves every image on a page into one ZIP. The feature people asked for first was a right-click entry, "Save image as JPG", that converts on your computer and never uploads anything. This post is about how that conversion runs inside a Manifest V3 extension, and the three things that bit me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the pixels can be decoded
&lt;/h2&gt;

&lt;p&gt;An MV3 background script is a service worker. It has &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;createImageBitmap&lt;/code&gt; and &lt;code&gt;OffscreenCanvas&lt;/code&gt;, so decoding and re-encoding an image is possible there. Two things are missing, though:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;URL.createObjectURL&lt;/code&gt; does not exist in a service worker, and &lt;code&gt;chrome.downloads.download&lt;/code&gt; wants a URL. A &lt;code&gt;data:&lt;/code&gt; URL works for a small file and falls over for a 40 MB ZIP.&lt;/li&gt;
&lt;li&gt;SVG cannot go through &lt;code&gt;createImageBitmap&lt;/code&gt; reliably. It needs an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element, which needs a document.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the work runs in an offscreen document (&lt;code&gt;chrome.offscreen.createDocument&lt;/code&gt; with the &lt;code&gt;BLOBS&lt;/code&gt; reason). It is a hidden page the worker opens on demand. The worker fetches nothing itself: it sends the job over &lt;code&gt;chrome.runtime.sendMessage&lt;/code&gt;, the offscreen page fetches, decodes, converts, zips, creates a blob URL and asks the worker to start the download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decode, then draw, then encode
&lt;/h2&gt;

&lt;p&gt;The decode step tries the fast path and falls back to &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; only for SVG:&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;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fromType&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;fromType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svg&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;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;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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="cm"&gt;/* fall through */&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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;img&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;Image&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="nx"&gt;reject&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;reject&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;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;Image decode timed out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;15000&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;onload&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&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="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&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;onerror&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&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="nf"&gt;reject&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;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;Unsupported or corrupt image&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;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&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;return&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&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;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&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="mi"&gt;1000&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;Note the timeout. An &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; for a broken SVG can sit there forever without firing either event, and &lt;code&gt;img.decode()&lt;/code&gt; may never resolve in a document nobody renders.&lt;/p&gt;

&lt;p&gt;The conversion is an &lt;code&gt;OffscreenCanvas&lt;/code&gt;, a &lt;code&gt;drawImage&lt;/code&gt;, and &lt;code&gt;convertToBlob&lt;/code&gt;:&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;canvas&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;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&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;toType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpg&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="c1"&gt;// JPEG has no alpha; without this, transparent pixels turn black&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&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;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// free the ImageBitmap now, not when GC gets around to it&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&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/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&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;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&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;out&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The three things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Canvas limits.&lt;/strong&gt; Chrome refuses a canvas over 32,767 px on a side or 268,435,456 px in total. A 20,000 × 15,000 scan from a museum site is under the side limit and over the area limit, and &lt;code&gt;getContext&lt;/code&gt; quietly returns a context that draws nothing. The fix is one scale factor applied before the canvas is created:&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;scale&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;min&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="mi"&gt;32767&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;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;268435456&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Trust the bytes, not the name.&lt;/strong&gt; A URL ending in &lt;code&gt;.jpg&lt;/code&gt; served with &lt;code&gt;Content-Type: image/webp&lt;/code&gt; is common on CDNs, and the other way round happens too. The first bytes decide: &lt;code&gt;RIFF....WEBP&lt;/code&gt; is WebP, &lt;code&gt;\xFF\xD8\xFF&lt;/code&gt; is JPEG, &lt;code&gt;\x89PNG&lt;/code&gt; is PNG, and AVIF is an ISO BMFF box with &lt;code&gt;ftyp&lt;/code&gt; at offset 4. Sniff those, then decide whether a conversion is needed at all. If the source is already the wanted format, keep the original bytes: re-encoding a JPEG as JPEG only loses quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Messages sent before the offscreen document is ready are lost.&lt;/strong&gt; &lt;code&gt;createDocument&lt;/code&gt; resolves before the page's script has registered its &lt;code&gt;onMessage&lt;/code&gt; listener. The offscreen page sends an &lt;code&gt;offscreen-ready&lt;/code&gt; message as its last line, and the worker holds jobs until it has seen that message. Without the handshake, the first job after a cold start can disappear, which is the worst kind of bug to reproduce.&lt;/p&gt;

&lt;p&gt;Two smaller ones: run jobs one at a time in the offscreen page so a 500-image batch does not hold 500 decoded bitmaps at once, and revoke each blob URL after &lt;code&gt;chrome.downloads&lt;/code&gt; reports the download complete, with a 30-minute timer as a safety net in case the worker restarts and never sends the revoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions
&lt;/h2&gt;

&lt;p&gt;The whole thing needs &lt;code&gt;offscreen&lt;/code&gt;, &lt;code&gt;downloads&lt;/code&gt; and &lt;code&gt;contextMenus&lt;/code&gt;. Image bytes are fetched from the offscreen page with the page's own cookies where the site allows it, and nothing leaves the browser. That last part is the reason I built it: the best-known "save as type" extension in this category was pulled from the store this year for injecting affiliate links, and the users who lost it deserve something that just converts the file.&lt;/p&gt;

&lt;p&gt;PixLasso is on the &lt;a href="https://chromewebstore.google.com/detail/plmojdihjlobobaddaacoojdkikdkppb" rel="noopener noreferrer"&gt;Chrome Web Store&lt;/a&gt; and &lt;a href="https://microsoftedge.microsoft.com/addons/detail/cikfpcigjkpiacfhlmhmdchjkdlejfhk" rel="noopener noreferrer"&gt;Edge Add-ons&lt;/a&gt;. The right-click conversion is free; the batch download is free up to 30 files and $9.99 once above that. More on the site: &lt;a href="https://pixlasso.com/?src=devto" rel="noopener noreferrer"&gt;https://pixlasso.com/?src=devto&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Disclosure: I am the developer. The code above is simplified from the extension's offscreen script; the real one also handles AVIF, square padding for marketplace photos, and ZIP splitting.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>chrome</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
