<?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: hao jia</title>
    <description>The latest articles on DEV Community by hao jia (@iterandum).</description>
    <link>https://dev.to/iterandum</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%2F4091913%2Fb40a7d0f-5ae5-45a5-99ab-68ae53a2cdb0.png</url>
      <title>DEV Community: hao jia</title>
      <link>https://dev.to/iterandum</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iterandum"/>
    <language>en</language>
    <item>
      <title>I replaced a docs-site GIF with animated WebP and it got bigger</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:02:56 +0000</pubDate>
      <link>https://dev.to/iterandum/i-replaced-a-docs-site-gif-with-animated-webp-and-it-got-bigger-i0</link>
      <guid>https://dev.to/iterandum/i-replaced-a-docs-site-gif-with-animated-webp-and-it-got-bigger-i0</guid>
      <description>&lt;p&gt;I had a one-line plan for our internal docs site: replace every GIF with animated WebP. Twenty-odd components, one screen recording each, and that layer was eating close to half of the first-load traffic. WebP is a decade and a half newer than GIF, so the win felt too obvious to measure.&lt;/p&gt;

&lt;p&gt;I measured it anyway. On my sample, animated WebP came out &lt;strong&gt;6.4% larger&lt;/strong&gt; than simply re-encoding the GIF.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sample
&lt;/h2&gt;

&lt;p&gt;I drew a fake back-office animation frame by frame with a script — a made-up order management page: skeleton shimmer, rows filling in, a filter dropdown opening, a confirmation dialog, and a clock ticking in the header. 960×600, 75 frames, 60 ms each, 4.5 seconds. As a GIF it weighs 1,585,112 bytes.&lt;/p&gt;

&lt;p&gt;It is a synthetic file, not the output of a real screen recorder, so treat every number below as describing this kind of picture — a mostly static UI recording — and nothing broader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four formats, one export run
&lt;/h2&gt;

&lt;p&gt;I dropped it into the animation workshop on ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), a browser-local image tool, and exported all four formats with the page defaults untouched.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;Share of the original&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Re-encoded GIF&lt;/td&gt;
&lt;td&gt;983,087&lt;/td&gt;
&lt;td&gt;62.02%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Animated WebP&lt;/td&gt;
&lt;td&gt;1,045,924&lt;/td&gt;
&lt;td&gt;65.98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APNG&lt;/td&gt;
&lt;td&gt;1,392,726&lt;/td&gt;
&lt;td&gt;87.86%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Animated AVIF&lt;/td&gt;
&lt;td&gt;157,490&lt;/td&gt;
&lt;td&gt;9.94%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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/..." 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/..." alt="One 4.5-second screen recording stored five ways" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My plan finished third out of four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the GIF refuses to shrink
&lt;/h2&gt;

&lt;p&gt;GIF saves bytes by redrawing only the part of the frame that changed, but it can only declare &lt;strong&gt;one rectangle&lt;/strong&gt; per frame. In this animation the cursor moves on the left while the clock ticks in the top right, so that rectangle has to swallow both ends plus all the untouched pixels in between. 68 of the 75 frames do use partial refresh, and the dirty rectangle still averages &lt;strong&gt;59% of the canvas&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two more structural costs pile on: at most 256 colours per frame, with every frame after the first carrying its own local palette (74 palettes × 256 × 3 ≈ 57 KB of pure lookup tables), and LZW that only rewards horizontal repetition.&lt;/p&gt;

&lt;p&gt;None of that explains why WebP lost, though. Judging from the output alone, I'd guess animated WebP also works in keyframes plus rectangular difference blocks — it inherits the same fat dirty rectangle and only saves on what happens inside it. With 59% of the canvas redrawn and small table text everywhere, there is no order-of-magnitude left to win. I haven't looked inside the encoder, so that's a guess from the artefacts.&lt;/p&gt;

&lt;p&gt;APNG deserves its own sentence: its frames are &lt;strong&gt;pixel-identical&lt;/strong&gt; to the source. It isn't a smaller format, it's a lossless one. On two other samples it came out 35% and 36% &lt;em&gt;larger&lt;/em&gt; than the original GIF, and the tool says so on the result card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking that nobody touched the timeline
&lt;/h2&gt;

&lt;p&gt;Size was never the part that worried me. Dropped frames or rewritten frame delays quietly change the playback speed, and nobody notices until much later. So I ignored the summary in the UI and parsed the files myself. The APNG export was the easiest one to audit, because its per-frame delays sit in plain &lt;code&gt;fcTL&lt;/code&gt; chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unpack&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apng_frames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                     &lt;span class="c1"&gt;# read the fcTL chunks of an APNG
&lt;/span&gt;    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fcTL&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;den&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;HH&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;num&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;den&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;                            &lt;span class="c1"&gt;# 75 delays of 60.0 ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns 75 delays of 60 ms, 4500 ms in total; the 1280×800 demo gives 40 delays of 100 ms. GIF hides the same numbers in its graphic control extensions and animated WebP in its ANMF chunks, so each format needs its own reader. Across four samples and sixteen exports, frame count, per-frame delay, total duration and the infinite-loop flag all matched the source.&lt;/p&gt;

&lt;p&gt;One quirk worth remembering: GIF stores delays in hundredths of a second, so only multiples of 10 ms survive. I asked for 66 ms while generating the sample and got 60 ms on disk. That's the format's resolution, not a compression loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stopped me from switching everything to AVIF
&lt;/h2&gt;

&lt;p&gt;The size argument is loud — the 1280×800 demo animation went from 1,127,031 bytes to 208,937, or 18.5% of the original. But I only put the WebP, APNG and AVIF files on one local page and opened it in Chromium 149, WebKit 26.5 and Firefox 151, screenshotting the same &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; twice 900 ms apart and comparing pixels to confirm it was genuinely animating rather than merely not erroring. All three engines passed.&lt;/p&gt;

&lt;p&gt;Three desktop engines is all I have. In-app WebViews, older machines, whatever renders animations inside messaging clients — I tested none of them, so I can't speak for them. ImgIng's result card also notes that its animated AVIF carries no transparency, which is fine for screen recordings and not fine for a diagram on a transparent background.&lt;/p&gt;

&lt;p&gt;So the plan turned into something duller: pick per asset, and keep a GIF fallback behind a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; for anything I haven't verified. I've only wired that markup up on a local page — how various docs pipelines treat it is something I haven't checked, so I won't claim it works everywhere.&lt;/p&gt;

&lt;p&gt;One last counterintuitive bit. Another sample had already been squeezed my usual way — scaled to 70%, every second frame dropped, down to 64 colours — 357,648 bytes. Re-encoding it as GIF saved 684 bytes, 99.81%, pixel-identical. The same file as animated AVIF saved another 62%. "This one can't be compressed any further" only holds inside a single format.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A PDF is its own reference when you verify the images you pulled out of it</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sun, 20 Sep 2026 08:03:04 +0000</pubDate>
      <link>https://dev.to/iterandum/a-pdf-is-its-own-reference-when-you-verify-the-images-you-pulled-out-of-it-oi8</link>
      <guid>https://dev.to/iterandum/a-pdf-is-its-own-reference-when-you-verify-the-images-you-pulled-out-of-it-oi8</guid>
      <description>&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%2Fjmr7tvjrhjsx9erwe0l0.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%2Fjmr7tvjrhjsx9erwe0l0.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;br&gt;
Most of what my team maintains sits somewhere on the product-image path of an e-commerce platform, and one input format keeps coming back that nobody likes: a catalogue PDF as the only copy of a set of product photos. Getting the pictures out is the easy half. The half I care about is being able to say, before those files enter our asset store, whether each one is what the PDF actually contains or something regenerated along the way, a re-encoded JPEG, a page render at display size, a PNG that lost its alpha. A folder of images that merely look fine doesn't answer that.&lt;/p&gt;

&lt;p&gt;The trick that makes this checkable is that you don't need the supplier's originals. The PDF is its own reference. A JPEG inside a PDF is stored as a &lt;code&gt;/DCTDecode&lt;/code&gt; stream, and that stream is a complete JPEG file, so a faithful extraction produces a file whose SHA-256 equals the SHA-256 of the raw stream. Flate-compressed images (what PNGs become) have no file to compare against, so for those I decode the stored object, fold in its soft mask if it has one, and compare pixels. Pixel size is enough to pair each recovered file with its object, and colour space comes straight from the PDF, which I want recorded because a CMYK product shot needs different handling downstream.&lt;/p&gt;
&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;

&lt;p&gt;I ran it on test PDFs I generated myself with reportlab: an 8-page brochure with JPEG and PNG product shots, a PDF holding one CMYK and one grayscale JPEG, and a single RGB JPEG at 1600×1067. The recovered files came from ImgIng's Extract PDF images. It runs in the browser, and the workspace states that the file is read only in the browser and never uploaded. For synthetic files I took that at face value; before feeding it anything under an NDA I would still watch the Network tab myself, because that's what the compliance people would ask me.&lt;/p&gt;

&lt;p&gt;The check walks every page with PyMuPDF's &lt;code&gt;get_images(full=True)&lt;/code&gt;, which lists each image object the page paints along with its size, colour space, filter and soft-mask reference, and files them by pixel size. Each recovered file is then opened, paired with the objects of the same size, and tested. For a &lt;code&gt;DCTDecode&lt;/code&gt; object the test is a straight hash comparison against the undecoded stream (excerpt; &lt;code&gt;f&lt;/code&gt; is the recovered file, &lt;code&gt;xref&lt;/code&gt; the candidate object):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DCTDecode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; \
           &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xref_stream_raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xref&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For everything else the stored object is decoded, and if it has a soft mask, the mask is folded into its alpha before comparing (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;smask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;smask&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# fold the mask into alpha
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sides are converted to RGBA and diffed with Pillow's &lt;code&gt;ImageChops.difference&lt;/code&gt;. If you write this yourself, call &lt;code&gt;getbbox(alpha_only=False)&lt;/code&gt; on the diff; on RGBA images Pillow's default looks only at the alpha channel, and two opaque images with completely different colours come out "identical". A JPEG whose object also carries a mask gets a warning appended to its verdict. Across the four test PDFs, every recovered file found its object:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recovered file&lt;/th&gt;
&lt;th&gt;Format / mode&lt;/th&gt;
&lt;th&gt;Matched object&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brochure, 5 files&lt;/td&gt;
&lt;td&gt;3 PNG RGBA, 2 JPEG RGB&lt;/td&gt;
&lt;td&gt;DeviceRGB&lt;/td&gt;
&lt;td&gt;PNGs pixels identical, JPEGs sha256 == PDF stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CMYK JPEG&lt;/td&gt;
&lt;td&gt;JPEG CMYK&lt;/td&gt;
&lt;td&gt;DeviceCMYK&lt;/td&gt;
&lt;td&gt;sha256 == PDF stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grayscale JPEG&lt;/td&gt;
&lt;td&gt;JPEG L&lt;/td&gt;
&lt;td&gt;DeviceGray&lt;/td&gt;
&lt;td&gt;sha256 == PDF stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RGB JPEG 1600×1067&lt;/td&gt;
&lt;td&gt;JPEG RGB&lt;/td&gt;
&lt;td&gt;DeviceRGB&lt;/td&gt;
&lt;td&gt;sha256 == PDF stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page render crop (control)&lt;/td&gt;
&lt;td&gt;PNG RGBA&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no image object is 1361x908&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Masked JPEG&lt;/td&gt;
&lt;td&gt;JPEG RGB&lt;/td&gt;
&lt;td&gt;DeviceRGB&lt;/td&gt;
&lt;td&gt;sha256 == PDF stream, but the PDF also has a mask for it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because I made these PDFs, I also had the source files and hashed those too: all three recovered JPEGs, RGB, CMYK and grayscale, match them exactly. Nothing got converted either; the CMYK file is still a CMYK JPEG, which I prefer to a silent RGB conversion. The colour decision stays with whoever owns the print or web pipeline. The PNG shots matched pixel for pixel, the one with transparency included. One thing I'd put in a pipeline note: every PNG came back as RGBA, even the ones that were opaque RGB going in (alpha is 255 everywhere). If a downstream service rejects images with an alpha channel, that's where it'll trip.&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/..." 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/..." alt="A single JPEG extracted from my 1600×1067 test PDF: one result, saved directly as a .jpg (note the " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A row that should fail, and one that passes when it shouldn't
&lt;/h2&gt;

&lt;p&gt;The page render crop is the control. I rendered the same page with ImgIng's PDF to images at 216 DPI (1786×2526 for the whole A4 page) and cropped the photo out of it: 1361×908, against 1600×1067 stored. Its PSNR against the original scaled to that size was 47.19 dB, so by eye it passes, but no object in the PDF has that size, and the check rejects it before any hashing happens. That's exactly the file I don't want in an asset store with a "supplier original" tag on it.&lt;/p&gt;

&lt;p&gt;The masked JPEG is the case a hash alone gets wrong. In that sample the JPEG has a soft mask that fades it to an oval on the page. The extracted file is byte-identical to the stored stream, so it is faithful in the narrow sense, but the transparency lived in a separate object and did not come along. I only have that one hand-built sample, which is why the script prints a warning next to the verdict for any masked JPEG.&lt;/p&gt;

&lt;p&gt;Two UI details matter for automation. With one result, the button reads "Save this image" and writes a single .jpg; with several, you only get "Save extracted images (ZIP)" and there is no per-card save, so I unpack the ZIP into a folder and point the script at it. The ZIP is stored uncompressed and the file names are just &lt;code&gt;-image-001&lt;/code&gt;, &lt;code&gt;-002&lt;/code&gt;, so the page numbers shown on the cards don't travel with the files.&lt;/p&gt;

&lt;p&gt;Everything above ran against files generated on my own machine, with the extractor at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; reading them inside the browser.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Our PDF upload limit says 2 MB. I tested auto-compression before adding it</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sat, 19 Sep 2026 08:02:59 +0000</pubDate>
      <link>https://dev.to/iterandum/our-pdf-upload-limit-says-2-mb-i-tested-auto-compression-before-adding-it-j83</link>
      <guid>https://dev.to/iterandum/our-pdf-upload-limit-says-2-mb-i-tested-auto-compression-before-adding-it-j83</guid>
      <description>&lt;p&gt;Our upload form rejects PDFs over "2 MB". Every month a few support tickets land with the same complaint: "My file says it's under the limit." A product manager on my team suggested the obvious fix: when a PDF is too big, compress it in the browser and retry automatically. Before estimating that, I ran a small test. The auto-retry idea didn't survive it, but two smaller changes did.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, decide which "2 MB" you mean
&lt;/h2&gt;

&lt;p&gt;"2 MB" is either 2,000,000 bytes or 2,097,152 bytes. Our frontend formatted sizes in base 1024. macOS Finder shows base 1000. The backend compared raw bytes. Three parties, three numbers, no bug anywhere, and still tickets.&lt;/p&gt;

&lt;p&gt;One of my test files makes the gap obvious: 19,458,294 bytes. The compression tool I used shows it as "18.56 MB" (base 1024). In base 1000 it's 19.46 MB. Near the limit, that difference decides pass or fail.&lt;/p&gt;

&lt;p&gt;We now store the limit as a byte constant, pick the stricter 2,000,000, and show both readings in the error message:&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;LIMIT_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000_000&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;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt; MiB / &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt; MB`&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;checkUpload&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="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;file&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;lt;=&lt;/span&gt; &lt;span class="nx"&gt;LIMIT_BYTES&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;ok&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`This file is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fmt&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="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;. The limit is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LIMIT_BYTES&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 2,905,274-byte file gets "This file is 2.77 MiB / 2.91 MB. The limit is 1.91 MiB / 2.00 MB." Nobody has to guess which unit we meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then, check whether compression would even help
&lt;/h2&gt;

&lt;p&gt;I didn't want to test with customer files (we went through a compliance review once and I'm not repeating it), so I built three fake samples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an 8-page product brochure, 19,458,294 bytes, photos from a CC0 Wikimedia Commons image, cropped and upscaled&lt;/li&gt;
&lt;li&gt;a 10-page text-only document, 137,782 bytes&lt;/li&gt;
&lt;li&gt;a 4-page 300 dpi "scan": a made-up form rendered to full-page images, no text layer, 8,948,995 bytes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I compressed them with the PDF tool on ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), mainly because it runs in the browser. I kept DevTools' Network tab open during every run and saw no non-GET requests, which is the first thing our compliance people would ask about. I don't know how it works internally; everything below is inferred from the output files.&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/..." 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/..." alt="English UI of the PDF compressor with the self-made scanned sample (Chinese placeholder text) on the E-book preset. Red boxes: the preset, and the result card showing 8.53 MB to 1.84 MB" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the default E-book preset (150 dpi):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample&lt;/th&gt;
&lt;th&gt;Before (bytes)&lt;/th&gt;
&lt;th&gt;After (bytes)&lt;/th&gt;
&lt;th&gt;Saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brochure&lt;/td&gt;
&lt;td&gt;19,458,294&lt;/td&gt;
&lt;td&gt;864,614&lt;/td&gt;
&lt;td&gt;95.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan&lt;/td&gt;
&lt;td&gt;8,948,995&lt;/td&gt;
&lt;td&gt;1,927,707&lt;/td&gt;
&lt;td&gt;78.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text-only&lt;/td&gt;
&lt;td&gt;137,782&lt;/td&gt;
&lt;td&gt;131,156&lt;/td&gt;
&lt;td&gt;4.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scan ends at 1.84 MiB, or 1.93 MB. Under 2 MB in both readings.&lt;/p&gt;

&lt;p&gt;The text-only file is the problem. All four presets produced the identical 131,156-byte file. Splitting the PDF by stream type explains it: 85.22% of its bytes are embedded fonts, and the tool's docs say it doesn't subset fonts. The brochure is 99.23% images. That's one sample with fonts that were already subset, so I'm not claiming every text PDF behaves like this. Still, it was enough to kill "compress and retry" as a silent fallback. For a file like that, the user waits through a compression pass and then gets rejected anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify what comes back
&lt;/h2&gt;

&lt;p&gt;A smaller file only helps if it's still the same document. I compared page count and extracted text before and after. The brochure (3,357 characters) and the text-only file (11,174 characters) matched exactly, and so did every page count. The scan has no text layer, so only the page count means anything there. For image quality I rendered the pages and looked. The lowest preset (Screen, 72 dpi) got the brochure down to 352,318 bytes, but the small table text in its screenshots became unreadable. That's why our message suggests "around 150 dpi" instead of "smallest".&lt;/p&gt;

&lt;h2&gt;
  
  
  What we shipped
&lt;/h2&gt;

&lt;p&gt;No automatic compression. When a PDF is over the limit, we check it before showing the error. If it looks like a scan, we suggest compressing to about 150 dpi. If it mixes images and text, we suggest compressing the images. If there are no images at all, we suggest splitting the file. Encrypted PDFs get their own message, because the compressor I tested refuses them until protection is removed.&lt;/p&gt;

&lt;p&gt;I only tested three self-made files, in Chromium on a Mac. Mobile browsers and Windows are untested, and scans that already have an OCR text layer will probably confuse our "looks like a scan" check. That's next on the list.&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%2F7xa6jawtox66yrbcfsrf.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%2F7xa6jawtox66yrbcfsrf.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Same 150 KB, three formats: what you actually get</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Fri, 18 Sep 2026 08:02:45 +0000</pubDate>
      <link>https://dev.to/iterandum/same-150-kb-three-formats-what-you-actually-get-56ba</link>
      <guid>https://dev.to/iterandum/same-150-kb-three-formats-what-you-actually-get-56ba</guid>
      <description>&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%2F0h8p1jge7mgty4fqgjax.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%2F0h8p1jge7mgty4fqgjax.png" alt=" " width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Someone on my team opened a PR that moved our export quality from JPEG 80 to WebP 80, with a note saying "same quality, smaller files". I didn't merge it. Those two 80s are not the same ruler, and the benchmark behind the claim was measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;Our constraint is a page budget: hero images on the product detail page must stay under 150 KB each. The budget is written in bytes. So the question is not "which format is smaller at the same quality" — it's "at the same 150 KB, which one buys me the most image quality". Those are two different experiments with two different answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why quality 80 is not quality 80
&lt;/h2&gt;

&lt;p&gt;JPEG's quality scales a quantization table. WebP's quality drives a whole group of prediction-search and quantization parameters. AVIF's quality gets mapped into an AV1 qindex. No standard ties these three scales to the same distortion level. Setting all three to 80 and comparing file sizes tells you one thing only: what those three unrelated rulers read at their own 80 mark.&lt;/p&gt;

&lt;p&gt;Think of each format as a rate-distortion curve. To compare curves you have to slice along an axis. Slice along quality and you learn "how many bytes to reach the same fidelity". Slice along size and you learn "how much fidelity you get for the same bytes". Comparing q80 to q80 picks one arbitrary point on each curve and leaves both variables moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pinning the bytes
&lt;/h2&gt;

&lt;p&gt;For each format, binary search quality 1..100 for the highest value that still fits the budget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bisect&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bisect_right&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SizeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;len(self) == 101; self[q] encodes at that quality and returns the byte size&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__getitem__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tell&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;best_quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bisect_right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SizeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This assumes size grows monotonically with quality. It usually does, but that's an observation, not a theorem — I swept the whole range once to confirm there was no inversion before trusting the search.&lt;/p&gt;

&lt;p&gt;The sample is a CC0 photo from Wikimedia Commons (shot on an iPhone 6, 2048×1536, 638.7 KB), not my own material. Lots of sky and metal reflections, so it is on the hard side to compress. Budget: 150 KB.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Quality that fits&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;PSNR&lt;/th&gt;
&lt;th&gt;SSIM&lt;/th&gt;
&lt;th&gt;Encode time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPEG&lt;/td&gt;
&lt;td&gt;q23&lt;/td&gt;
&lt;td&gt;147.6 KB&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;0.938&lt;/td&gt;
&lt;td&gt;4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;q65&lt;/td&gt;
&lt;td&gt;149.8 KB&lt;/td&gt;
&lt;td&gt;37.6&lt;/td&gt;
&lt;td&gt;0.965&lt;/td&gt;
&lt;td&gt;126 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;q59&lt;/td&gt;
&lt;td&gt;145.8 KB&lt;/td&gt;
&lt;td&gt;38.9&lt;/td&gt;
&lt;td&gt;0.973&lt;/td&gt;
&lt;td&gt;131 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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/..." 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/..." alt="Same photo squeezed to roughly 150 KB in three formats, with the quality setting each one needed" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting column is the second one. To fit 150 KB, JPEG has to fall all the way to q23 — zoom into the sky and you can see blocking. WebP stops at q65, AVIF at q59. That leftover quality headroom is exactly where the 5.2 dB spread comes from. With a q80-vs-q80 table, that column never appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;Budget first, format second. We now derive a per-image byte ceiling from the page budget, then pick the format by measured fidelity at that ceiling. Quality is a means to hit the budget, not a number you compare across formats.&lt;/p&gt;

&lt;p&gt;Encoding time is a real cost. 4 ms vs 126 ms vs 131 ms is a thirty-fold spread. That number only describes one Pillow call on my Mac, but the direction holds: the bytes you save on the wire get paid for on the encoder. Our upload pipeline fans one original out into five sizes, so switching the primary format means re-running load tests, not flipping a constant. I did not test AVIF decode support on older devices in this round, so the JPEG fallback in &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; stays.&lt;/p&gt;

&lt;p&gt;Budgets belong per content type. The same run included a 1280×800 UI screenshot: 94.0 KB in, 31.6 KB out as WebP q80, 18.5 KB as AVIF q60, both above 40 dB. Flat color and sharp text compress far better than photos, so one global target wastes headroom.&lt;/p&gt;

&lt;p&gt;Soft-edged and semi-transparent assets get their own path. Badges and shadowed overlays lose their gradient alpha when you push them through palette reduction. The shadowed transparent badge in that run went from 35.2 KB to 20.2 KB as WebP and 2.4 KB as AVIF, with the alpha levels intact.&lt;/p&gt;

&lt;p&gt;One last constraint that isn't technical. These were unreleased product assets, and anything leaving our network needs a compliance sign-off — we spent three months under legal review after a data remediation two years ago, and I've asked that question first ever since. So besides the local script I ran the same comparison in the browser with Imging, where compression and conversion for common formats happen locally without an upload. The numbers lined up with my script, and I didn't have to file a request to check a table.&lt;/p&gt;

&lt;p&gt;If you want the version of this that applies to your images: take your largest hero image, pick a byte ceiling, binary search each format, then open the three outputs side by side at 200% and look at a gradient area.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your thumbnails look washed out: it's the ICC profile, not JPEG quality</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Thu, 17 Sep 2026 08:31:47 +0000</pubDate>
      <link>https://dev.to/iterandum/your-thumbnails-look-washed-out-its-the-icc-profile-not-jpeg-quality-4mh7</link>
      <guid>https://dev.to/iterandum/your-thumbnails-look-washed-out-its-the-icc-profile-not-jpeg-quality-4mh7</guid>
      <description>&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%2Fsjcz2f12wo55omcleoa6.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%2Fsjcz2f12wo55omcleoa6.png" alt=" " width="800" height="1342"&gt;&lt;/a&gt;&lt;br&gt;
If someone on your team has bumped a thumbnail service from quality 80 to 95 because the images look faded, check one thing before touching quality again: does the output file still carry the source image's ICC profile? On the sample I tested, removing the profile made the photo visibly duller while every pixel value stayed exactly the same. Saving that file at quality 100 wouldn't bring the color back, because nothing was lost to compression in the first place.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sample
&lt;/h2&gt;

&lt;p&gt;A CC0 photo from Wikimedia Commons, shot on an iPhone 6 at Madrid airport, resized to 2048×1536. With Pillow 11.3's ImageCms and the ICC files that ship with macOS, I wrote three JPEGs at quality 92: one tagged sRGB, one converted to Display P3, one converted to Adobe RGB (1998). The photo's colors sit inside sRGB, so this isn't a wide-gamut stress test, and the numbers below describe this one image only.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two different kinds of worse
&lt;/h2&gt;

&lt;p&gt;Compression damage lives in the details: soft edges, banding across smooth sky, 8×8 blocks when you zoom in. A dropped profile does something else. The RGB numbers don't change, but without a profile the viewer assumes sRGB, and P3 or Adobe RGB values read as sRGB come out less saturated. I measured the difference in CIELAB (ΔE76) between each tagged file and the same pixels read as sRGB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile dropped&lt;/th&gt;
&lt;th&gt;mean ΔE&lt;/th&gt;
&lt;th&gt;p95 ΔE&lt;/th&gt;
&lt;th&gt;pixels with ΔE &amp;gt; 5&lt;/th&gt;
&lt;th&gt;mean chroma loss&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Display P3&lt;/td&gt;
&lt;td&gt;1.83&lt;/td&gt;
&lt;td&gt;3.9&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;td&gt;16.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adobe RGB&lt;/td&gt;
&lt;td&gt;2.91&lt;/td&gt;
&lt;td&gt;5.27&lt;/td&gt;
&lt;td&gt;7.4%&lt;/td&gt;
&lt;td&gt;20.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A mean ΔE under 3 sounds harmless, and the chroma column is where the problem shows. Grays, whites and shadows barely move. The most saturated 5% of pixels shift by ΔE 5.4 with P3 and 7.2 with Adobe RGB, which in this photo is the red and green tail livery and the blue sky going pale. So blurry or blocky points at compression, while uniformly faded with intact detail points at the profile.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the profile gets dropped
&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/..." 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/..." alt="Default save paths in common tools: profile kept or dropped" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Input: JPEG tagged Display P3. Sample: Wikimedia Commons CC0 photo (iPhone 6), profile converted locally.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I fed the P3 JPEG through default save paths. Pillow 11.3 drops the profile when saving JPEG or WebP, after &lt;code&gt;resize()&lt;/code&gt; or &lt;code&gt;thumbnail()&lt;/code&gt; too, and quality 100 changes nothing; saving PNG keeps it. ffmpeg 7.1 keeps it converting to JPEG or PNG and drops it converting to WebP. macOS &lt;code&gt;sips&lt;/code&gt; kept it for PNG, &lt;code&gt;-Z&lt;/code&gt; resizing and a quality 60 re-save. Pillow resize followed by WebP output, which is probably the most common thumbnail recipe, hits the problem twice. The fix is to pass the profile through explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="n"&gt;thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;icc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icc_profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# None when the source is untagged
&lt;/span&gt;&lt;span class="n"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;icc_profile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;icc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against the P3 sample, both the JPEG and WebP outputs read back as Display P3. The other option is converting to sRGB with &lt;code&gt;ImageCms.profileToProfile&lt;/code&gt; before saving, which protects you from any later step that strips profiles again. I haven't settled which one I want for thumbnails. On a sample that fits inside sRGB the two look identical, and I have no wide-gamut data to judge what the conversion clips.&lt;/p&gt;

&lt;p&gt;One trap while checking: &lt;code&gt;sips -g profile&lt;/code&gt; reports sRGB IEC61966-2.1 for a JPEG with no embedded profile, so it can't tell untagged from real sRGB. I trust &lt;code&gt;Image.open(path).info.get("icc_profile")&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about canvas compression in the browser?
&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%2Fabrcey5cjta8f7ruy1kw.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%2Fabrcey5cjta8f7ruy1kw.png" alt="The same image with and without its P3 profile in three browser engines" width="800" height="381"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Sample: Wikimedia Commons CC0 photo (iPhone 6), converted to Display P3 locally. Firefox is the build bundled with Playwright.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Using Playwright's bundled engines on macOS 26.5, Chromium 149 and WebKit 26.5 honor the profile in &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, and pixels read back from a canvas are already converted to sRGB (mean difference from the sRGB source: 0.66 and 0.69, which is JPEG noise). &lt;code&gt;toBlob('image/jpeg')&lt;/code&gt; output looks right in both. Chromium also embeds an sRGB profile; WebKit's JPEG has none, which is fine because the pixels are already sRGB. The Firefox 151 build bundled with Playwright rendered the tagged and stripped files the same (mean screenshot difference 0.03), read back unconverted P3 values, and exported an untagged JPEG that looks washed out. That's one build with default settings, not a statement about release Firefox. It also means a has-profile check alone can mislead on browser exports, since WebKit's untagged JPEG is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I check things in now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Does the source have a profile, and which one? If it's sRGB or absent, a dropped profile can't explain the fade, so go look at compression.&lt;/li&gt;
&lt;li&gt;Does the output still have it? If the source is P3 and the output has nothing, the pipeline dropped it.&lt;/li&gt;
&lt;li&gt;Only when both are tagged and colors are still off do quality settings and client-side export become suspects.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Apple's developer documentation notes that iPhone 7 and later can capture in P3, so tagged uploads are ordinary input rather than an edge case.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Chrome fires onerror, WebKit shows it: feature-detect HEIC before you preview</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:04:21 +0000</pubDate>
      <link>https://dev.to/iterandum/chrome-fires-onerror-webkit-shows-it-feature-detect-heic-before-you-preview-3ake</link>
      <guid>https://dev.to/iterandum/chrome-fires-onerror-webkit-shows-it-feature-detect-heic-before-you-preview-3ake</guid>
      <description>&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%2Fbox4vfokfracyh9s00nr.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%2Fbox4vfokfracyh9s00nr.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
Short version: if your upload preview goes blank when someone picks an iPhone photo, the browser most likely can't decode HEIC at all, and your component is treating a decode failure as "nothing to show". Don't sniff the user agent and don't trust the &lt;code&gt;.heic&lt;/code&gt; extension. Ask the browser to decode the actual file, and branch on the answer.&lt;/p&gt;

&lt;p&gt;I tested this because the bug report pattern is so common: preview empty in Chrome, fine on the developer's Mac. Before touching the component I wanted to know which engines fail, how the failure surfaces in code, and whether anything about the file itself matters. Here's what three engines did with the same file.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sample
&lt;/h2&gt;

&lt;p&gt;I don't have an iPhone original I can publish, so I took a CC0 photo from Wikimedia Commons, shot on an iPhone 6 (3264×2448 JPEG, an apron at Madrid airport), and converted it to HEIC with the &lt;code&gt;sips&lt;/code&gt; tool that ships with macOS 26.5. One copy has EXIF orientation 6, i.e. portrait. It is not straight off a phone, so nothing below covers HDR, Live Photos or depth data.&lt;/p&gt;

&lt;p&gt;Parsing the ISOBMFF boxes of the portrait copy shows why decoding is not a small feature. The primary item is a &lt;code&gt;grid&lt;/code&gt; built from 35 &lt;code&gt;hvc1&lt;/code&gt; tiles, each a 512×512 HEVC image laid out 7 × 5. The &lt;code&gt;ispe&lt;/code&gt; property says 3264×2448, landscape; the portrait orientation lives in an &lt;code&gt;irot&lt;/code&gt; property of 270°. Upscaled to 4032×3024, the same photo becomes 48 tiles. To display it, an engine needs an HEVC decoder, has to stitch the grid and has to apply the rotation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three engines, three APIs
&lt;/h2&gt;

&lt;p&gt;I used Playwright's bundled engines on macOS 26.5 and pointed all three APIs at the same portrait file, passed in as the &lt;code&gt;File&lt;/code&gt; object you'd get from an &lt;code&gt;&amp;lt;input type=file&amp;gt;&lt;/code&gt;. The table is the raw outcome; sizes are &lt;code&gt;naturalWidth×naturalHeight&lt;/code&gt; or the bitmap size:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;createImageBitmap&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;img.decode()&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chromium 149&lt;/td&gt;
&lt;td&gt;onerror&lt;/td&gt;
&lt;td&gt;InvalidStateError&lt;/td&gt;
&lt;td&gt;EncodingError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox 151&lt;/td&gt;
&lt;td&gt;onerror&lt;/td&gt;
&lt;td&gt;InvalidStateError&lt;/td&gt;
&lt;td&gt;EncodingError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebKit 26.5&lt;/td&gt;
&lt;td&gt;2448×3264&lt;/td&gt;
&lt;td&gt;2448×3264&lt;/td&gt;
&lt;td&gt;2448×3264&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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/..." 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/..." alt="Same HEIC in an img tag: Chromium 149 and Firefox 151 fail with onerror, WebKit 26.5 on macOS shows it upright" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Sample: CC0 iPhone 6 JPEG from Wikimedia Commons, converted to HEIC with macOS sips. Not a phone original.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;WebKit returns 2448×3264, so it stitched the grid and honoured &lt;code&gt;irot&lt;/code&gt;. Two caveats I'd put in any ticket. WebKit is borrowing the macOS system decoder here. And this is Playwright's WebKit build, not Safari; I haven't tested real Safari, Windows or any phone. "Works on my Mac" tells you nothing about the Chrome user who filed the bug.&lt;/p&gt;

&lt;p&gt;One more thing I checked: I wrapped the HEIC in a &lt;code&gt;File&lt;/code&gt; named &lt;code&gt;x.bin&lt;/code&gt; with an empty type. All three engines gave exactly the same results. Browsers sniff the bytes, so renaming doesn't help, and checking &lt;code&gt;file.type&lt;/code&gt; doesn't tell you whether the preview will work.&lt;/p&gt;
&lt;h2&gt;
  
  
  Detect by decoding
&lt;/h2&gt;

&lt;p&gt;So the check is to decode the file the user actually picked, in the browser they're actually using, before you build the preview. This is all of it, and I ran it in all three engines against the HEIC and a JPEG control:&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;supportsDecode&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;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;bitmap&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;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="nx"&gt;bitmap&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="k"&gt;return&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;HEIC: &lt;code&gt;false&lt;/code&gt; in Chromium and Firefox, &lt;code&gt;true&lt;/code&gt; in WebKit. JPEG: &lt;code&gt;true&lt;/code&gt; everywhere, already rotated to 2448×3264 from its EXIF. I prefer this over listening to &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; errors because it rejects into a &lt;code&gt;catch&lt;/code&gt; you control. Don't branch on &lt;code&gt;e.name&lt;/code&gt; though; the two failing engines use different error names across APIs.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;false&lt;/code&gt; doesn't mean the file is broken. Bytes 4 to 12 of the sample read &lt;code&gt;ftypheic&lt;/code&gt;, so a header check separates "HEIC this browser can't decode" from "corrupt file". Those deserve different messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fallback
&lt;/h2&gt;

&lt;p&gt;Once you know, the options are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decodes: preview as usual with an object URL.&lt;/li&gt;
&lt;li&gt;HEIC, can't decode: replace the empty box with a sentence like "This browser can't preview HEIC. The file is still selected." That alone kills the re-select loop.&lt;/li&gt;
&lt;li&gt;Want an actual preview or a JPG: load a WASM decoder on demand (libheif builds are the usual choice) and decode locally, or upload and convert on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I lean local-first. I work under compliance rules where "did the original leave the user's machine" is a question I've had to answer for months, and local decoding is the one answer that's simply "no". A converter I used as a reference, imging, does exactly this: pick a HEIC and it says the browser can't decode it natively and offers a "Load decoder" button; after loading, the file reads locally, and I saw no upload request for the original. Converting the portrait sample to JPG gave 2448×3264 with EXIF orientation 1, so no double rotation. Its HEIC &lt;em&gt;output&lt;/em&gt; is marked server-side, so reading and writing are different stories there.&lt;/p&gt;

&lt;p&gt;If you pick the server route, test with a multi-tile, rotated file. ffmpeg 7.1 with a plain &lt;code&gt;ffmpeg -i in.heic out.png&lt;/code&gt; wrote 512×512, the first tile; ffprobe does report the tile grid, the default command just doesn't stitch it. Pillow 11.3 without the pillow-heif plugin raised "cannot identify image file". Both can handle HEIC with the right setup; the defaults can't.&lt;/p&gt;

&lt;p&gt;What I still can't tell you: how a real iPhone HDR photo comes out of a WASM decoder versus a server conversion. I don't have a sample, so I'm leaving that open. The converter I referenced: &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>canPlayType said ‘probably’ in three engines; only two kept my transparent WebM transparent</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:04:17 +0000</pubDate>
      <link>https://dev.to/iterandum/canplaytype-said-probably-in-three-engines-only-two-kept-my-transparent-webm-transparent-1ma4</link>
      <guid>https://dev.to/iterandum/canplaytype-said-probably-in-three-engines-only-two-kept-my-transparent-webm-transparent-1ma4</guid>
      <description>&lt;p&gt;A cut-out presenter floating over a product page, person only, no rectangle, the page showing through, is the kind of request that reaches a frontend team sooner or later. The obvious deliverable is a transparent WebM, and the obvious way to decide whether the browser can handle it is &lt;code&gt;canPlayType&lt;/code&gt;. I ran both through three engines before I'd estimate it, and the feature check turned out to be the part that tells you nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file
&lt;/h2&gt;

&lt;p&gt;I made the test file with a browser-side video matting tool, because the source footage wasn't supposed to leave my machine, and after a compliance cleanup I once sat through, that's the first filter I apply. Input was "Tarun speaking 01" from Wikimedia Commons (CC0), re-encoded to H.264 at 464×832. With the background set to Transparent, the tool greys out MP4 and switches the format to WebM on its own, with a note that transparent backgrounds need WebM VP9 alpha.&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/..." 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/..." alt="Export settings with Transparent selected: MP4 disabled, WebM VP9 / Opus chosen, format note shown" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ffmpeg reports VP9 Profile 0, &lt;code&gt;alpha_mode: 1&lt;/code&gt; on the stream, Opus 48 kHz audio, same resolution and all 511 frames. One trap before you even get to a browser: the stream line says &lt;code&gt;yuv420p&lt;/code&gt;. ffmpeg's built-in VP9 decoder ignores the alpha. Force &lt;code&gt;-c:v libvpx-vp9&lt;/code&gt; before the input and you get &lt;code&gt;yuva420p&lt;/code&gt;. If your pipeline sanity-checks transparency by reading pix_fmt from a default probe, it will say the alpha isn't there when it is. The file came out at 5.47 MB, where the export panel had estimated about 2.86 MB (both in the 1024-based MB the page uses), so don't size a CDN budget from that number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three engines, one probe
&lt;/h2&gt;

&lt;p&gt;I used Playwright's bundled browsers: Chromium 149, Firefox 151 and WebKit 26.5. That WebKit is Playwright's build, not Safari, and I haven't tested real Safari on macOS or iOS. The probe seeks to 1 s, draws the frame into a canvas and reads the alpha of two 40×40 corners that are ceiling in the source, so nothing there should be opaque:&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;cornerAlpha&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="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&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="nx"&gt;currentTime&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="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;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seeked&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&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="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&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;c&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;willReadFrequently&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="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;v&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;d&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&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;return&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;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&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="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;40&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;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxA&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;Chromium and Firefox returned a maximum alpha of 1 and 0 for the two corners, so the transparency survived decoding. WebKit returned 255 and 255, and the pixels are black. It plays the file without complaint and throws the alpha away. All three answered &lt;code&gt;"probably"&lt;/code&gt; to &lt;code&gt;canPlayType('video/webm; codecs="vp9"')&lt;/code&gt;. There's no error event and no warning, just a black box where the page should be.&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%2Fbq7grvo10f01b597b1co.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%2Fbq7grvo10f01b597b1co.png" alt="The same transparent WebM over a checkerboard and a red panel: alpha kept in Chromium 149 and Firefox 151, black background in Playwright WebKit 26.5" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Clip: Wikimedia Commons "Tarun speaking 01" (CC0), tested 2026-09-14.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The black isn't WebKit inventing a colour. The YUV stored under transparent pixels in this file is black, so anything that drops the alpha channel shows black. Which leads to the second way this breaks.&lt;/p&gt;
&lt;h2&gt;
  
  
  Converting it breaks it the same way
&lt;/h2&gt;

&lt;p&gt;The usual reflex when a format doesn't play somewhere is to convert to MP4. &lt;code&gt;ffmpeg -i transparent.webm -c:v libx264 -pix_fmt yuv420p -c:a aac converted.mp4&lt;/code&gt; gives a clean H.264 file with black corners, and my probe reads 255 in every engine, because yuv420p has nowhere to put alpha. A default &lt;code&gt;ffmpeg -i transparent.webm out.gif&lt;/code&gt; is also black: 511 frames, no transparent palette entry. A GIF can keep transparency if you ask for it explicitly and decode with libvpx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-c&lt;/span&gt;:v libvpx-vp9 &lt;span class="nt"&gt;-i&lt;/span&gt; transparent.webm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"fps=15,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128"&lt;/span&gt; keep.gif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The corner pixel of that file is fully transparent. It's 1-bit transparency though, so every semi-transparent pixel at the hair and shoulders gets forced to either solid or gone. Those soft edge pixels are exactly the part of an overlay people's eyes go to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd ship
&lt;/h2&gt;

&lt;p&gt;Since detection can't see the problem, the decision has to rest on which engines you serve. The WebM overlay is fine for Chromium and Firefox. For anything WebKit-based I'd ship a separate MP4 with the background already composited in the page colour. The same tool exports that directly as a solid-colour H.264/AAC file, so it's a second export and not a hand-built fallback. What I still don't have is a result from real Safari, and until I do, WebKit gets the MP4. The acceptance list I'd attach to that ticket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decode one frame with &lt;code&gt;-c:v libvpx-vp9&lt;/code&gt; and confirm &lt;code&gt;yuva420p&lt;/code&gt;; don't trust the default pix_fmt line.&lt;/li&gt;
&lt;li&gt;In each target engine, draw a frame at a known background spot into a canvas and read alpha. &lt;code&gt;canPlayType&lt;/code&gt; doesn't count as a check.&lt;/li&gt;
&lt;li&gt;Include a WebKit run, and write down that it isn't Safari.&lt;/li&gt;
&lt;li&gt;Any conversion step (MP4, GIF, a transcoding upload) gets the same corner check afterwards.&lt;/li&gt;
&lt;li&gt;Look at hair and shoulder edges on the real page background, not on a checkerboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool I used for the export is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Upright in the upload preview, sideways after the server saved it</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Mon, 14 Sep 2026 08:06:24 +0000</pubDate>
      <link>https://dev.to/iterandum/upright-in-the-upload-preview-sideways-after-the-server-saved-it-175g</link>
      <guid>https://dev.to/iterandum/upright-in-the-upload-preview-sideways-after-the-server-saved-it-175g</guid>
      <description>&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%2Fz1qljr3ts5m6nsnsohu7.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%2Fz1qljr3ts5m6nsnsohu7.png" alt=" " width="799" height="512"&gt;&lt;/a&gt;&lt;br&gt;
The bug report is always some version of this: a user uploads a portrait photo from a phone, the preview in the upload form looks right, and the thumbnail our backend generates is lying on its side. Frontend says the image is fine, backend says it saved exactly what it received, and both are telling the truth. The photo's pixels are stored sideways and a single EXIF tag says how to turn them. The browser reads that tag and Pillow's default save throws it away. Reproducing it on a test photo, I also found that the textbook fix rotates the image twice, and that once you start writing EXIF on purpose you have to decide what to do with GPS.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the preview is upright
&lt;/h2&gt;

&lt;p&gt;CSS &lt;code&gt;image-orientation&lt;/code&gt; has an initial value of &lt;code&gt;from-image&lt;/code&gt;, and MDN's description is that the EXIF information in the image "is used to rotate the image appropriately". That became the default in Chrome 81 and, according to Mozilla's bug tracker, in Firefox 77, so any current &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; shows a phone photo the way the phone meant it. I checked in HeadlessChrome 149 with a synthetic test photo I generated (4032×3024 stored, Orientation 6): &lt;code&gt;naturalWidth × naturalHeight&lt;/code&gt; came back 3024×4032, already turned, and &lt;code&gt;createImageBitmap&lt;/code&gt; reported the same, since its &lt;code&gt;imageOrientation&lt;/code&gt; also defaults to &lt;code&gt;from-image&lt;/code&gt;. One caveat from MDN is worth knowing if you ever try to switch this off: &lt;code&gt;image-orientation: none&lt;/code&gt; does not override the orientation of images from a non-secure origin. The preview isn't lying. It's applying metadata the server is about to delete.&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/..." 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/..." alt="Same upload rendered in HeadlessChrome 149: the original with Orientation 6 displays upright; the thumbnail saved by Pillow with default settings displays sideways. Synthetic sample, 4032×3024" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Two ways to get it wrong on the server
&lt;/h2&gt;

&lt;p&gt;The first is the plain &lt;code&gt;save()&lt;/code&gt;. Pillow writes no EXIF unless you pass it, so Orientation disappears but the pixels stay 4032×3024. The second is the fix most people reach for next: call &lt;code&gt;ImageOps.exif_transpose()&lt;/code&gt; to rotate the pixels, then pass the original EXIF back to keep camera data. The pixels are now upright, but the copied block still says Orientation 6, so every viewer that respects the tag rotates the already-rotated image another 90 degrees. Here are both, on the same synthetic upload, read back with Pillow for the stored size and the tag, and with piexif for the GPS block:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Stored pixels&lt;/th&gt;
&lt;th&gt;Orientation&lt;/th&gt;
&lt;th&gt;GPS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The upload&lt;/td&gt;
&lt;td&gt;4032×3024&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain &lt;code&gt;save()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4032×3024&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;exif_transpose()&lt;/code&gt; + original EXIF&lt;/td&gt;
&lt;td&gt;3024×4032&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the GPS column. The sideways version is the one without location, and the "fixed" version quietly kept it. So fixing the rotation bug the obvious way is also what brought the location back into storage. The test photo's GPS is a made-up point at sea, but a real upload would carry whatever coordinates the phone recorded.&lt;/p&gt;
&lt;h2&gt;
  
  
  GPS is a decision, not a side effect
&lt;/h2&gt;

&lt;p&gt;I spent three months in a data-compliance cleanup a while ago, and the thing I took from it is that data we hold by accident is still data we hold. Whether coordinates in a user's photo count as personal data under the law that applies to you is for whoever owns compliance on your side to answer, and I'm not going to guess it for every jurisdiction. What I can control is that the answer lives in code as an explicit list, not in whichever Pillow call someone happened to write. For product and avatar images my default is to keep a handful of camera fields and drop everything else, GPS and the embedded thumbnail included, because nothing downstream uses them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;normalize()&lt;/code&gt; rotates the pixels with &lt;code&gt;exif_transpose()&lt;/code&gt; so the tag is honoured exactly once, shrinks the image to 1600 px on the long side, and then builds a fresh EXIF block from an allowlist instead of editing the old one. The allowlist is Make and Model from the main IFD plus DateTimeOriginal, ExposureTime, FNumber and ISOSpeedRatings from the Exif IFD. The part that matters (excerpt; &lt;code&gt;old&lt;/code&gt; is the source EXIF parsed with piexif):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0th&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0th&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;KEEP_0TH&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;old&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;KEEP_EXIF&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1st&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thumbnail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0th&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;piexif&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImageIFD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orientation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that the two dimension fields are set to the new width and height, and the file is saved at quality 85 with the new block and the source's ICC profile passed through explicitly. The check next to it opens the source and the stored file and asserts on what it reads back (excerpt; &lt;code&gt;s&lt;/code&gt; is the source, &lt;code&gt;d&lt;/code&gt; the stored file, &lt;code&gt;e&lt;/code&gt; its parsed EXIF):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;upright&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exif_transpose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
&lt;span class="nf"&gt;assert &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upright&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;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;upright&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aspect flipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0th&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;piexif&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImageIFD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orientation&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orientation tag left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPS survived&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thumbnail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;old thumbnail survived&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icc_profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ICC dropped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it on the synthetic upload (Python 3.9, Pillow 11.3, piexif 1.1.3): the 4032×3024 source with tag 6 came out as a 1200×1600 file with 9 tags kept, and the check passed. Then I pointed &lt;code&gt;check()&lt;/code&gt; at the two broken files from the previous section, so I know the assertions actually fail when they should. The plain &lt;code&gt;save()&lt;/code&gt; output failed on "aspect flipped", and the transpose-plus-old-EXIF output failed on "orientation tag left".&lt;/p&gt;

&lt;p&gt;The 9 tags are Make, Model, Orientation, the four exposure fields and the two new dimensions. The ICC line is there because the plain &lt;code&gt;save()&lt;/code&gt; drops the colour profile along with the EXIF, and a product shot tagged with a wide-gamut profile can look off without it. The &lt;code&gt;check()&lt;/code&gt; is the part I care about more than &lt;code&gt;normalize()&lt;/code&gt;. It belongs in CI next to fixture photos that include at least one with Orientation 6 and GPS, and it reads the output file back instead of trusting what the code intended to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  The acceptance list I review against
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A portrait upload with Orientation 6 is stored with upright pixels and Orientation 1 or no tag, and the stored aspect ratio matches what the browser showed.&lt;/li&gt;
&lt;li&gt;No GPS IFD in anything we store, unless a written requirement says otherwise.&lt;/li&gt;
&lt;li&gt;No IFD1 thumbnail carried over from the source.&lt;/li&gt;
&lt;li&gt;Colour profile preserved when the source had one.&lt;/li&gt;
&lt;li&gt;Every rule is checked by reading the saved file, in CI, with at least one fixture that has both rotation and location.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you resize on the client with canvas instead, the picture changes again. In my Chromium test, drawing a JPEG to canvas and calling &lt;code&gt;toBlob&lt;/code&gt; gave upright pixels with no EXIF, GPS or XMP at all, which is fine for privacy but loses the camera fields. The HTML spec doesn't say whether &lt;code&gt;drawImage&lt;/code&gt; should honour EXIF orientation (the WHATWG issue is still open), and MDN notes that some older browsers ignore it, so I wouldn't lean on that behaviour for users on old devices. Server-side normalisation with a read-back check is the version I'm comfortable signing off on.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>sort=True put both columns of a real arXiv paper on the same line</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sat, 12 Sep 2026 08:02:23 +0000</pubDate>
      <link>https://dev.to/iterandum/sorttrue-put-both-columns-of-a-real-arxiv-paper-on-the-same-line-212c</link>
      <guid>https://dev.to/iterandum/sorttrue-put-both-columns-of-a-real-arxiv-paper-on-the-same-line-212c</guid>
      <description>&lt;p&gt;Before PDFs go into our internal RAG index, they pass through a preprocessing step my team owns, and the files are not allowed to leave the network. I sat through three months of a data-compliance cleanup once, so "upload it to a hosted parser and see" is not an option I reach for. That leaves the local Python stack, and the tip that comes up most for two-column documents is PyMuPDF's &lt;code&gt;page.get_text(sort=True)&lt;/code&gt;, which re-orders text by position on the page. I tested it before adopting it. On a real paper it did the opposite of what I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sort=True did to an arXiv paper
&lt;/h2&gt;

&lt;p&gt;The paper is &lt;em&gt;RAG-Safety-Bench: Reliable Evaluation of Retrieval-Augmented LLM Safety&lt;/em&gt; by Adithiyan Rajan Indira Saravanan and Kathleen C. Fraser (arXiv:2609.11758, CC BY 4.0), a standard two-column LaTeX layout. I took pages 1–2 and extracted them twice, once in default order and once with &lt;code&gt;sort=True&lt;/code&gt;. On the non-empty lines I counted three things: lines with a run of six or more spaces between two words, lines ending in a lowercase letter followed by a hyphen, and lowercase words with a year, a month abbreviation or the arXiv ID stuck to their end.&lt;/p&gt;

&lt;p&gt;Default order gives 199 lines, no wide gaps, 41 hyphenated line ends, nothing glued, and the right column order, because LaTeX writes the left column before the right one and stream order already matches reading order. With &lt;code&gt;sort=True&lt;/code&gt; there are 122 lines, and 47 of them contain a run of six or more spaces, which is what a left-column line and a right-column line pasted side by side look like. The vertical arXiv stamp in the left margin got merged into body text as well, as four glued tokens: &lt;code&gt;reduce2026&lt;/code&gt;, &lt;code&gt;onSep&lt;/code&gt;, &lt;code&gt;to[cs.CL]&lt;/code&gt; and &lt;code&gt;arearXiv:2609.11758v1&lt;/code&gt;. One sorted line on page 1 reads "uments can increase reliability and reduce2026", another "tion (RAG) can have unintended side effects onSep". &lt;code&gt;reduce2026&lt;/code&gt; would be embedded as a token. The drop from 41 to 26 lines ending in a hyphen is not an improvement either; the hyphenated fragments are still there, now in the middle of merged lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  And a file where default order is the wrong one
&lt;/h2&gt;

&lt;p&gt;That doesn't make default order safe. I generated a two-column test report with made-up content whose content stream writes a left line, then the right line at the same height, then the next left line. Nothing on the rendered page tells you the stream is in that order. pypdf and PyMuPDF's default both follow the stream and alternate columns every line.&lt;/p&gt;

&lt;p&gt;I also ran it through ImgIng's Extract PDF content, a browser tool whose workspace states the file is read only in the browser and never uploaded, which is the property I need. Its output doesn't depend on stream order at all: this file and a copy written column by column gave identical TXT. The first three lines of each column came out correctly as two columns. Lines 4 to 7 were fused, left then right, with nothing between them, which produced strings like "Finance4. Result" and "differs byto 2":&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/..." 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/..." alt="English Extract PDF content workspace on a synthetic two-column test file: the boxed paragraph shows left- and right-column lines fused (" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the arXiv paper the same tool kept the column order, left the margin stamp out, and moved the second author's affiliation block to after the left column's text. So four extraction paths, two files, and not one path right on both. That changed what I was building. Instead of picking a better extractor, I wanted a check that runs after whichever one we use.&lt;/p&gt;

&lt;h2&gt;
  
  
  A check that uses the PDF's own geometry
&lt;/h2&gt;

&lt;p&gt;For files with native text, the PDF already knows where every line sits. The check pulls each body line with its bounding box from PyMuPDF's &lt;code&gt;get_text("dict")&lt;/code&gt;, skips rotated lines (that is how the margin stamp stays out), anything in the top or bottom 8% of the page, and lines shorter than 8 characters once whitespace is removed. What's left is tagged by which side of the page centre it sits on (excerpt):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;x0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;x0&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;R&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A line that straddles the centre, like a full-width title, gets no tag at all. Then an extractor's output is read two ways. The first asks whether an output line, with whitespace squashed out, contains one complete left line and one complete right line side by side, filling the row. The second walks the output in order, keeps only lines it can match to a tagged line, and counts how often consecutive ones jump between columns. The core of the first test (excerpt, &lt;code&gt;s&lt;/code&gt; is the squashed output line):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;R&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;R&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running both tests over all four extraction paths, on the paper and on the test report:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extractor&lt;/th&gt;
&lt;th&gt;arXiv: mixed lines&lt;/th&gt;
&lt;th&gt;arXiv: L/R jumps&lt;/th&gt;
&lt;th&gt;Test report: mixed lines&lt;/th&gt;
&lt;th&gt;Test report: L/R jumps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pypdf&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;11 over 169&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18 over 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyMuPDF default&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;11 over 182&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;18 over 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyMuPDF sort=True&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;44 over 56&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;1 over 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;browser extractor&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9 over 169&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2 over 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the arXiv pages the sorted output has 50 mixed lines and nothing else has any. On the test report, default extraction has no mixed lines but jumps columns 18 times in 20 lines, and the browser tool's 6 fused lines are all caught. Default pypdf on the paper jumps 11 times over 169 lines, which is roughly the cost of an author block, figure captions and one column break per page. In our pipeline a single mixed line quarantines the document, and so does a jump rate far above that baseline. I don't have enough files yet to put a hard number on "far".&lt;/p&gt;

&lt;p&gt;It has limits. It only works when the PDF has native text; scanned pages need an OCR step that returns boxes first. Lines under 8 characters are skipped, and a line that appears identically in both columns can't be attributed, so it is dropped. My first version flagged 17 lines on the paper that were fine, among them the identical "Faculty of Engineering" and "University of Ottawa" lines in the two author blocks; the second still flagged 6 where a short line from one column happened to sit inside a line from the other. It also says nothing about the 41 line-end hyphen breaks every extractor kept on these two pages, which need their own pass before chunking. The browser tool mentioned above is at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&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%2Fy7l32l877587bg68v3oe.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%2Fy7l32l877587bg68v3oe.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Client-side or server-side image compression? How I decide per project</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Fri, 11 Sep 2026 08:02:40 +0000</pubDate>
      <link>https://dev.to/iterandum/client-side-or-server-side-image-compression-how-i-decide-per-project-goh</link>
      <guid>https://dev.to/iterandum/client-side-or-server-side-image-compression-how-i-decide-per-project-goh</guid>
      <description>&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%2Fx84td4jau16wydwigs1t.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%2Fx84td4jau16wydwigs1t.png" alt=" " width="800" height="365"&gt;&lt;/a&gt;&lt;br&gt;
Every project I touch ends up with an image compression step somewhere, and the question that keeps coming back is not "which tool has the better ratio" but "where should the bytes be crunched". Once you frame it that way, most of the popular tools sort themselves into two buckets, and the choice depends more on the workflow than on the compressor.&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/..." 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/..." alt="Five image-compression tools compared (from public product info)" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first bucket is upload-based. TinyPNG is the one most teams already know: you send the file, their servers compress it, you get it back. The free plan covers 500 images a month with a 5 MB ceiling per file, and the API is mature enough that wiring it into a CI pipeline or an upload hook takes an afternoon. compressor.io sits in the same bucket with a 10 MB free ceiling and SVG support, though it handles one file at a time. docsmall, which is popular in Chinese-speaking teams, adds batch handling and PDF compression, keeps the free tier at 5 MB per file, and puts large files behind a membership.&lt;/p&gt;

&lt;p&gt;The second bucket runs in the browser. Squoosh from Google is the reference point here: everything happens locally, it is completely free, and the side-by-side viewer is still the nicest way I know to eyeball a quality slider. Its limits are structural rather than commercial. It takes one image at a time, has no batch mode, does not handle animated files, and does not open HEIC. ImgIng at imging.ai is the other local option I use, built on WASM so that common formats are processed on the machine without uploading, and it adds the things Squoosh leaves out: drop a folder, get a folder back, no login, no watermark, no monthly count.&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;Where it runs&lt;/th&gt;
&lt;th&gt;Batch&lt;/th&gt;
&lt;th&gt;Animated GIF/APNG/WebP&lt;/th&gt;
&lt;th&gt;Free ceiling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TinyPNG&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;500/month, 5 MB each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;compressor.io&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;10 MB per file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docsmall&lt;/td&gt;
&lt;td&gt;server&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;not compared&lt;/td&gt;
&lt;td&gt;5 MB per file, large files paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Squoosh&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImgIng&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is how the buckets map onto actual work for me. When the pipeline is automated and images arrive from users at runtime, a server-side API wins. TinyPNG's API is the sensible default there because someone else operates the fleet, the SLA is theirs, and a 500-image free allowance is enough to prototype before the invoice matters. I would not try to replicate that with a browser tool; the whole point is that no human is in the loop.&lt;/p&gt;

&lt;p&gt;When a human is in the loop, the calculation flips. Marketing hands me two hundred product shots, a designer exports a batch of PNG icons, or a client sends screenshots that contain internal dashboards. Uploading those to a third party raises a question I would rather not answer in a security review, and counting them against a monthly quota is friction I do not need. A local batch tool handles the whole folder in one drag, and if a file will not get smaller, ImgIng keeps the original instead of handing back a larger one, which is a small detail that saves a manual check.&lt;/p&gt;

&lt;p&gt;For a single hero image where I want to squint at the artifacts, I still open Squoosh. Its encoder lineup and the visual comparison are its home turf, and ImgIng's AVIF output comes from the same libavif lineage, so I do not expect a night-and-day difference in compression ratio between the two on one photo. The reasons to reach for ImgIng are volume, animation, and not thinking about limits, not a claim that its single-image output is superior.&lt;/p&gt;

&lt;p&gt;Two honest caveats. Local tools depend on the machine: a laptop compressing 300 AVIFs in WASM will take longer than a warm server, and mobile browsers can run out of memory on very large files. And "common formats stay local" is not the same as "nothing ever leaves the browser", so read the tool's own notes on which formats fall back to a server path before you promise anything to a compliance team.&lt;/p&gt;

&lt;p&gt;So the decision tree I actually use is short. Automated ingestion, no human present: server API, TinyPNG first. Human-driven batches, private material, animated assets, or any month where the quota would bite: local batch, ImgIng first. One image, maximum scrutiny: Squoosh. Fitting the tool to where the work happens has done more for my image budgets than chasing an extra percent of compression ever did.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A 14-page report through HTML-to-PDF 4.8MB to 1.5MB</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Wed, 09 Sep 2026 08:02:41 +0000</pubDate>
      <link>https://dev.to/iterandum/a-14-page-report-through-html-to-pdf-48mb-to-15mb-3el7</link>
      <guid>https://dev.to/iterandum/a-14-page-report-through-html-to-pdf-48mb-to-15mb-3el7</guid>
      <description>&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%2Fc8egn50r8uxrhddyq8jf.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%2Fc8egn50r8uxrhddyq8jf.png" alt=" " width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I had a 14-page internal report to export from a webpage into a tidy PDF for archiving and sending. Ran it through HTML-to-PDF; the before/after is clean, but there's a boundary I have to state up front, because it's not what most people assume.&lt;/p&gt;

&lt;p&gt;The numbers first. The report was ~4.82 MB, the output PDF ~1.56 MB, generation time ~5.3 seconds down to 2.6. &lt;strong&gt;This is one internal sample and results vary by page structure&lt;/strong&gt; — image-heavy vs text-heavy, simple vs complex layout, all swing the size and timing a lot; don't take this as a universal figure. All it shows is: for this kind of image-and-text report, HTML-to-PDF can trim the size while keeping the layout.&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/..." 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/..." alt="HTML to PDF workspace" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the thing to be clear about is &lt;strong&gt;where it actually runs&lt;/strong&gt;, because this is the most misunderstood part. People default to "anything in the browser is local"; HTML-to-PDF isn't. The flow: &lt;strong&gt;import, table-of-contents reading, resource mapping and full preview happen locally in the browser&lt;/strong&gt; — you tune and verify the final frame locally; &lt;strong&gt;only when you click "convert" is a script-stripped self-contained snapshot submitted to the server&lt;/strong&gt;, which does the actual PDF generation, with temp files deleted when the request ends. So it is &lt;strong&gt;not "zero-upload"&lt;/strong&gt; — the convert step goes server-side. I single this out because in the same product "PDF compression" and "PDF-to-HTML" are fully local, only "HTML-to-PDF" has a server step; conflating them gets you burned — like running absolutely-must-not-leave-the-network content through it.&lt;/p&gt;

&lt;p&gt;Why does the convert step have to go server-side, not be fully local? My read: the hard part isn't the layout, it's pinning down which frame of the page to save — the page may still be loading fonts, images, running scripts, and local preview lets you stabilise that frame; on convert, a script-stripped self-contained snapshot is submitted and generated once by the browser-grade engine, avoiding wildly different exports from everyone's different environment. Local tunes it right, the server generates it consistently.&lt;/p&gt;

&lt;p&gt;One more thing to set straight: &lt;strong&gt;the output PDF's layout and generation are done by browser-grade layout and PDF-generation (Chromium / Skia), not an in-house layout engine&lt;/strong&gt;. The tool does the rest — local preview, deciding "which frame" to save, page-size strategy, and lossless post-generation optimisation. Crediting this step as "in-house layout" is inaccurate; the real value is "reliably feeding the correct frame to the generation engine and cleaning up after".&lt;/p&gt;

&lt;p&gt;So what it fits and doesn't is clear. &lt;strong&gt;Fits&lt;/strong&gt;: image-and-text reports, web content to archive as tidy PDF, non-confidential — tune the layout locally, get a clean PDF from the server, size trimmed as a bonus. &lt;strong&gt;Doesn't fit&lt;/strong&gt;: absolutely-must-not-leave-the-network content — that server submit on convert is unavoidable, so don't take this path for it, use a fully-local route instead. Good numbers are one thing; knowing where it runs and what to feed it matters more than the compression ratio. Tool is ImgIng (imging.ai).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Send a proposal without making them install Office or accept a PDF</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Tue, 08 Sep 2026 08:02:41 +0000</pubDate>
      <link>https://dev.to/iterandum/send-a-proposal-without-making-them-install-office-or-accept-a-pdf-5ghn</link>
      <guid>https://dev.to/iterandum/send-a-proposal-without-making-them-install-office-or-accept-a-pdf-5ghn</guid>
      <description>&lt;p&gt;Sending a proposal to a client, the format alone is a headache. Send Word, and you worry they don't have Office, or their version reflows and wrecks the layout — worse, it's a file they can edit, and if they change something and send it back you won't know what moved. Send PDF, and the layout is stable but rigid, awkward to navigate, and exporting from Word can drag along comments, tracked changes and original content you didn't mean to share. Both paths have their annoyance.&lt;/p&gt;

&lt;p&gt;So I moved to a third path: convert the document to a &lt;strong&gt;self-contained HTML single file&lt;/strong&gt; and send that. They get a .html, double-click, it opens in the browser — no software to install, layout basically identical to what I see.&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/..." 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/..." alt="DOCX to HTML operation page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what's good about it. ImgIng's docx-to-html inlines the body text, paragraph styles, lists, tables, images, hyperlinks, headers/footers, footnotes and endnotes — plus embeddable fonts — into &lt;strong&gt;one single file&lt;/strong&gt;. That "single file" is the point: it carries no DOCX original, no parser, and doesn't depend on the converter's server; it's a self-contained html you can send, save, open offline. And it stays readable with JavaScript disabled — scripts only handle page numbers, zoom, find, fullscreen, print; the content isn't script-rendered. For "must still open years from now" that's reassuring — it won't break because some dependency died.&lt;/p&gt;

&lt;p&gt;Against the other two paths the wins are clear: steadier than Word — they don't need Office, the layout won't reflow on their version, and it's read-only so nothing gets silently edited; livelier than PDF — the browser searches and follows hyperlinks natively, and it doesn't smuggle the original DOCX or tracked changes, only the content you meant to send.&lt;/p&gt;

&lt;p&gt;Practically it's simple too: drop the docx into the converter, out comes one html, send it. One file — not a folder of loose images and css you have to zip up and hope survives.&lt;/p&gt;

&lt;p&gt;But state the cost, don't oversell. &lt;strong&gt;It's a read-only reading copy, not an editable Word&lt;/strong&gt; — if the client needs to edit in place, fill a table, run a review cycle, this path won't do; send Word for that. &lt;strong&gt;The "live" parts degrade&lt;/strong&gt;: macros won't run, complex formulas and some floating shapes may be static previews only. &lt;strong&gt;And pagination isn't guaranteed identical&lt;/strong&gt; to your Word (Word pagination varies by environment anyway — a separate topic). So it fits finished deliverables — proposals, reports, docs meant to be read — not documents still being co-edited. Split by intent: still iterating with the client, send Word; finalized and they just need to open it cleanly, send a single HTML file. Tool is ImgIng (imging.ai).&lt;/p&gt;

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