<?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: NullPointerZen</title>
    <description>The latest articles on DEV Community by NullPointerZen (@yue_shu_c621a4a637f22396f).</description>
    <link>https://dev.to/yue_shu_c621a4a637f22396f</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%2F4091461%2F67d2ab23-740e-4763-8279-b58904d35440.png</url>
      <title>DEV Community: NullPointerZen</title>
      <link>https://dev.to/yue_shu_c621a4a637f22396f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yue_shu_c621a4a637f22396f"/>
    <language>en</language>
    <item>
      <title>Two canvas font shortcuts worked in three browsers and drew the fallback in Firefox</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Thu, 15 Oct 2026 08:07:56 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/two-canvas-font-shortcuts-worked-in-three-browsers-and-drew-the-fallback-in-firefox-5259</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/two-canvas-font-shortcuts-worked-in-three-browsers-and-drew-the-fallback-in-firefox-5259</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%2Ftacen4g652zzq259caq2.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%2Ftacen4g652zzq259caq2.png" alt=" " width="800" height="1119"&gt;&lt;/a&gt;&lt;br&gt;
You build a share card on a canvas: a headline in a nice serif, a price, a button that saves the whole thing as a PNG. The first time the page opens, the saved image has the headline in plain sans-serif. Reload and it looks right, and the console is empty both times. I make small share-image generators for a content team and wanted to know which way of "waiting for the font" I could lean on, so I wrote a test page to compare them.&lt;/p&gt;

&lt;p&gt;The plain version first. &lt;code&gt;@font-face&lt;/code&gt; is the CSS rule that gives a font a name and says where its file lives. Declaring it doesn't download the file, and canvas &lt;code&gt;fillText&lt;/code&gt; doesn't wait for it. On my page, drawing straight after the declaration came out in the fallback, the generic font listed after mine in the &lt;code&gt;font&lt;/code&gt; string, with no error. That happened in all four browsers I tried, Chromium 149, Chrome 155, Firefox 151 and WebKit 26.5, on one Apple M4 running macOS.&lt;/p&gt;

&lt;p&gt;So I have to wait for something, and I had three candidates. The first two are shortcuts that look like waiting. One is to put some ordinary page text in the font and draw in the &lt;code&gt;load&lt;/code&gt; event, which fires once the page and its resources have finished loading. The other is a preload, &lt;code&gt;&amp;lt;link rel="preload" as="font" crossorigin&amp;gt;&lt;/code&gt;, which asks the browser to fetch the file early, and then drawing as soon as that link reports it is done. Neither one asks about the font itself. It is like deciding the kettle has boiled because the toast popped. The third asks directly: &lt;code&gt;document.fonts.load()&lt;/code&gt; takes a font string and returns a promise that resolves when that font has been loaded.&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="Nine ways to draw on a canvas and which font showed up in Chromium 149, Chrome 155, Firefox 151 and WebKit 26.5" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every cell was compared pixel by pixel with two reference renders, one drawn with the font fully loaded and one with the fallback alone. The rows to look at are the fifth, sixth and eighth. Both shortcuts are green in three columns and red under Firefox 151, and the &lt;code&gt;document.fonts.load&lt;/code&gt; row is green all the way across. The test font is a 3,755-character subset of Noto Serif SC, 1,046,216 bytes as WOFF2. Noto Serif SC and Pacifico, the Latin font mentioned below, are both SIL OFL 1.1.&lt;/p&gt;

&lt;p&gt;Firefox was the odd one out both times. With the preload, drawing the moment the download finished gave the fallback there, and drawing again 1,000 ms later gave the custom font. I didn't look into why. The second row of the chart bothered me more. The &lt;code&gt;load&lt;/code&gt; shortcut only worked anywhere because some page text used the font. When I removed that text, drawing in &lt;code&gt;load&lt;/code&gt; gave the fallback in all four browsers, and the custom font appeared when I drew again 1,500 ms later. &lt;code&gt;document.fonts.ready&lt;/code&gt;, a promise for the page's fonts as a whole, behaved the same way. With the text on the page it was fine in all four. Without it the promise came back in under 20 ms and I got the fallback in all four. A wait that only holds while some unrelated text stays on the page is one I don't trust myself to keep intact.&lt;/p&gt;

&lt;p&gt;Awaiting &lt;code&gt;document.fonts.load&lt;/code&gt; gave the custom font in all four browsers, with the Chinese subset, with Pacifico, and with the full 11.6 MB Noto Serif SC file. The wait does cost time. On my local server throttled to a fixed 20 Mbps, a rate I set myself and not a real network, the same subset took about 0.5 seconds to come back. I also tried &lt;code&gt;document.fonts.check()&lt;/code&gt;, the call that answers whether a font is ready. It returned true for a font name I had never declared, in all four browsers, so I don't use it as a gate.&lt;/p&gt;

&lt;p&gt;To see how a finished product deals with this, I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), whose online design editor has text layers with downloadable fonts and says under the font menu how big the first download is. I delayed the Korean font's response on purpose, picked Noto Sans Korean and clicked export about 0.2 seconds later. The file was written only after the font response arrived, and it was byte-for-byte identical to the PNG I exported after waiting, 39,765 bytes, against 39,854 for the system-font export.&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%2Fcqkto5pk0l534xe1m571.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%2Fcqkto5pk0l534xe1m571.png" alt="The online design editor while the Korean font is downloading and after it has arrived" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the top half the status bar says the text layer font is still downloading and the layer is 648 wide. In the bottom half the glyphs have changed and the width reads 677. I only watched this one flow, in Chromium 149 with one font, and I haven't seen how it is written inside.&lt;/p&gt;

&lt;p&gt;My share-card pages now await &lt;code&gt;document.fonts.load&lt;/code&gt; with the exact font string right before the first draw. How any of this behaves on phones, on Windows, in release Safari or in Edge I can't say, because I tested none of them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I closed every VideoFrame and memory still climbed by gigabytes</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:08:05 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/i-closed-every-videoframe-and-memory-still-climbed-by-gigabytes-dnj</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/i-closed-every-videoframe-and-memory-still-climbed-by-gigabytes-dnj</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%2Ftfgkv89l7kbr8umygevk.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%2Ftfgkv89l7kbr8umygevk.png" alt=" " width="800" height="699"&gt;&lt;/a&gt;&lt;br&gt;
I build small tools for a content team, and a lot of them come down to the same job: open a video, do something to every frame, save the result. In a browser you hand the compressed video to a &lt;code&gt;VideoDecoder&lt;/code&gt;, which turns it back into full pictures, and each picture arrives as a &lt;code&gt;VideoFrame&lt;/code&gt;. Every guide attaches one rule: call &lt;code&gt;close()&lt;/code&gt; when you are done with a frame, because it is big. On my 1080p sample one frame reported 3,110,400 bytes, about 3.1 MB.&lt;/p&gt;

&lt;p&gt;So I wrote the loop by the book. The sample is a clip of waves from Wikimedia Commons that I converted to H.264 MP4 with ffmpeg 7.1 and looped end to end into 60 seconds of 1920×1080, which makes 1,500 frames. It is a stitched loop, not real long footage. I unpacked it with mp4box.js 0.5.2 and handed every compressed chunk to the decoder in one go. Decoded frames went into an array, and a second loop took them out one at a time: draw on a small canvas, read one pixel, wait 10 ms to stand in for real work, &lt;code&gt;close()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;waiting&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VideoDecoder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// all 1,500, no questions asked&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Chromium 149 all 1,500 frames came out, nothing was logged, and every one of them got its &lt;code&gt;close()&lt;/code&gt;. System memory still peaked 3,752 MB above where it started. That reading is noisy, and the same scene can land more than 1 GB apart between two runs, but it is gigabytes either way.&lt;/p&gt;

&lt;p&gt;The reason turned up when I ran the plainest version, the one that closes each frame the moment it arrives. The same 1,500 frames were through in 752 ms. Decoding the whole clip takes under a second, and my pretend work takes 10 ms per frame. The array in the middle is where the difference went: at its worst it held 1,421 frames that were decoded and not yet processed. Each one did get closed when its turn came. Until then it was a full picture in memory.&lt;/p&gt;

&lt;p&gt;It reminds me of moving flats. The driver empties the van onto the pavement in minutes, and I carry one box upstairs at a time. Every box gets there in the end, but for most of the afternoon the pavement is covered in my stuff. &lt;code&gt;close()&lt;/code&gt; is me putting a box away. It says nothing about how fast the driver unloads.&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="Decoded frames waiting to be processed over time, with and without pacing" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The red line is the loop above. It shoots up to about 1,400 waiting frames in the first couple of seconds and needs the rest of the 20 seconds to come back down. The blue line is the same work with the feed paced, which means I check two numbers before handing over the next chunk. One is &lt;code&gt;decoder.decodeQueueSize&lt;/code&gt;, the count of chunks the decoder has accepted and not decoded yet. The other is the length of my own array.&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decodeQueueSize&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;nap&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="c1"&gt;// nap = setTimeout as a promise&lt;/span&gt;
  &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&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;With that one extra line the backlog peaked at 17 frames instead of 1,421, and the system memory peak was 165 MB. The total time barely moved, 18.9 s against 20.3 s, which I read as "about the same" and nothing more.&lt;/p&gt;

&lt;p&gt;What threw me is where the memory was. In the unpaced run the browser's own process memory, summed over all its processes, changed by 0 MB. On macOS the frames sat in graphics buffers, blocks of memory the system shares with the graphics side and calls IOSurface. Running &lt;code&gt;ioclasscount IOSurface&lt;/code&gt; in a terminal counts them.&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%2Fui3xhyqqa3u4674a8mpg.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%2Fui3xhyqqa3u4674a8mpg.png" alt="Extra graphics buffers held by six ways of handling frames in Chromium 149" width="799" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the fourth and fifth bars: 1,488 extra buffers for the unpaced loop, 29 for the paced one. On a 4K clip of a river I only fed 400 frames to spare the machine, and the backlog peaked at 332 without pacing and 16 with it.&lt;/p&gt;

&lt;p&gt;For comparison I wanted a per-frame pipeline that somebody had already shipped. For that I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;). Its video matting tool is still labeled Beta, and the site says desktop Chrome and Edge are supported first. I ran it on the same 1,500-frame wave clip in Chromium 149. Sampled every 2 seconds for the whole job, the graphics-buffer count stayed between −2 and 79 above its starting level and did not rise with progress. Waves have no subject to cut out, so this is about memory and says nothing about matting quality.&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%2F9cy99155a1rhhyumr3zl.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%2F9cy99155a1rhhyumr3zl.png" alt="Video matting (Beta) cutting a street guitarist out of a night scene" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is what the tool looks like while it works. There is nobody in the wave clip, so I took the screenshot with a different one: 8 seconds of a street guitarist in Ljubljana, video by Isak Zvegelj on Wikimedia Commons (CC BY 3.0). I did not re-measure memory with it.&lt;/p&gt;

&lt;p&gt;One thing I can't explain yet. In the unpaced run the backlog drained from 1,403 frames down to 4, but the buffer count stayed between 1,430 and 1,490 the whole time and only fell when the browser quit. I haven't looked into why. Everything here is from one Apple M4 with 16 GB on macOS, and I don't know where frames live on other systems. No run fell over, so I can't say where the ceiling is. What I do now is log the peak of &lt;code&gt;waiting.length&lt;/code&gt; next to the frame count. If the two are close, the frames are queuing.&lt;/p&gt;

&lt;p&gt;The wave footage is by David Shay (דוד שי) and the river footage by Nesnad, both from Wikimedia Commons under CC BY-SA 4.0.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Five ways to grab a frame from a portrait video, and they don't agree on which way is up</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:06:57 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/five-ways-to-grab-a-frame-from-a-portrait-video-and-they-dont-agree-on-which-way-is-up-41ec</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/five-ways-to-grab-a-frame-from-a-portrait-video-and-they-dont-agree-on-which-way-is-up-41ec</guid>
      <description>&lt;p&gt;A portrait clip opens fine on the watch page. Tall, upright, nothing to see. Then you scroll back to the list and its thumbnail is a wind turbine lying on its side. I build small internal tools for a content team, and this is the kind of bug that lands on my desk as a screenshot with three question marks.&lt;/p&gt;

&lt;p&gt;It took me a while to accept that the video was never upright in the first place. A lot of portrait video is stored with the pixels lying down, as an ordinary landscape picture, and the file carries a separate note that says "turn this 90 degrees when you play it". I think of it as a sticky note on the box. A player reads the note and turns the picture. Anything that opens the box without looking at the lid gets the picture the way it was packed.&lt;/p&gt;

&lt;p&gt;To find out who reads the note, I took an example clip from Wikimedia Commons, a wind turbine against a blue sky. I painted a red block in its top-left corner and a green one in the top-right to mark which way is up. Then I had ffmpeg 7.1 store it sideways as 1920×1080 H.264 with the note attached, which ffmpeg reports as &lt;code&gt;rotation of -90.00 degrees&lt;/code&gt;. So this is a lab sample, not footage straight off a phone, and I didn't test HEVC, iPhone Safari or Android. I grabbed a frame from it five different ways in Chromium 149, Chrome 154, Firefox 151 and WebKit 26.5 on macOS.&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 ways to grab a frame from the same tagged MP4 in Chrome 154, Firefox 151 and WebKit 26" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read each row left to right and follow the red block. Top-left means the frame is upright, bottom-left means it is lying down. The turbine footage is by Grendelkhan on Wikimedia Commons, CC BY-SA 4.0.&lt;/p&gt;

&lt;p&gt;Two of the five never let me down. Plain &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; playback was upright in all four browsers and reported &lt;code&gt;videoWidth × videoHeight&lt;/code&gt; as 1080×1920. &lt;code&gt;canvas.drawImage(video)&lt;/code&gt;, the old trick of painting the current frame onto a canvas, was upright in all four as well. The trouble starts with the two newer shortcuts.&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;bmp&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;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the current frame as a ready-to-draw bitmap&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VideoFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// the current frame as a WebCodecs object&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Chrome 154 and WebKit 26.5 both lines gave me an upright 1080×1920 frame. In Firefox 151 both came back as 1920×1080 with the turbine on its side, and the &lt;code&gt;VideoFrame&lt;/code&gt; had no &lt;code&gt;rotation&lt;/code&gt; property at all, so there was nothing to check against. Chromium 149 found a third answer. Its &lt;code&gt;createImageBitmap&lt;/code&gt; returned a 1920×1080 bitmap whose picture was the right way up but squashed: my 160×160 corner blocks came out at roughly 284×90. Chrome 154 runs the same line correctly. I haven't gone looking for why the two builds differ.&lt;/p&gt;

&lt;p&gt;The fifth way is the one where every browser agrees, and they agree on the wrong answer. If you unpack the MP4 yourself (I used mp4box.js 0.5.2) and feed the compressed samples to &lt;code&gt;VideoDecoder&lt;/code&gt;, the browser's raw decoder, the frames come out sideways in all four. In the two Chromium builds the decoded frame even says &lt;code&gt;rotation&lt;/code&gt; is 0. That is when the sticky note finally clicked for me. The note is not inside the video stream. It sits in the container, in a field of the track header called the matrix, and the decoder only ever sees the stream. mp4box will show it to you if you ask.&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoTracks&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matrix&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTrackById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;tkhd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track_width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track_height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// 1920 1080 [0, 65536, 0, -65536, 0, 0, 0, 0, 1073741824]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even the width and height in there are the packed size, not what a viewer sees. So when I do the unpacking myself, the rotation is my job too. I assume &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; gets it right because the browser's own unpacker reads the matrix and passes it along, but that is a guess from behaviour, not from reading source.&lt;/p&gt;

&lt;p&gt;The container matters in the other direction too. I moved the same sideways pixels and the same note into a VP9 WebM with ffmpeg's &lt;code&gt;-noautorotate&lt;/code&gt;, and ffmpeg still read -90 from the file. Chromium 149 and Chrome 154 played it upright at 1080×1920. Firefox 151 and WebKit 26.5 played it lying down at 1920×1080. It was the only time plain playback of a file that still carried its note went wrong for me.&lt;/p&gt;

&lt;p&gt;What I do now is boring. For a thumbnail from a playing video I stay with &lt;code&gt;drawImage&lt;/code&gt;. For files I control, I get rid of the note by baking the turn into the pixels once. ffmpeg's default transcode did that for my sample: 1080×1920 out, no rotation tag. For the no-install route I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in Chromium 149 on its size-first preset, and the samples tagged 90, 270 and 180 degrees each came out as 608×1080 with upright pixels and no rotation note.&lt;/p&gt;

&lt;p&gt;The 180 one still bothers me. In every cell that failed, that sample came out upside down with its width and height unchanged, so a "wider than tall" check catches nothing. My corner blocks catch it, but only on files I made myself.&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%2Fdgj262sfw8w98z9zw7nu.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%2Fdgj262sfw8w98z9zw7nu.png" alt=" " width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I ran a cropped-image DOCX through 5 converters. None got it all right</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:05:20 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/i-ran-a-cropped-image-docx-through-5-converters-none-got-it-all-right-4cag</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/i-ran-a-cropped-image-docx-through-5-converters-none-got-it-all-right-4cag</guid>
      <description>&lt;p&gt;Cropping a picture in Word feels final. You drag the black handles, the part you don't want disappears, and you save. But Microsoft's own support page on shrinking picture file sizes offers an option called "Delete cropped areas of pictures", and that option only makes sense if the cropped part is still sitting in the file. In OOXML terms a document can keep the whole image and add three small settings next to it: crop percentages for each edge (&lt;code&gt;a:srcRect&lt;/code&gt;), the display size (&lt;code&gt;wp:extent&lt;/code&gt;) and a rotation (&lt;code&gt;a:xfrm rot&lt;/code&gt;). Any converter that turns the DOCX into a web page has to read those settings back. I wanted to know which ones actually do, since docs full of trimmed screenshots are exactly what people ask me to turn into pages.&lt;/p&gt;

&lt;p&gt;One honest caveat first. I don't have Word on this Mac, so my test file is not a Word export. I wrote the DOCX by hand following the OOXML structure, with seven image cases: plain scaling, an uneven crop on all four sides, a 15° rotation, a screenshot with its bottom cut off, a squashed resize, a crop plus a 90° rotation, and an even 15% crop on every side. The screenshot comes from a fake order admin page I built myself, so the recipient, phone number, address and payment ID under "Shipping info" are placeholders. The photo is a landscape from Wikimedia Commons by Dietmar Rabich, CC BY-SA 4.0.&lt;/p&gt;

&lt;p&gt;I ran the same file through mammoth.js 1.13.0, docx-preview 0.4.1, the macOS Quick Look thumbnail and macOS &lt;code&gt;textutil&lt;/code&gt;. For a browser tool I used ImgIng's DOCX to HTML (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;imging.ai&lt;/a&gt;) on the same 7-case sample in a Chromium 149 open-source build, going through the site's Chinese-language deployment. Here is what each one did.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Converter&lt;/th&gt;
&lt;th&gt;Crop&lt;/th&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;th&gt;Rotation&lt;/th&gt;
&lt;th&gt;Cropped part still in the output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mammoth.js 1.13.0&lt;/td&gt;
&lt;td&gt;Ignored, whole image shown&lt;/td&gt;
&lt;td&gt;Ignored, fills the container&lt;/td&gt;
&lt;td&gt;Ignored&lt;/td&gt;
&lt;td&gt;Yes, all 7 images are full originals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docx-preview 0.4.1&lt;/td&gt;
&lt;td&gt;Right content, drifts off its frame on uneven crops&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Right direction, no space reserved&lt;/td&gt;
&lt;td&gt;Yes, behind &lt;code&gt;blob:&lt;/code&gt; URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImgIng export&lt;/td&gt;
&lt;td&gt;Right content, same drift&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Same as docx-preview&lt;/td&gt;
&lt;td&gt;Yes, 7 data URIs byte-identical to the originals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick Look thumbnail&lt;/td&gt;
&lt;td&gt;Ignored, whole image squeezed into the frame&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Not applicable, it's a bitmap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;textutil&lt;/td&gt;
&lt;td&gt;All 7 images dropped&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;No images at all (1,380 bytes of HTML)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;mammoth was the rough one. It skips the crop completely, so the shipping block I had trimmed off the screenshot shows up right there on the page. The red box is mine.&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="mammoth output: the cropped-out shipping info is back on the page, inside the red box" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;docx-preview gets the visible content right, which is why its failure took me a minute to spot. Going by the inline styles it writes, it clips the image to the crop area and then scales it up from the element's center. When an edge pair is cut unevenly, the picture drifts. The four-side case slid right and up. The screenshot with only its bottom cut moved up 69.7 px and covered the heading above it, leaving just "Scree" visible. The even 15% crop didn't move at all.&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%2F6cnuuelo5xhy0koy967t.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%2F6cnuuelo5xhy0koy967t.png" alt="docx-preview output: the crop is right, but the image slid up over its own heading" width="800" height="608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ImgIng landed in the same spot, and I'd rather say that plainly than dress it up. It read the crop content and the display size correctly, and the conversion made 0 non-GET requests. Still, the four-side case drifted by (58, −19) px and the bottom-cut screenshot went up 70.4 px over its heading. The UI gave no warning about crops or rotation either. The bigger catch is in the exported file. It is 9,665,338 bytes, and all seven inline images are the untouched originals. Decode the screenshot and you get the full 1800×1240 PNG with the placeholder phone number and address intact. Hidden on screen is not the same as removed.&lt;/p&gt;

&lt;p&gt;What fixed it was baking the crop before converting. A small script cut each media file to its &lt;code&gt;srcRect&lt;/code&gt; and then cleared the setting. The DOCX shrank 21.0%, ImgIng's offsets went to (0, 0) and (0, −0.4), and the cropped area could no longer be found in any of the three outputs. In Word itself the equivalent should be that "Delete cropped areas" option, but I haven't tried it without a copy of Word. Pure CSS can only hide the cut part. The bytes still ship with the page.&lt;/p&gt;

&lt;p&gt;I didn't test Word, WPS, LibreOffice or pandoc, I only checked ImgIng's export in Chromium 149, and floating images weren't part of the sample. If your docs have cropped screenshots, rename a copy to .zip and open &lt;code&gt;word/media&lt;/code&gt;. Full-size originals in there mean the crop never happened, so bake it or retake the screenshot before converting, then look at the line right above any bottom-cropped image.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My PDF came out blank because the load event doesn't wait for fonts</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:04:21 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/my-pdf-came-out-blank-because-the-load-event-doesnt-wait-for-fonts-3b1b</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/my-pdf-came-out-blank-because-the-load-event-doesnt-wait-for-fonts-3b1b</guid>
      <description>&lt;p&gt;I made a one-page placeholder statement to test PDF export, the kind of thing a billing page spits out. The body uses Noto Sans SC and the title uses ZCOOL KuaiLe, a round, playful Chinese display font that's easy to spot when it's missing. Both came from Google Fonts through a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag. My print script did what most snippets online do. It opened the page, waited for the &lt;code&gt;load&lt;/code&gt; event and called &lt;code&gt;page.pdf()&lt;/code&gt; straight away, in an open-source Chromium 149 build on an M4 Mac. The PDF looked fine at a glance, except the title was in PingFang, the Mac system font. I ran it again in a fresh browser. Same thing, 3 times out of 3. Then I dropped &lt;code&gt;display=swap&lt;/code&gt; from the font URL, and the PDF came back as 1,073 bytes of nothing. A blank page, no text layer, no error, also 3 out of 3.&lt;/p&gt;

&lt;p&gt;That sent me back to a question I had never really asked. When the browser prints, where do its fonts come from, and are they ready at that moment? On a page like mine there are three sources. System fonts are already on the machine, so they're ready, but they're whatever that machine has. Remote web fonts have to be downloaded, and &lt;code&gt;load&lt;/code&gt; doesn't wait for them. The Google Fonts version of my page pulled 16 woff2 slices, 412,984 bytes in total, because the CSS splits the font by unicode-range and only fetches the slices the page uses. At the moment &lt;code&gt;load&lt;/code&gt; fired, 13 of the 101 Noto slices were still loading. Fonts bundled with the page are the third source, and I assumed they'd be instant. They weren't.&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="Top: PDF printed right after load, the title fell back to the system font. Bottom: the same page printed after document.fonts.ready, the title is in ZCOOL KuaiLe" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What to look at in this one is the title line. The top PDF was printed the moment &lt;code&gt;load&lt;/code&gt; fired, and the rounded KuaiLe title is simply gone, replaced by the system font. The body text looks normal, so if you only skim the PDF you'd never notice.&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%2Fw1m126d5wjcfgyrhe2ky.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%2Fw1m126d5wjcfgyrhe2ky.png" alt="Left: PDF printed at load with a remote font and no font-display swap, the page is blank. Right: the normal PDF" width="799" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one is the version without swap. The default font-display treats the font as blocking, so while it's still downloading the text is drawn invisible. Print during that window and you get the empty page on the left, with no warning at all.&lt;/p&gt;

&lt;p&gt;For the bundled case I put the two TTF files in a &lt;code&gt;fonts/&lt;/code&gt; folder next to &lt;code&gt;index.html&lt;/code&gt; and pointed &lt;code&gt;@font-face&lt;/code&gt; at them with relative URLs. Printing right after &lt;code&gt;DOMContentLoaded&lt;/code&gt; in a cold browser, the body text and the table text vanished in 4 of 8 runs. Only the KuaiLe title and the empty table lines were left, 6,476 bytes. One earlier run came out fully blank. Printing after &lt;code&gt;load&lt;/code&gt; was correct every time, 71,102 bytes. Local files were faster than Google Fonts, just not fast enough for a print call fired that early.&lt;/p&gt;

&lt;p&gt;Two waits fixed the remote case. Printing after &lt;code&gt;networkidle&lt;/code&gt;, or after &lt;code&gt;load&lt;/code&gt; plus &lt;code&gt;await document.fonts.ready&lt;/code&gt;, gave the correct 117,799-byte PDF in all 3 runs each. I went with &lt;code&gt;fonts.ready&lt;/code&gt; because it waits for the thing I care about. My script now also logs how many fonts are still loading before it prints.&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;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&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;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fonts still loading:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&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;pdfBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code notes: &lt;code&gt;tab&lt;/code&gt; is the page I opened. &lt;code&gt;document.fonts&lt;/code&gt; lists every font face the page declared, with a status for each, so the first line counts the ones still in flight. In my runs it was never zero right after &lt;code&gt;load&lt;/code&gt;, so I keep the log line in. &lt;code&gt;document.fonts.ready&lt;/code&gt; is a promise that resolves once nothing is loading anymore.&lt;/p&gt;

&lt;p&gt;Then I wanted to see what happens on a machine I don't control. I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) HTML to PDF with the same placeholder statement, importing it once as a single file and once as a whole folder. Import and preview happen in the browser. When you click convert, it sends one POST with a script-free snapshot to a server-side Chromium 151 on Linux. I counted 0 non-GET requests before the click and exactly 1 after. For pages with &lt;code&gt;@font-face&lt;/code&gt; rules, its quality report says it waited for &lt;code&gt;document.fonts&lt;/code&gt; and the page images. Remote fonts still never made it. With the network toggle off, which is the default, the report said the remote resource was blocked. With it on, the preview downloaded the fonts, but the snapshot kept the &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; as is, and the server PDF used Noto Sans CJK SC. Inlined fonts weren't a sure thing either. The full 3.27 MB KuaiLe file didn't show up in the server PDF, while a 48 KB subset of the same font did. I can't tell why from the outside.&lt;/p&gt;

&lt;p&gt;What I haven't tested is Firefox and WebKit printing, the real Chrome print dialog, and folder fonts with &lt;code&gt;font-display: swap&lt;/code&gt;. If you print pages to PDF, log the loading count before &lt;code&gt;page.pdf()&lt;/code&gt;. Don't print until it reaches zero, then open the PDF and check the title font.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I tried 13 OCR preprocessing tricks on screenshots. None helped</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Sat, 10 Oct 2026 08:04:58 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/i-tried-13-ocr-preprocessing-tricks-on-screenshots-none-helped-115p</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/i-tried-13-ocr-preprocessing-tricks-on-screenshots-none-helped-115p</guid>
      <description>&lt;p&gt;Almost every OCR tip I found said the same thing. Before recognizing a screenshot, clean it up: go grayscale, binarize it, run a denoise filter, maybe upscale it 2x. I had never checked whether any of that actually helps, so I took one small set of screenshots and ran every trick on the list, one at a time, against text I already knew. On clean screenshots, not a single step beat the untouched original. Several of them made things a lot worse.&lt;/p&gt;

&lt;p&gt;The samples were boring on purpose. I wrote a small web page, a made-up Chinese notice about community service hours, and screenshotted parts of it at 1x and 2x device pixel ratio: 16 px body text, 12 px sidebar text, 11 px light-gray footer text (#999 on #f5f5f5), and a 13 px fee table. I added two crops from a holiday notice template I found online, white text on red. That gave me 10 crops with an exact answer key. For recognition I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), with its default Professional OCR tier plus Fast OCR and Ultimate OCR on every version, in a Chromium 149 open-source build on an M4 Mac. As a reference engine I ran tesseract.js 5 with default parameters and chi_sim+eng. Recognition runs in a local Worker, and across roughly 800 runs I saw zero non-GET requests. The first run of each tier does download a model.&lt;/p&gt;

&lt;p&gt;The score is character error rate: edit distance divided by the length of the answer, summed over all 10 crops. Here is every version side by side.&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="Character error rate for 14 image versions, three ImgIng tiers plus tesseract.js 5" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look for the short bars first. The original scored 0.1% on Professional OCR. The only versions that matched it were grayscale at 0.1% and the built-in "Scan enhancement" switch at 0.2%. That switch describes itself as "Gentle grayscale, contrast and sharpening; no forced binarization." It didn't lower the error rate on this set, and that seems fine to me, because the steps it skips are the ones that cost the most. Everything else added errors. The small losses were a 2x upscale (0.6%), JPEG re-saves at quality 60 and 30 (0.8% and 1.1%), Otsu binarization (1.1%), a fixed threshold of 200 (1.2%), adaptive thresholding (1.4%) and non-local-means denoising (2.7%). The big losses were a 3×3 median filter at 9.7%, threshold 160 at 9.4%, threshold 128 at 25.8% and threshold 100 at 32.2%.&lt;/p&gt;

&lt;p&gt;Threshold 128 looks like a sensible default, so I went back to see what happened. The gray footer text is simply lighter than the threshold. Its darkest pixel is 153, which sits above 128, so binarizing turns the whole strip white. All three tiers and tesseract.js returned 0 characters from it. So for that strip I got an empty result rather than a handful of typos.&lt;/p&gt;

&lt;p&gt;The median filter fails in a quieter way. On 12 px text captured at 1x, the strokes merge into blobs. My guess is that a 3×3 window is about as wide as a stroke at that size, but I only looked at the output. That single crop went from 0% to 32.4% on Professional OCR.&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%2Fli6p2tn202zwzbt7oo1e.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%2Fli6p2tn202zwzbt7oo1e.png" alt="12 px text before and after a 3×3 median filter" width="800" height="644"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also expected the tiers to matter more than they did. On the originals, Fast, Professional and Ultimate landed at 0.8%, 0.1% and 0.7%. They are less than one point apart. Threshold 128 moved every tier by about 25 points. Picking the wrong filter hurt far more than picking a different tier. Preprocessing didn't clearly help tesseract.js either. Its original scored 6.2%, and its best version was the 2x upscale at 5.8%.&lt;/p&gt;

&lt;p&gt;Vertical text needs its own paragraph, since every tier scored a scary 92.3% on my four-column sample. The characters were fine, though. Counted without regard to order, Professional OCR got 0.0% to 7.7% wrong. The columns came out left to right, while this kind of vertical text reads right to left. None of the binarizing, denoising or upscaling variants fixed that, and they all stayed between 89.7% and 94.9%. What fixed it was the rotate-left-90° button in the image adjustments. After rotating the 2x screenshot, all three tiers read it with 0 errors. On the smaller 1x capture, Fast and Professional still dropped two punctuation marks, which works out to 5.1%.&lt;/p&gt;

&lt;p&gt;This covers clean screenshots only. I ran each combination once, so a 1-point wobble means nothing, and I haven't tested real phone photos yet. Still, my routine changed. I recognize the untouched file first and keep that result as a baseline. If I really want to binarize, I measure the darkest gray in the text before picking a threshold, and I compare the output against the baseline instead of assuming it improved. If the text runs vertically, I rotate it before I touch anything else.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>256 colors turned a soft PNG shadow into a black band. I tried 4 fixes</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:15:08 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/256-colors-turned-a-soft-png-shadow-into-a-black-band-i-tried-4-fixes-dle</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/256-colors-turned-a-soft-png-shadow-into-a-black-band-i-tried-4-fixes-dle</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%2Ftm6kay0mlab0g5z7jc2m.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%2Ftm6kay0mlab0g5z7jc2m.png" alt=" " width="800" height="263"&gt;&lt;/a&gt;&lt;br&gt;
I cut a transparent sticker PNG down to 256 colors and dropped it on a white page, and its soft grey shadow came back as a solid black band with a hard outer edge. On a black background the same file looked almost normal. I'm not a graphics person (I studied literature and drifted into writing internal tools), so I skipped the palette theory and just tried every fix I could, scoring each by file size and by how far the semi-transparent pixels drift from the original on white. The drift is the mean absolute RGB difference (0 to 255) over pixels that are semi-transparent in the source, after straight-alpha blending onto the background.&lt;/p&gt;

&lt;p&gt;The sample is a pink ribbon sticker with a big soft shadow, 760×1662 and 441,038 bytes, a third-party image from Wikimedia Commons (by K B, CC BY 2.0). I only show its top loop. A Noto Color Emoji face (Apache 2.0) was my second sample. Everything ran on an Apple M4 Mac.&lt;/p&gt;
&lt;h2&gt;
  
  
  The black band comes from two alpha levels
&lt;/h2&gt;

&lt;p&gt;First I counted distinct alpha values per file with Pillow and NumPy.&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;img&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&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;img&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;soft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;&amp;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;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  levels=&lt;/span&gt;&lt;span class="si"&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  soft=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;soft&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ribbon.png                    levels=256  soft=225,652
ribbon-png8-default.png       levels=  2  soft=      0
ribbon-34-levels.png          levels= 34  soft=215,937
ribbon-q84.webp               levels=256  soft=225,652
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original has 256 alpha values and 225,652 semi-transparent pixels, 17.9% of the image. The second file is the PNG-8 that ImgIng's image compressor gave me on 2026-09-30 with default settings (256 colors, dithering on), and only two alpha values are left. Every pixel at 128 or above became opaque and everything below became fully transparent, matching a plain 128 threshold pixel for pixel on both samples. The shadow's dark core turns into a block and its faint rim vanishes. ImgIng's png-compress page says its transparent PNG-8 supports multi-level alpha, but the files I exported that day had two. The background matters too. On white the two-level ribbon scores 44.17, on blue #1976D2 it's 20.79, and on black only 2.33, because a near-black shadow disappears into a black page. Preview only on dark and you'll miss it.&lt;/p&gt;

&lt;h2&gt;
  
  
  More alpha levels bring the soft edge back
&lt;/h2&gt;

&lt;p&gt;The format isn't the limit. An indexed PNG's tRNS chunk gives every palette entry its own alpha, so I wrote a rough reducer of my own that keeps some palette slots semi-transparent. It isn't pngquant or any product's output, so compare its sizes only with each other.&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="Mean error on white for the ribbon's semi-transparent area: 2 alpha levels 44.17, 10 levels 8.23, 98 levels 1.75, lossy WebP 0.61" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each bar is the same ribbon on white, shorter is closer. Ten alpha levels drop the error from 44.17 to 8.23 and 98 levels reach 1.75. At 10 you still see contour rings in the shadow, while 98 is close to the source. Size barely moved: 111,178 bytes for two levels, 121,346 for 34, about 9.1% more. A Chromium 149 open-source build, Firefox 151 and WebKit 26.5 (not a Safari release) all read back the same 34 levels as Pillow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dithering only made the file bigger
&lt;/h2&gt;

&lt;p&gt;On the two-level output dithering never touches alpha. The alpha channel was identical with it on or off and the white error stayed at 44.07 versus 44.06, while the ribbon grew from 93,317 to 148,242 bytes and the emoji from 11,213 to 23,528.&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%2Fhilbpk7ha8a995hqx9ub.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%2Fhilbpk7ha8a995hqx9ub.png" alt="Ribbon shadow edge on white at 4x: original, 34 alpha levels without dithering, 34 alpha levels with dithering" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Left is the original, middle my 34-level file, right the same with Floyd-Steinberg dithering on color and alpha. The steps turn into grain and look smoother, but stray light dots show up in the pink where solid pixels went semi-transparent. Error rose from 3.48 to 4.29 and the file grew 65% to 200,717 bytes. The number of alpha levels decides the edge, not dithering.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebP kept every alpha level
&lt;/h2&gt;

&lt;p&gt;For the WebP conversion I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) at its default quality 84 in the same Chromium build, and the ribbon went from 430.7 KB to 98.8 KB with alpha matching the original pixel for pixel, error 0.61 on white, black and blue. It ran in the browser tab with zero non-GET requests during export. Color is still lossy, and on the emoji the 4.02 error came from color while all 17 alpha levels survived. The same tool's default PNG-8 was 144.8 KB with the band.&lt;/p&gt;

&lt;p&gt;My order now is simple. Soft shadows, glows or feathered edges go to WebP, and I run the script above on the result. If it must stay PNG-8, the palette needs dozens of alpha levels, and I leave dithering alone. I check on white and on a color, never only black. Colored shadows, glassy art, pngquant, low color counts and social-app recompression are all untested, and two images make a way to check your own files, not a rule.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>One PDF said no printing or copying. Each reader obeyed differently</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:04:31 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/one-pdf-said-no-printing-or-copying-each-reader-obeyed-differently-hkm</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/one-pdf-said-no-printing-or-copying-each-reader-obeyed-differently-hkm</guid>
      <description>&lt;p&gt;A PDF that opens without asking for anything can still be encrypted. I ran into it with a file I had made myself: a tool called it "encrypted" while every viewer I had opened it instantly. The file only had a permission password. The user password was empty, so any reader unlocks it silently, and the permission flags inside say what you may not do: print, copy, edit. Whether those flags mean anything depends on who reads the file. So I made seven two-page placeholder PDFs myself, a fake "Sample Agreement" with no real content, and fed them to five different handlers to see who actually enforces what.&lt;/p&gt;

&lt;h2&gt;
  
  
  I tried the same files in five handlers
&lt;/h2&gt;

&lt;p&gt;One file had no encryption at all. Five had a permission password only, with different ciphers (AES-256, AES-128, RC4-128 and RC4-40) and different flags: some denied everything, one denied only copying, one denied only printing. The last one had a real open password. The handlers were the built-in viewers of Chromium 149 (an open-source build) and Firefox 151, Firefox again with its &lt;code&gt;pdfjs.enablePermissions&lt;/code&gt; setting turned on, pdf.js 6.3 used as a library in a page, and PyMuPDF 1.26 as a processing library. Here is the whole result for the file that denies everything.&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="Grid of print, copy and edit/annotate results for five PDF handlers on the same permission-only test file: Chromium blocks print and copy but still allows drawing, default Firefox allows all three, Firefox with enablePermissions blocks print and editing but not copying, and both libraries only report the flags" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read it row by row. Green means the handler refused the action, orange means the action still worked, grey means the handler just passed the flags to the calling code. No two viewer rows match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chromium still allowed drawing
&lt;/h2&gt;

&lt;p&gt;Out of the box, Chromium enforced more than anything else I tried, and still not everything. Dragging across the text gave the normal blue highlight, which made me think copying worked. Pasting into a text box gave me nothing. The unencrypted twin pasted all four lines. Printing was blocked in a quieter way: the print button stayed visible, and on the four files that deny printing, clicking it did nothing. I never saw a real print dialog in this build, so "blocked" here means the click had no effect. The part that surprised me was drawing. All seven files, including the ones that deny annotation, let me enter drawing mode, leave a stroke and light up the undo button. I did not try saving the result.&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%2Fg4qbmfq9cuuchmy37534.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%2Fg4qbmfq9cuuchmy37534.png" alt="Two Chromium viewer panes with the same four lines selected in blue; the unencrypted file pastes its text into the box below, while the permission-only file leaves the paste box empty" width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Firefox enforced nothing by default
&lt;/h2&gt;

&lt;p&gt;With default settings, Firefox 151 printed, copied and opened every edit tool on all seven files. After I turned on &lt;code&gt;pdfjs.enablePermissions&lt;/code&gt;, it hid the print button on the files that deny printing and greyed out the edit tools on the files that deny editing. Copying still worked, even on the file whose only restriction was "no copying". The toolbar change is easy to see side by side.&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%2Fg6iq1is4nm67jzpvebsc.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%2Fg6iq1is4nm67jzpvebsc.png" alt="Firefox toolbar for the same fully restricted file: the top row with default settings shows active edit buttons and a print button, the bottom row with the permission setting on shows the edit buttons greyed out inside a red box and the print button gone" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cipher changed nothing
&lt;/h2&gt;

&lt;p&gt;I expected RC4 versus AES to matter somewhere. It didn't. The AES-256, RC4-128 and RC4-40 files carry the same flags and behaved identically in Chromium and in both Firefox modes. Only the flags changed the outcome. The open-password file is a different animal: both viewers stopped at a password prompt, and after I typed the password, Chromium still refused copying and printing, because the flags apply after opening too.&lt;/p&gt;

&lt;p&gt;Processing tools have to pick a side here too. The compressor I used was the PDF one on &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, with my permission-only and open-password test files as input. It processes PDFs on the device, and my run made zero non-GET requests. Every encrypted file I gave it failed with the same message: 这份 PDF 有加密或权限保护，需要先解除保护 ("this PDF has encryption or permission protection; remove the protection first"). The file that only denied copying failed as well. The interface doesn't say why it refuses, and oddly the progress card still read "全部完成" (all done) with zero files compressed. I tested the Chinese version of the site, so that's where the quoted text comes from.&lt;/p&gt;

&lt;p&gt;My takeaway after all this is modest. A permission password is a request that each reader decides how to honour, and the answers differ a lot. If you send PDFs, don't treat the flags as a guarantee, since the same file was copy-protected in one viewer and not in the other. If you receive one and need an editable copy, ask the author for a version without restrictions, or, if the file is yours and you remember the owner password, re-export it from the software that created it. I didn't test Acrobat, macOS Preview or release Safari, so check those yourself before you rely on them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Changing the preview background exposed my empty video screenshot</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:04:07 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/changing-the-preview-background-exposed-my-empty-video-screenshot-339m</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/changing-the-preview-background-exposed-my-empty-video-screenshot-339m</guid>
      <description>&lt;p&gt;Before changing a video conversion setting, I would change the background behind its failed screenshot. That sounds cosmetic, but it answered a useful question in my test: was the image black, or was I looking through an empty image at a dark surface? The two can look similar in a preview. They are different things to hand to another tool, and exporting again can make the distinction harder to see.&lt;/p&gt;

&lt;p&gt;I used a short public clip of waves on an Icelandic beach. The test copies were six seconds long, 640×360, and had no audio. One was an H.264 MP4 and the other a VP8 WebM. I opened them through a small local HTTP page that could draw the video into a canvas at different loading stages. This was a controlled reproduction, not a claim about a particular editing application losing someone’s footage.&lt;/p&gt;

&lt;p&gt;The early capture looked empty even though the page already knew the video dimensions. Pixel inspection made that concrete: after &lt;code&gt;loadedmetadata&lt;/code&gt;, every alpha value in the resulting canvas was zero. Across Chromium 149, Firefox 151, and a WebKit 26.5 test build, ten runs per format gave 60 such outputs from this first source clip. Drawing did not throw an error. A checkerboard behind the image simply became visible through it.&lt;/p&gt;

&lt;p&gt;Waiting until &lt;code&gt;loadeddata&lt;/code&gt; changed those same HTTP tests. All 60 outputs then contained opaque, non-black waves. That was enough to make me check when the picture was being taken before converting the input to another format. Both versions had failed at the early stage and produced a picture later. It was not evidence that MP4 or WebM is always the better thumbnail source; it was a reason to leave the file alone while checking the capture timing.&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="Transparent PNG output compared with the same empty canvas exported as JPEG" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This illustration comes from a separate Chromium check of the empty canvas. The PNG decoded with zero alpha. A JPEG made from that same empty canvas decoded as opaque black: RGB zero and alpha 255. The checkerboard is the test page’s background, not pixels in the video. This page is an independent reproduction and is not an ImgIng export screen. The JPEG result was checked in Chromium only, so I would not silently apply it to every browser and export pipeline.&lt;/p&gt;

&lt;p&gt;That changed which file I would retain while troubleshooting. I would keep the transparent PNG as evidence instead of using only the JPEG someone sees against a black page. The JPEG has lost the visible clue that the original canvas was empty. Once all I have is opaque black, I need the input video and capture position to distinguish a real dark frame from an earlier empty drawing. A format change can conceal useful information without fixing the capture.&lt;/p&gt;

&lt;p&gt;I also checked a real product preview with &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, operating the Chinese video-matting Beta workbench. The six-second wave video displayed, and I moved to 2.117 seconds before running the current-frame preview. It returned a message that no obvious subject had been recognized. That is another reason to retain the exact result text: it describes the subject-processing stage, not necessarily a missing video frame. I did not test a thumbnail export there or compare matting quality.&lt;/p&gt;

&lt;p&gt;There is a limit to the background trick. I added a one-second black introduction to the six-second waves as another controlled clip. Its first loaded frame was genuinely black and opaque in one run per engine. Changing the preview background could not reveal scenery through it. Choosing a later position would change the content being captured; waiting for more loading would not make the selected black opening become a different scene.&lt;/p&gt;

&lt;p&gt;The source is Alexander Grebenkov’s &lt;a href="https://commons.wikimedia.org/wiki/File:Ocean_waves_at_L%C3%A6kjavik_beach,_Iceland.webm" rel="noopener noreferrer"&gt;Ocean waves at Lækjavik beach, Iceland&lt;/a&gt;, under CC BY 3.0, trimmed, resized, and transcoded for these tests. I did not shoot it, and the WebKit test build was not released Safari. For a failed cover, I would first save a PNG, inspect its alpha, and compare the selected moment with the original video. Then I would repeat the capture after current-frame data arrives. That sequence preserves the clue that a dark preview can otherwise hide.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My WebM said Infinity, but the recorded video was still there</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:03:59 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/my-webm-said-infinity-but-the-recorded-video-was-still-there-2j8g</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/my-webm-said-infinity-but-the-recorded-video-was-still-there-2j8g</guid>
      <description>&lt;p&gt;The player showed an ocean, but its duration was &lt;code&gt;Infinity&lt;/code&gt;. I had recorded only a few seconds, so that value looked more alarming than the picture did. Before discarding the WebM, I wanted to separate two questions: had the recording lost its video, or was the player unable to report a useful length at that point? The visible waves were already a reason not to treat those questions as interchangeable.&lt;/p&gt;

&lt;p&gt;For the test, a short section of an existing waves video was drawn onto a canvas and recorded through MediaRecorder. This was not a webcam recording or a capture of my desktop. Each of three browser engines produced a WebM recording lasting roughly four seconds, with recording chunks collected into the completed file. I then opened those files in different playback engines. I kept the original recordings rather than replacing them with conversions straight away.&lt;/p&gt;

&lt;p&gt;I also used &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt; during this video check, selecting a size-prioritizing MP4 export in its Chinese compression workbench. That separate operation completed and produced a real output file. It was not the duration repair used below. Keeping the results separate matters: a successful conversion in a tool does not tell me what was wrong with an earlier WebM recording, and I did not verify an ImgIng feature that repairs missing duration information.&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="Visible WebM frames before and after remuxing, with Infinity and 3.912 reported as duration" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The left-hand player contains the recording made by Chromium. The right-hand player contains a remuxed copy of that recording. Both were freshly loaded over local HTTP in Chromium 149 for this screenshot, and the displayed values were read after &lt;code&gt;loadeddata&lt;/code&gt;: &lt;code&gt;Infinity&lt;/code&gt; on the left, 3.912 seconds on the right. Both pictures contain actual decoded waves. This is a controlled playback page, not the product interface, and the screenshot does not measure how accurately either player seeks.&lt;/p&gt;

&lt;p&gt;The file inspection gave the comparison something firmer than a changed readout. The original Chromium recording had no Duration element in its Info section and no Cues. The remuxed file had both. Remuxing here meant copying the existing encoded media packets into a new container layout with FFmpeg, rather than encoding the pictures again. The encoded-packet hashes matched before and after. I did not need to claim improved picture quality to explain the changed duration display.&lt;/p&gt;

&lt;p&gt;Changing the playback engine also changed the answer. For this same original Chromium recording, Firefox reported 3.464 seconds and WebKit reported 3.950 seconds at the measured loading point, while Chromium reported &lt;code&gt;Infinity&lt;/code&gt;. After remuxing, all three reported 3.912 seconds. Other recordings in the small test behaved differently again: one raw recording appeared only 1.093 seconds long in Firefox, and a request to seek to three seconds was clamped to that shorter position. A displayed length can therefore affect what the player lets me do without proving the video packets have disappeared.&lt;/p&gt;

&lt;p&gt;I am keeping the claim narrow. Each recording and playback combination was tested once in the main comparison. The English screenshot was a later reload of an existing pair, not another set of recordings. The engines were Chromium 149, Firefox 151, and WebKit 26.5, with WebKit being a test build rather than released Safari. This does not show that remuxing repairs every WebM, nor does a finite duration prove that every target frame can now be reached correctly.&lt;/p&gt;

&lt;p&gt;The footage is a shortened, resized recording derived from Alexander Grebenkov’s &lt;a href="https://commons.wikimedia.org/wiki/File:Ocean_waves_at_L%C3%A6kjavik_beach,_Iceland.webm" rel="noopener noreferrer"&gt;Ocean waves at Lækjavik beach, Iceland&lt;/a&gt;, used under &lt;a href="https://creativecommons.org/licenses/by/3.0/" rel="noopener noreferrer"&gt;CC BY 3.0&lt;/a&gt;. I did not shoot it. If I hit this symptom in another tool, I would first save the original, check whether it contains visible video, and compare the reported duration in another player. Then I would try a remuxed copy and test duration and seeking separately. &lt;code&gt;Infinity&lt;/code&gt; is a useful clue here, not a verdict that the recording is empty.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I tested the OCR switch instead of trusting its label</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:04:44 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/i-tested-the-ocr-switch-instead-of-trusting-its-label-4iig</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/i-tested-the-ocr-switch-instead-of-trusting-its-label-4iig</guid>
      <description>&lt;p&gt;An OCR checkbox is easy to overinterpret. I see it next to a PDF extractor and assume it will recognize the page again when the existing text is broken. That assumption deserves a test. In a controlled example this week, enabling the checkbox produced exactly the same damaged text as disabling it. The tool reached its completed state both times. The Chinese sentence I needed did not return.&lt;/p&gt;

&lt;p&gt;The useful question turned out to be narrower than whether the application “supports OCR.” I wanted to know whether this particular option replaced an unreliable text interpretation with a fresh reading of the visible page. Those are different claims. A feature label cannot tell me which one happened, but comparing the actual output can. This post follows that comparison and the separate image-recognition step that eventually gave me usable text.&lt;/p&gt;

&lt;p&gt;The input was a synthetic one-page notice, created for testing rather than taken from someone’s company documents. It contained five lines of Chinese and English, including a holiday interval, a return date, and a deadline. One copy retained its font’s ToUnicode mapping. In another copy, that mapping was removed without changing the page’s drawing instructions. This let me compare two known inputs rather than guess what an unfamiliar PDF originally contained.&lt;/p&gt;

&lt;p&gt;Under the same MuPDF rendering conditions, both copies produced identical 893 by 1263 pixel page images. Text extraction told a different story. PyMuPDF, pypdf, and PDF.js each recovered 56 Chinese characters from the normal copy and none from the missing-mapping copy. English and digits survived. I therefore had a precise failure to test: could the browser tool recover the missing Chinese content, rather than merely produce another nonempty result?&lt;/p&gt;

&lt;p&gt;I chose a sentence containing both Chinese words and dates as the comparison target. Checking only the year would have been misleading because the broken output still contained numbers. I also kept the unedited output from each attempt. That made it possible to distinguish an actual improvement from a nicer preview, a different line break, or a newly enabled export button. I wanted changed evidence, not a changed interface state.&lt;/p&gt;

&lt;p&gt;For the browser portion I used ImgIng, an image and document utility with a public homepage at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;imging.ai&lt;/a&gt;. The tested deployment was its Chinese site, imging.cn, on September 30, 2026. I have not repeated the entire PDF workflow on the international domain. The result screenshot below came from switching that tested tool to its English interface and rerunning image recognition on the same test image.&lt;/p&gt;

&lt;p&gt;In the PDF content extractor, the option specifically referred to recognizing text inside images. I processed the missing-mapping PDF once with it enabled and once with it disabled. The resulting text was identical. The title retained its year, parts of the body retained dates and odd characters, and the English line remained readable. Whatever the tool’s internal decision was, this option did not replace the damaged native Chinese text in this sample.&lt;/p&gt;

&lt;p&gt;That finding does not establish that the checkbox never works. I did not test every combination of scanned pages, embedded images, and native text. It does establish a stopping point for this input. Repeating the same operation with the same setting would not be evidence of a new recovery route. I needed either a different extraction result or a correctly rendered page image that an independent recognition step could actually inspect.&lt;/p&gt;

&lt;p&gt;I tried the PDF-to-image route in the same product next: one page, PNG, and 216 DPI. It failed with “SVG render failed.” This matters because the later OCR result would be easy to present as one uninterrupted success story. It was not. The usable PNG came from an existing external rendering step. It was 105,697 bytes and recorded as a 216 DPI page image. It was not downloaded from the failed online conversion.&lt;/p&gt;

&lt;p&gt;I then imported that clear image into the separate Image OCR tool. The default Professional tier returned five lines, with 134 characters reported by the interface. The holiday dates, return date, deadline, and Chinese wording were available for inspection again. That count is not an accuracy percentage. I compared the actual lines against the visible source, and spacing within the Chinese text differed from the PDF’s original spacing.&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="Actual English interface after recognizing the controlled page image" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The distinction between these two OCR operations is the recommendation I would keep. If native extraction is already correct, I would use it. If it is wrong and a clear page image exists, a separate image-recognition step is worth testing. Before relying on an OCR option inside a PDF workflow, I would check whether the disputed sentence changed. The presence of a checkbox, a completion badge, or an export format does not answer that question.&lt;/p&gt;

&lt;p&gt;There is also a limit to judging by readability. A separate controlled variant changed the mapping for the Chinese character 假 to 真 while leaving the visible glyph unchanged. All three extractors agreed on the wrong text, with two affected occurrences. The result still contained Chinese and had the expected Chinese character count. Agreement between tools was therefore useful diagnostic information, but the visible source remained necessary for checking meaning.&lt;/p&gt;

&lt;p&gt;My browser automation captured the result previews and their text. An attempted save action did not produce a download event in that automated run, so I am not claiming that every local save dialog was verified. Likewise, obtaining OCR text did not repair the original PDF’s mapping or create a new searchable PDF. Those would be additional output requirements. Keeping the original file, the recognition image, and the checked text separate made the limits easier to see.&lt;/p&gt;

&lt;p&gt;For a quick test on your own material, choose one disputed sentence before changing tools. Preserve the original extraction, run the proposed alternative, and compare the same words rather than overall character counts. If the route switches to images, confirm that the image is actually readable before recognizing it. Finally, check the text where you intend to use it. That last check catches the unglamorous mistakes, including copying an older result after the newer one finally became correct.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Canvas memory starts at draw</title>
      <dc:creator>NullPointerZen</dc:creator>
      <pubDate>Sun, 04 Oct 2026 08:04:03 +0000</pubDate>
      <link>https://dev.to/yue_shu_c621a4a637f22396f/canvas-memory-starts-at-draw-2mpj</link>
      <guid>https://dev.to/yue_shu_c621a4a637f22396f/canvas-memory-starts-at-draw-2mpj</guid>
      <description>&lt;p&gt;I build small internal tools for a content team, and one of them stitches large images on a canvas. When someone asked how big an image it could take before the tab fell over, I realized I had no idea what a canvas actually costs in memory. So I measured it in three engine builds: the Chromium 149 open-source build (not Chrome itself), a WebKit 26.5 build, which is not Safari, and a Firefox 151 build that isn't release Firefox either. Treat every number here as belonging to those builds only. The machine was an M4 Mac with 16 GB of RAM that was busy with other jobs at the same time. All images were synthetic ones I generated with a script, not photos.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I measured canvas memory
&lt;/h2&gt;

&lt;p&gt;Browser APIs were no help here. &lt;code&gt;performance.measureUserAgentSpecificMemory&lt;/code&gt; refused to run in the Chromium build even with cross-origin isolation on, and the other two builds don't have it. The JS heap size shown in DevTools barely moved while a gigabyte-sized canvas sat in memory. So I went one level down and summed &lt;code&gt;phys_footprint&lt;/code&gt; from macOS &lt;code&gt;footprint&lt;/code&gt; for every process the browser launched, then subtracted the idle page. It's a blunt number, but it's the only one that actually saw the canvas. For a reference point on real in-browser processing I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) with a 10000×10000 synthetic JPEG, operating the Chinese-language interface in all three engines. Across its 12 test runs the network log showed zero non-GET requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a canvas costs almost nothing
&lt;/h2&gt;

&lt;p&gt;Setting width and height and calling &lt;code&gt;getContext('2d')&lt;/code&gt; added less than 1 MiB in all three engines, even for large sizes. Memory shows up on the first draw. After one full-size &lt;code&gt;fillRect&lt;/code&gt;, Chromium and Firefox grew by exactly width × height × 4 bytes: a 16384×16384 canvas added 1025 MiB. Reading the whole thing back with &lt;code&gt;getImageData&lt;/code&gt; added another copy of the same size, which took Chromium to 2050 MiB above idle. WebKit had its own overhead on top: the same 16384×16384 canvas reached 3265 MiB after the full readback. If you pull pixels out of a big canvas just to loop over them, you are paying for the image twice.&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="Memory increase against canvas size in millions of pixels, two lines per engine: after one full fill and after a full getImageData. Read the points at 268 million pixels (16384×16384): Chromium and Firefox overlap at about 1025 MiB after the fill and about 2050 MiB after the readback, while WebKit's readback line climbs to 3265 MiB" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding a big JPEG costs more than the file
&lt;/h2&gt;

&lt;p&gt;A 13.4 MB JPEG at 100 megapixels looks harmless on disk. Decoding it pushed the summed process peaks to 1289 MiB in Chromium, 1550 MiB in Firefox and 3525 MiB in WebKit. Those are peaks added together across processes, and they don't necessarily happen at the same moment, so they overstate how much is held at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Many canvases in a row
&lt;/h2&gt;

&lt;p&gt;I kept creating 4096×4096 canvases and holding on to all of them. None of the three builds crashed or lost a canvas before my own safety stop at 7 GiB with solid fills, or 4 GiB with noise. I stopped there on purpose because the machine only has 16 GB, so I still don't know where the real ceiling is. One thing surprised me: with solid colors the system's free memory barely dropped. My guess is macOS memory compression, but I didn't capture the compressor numbers, so that stays a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in my tool
&lt;/h2&gt;

&lt;p&gt;I budget memory per image as width × height × 4 before drawing anything, then double it if the code needs &lt;code&gt;getImageData&lt;/code&gt; on the full canvas. For anything near 100 megapixels I check that budget first and scale down when it doesn't fit. I also stopped reading back whole canvases when I only need a few pixels.&lt;/p&gt;

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