<?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: AsyncMonk</title>
    <description>The latest articles on DEV Community by AsyncMonk (@shu_jing_915fa287b22539ad).</description>
    <link>https://dev.to/shu_jing_915fa287b22539ad</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%2F4087883%2F59a5c86a-9fa5-4349-971d-f26ba405bf1e.png</url>
      <title>DEV Community: AsyncMonk</title>
      <link>https://dev.to/shu_jing_915fa287b22539ad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shu_jing_915fa287b22539ad"/>
    <language>en</language>
    <item>
      <title>5 things I check before processing video frame by frame in the browser</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:10:07 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/5-things-i-check-before-processing-video-frame-by-frame-in-the-browser-1kk0</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/5-things-i-check-before-processing-video-frame-by-frame-in-the-browser-1kk0</guid>
      <description>&lt;p&gt;You write a loop that decodes a video and does something to every frame: a strip of thumbnails, a watermark, a filter. It runs fine on a short clip and the tab looks small in the browser's task manager. I run a small tool on my own and would rather do this work in the visitor's browser than rent a bigger server, so I wanted to know what that loop really costs. Here are the five things I now check, each with a number from my own test.&lt;/p&gt;

&lt;p&gt;Some terms first. Decoding means turning compressed video back into full pictures, and &lt;code&gt;VideoDecoder&lt;/code&gt; is the browser API that hands them to you one by one as &lt;code&gt;VideoFrame&lt;/code&gt; objects. A graphics buffer is a block of image memory handed out by the operating system; macOS calls it an IOSurface and can count them system-wide. I tested on an Apple M4 with 16 GB running macOS, mostly in Chromium 149, reading the MP4 with mp4box.js 0.5.2. The clip is not a native long video: I took an example video from Wikimedia Commons, transcoded it with ffmpeg 7.1 and looped it end to end into 60 seconds of 1920×1080, which is 1500 frames.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Know how big one frame is&lt;/strong&gt;. File size tells you nothing here. For a decoded 1920×1080 frame, &lt;code&gt;allocationSize()&lt;/code&gt; read 3,110,400 bytes, about 3.1 MB. A 3840×2160 frame works out to 12,441,600 bytes by the same math, about 12.4 MB. That one is calculated, not read. Holding on to just 300 frames of a 4K clip pushed system memory up by 4546 MB, and I stopped at 300 to protect the machine.&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 taken by one decoded frame: 3.1 MB at 1920×1080, 12.4 MB at 3840×2160" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The blue bar is the value I read. The red one is the calculated one, four times as long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Close every frame&lt;/strong&gt;. When I called &lt;code&gt;close()&lt;/code&gt; on each frame as soon as it arrived, all 1500 frames left the system with 12 extra graphics buffers. Pushing them into an array instead left 1508 extra and system memory peaked 2694 MB higher. I trust the buffer count more than the megabytes, because the same scenario could differ by more than 1 GB of system memory between two runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pace the decoder when your work is slower than decoding&lt;/strong&gt;. I faked slow work by drawing each frame to a small canvas, reading one pixel, waiting 10 ms and then closing it. When I fed the whole file to the decoder in one go, the line of decoded frames waiting for me peaked at 1421, with 1488 extra buffers and 3752 MB more system memory. Then I made the feeding loop wait whenever too much was queued:&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;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vd&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;vd&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;piece&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;decodeQueueSize&lt;/code&gt; is how many compressed chunks the decoder has not got to yet, and &lt;code&gt;waiting&lt;/code&gt; is my own array of unprocessed frames. With that check the line peaked at 17 frames, with 29 extra buffers and 165 MB more system memory, and the two runs finished in about the same total time. One thing I can't explain: in the unpaced run every frame did get closed after processing, yet the buffer count sat between 1430 and 1490 until the browser quit. I did not dig into why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Don't count on garbage collection&lt;/strong&gt;. I also tried neither closing a frame nor keeping a reference to it. In Chromium 149 the 1508 extra buffers were still there after the 6 seconds I kept sampling, and Chrome 154 and Firefox 151 did not give the memory back either. WebKit 26.5 (macOS) did reclaim it, down to 32 extra buffers, and printed "A VideoFrame was destroyed without having been closed explicitly" in the console. What happens after 6 seconds I don't know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Watch system memory, not the browser process&lt;/strong&gt;. In the Chromium run that kept all 1500 frames, the browser's process memory grew by 138 MB while 1508 buffers piled up where that number doesn't show them. Firefox 151 was the opposite, with process memory up 2291 MB and only 7 extra buffers. On macOS, &lt;code&gt;ioclasscount IOSurface&lt;/code&gt; prints the system-wide count.&lt;/p&gt;

&lt;p&gt;To compare with a pipeline somebody else built, I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), whose video matting goes through a clip frame by frame in the browser. The feature is in Beta and supports desktop Chrome and Edge first. I ran it on the same 1500-frame clip in Chromium 149. Its extra graphics buffers stayed between −2 and 79 throughout and did not climb with progress. The clip is ocean waves with nothing in it to cut out, so this tells me about memory and 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%2F53ifjyolzgn281sti9rj.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%2F53ifjyolzgn281sti9rj.png" alt="An 8-second clip before and after video matting (Beta)" width="799" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A look at the tool itself. Matting needs a subject and the wave clip has none, so the screenshots are from another clip, 8 seconds of a street guitarist (video by Isak Zvegelj, Wikimedia Commons, CC BY 3.0): imported on top, background replaced below. The buffer counts were not taken on this clip.&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%2Fr2w1a9giwxob5net2wdk.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%2Fr2w1a9giwxob5net2wdk.png" alt="Extra system graphics buffers while video matting (Beta) processed 1,500 frames, sampled every 2 s" width="800" height="1109"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The line hits its peak of 79 at the start, then settles in the twenties and stays flat to the end.&lt;/p&gt;

&lt;p&gt;Not covered: Windows, phones, codecs besides H.264. Where frames live on those systems I have not tested. Nothing crashed in any run, so I can't say where the limit is, and I did not measure frame rate. My habit from here is cheap: count the buffers, run 1500 frames, count again. If the difference looks like the frame count, a frame is not being closed. The wave footage is from a video by David Shay (דוד שי) and the 4K river footage from one by Nesnad, both on Wikimedia Commons under CC BY-SA 4.0.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Portrait video comes in two kinds. I bake the rotation in at upload</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:08:40 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/portrait-video-comes-in-two-kinds-i-bake-the-rotation-in-at-upload-p97</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/portrait-video-comes-in-two-kinds-i-bake-the-rotation-in-at-upload-p97</guid>
      <description>&lt;p&gt;A portrait clip can play upright in every browser as an MP4, then end up lying on its side after the tooling converts it to WebM or "cleans" its metadata. Nothing looks wrong in between. I ran into this while deciding whether to add a WebM rendition to a small upload feature, so before shipping I spent an afternoon testing what happens to orientation along the way. The short version is that "portrait" means two different files, and I now turn both into the same kind at the door.&lt;/p&gt;

&lt;p&gt;The first kind is what you would expect: the pixels are stored tall, say 1080×1920. The second kind is how tall footage is commonly saved: the pixels are stored sideways as 1920×1080, and the file carries a small rotation tag, a label that says "turn this 90 degrees when you play it". The picture itself is still lying down. &lt;code&gt;ffmpeg -i&lt;/code&gt; gives it away, because it prints the stream as 1920x1080 plus a line like &lt;code&gt;rotation of -90.00 degrees&lt;/code&gt;. My test files were of that second kind, with one caveat I should state early. They are not phone originals. I took a public clip of a wind turbine, added a red square top-left and a green square top-right so orientation is obvious, and used ffmpeg 7.1 to store the pixels sideways and write the tag.&lt;/p&gt;

&lt;p&gt;As MP4 and MOV, the tagged files were boring in a good way. Chromium 149, Chrome 154, Firefox 151 and WebKit 26.5 (the macOS build) all played them upright in a plain &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element and reported &lt;code&gt;videoWidth × videoHeight&lt;/code&gt; as 1080×1920. So the label is honored on playback in those four. If I only ever served the uploaded MP4 untouched, I would probably never have looked further.&lt;/p&gt;

&lt;p&gt;WebM was different. I made a VP9 WebM that keeps the sideways pixels and the tag, using ffmpeg's &lt;code&gt;-noautorotate&lt;/code&gt; flag, and ffmpeg could still read the -90 rotation from it. Chromium 149 and Chrome 154 played it upright at 1080×1920. Firefox 151 and WebKit 26.5 played it sideways at 1920×1080.&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="The same tagged WebM in three browsers: upright in Chrome 154, sideways in Firefox 151 and WebKit 26.5" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All three panels are the same file in a plain &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element. Watch the red and green squares: they belong at the top, and in Firefox and WebKit they sit on the left edge. The size under each panel is what the browser reported. Footage is from a video by Grendelkhan on Wikimedia Commons, CC BY-SA 4.0.&lt;/p&gt;

&lt;p&gt;My first instinct was the cheap fix. Stream copy costs almost no CPU, and on a small VPS that matters to me, so I tried to simply get rid of the tag without re-encoding. Attempt one, &lt;code&gt;-c copy -metadata:s:v rotate=0&lt;/code&gt;, did nothing: the output still read -90. Attempt two did remove it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-display_rotation&lt;/span&gt; 0 &lt;span class="nt"&gt;-i&lt;/span&gt; tagged.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt; copy untagged.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is 1920×1080 with no tag, and the pixels are still lying down, because nothing ever turned them. All four browsers then played it sideways, with &lt;code&gt;videoWidth&lt;/code&gt; at 1920×1080. The tag was the only thing keeping the video upright, and I had just deleted it.&lt;/p&gt;

&lt;p&gt;So the fix that holds is the one that costs CPU: re-encode once, and let ffmpeg rotate the pixels while it does. That is its default behavior. A plain transcode of the tagged sample came out as 1080×1920, pixels upright, no tag, and the default VP9 WebM conversion did the same.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; upload.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-preset&lt;/span&gt; veryfast &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="nt"&gt;-c&lt;/span&gt;:a copy normalized.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flags that bit me were the ones that opt out of this: &lt;code&gt;-noautorotate&lt;/code&gt;, &lt;code&gt;-c copy&lt;/code&gt;, and anything that edits the tag by hand. One encode per upload is a cost I can live with, because everything downstream then sees one kind of portrait.&lt;/p&gt;

&lt;p&gt;I also wanted a second opinion from something that isn't my own script. I ran the tagged samples through ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), a browser-based compressor, in Chromium 149 on its smallest-size preset. The 90, 270 and 180 degree files all came back as 608×1080 with upright pixels and no tag, as MP4, and the 90 degree one as WebM too. After import, the page already listed the 90 degree file as 1080 × 1920. These were 5-second clips, one browser, one preset, so I read it as a check on orientation and nothing more.&lt;/p&gt;

&lt;p&gt;What I have not covered: real phone originals, HEVC, Safari on iPhone, anything on Android. A phone may write its tag differently from ffmpeg, and I would rather find that out with one real upload than assume. For now my upload step reads the rotation line, re-encodes whenever there is one, and I keep a tagged sample around to rerun whenever I touch the ffmpeg flags.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Cropping a screenshot in Word isn't redaction. I checked the HTML</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:07:28 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/cropping-a-screenshot-in-word-isnt-redaction-i-checked-the-html-43kj</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/cropping-a-screenshot-in-word-isnt-redaction-i-checked-the-html-43kj</guid>
      <description>&lt;p&gt;Every so often I get offered a job where a small company has its help docs in Word, full of admin-panel screenshots, and wants me to turn them into a static help page. People who write those docs often crop the customer details off a screenshot before pasting it in. Before I'd quote a job like that, I wanted to know whether the crop survives the trip to HTML, because if it doesn't, somebody's customer data ends up on a host I pay for. I don't have Word on this Mac, so I wrote a test .docx by hand following the OOXML structure, with seven picture cases. The screenshot in it comes from an order admin page I built myself, and every recipient, phone number, address and payment ID on it is a placeholder. One case crops 35.484% off the bottom of that screenshot to hide the shipping card.&lt;/p&gt;

&lt;p&gt;A .docx is a zip, and the crop never touches the picture. The file in &lt;code&gt;word/media/&lt;/code&gt; is the whole original, and the crop is one &lt;code&gt;a:srcRect&lt;/code&gt; line in &lt;code&gt;document.xml&lt;/code&gt;, with each edge in thousandths of a percent. Listing the crops takes one pipe.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unzip &lt;span class="nt"&gt;-p&lt;/span&gt; seven-cases.docx word/document.xml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;a:srcRect [^/]*/&amp;gt;'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="c"&gt;#    2 &amp;lt;a:srcRect l="0" t="0" r="0" b="35484"/&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;#    1 &amp;lt;a:srcRect l="15000" t="15000" r="15000" b="15000"/&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;#    1 &amp;lt;a:srcRect l="30000" t="20000" r="20000" b="25000"/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be fair to Word, I put the full images in that file myself, so my sample proves nothing about what Word saves. Microsoft's support page on shrinking picture sizes does offer a "Delete cropped areas of pictures" option on macOS, and that option only makes sense if the cropped part stays until you tick it. I couldn't check what Word does by default on save.&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="Left: the screenshot as the document shows it. Right: the full image stored in the file, with the cropped shipping info in a red box" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The left side is what a reader of the document sees. The right side is what the .docx actually carries, and the red box is the part someone meant to remove.&lt;/p&gt;

&lt;p&gt;mammoth.js 1.13.0 was the easy one to judge. It ignores the crop, so the shipping card shows up right there on the converted page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3spok370tnow56spafir.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%2F3spok370tnow56spafir.png" alt="mammoth output: the cropped-out shipping info is back on the page, inside the red box" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a browser-side converter I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) and its DOCX to HTML workbench, on the same seven-case sample in a Chromium 149 open-source build. It read the crop content and the display size correctly, and the conversion made 0 non-GET requests. It also showed no warning about crops, and the bottom-cropped screenshot moved up 70.4 px over the heading line above it. The bigger issue for me was inside the exported file. It came to 9,665,338 bytes, and all seven inline images were the untouched originals, byte for byte the same as the media in the .docx. Pulling the screenshot back out of the single-case export took one more line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'data:image/png;base64,[^")]*'&lt;/span&gt; screenshot-case.html | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;, &lt;span class="nt"&gt;-f2&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; inline.png &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sips &lt;span class="nt"&gt;-g&lt;/span&gt; pixelWidth &lt;span class="nt"&gt;-g&lt;/span&gt; pixelHeight inline.png
&lt;span class="c"&gt;#   pixelWidth: 1800&lt;/span&gt;
&lt;span class="c"&gt;#   pixelHeight: 1240&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the full 1800×1240 screenshot, shipping card and placeholder phone number included, and it's what "Save image as" would hand anyone visiting the page. This is the part I think people in my line of work underestimate. A client reviews a converted page by looking at it, and the page can look right while the bytes still carry everything. Any CSS fix only hides the cut-off area, and the bytes go out with the page anyway.&lt;/p&gt;

&lt;p&gt;So the fix has to happen before conversion. Baking the crop in, meaning cutting each media file to its srcRect and then clearing the setting, took the sample from 7,234,550 to 5,714,763 bytes, 21.0% smaller. The screenshot's media became 1800×800, the 70.4 px shift dropped to 0.4 px, and none of the three converters left a shipping card to find. As a side bonus for someone paying for bandwidth, the full ImgIng export went from 9.22 MB to 7.28 MB. Honestly, for a handful of screenshots I'd skip the script and ask the client for fresh screenshots without the customer data. That costs me one email and no script.&lt;/p&gt;

&lt;p&gt;I haven't tested Word itself, WPS, LibreOffice or pandoc, I only checked ImgIng's export in Chromium 149, and floating images weren't in the sample. Whether Word removes cropped areas on its own in some versions, I still don't know. For now, if a job like this comes in, I'd run that first pipe on the client's files before quoting. Any line it prints goes into the quote as either baking or a fresh screenshot.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>If users say the PDF has wrong characters, here's what I check</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:05:41 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/if-users-say-the-pdf-has-wrong-characters-heres-what-i-check-1m22</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/if-users-say-the-pdf-has-wrong-characters-heres-what-i-check-1m22</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%2Fkslkutwy7yxcocj5jthp.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%2Fkslkutwy7yxcocj5jthp.png" alt=" " width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My side project doesn't have PDF export yet, but I know the message I'll get once it does. Someone will write "the PDF has the wrong characters" and attach a blurry screenshot. That sentence covers at least four bugs, and most answers online jump to the charset. I didn't want to guess from a screenshot at 1 a.m., so I broke a test page on purpose first. It's a fake one-page statement I wrote myself, with a ZCOOL KuaiLe title, Noto Sans SC body text and a line of seven rare characters. I printed it in an open-source Chromium 149 build on my M4 Mac, then on someone else's server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with what the wrong text looks like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Top: a simulated missing font. Middle: a GBK file declared as UTF-8. Bottom: a UTF-8 file declared as GBK" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first thing I'd look at in the screenshot is the English. The top block is simulated. My Mac has PingFang, so I wrote a font whose only glyph is an empty box and mapped every CJK code point to it. The Chinese turns into rows of identical boxes, and "Statement No. 2026-0930-001" comes out intact. The middle block is a GBK file declared as UTF-8, mostly black diamond question marks. In the bottom block it's the other way round, and you get real but wrong Chinese characters, plus "Statement" loses its S. Tidy boxes with clean English mean a font is missing a glyph. Diamonds or damaged English mean the bytes were decoded wrong, and I'd go look at how the file was saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then check whether the server has the character
&lt;/h2&gt;

&lt;p&gt;Boxes send me to the machine that printed. For a machine with fonts I didn't choose I used ImgIng's HTML to PDF (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), fed the same statement once as a single HTML file and once as a whole folder. Import and preview happen in the browser. Clicking convert sends one POST with a script-free snapshot to a server-side Chromium 151 on Linux, and I counted 0 non-GET requests before the click and exactly 1 after. It's the one ImgIng document tool that runs on a server. The fonts in its PDF were Noto Sans CJK SC and Noto Color Emoji. My CSS asked for PingFang SC and Microsoft YaHei, which weren't there, and nothing said so. Of my seven rare ones, 𠀀 (U+20000) and 𪚥 (U+2A6A5) came out as crossed boxes. Text extracted from that PDF still says 𪚥, so a text diff passes while the page shows a box.&lt;/p&gt;

&lt;p&gt;So I wrote a cheap check that runs on the page before printing:&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;triage&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bodyText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerText&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;badBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bodyText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;uFFFD&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="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;astral&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyText&lt;/span&gt;&lt;span class="p"&gt;)]&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;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Extended_Pictographic}/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;U+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&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="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;badBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;astral&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;Code notes: &lt;code&gt;tab&lt;/code&gt; is the loaded page. &lt;code&gt;badBytes&lt;/code&gt; counts U+FFFD, the replacement character a decoder leaves behind. &lt;code&gt;astral&lt;/code&gt; lists characters outside the Basic Multilingual Plane, minus emoji. On my clean page it returned five code points, U+2B5AF, U+2C317, U+30EDD, U+20000 and U+2A6A5. Only the last two were boxed on the server, so the list tells me which characters deserve a rendered look, not which ones will break.&lt;/p&gt;

&lt;h2&gt;
  
  
  A blank page means the font loaded too late
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6ypdy5kuc9uj4i33ite.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%2Fs6ypdy5kuc9uj4i33ite.png" alt="Left: PDF printed right after load with a remote font and no font-display swap. Right: the normal PDF" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the user says the PDF is empty, it probably looks like the left one, with table lines and no words. I loaded the fonts from a remote CSS link without swap and printed right after &lt;code&gt;load&lt;/code&gt;. The result was 1,073 bytes, 3 times out of 3, with no error. With swap the title quietly fell back to the system font instead, also 3 out of 3. Waiting for &lt;code&gt;document.fonts.ready&lt;/code&gt; gave the correct 117,799-byte PDF every time.&lt;/p&gt;

&lt;p&gt;On ImgIng's server, inlining the full font files gave a KuaiLe title in the preview and Noto Sans CJK SC in the PDF, and the report showed no font warning. The full 3.27 MB KuaiLe alone wasn't used either, but a 48,088-byte subset of it was. I can't see why from outside. What I can see is the bandwidth. Both full fonts made a snapshot of 18,509,983 bytes that bought nothing, while both subsets came to 501,152 bytes, worked, and produced a 44,084-byte PDF. If my own endpoint ever accepts uploads like that, I'm sending subsets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The charset comes last
&lt;/h2&gt;

&lt;p&gt;Encoding comes last, and the same script already answers it. The GBK-declared-as-UTF-8 page had 139 replacement characters in its text, and the reverse case had 10. The clean page and the box page both had 0. Missing glyphs don't show up in this count at all, so I still look at the shape before anything else.&lt;/p&gt;

&lt;p&gt;Server, my orange cat, slept through all of this on the warm corner of the laptop. I haven't tested a real Linux box with no Chinese fonts, and I don't know why the full fonts weren't used. My ImgIng runs are from the evening of 2026-09-30, and the server may change. For now my export logs the &lt;code&gt;badBytes&lt;/code&gt; count and the &lt;code&gt;astral&lt;/code&gt; list next to every PDF, so the next "wrong characters" message starts with numbers instead of a guess.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I almost shipped binarize-at-128 before OCR. Gray text vanished</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sat, 10 Oct 2026 08:06:23 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/i-almost-shipped-binarize-at-128-before-ocr-gray-text-vanished-2pak</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/i-almost-shipped-binarize-at-128-before-ocr-gray-text-vanished-2pak</guid>
      <description>&lt;p&gt;One of my small tool sites is getting a "paste a screenshot, get the text" box, and before wiring it up I wanted to squeeze a bit more accuracy out of it for free. Many OCR tutorials say: convert to grayscale, binarize at 128, then recognize. It's one line of OpenCV and costs nothing on the client, so I planned to make it the default. This is the test I ran before shipping it, not a post-mortem of anything users saw. On one sample, that line deleted every character.&lt;/p&gt;

&lt;p&gt;The samples are screenshots of a web page I wrote myself, a made-up "community service hours" notice in Chinese, cut into blocks: body text at 16 px, a sidebar at 12 px, a small fee table, and an 11 px footer in #999 gray on a #f5f5f5 background. Each block was captured at 1x and 2x device pixel ratio, which gives eight crops. I added two more from a holiday-notice template I found online. I scored everything with character error rate, the edit distance to the known text divided by its length. For the OCR side 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, and ran Fast and Ultimate OCR over the same ten crops as a check, on an Apple M4 Mac in an open-source Chromium 149 build with the WebGPU backend. The samples are Chinese, so the runs went through its Chinese page. As a second opinion I ran tesseract.js 5 (default parameters, chi_sim+eng).&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="The 11 px gray footer from my test page: original on top, binarized at 128 in the middle, binarized at 200 at the bottom, shown at 3x" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The middle strip, thresholded at 128, is a completely white image. The darkest pixel anywhere in that gray text is 153, so with the cutoff at 128 every pixel counts as background. All three tiers, at both 1x and 2x, returned 0 characters, a 100% error rate, and so did tesseract.js. The result panel read 0 lines, 0 characters, with a hint to try rotating the image, turning off scan enhancement, or switching to a higher tier. Nothing was left to read.&lt;/p&gt;

&lt;p&gt;Moving the threshold up didn't fix it cleanly either. At 160 the cutoff sits just above those darkest pixels, so only the core of each stroke survives. Professional OCR read the 1x crop as garbage, 75.5% wrong. The 2x crop has thicker strokes and got 1.0% at the same threshold. At 200, and with Otsu (which picked 208 for this block), the text survived at 3.1% and 5.1%. The untouched original was 0.0%. So the best a threshold did on this block was a little worse than doing nothing.&lt;/p&gt;

&lt;p&gt;Across all ten crops the pattern held. Professional OCR on the originals had a combined error rate of 0.1%. Binarizing at 128 pushed that to 25.8%, and 100 made it 32.2%. Otsu came in at 1.1% and adaptive thresholding at 1.4%. Grayscale stayed at 0.1%. The tier gap was smaller than any of that: 0.8%, 0.1% and 0.7% on the originals, less than one point apart. Picking the wrong preprocessing cost about 25 points. Tesseract.js went from 6.2% on the originals to 37.5% at 128, so the old advice didn't rescue it either.&lt;/p&gt;

&lt;p&gt;ImgIng has its own toggle for this, "Scan enhancement", described in the English UI as "Gentle grayscale, contrast and sharpening; no forced binarization." It's off by default. With it on, Professional OCR measured 0.2% against 0.1% without, so I wouldn't call it a gain. What I liked is that it doesn't binarize. Recognition runs in a Worker on the machine, and across the whole session I saw zero non-GET requests. The first run downloads the model, 29.8 MB for Professional OCR, which is a download, not an upload of my screenshots. For me that also means no OCR server on my bill, and my cat, whom I named Server, stays the only server I pay to feed.&lt;/p&gt;

&lt;p&gt;If you still want a preprocessing step, this is the guard I would put in front of it. It reads the darkest pixel and refuses a fixed threshold that sits below the ink:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_to_binarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IMREAD_GRAYSCALE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gray&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ink&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ink&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the footer crop it prints &lt;code&gt;darkest ink 153, binarize at 128: skip, text would vanish&lt;/code&gt;. It only catches the worst case. The 160 threshold that keeps half a stroke passes it.&lt;/p&gt;

&lt;p&gt;In the end I didn't ship the guard either. The upload box sends the screenshot untouched. The one fix I did add came from a different sample. A four-column vertical text block scored 92.3% even though the characters were right, because the columns came out left to right instead of right to left. After ImgIng's "rotate left 90°" button, the 2x crop was read with zero errors, so my box gets a rotate button instead of a filter. I only tested screenshots here. The only paper samples I had were synthetic ones made by a script, and real phone photos are still untested. Before you add a binarize line to your own OCR pipeline, check the darkest pixel of your lightest text.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Soft shadows and icon edges need different fixes in 256-colour PNGs</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:17:59 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/soft-shadows-and-icon-edges-need-different-fixes-in-256-colour-pngs-5fko</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/soft-shadows-and-icon-edges-need-different-fixes-in-256-colour-pngs-5fko</guid>
      <description>&lt;p&gt;I run a couple of small tool sites and take client work on the side, and the asset folder I reuse has two kinds of transparent PNGs in it: stickers with big soft drop shadows, and small flat icons whose only soft part is a thin antialiased rim. Bandwidth is a line on my bill, so I wanted that folder down to 256-colour PNGs. First I ran a trial on two third-party files, one of each kind. The sticker is a pink ribbon from Wikimedia Commons (by K B, CC BY 2.0), and I only show its top loop. The icon is the melting face from the open-source Noto Color Emoji set (Apache 2.0). Neither is my artwork. The short answer is that both break the same way, and they still need different fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two alpha levels break both kinds
&lt;/h2&gt;

&lt;p&gt;A 256-colour PNG that keeps only two alpha levels splits every pixel at 128. Alpha 128 and up becomes fully opaque, everything below disappears. On the ribbon that matters a lot, because 225,652 pixels are semi-transparent, 17.9% of the image, almost all of them in the shadow. The dark inner part of the shadow survives as a solid black band and the faint outer fade is deleted, so the edge becomes a knife cut.&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="Ribbon shadow on white: original, two alpha levels, 34 alpha levels" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Left is the original on white, middle the two-level copy, right a 34-level copy, all at 2x. Under the loop, the middle panel goes from black straight to nothing, while the right one still fades out with a few fine steps. I scored each copy by compositing it onto a background and taking the average RGB difference from the original over the semi-transparent pixels, on a 0 to 255 scale. On white the two-level ribbon is off by 44.17.&lt;/p&gt;

&lt;p&gt;The emoji surprised me more. It has only 17 alpha levels and 1,548 semi-transparent pixels, 0.59% of the image, so I expected two levels to be harmless.&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%2Fkvpwmgpqhtk4r2l0fwe1.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%2Fkvpwmgpqhtk4r2l0fwe1.png" alt="Emoji outline at 4x on blue: original, two alpha levels, multi-level" width="800" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the outer outline at 4x on blue. The two-level copy in the middle turns the curve into stairs, and the multi-level copy on the right looks like the original. The error is 29.51 on white and 39.06 on blue. On an icon that thin rim is the outline, so you notice when it goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sort the folder by how much of it is soft
&lt;/h2&gt;

&lt;p&gt;So the useful question is how much of each file is soft. I now run this over a folder before compressing anything.&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&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="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.png&lt;/span&gt;&lt;span class="sh"&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;=&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;array&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;f&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="nf"&gt;getchannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;partial&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;count_nonzero&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="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sticker: WebP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;icon: PNG-8 ok if alpha kept&lt;/span&gt;&lt;span class="sh"&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;18&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="nf"&gt;set&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="n"&gt;flat&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;  partial=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="si"&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="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&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;emoji.png          levels= 17  partial=  1,548 (0.6%)  icon: PNG-8 ok if alpha kept
ribbon.png         levels=256  partial=225,652 (17.9%)  sticker: WebP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 5% line is my own rule of thumb from these two files, nothing official. Files under it get a palette PNG, files over it go to WebP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Icons can stay palette PNGs with more levels
&lt;/h2&gt;

&lt;p&gt;PNG-8 can store many alpha levels. The tRNS chunk gives each palette entry its own alpha, and Chromium 149, Firefox 151 and WebKit 26.5 all read my multi-level files back exactly as Pillow decodes them. To test it I wrote a quick reducer of my own, not any real tool. With 20 alpha levels the emoji error on white drops to 2.23, and the file grows from 20,370 to 21,406 bytes, 5.1% more. For an icon that's an easy trade. Dithering is no substitute. On a two-level file it doesn't touch alpha at all and only made the files 1.6 to 2.1 times bigger.&lt;/p&gt;

&lt;p&gt;To check an export I read tRNS straight from the file.&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;def&lt;/span&gt; &lt;span class="nf"&gt;trns_entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;blob&lt;/span&gt; &lt;span class="o"&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;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;I4s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IHDR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;color_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tRNS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;trns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
        &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;color_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trns&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="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;trns&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;255&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;emoji_2lv.png: color type 3, tRNS 255 bytes, 1 entries below 255
emoji_multi.png: color type 3, tRNS 255 bytes, 33 entries below 255
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One entry below 255 means only a single fully transparent colour, which is the two-level case. Careful with the other line, though. My 20-level emoji has 33 entries below 255, because palette slots are not the same as alpha values the pixels actually use. So I treat tRNS as a quick flag and use the pixel count from the first script for the real number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stickers go to WebP
&lt;/h2&gt;

&lt;p&gt;For the ribbon, 34 levels still leaves visible steps in the shadow at 4x,. WebP keeps the full alpha channel. For the 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 Chromium 149. The ribbon went from 430.7 KB to 98.8 KB, alpha matched the original pixel for pixel, and the error was 0.61 on white, black and blue. It runs in the browser, and the network panel showed zero non-GET requests during export. Its 8-bit PNG mode on the build I tested kept only two alpha levels on both files, so soft-edged art doesn't go through that path for me. The emoji as WebP is 12.1 KB with all 17 alpha levels kept. Its 4.02 error comes from lossy colour, not transparency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this trial does not cover
&lt;/h2&gt;

&lt;p&gt;Two files is small. Coloured shadows, outer glow and glassy assets are untested, and so are pngquant and other real reducers. Also check on white or a colour, never only on black. The ribbon's two-level copy scores 2.33 on black because a near-black shadow disappears into it. Before you shrink your own folder, run the soft-pixel count and look at a few results on a white page.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Can't copy or edit a contract PDF? Ask the sender before trying tools</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:05:54 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/cant-copy-or-edit-a-contract-pdf-ask-the-sender-before-trying-tools-1l00</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/cant-copy-or-edit-a-contract-pdf-ask-the-sender-before-trying-tools-1l00</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%2Fr20gxvexhznl2z9vuehq.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%2Fr20gxvexhznl2z9vuehq.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;br&gt;
Picture a client sending the contract PDF for a small freelance job. It opens straight away, with no password prompt, but the text won't copy into your notes, print does nothing, and the one tool you try says the file is encrypted. This hasn't happened to me with a real contract. I set it up as an experiment to see where that afternoon would go. I generated seven two-page placeholder PDFs that say "sample contract (placeholder text, test only)": one with no protection, five with only a permissions password, and one with an open password. Then I opened them in Chromium 149 (an open-source build) and Firefox 151.&lt;/p&gt;

&lt;p&gt;The first thing I would check is whether the file asked for a password when it opened, because the answer points to two different problems.&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="Firefox asking for a password before it will show the file" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is what the open-password file looks like in Firefox 151. Nothing of the document shows behind the dialog. Chromium stopped at a password box too. Without that password you have no contract to read, so the only useful move is asking the sender for it. The other five protected files never showed a box like this. They had a complete encryption dictionary and the content really was encrypted, but the user password was empty, so every viewer opened them silently. A permissions password only states what the reader software should allow afterwards: printing, copying, editing.&lt;/p&gt;

&lt;p&gt;Whether the software actually does that varies. With the same fully restricted file, Chromium's built-in viewer blocked copying (I could drag a blue selection, but nothing reached the clipboard) and blocked printing, yet it still let me draw on the page. Firefox 151 with default settings enforced none of the three. With its permission setting turned on, it hid the print button and greyed out the editing tools, while copying still went through. Files with the same permission bits behaved the same whether they used AES-256, RC4-128, or RC4-40. I don't read that as a tip for getting around a client's settings. I read it as a reason not to plan work around any one viewer's behaviour. A restriction that one program honours and another ignores won't give you a dependable editable copy, and it isn't dependable protection for the sender either.&lt;/p&gt;

&lt;p&gt;Then I checked what a processing tool does with these files. The compressor I used was ImgIng's PDF compression (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), with English versions of my own test files: one unprotected, two with only a permissions password, and one with an open password. The unprotected file went from 52 KB to 35 KB. All three encrypted files ended as "Failed 53 KB · This PDF is encrypted; remove protection first", including the one that only blocked copying and allowed editing. It didn't offer a password field, even for the open-password file. The page made 41 requests during the run and every one was a GET, so no file was posted anywhere. After the failures the progress card still said "All done", next to "0 file(s) compressed". The interface doesn't explain why it refuses. My guess, and it is only a guess, is that rewriting would force a decision about re-encrypting the file and honouring the owner's restrictions, and refusing avoids that.&lt;/p&gt;

&lt;p&gt;So I would stop trying tools at that point. A freelancer's hour spent fighting a restricted file is an hour nobody pays for, and the proper fix sits with the person who made it. If the file opened without a password, I would ask the sender to re-export it from the original program without the restrictions, which they can do by entering the owner password they set, or to send the editable source, usually the .docx. If it asked for a password, I would ask for the password and nothing else. The message can be short: "The PDF opens fine, but it's set to block copying and editing, so I can't add my changes. Could you send the Word file, or export the PDF again without the restrictions? If you'd rather keep it locked, I'll send my changes as a list and you can apply them."&lt;/p&gt;

&lt;p&gt;I include the last option because some senders lock the file on purpose. They want a review, not edits, and a list of changes lets the job continue without arguing about it.&lt;/p&gt;

&lt;p&gt;My test has limits. The files were my own placeholders, so real contracts may use other permission combinations. I didn't test Acrobat Reader, macOS Preview, released Safari, or any phone app. In Chromium I only saw that drawing worked; I didn't save a modified file.&lt;/p&gt;

&lt;p&gt;Next time a PDF opens but won't cooperate, note whether it asked for a password, then write the two-line message before trying another tool.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A valid PNG was still the wrong video thumbnail deliverable</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:05:25 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/a-valid-png-was-still-the-wrong-video-thumbnail-deliverable-3mg</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/a-valid-png-was-still-the-wrong-video-thumbnail-deliverable-3mg</guid>
      <description>&lt;p&gt;A PNG that opens successfully is a weak acceptance test for a video thumbnail. I made one from a canvas that contained no visible video pixels at all. I also captured a genuinely black opening frame after video data had arrived. Both outputs could look like a broken preview to someone receiving the image, although they called for different next steps. For a small project, I would write those distinctions into the acceptance criteria before adding automatic retries.&lt;/p&gt;

&lt;p&gt;I started with a public clip of ocean waves, preparing a six-second, 640×360 version in H.264 MP4 and VP8 WebM. A separate test page loaded each file over local HTTP. In Chromium 149, Firefox 151, and a WebKit 26.5 test build, I repeated each format ten times. That made 60 runs from one original scene. It did not make 60 different videos or two independent source scenes.&lt;/p&gt;

&lt;p&gt;At &lt;code&gt;loadedmetadata&lt;/code&gt;, every canvas in that set was transparent: all pixel alpha values were zero. The video’s dimensions could already be known, and drawing raised no exception. The image was still an empty deliverable. At &lt;code&gt;loadeddata&lt;/code&gt;, all 60 outputs instead contained opaque, non-black footage. For these particular HTTP loads, waiting for current video data changed the result. File creation alone would not have told me which image I was handing over.&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="An empty canvas and a visible wave frame from a controlled capture page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The checkerboard on the left is the page background showing through transparent pixels. It is not part of the source video. The right side displays the output after current-frame data became available. These are saved outputs from the independent test page, arranged to make their contents comparable. This is not an ImgIng screen, and the comparison does not establish that every input method has the same event timing.&lt;/p&gt;

&lt;p&gt;I then put a one-second black introduction before the six-second waves, producing a seven-second controlled clip. Each of the three engines loaded that clip once. After &lt;code&gt;loadeddata&lt;/code&gt;, the captured opening image remained black, with opaque alpha and all RGB channels below eight. That was real image content. Repeating the same capture after another loading event would not turn the chosen opening frame into a view of the sea. The acceptance decision had to include whether that opening was a useful cover.&lt;/p&gt;

&lt;p&gt;As a separate product check, I opened the original six-second MP4 in &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, using its Chinese video-matting Beta workbench. After moving to 2.117 seconds and waiting for seeking to finish, I ran its current-frame preview. The page reported that no obvious subject had been recognized. I had already seen the source video. I would record that as the result of the subject-processing stage, rather than relabel it a decoding failure. This check did not export a thumbnail or validate full-video processing.&lt;/p&gt;

&lt;p&gt;For my own feature, I would keep three acceptance questions separate: did an image get produced, does it contain pixels from the selected video position, and is that content acceptable for the intended cover? The last answer belongs to the product requirement. A truthful black opening might be acceptable for an exact-frame capture and unsuitable for a promotional card. Automatically skipping it would change what the user selected, so I would make that behavior explicit before implementing it.&lt;/p&gt;

&lt;p&gt;The footage 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;, used under CC BY 3.0, with trimming, resizing, and transcoding for this test. The black introduction was added for the controlled case. I did not shoot the source. These results cover local HTTP and the listed desktop engines; the WebKit build is not released Safari, and mobile inputs were not tested.&lt;/p&gt;

&lt;p&gt;Before handing over a thumbnail feature, I would save one transparent-output case and one intentional black-opening case alongside the original files and requested times. Then I would check the actual exported pixels and document which outcomes trigger a retry or ask for a different frame. That small set gives a reviewer something concrete to accept. A successful PNG decode can remain one check, but it cannot carry the whole decision.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Before adding a streaming stack I tested startup and seeking separately</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:05:17 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/before-adding-a-streaming-stack-i-tested-startup-and-seeking-separately-1b43</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/before-adding-a-streaming-stack-i-tested-startup-and-seeking-separately-1b43</guid>
      <description>&lt;p&gt;Before replacing a small site’s video delivery with a streaming stack, I would want to know which interaction is actually failing. A slow first picture and a slow jump halfway through the clip can look like the same complaint, but they produced different results in my test. Improving one did not settle the other. For an independent project, that difference is useful before adding another system to build and maintain.&lt;/p&gt;

&lt;p&gt;I tested two public waves clips through a local HTTP player. Both comparison clips were eight-second, silent H.264 MP4 files at 960×540. Each had a version with movie metadata toward the front and a version with it at the end. The paired files retained identical encoded media payloads. I then changed whether the server honored Range requests. This let me change file delivery without also changing the encoded pictures.&lt;/p&gt;

&lt;p&gt;All active responses within a test shared a 256 KiB/s write budget, with an 80 ms initial delay per request. Each ordinary MP4 condition ran twice in Chromium 149, Firefox 151, and a WebKit 26.5 test build. I measured the first video-frame callback separately from a later jump to six seconds, requested 500 ms after the first frame. This was a controlled local setup, not a test of commercial hosting or mobile networks.&lt;/p&gt;

&lt;p&gt;One result would have looked reassuring if I had stopped at the first picture. In Firefox, the smaller clip with front metadata and Range support started after a median 0.607 seconds. Reaching the six-second target then took a median 3.919 seconds. The request record showed only one range request beginning at byte zero; playback continued reading that response. Support for fetching byte ranges did not oblige this browser to make a new request for every jump.&lt;/p&gt;

&lt;p&gt;The same distinction appeared from the opposite direction. For that clip’s end-metadata version without Range support, Firefox waited a median 5.786 seconds for the first frame. Once the early seek test began, reaching six seconds took only 0.023 seconds at the median. Much of the waiting had already happened. Calling the latter “fast seeking” without the startup cost would give a misleading picture of what a visitor experienced.&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="The end-metadata comparison file is still waiting during an early load" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This illustration shows a separate Chromium load 1,250 ms after starting the same end-metadata, no-Range comparison file. It is a native player view from the local reproduction. It demonstrates the visible waiting state at that instant, not the Firefox timing quoted above. The file later produced a picture. An empty player screenshot alone does not establish that the video is corrupt.&lt;/p&gt;

&lt;p&gt;I also exported the original clips with &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, using the Chinese compression workbench for the actual operation. Those completed outputs had fragmented MP4 structures, so I kept their playback results separate from the ordinary comparison files. Their full durations and audio tracks also differed. A file extension was not enough to make them interchangeable test inputs, and the ordinary comparison should not be read as an ImgIng speed ranking.&lt;/p&gt;

&lt;p&gt;For a short product demo, I would therefore define the interaction I need before choosing a delivery change. If visitors mostly start at the beginning, I need a defensible first-picture check. If they frequently jump to a specific moment, that destination needs its own acceptance test. A single MP4 that starts before its full transfer completes demonstrates progressive playback; this experiment did not build or evaluate an adaptive streaming service. It also provides no hosting-cost comparison.&lt;/p&gt;

&lt;p&gt;The illustrated Iceland footage 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, stripped of audio, and transcoded for the comparison. The WebKit build was not released Safari. I would keep the original file, test the final delivery URL, and record first-frame and target-frame waits in separate fields. That gives me a concrete interaction to improve before I decide whether a larger delivery stack is justified.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>When I would stop trying PDF extractors and ask for the source</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:06:16 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/when-i-would-stop-trying-pdf-extractors-and-ask-for-the-source-1o8b</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/when-i-would-stop-trying-pdf-extractors-and-ask-for-the-source-1o8b</guid>
      <description>&lt;p&gt;After trying three PDF extractors on the same damaged notice, I still did not have the Chinese text I needed. The outputs looked different, but none contained the original Chinese title. That distinction matters when I am deciding whether another dependency is worth adding to a small tool. A different-looking failure is not yet a better deliverable. Before trying another converter, I want a reason to expect it to obtain information that the previous route could not recover.&lt;/p&gt;

&lt;p&gt;This was a controlled experiment, not a client incident. On September 30, 2026, I used a synthetic, one-page holiday notice containing five lines of Chinese and English. I kept the original, made a copy without the Chinese font's ToUnicode mapping, and made another copy with one incorrect mapping. Having the source text let me judge the outputs directly. I did not need to treat one extractor's answer as the standard that every other extractor should match.&lt;/p&gt;

&lt;p&gt;The original notice contained 56 Han characters. PyMuPDF 1.26.5, pypdf 6.10.2, and PDF.js 5.6.205 all recovered them. In the copy with the mapping removed, all three recovered zero Han characters. Numbers and English survived, mixed with unwanted characters. Some of those unwanted characters differed between implementations. None of that variation restored the words I needed. At that point, adding a fourth implementation would need a specific diagnostic purpose rather than a hope that another button might help.&lt;/p&gt;

&lt;p&gt;The other damaged copy gave me a stronger reason to keep the source involved. Its mapping changed 假 to 真, affecting two occurrences in the notice. All three extractors returned the same wrong characters. Their agreement was real, but it was agreement with an incorrect mapping. The rendered page still showed the original wording under my MuPDF test conditions. This was not a case where choosing the most common extraction result would establish what the document actually said.&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="Known source text compared with extracted fields" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a report assembled from saved experimental outputs, not a product screen. The last column keeps the expected Han-character count while failing the word-level comparison. For a small project, that changes what I would ask a document provider for. A statement that “the PDF opens correctly” does not answer the question. A short, confirmed source passage does. It gives me something concrete to compare before I promise that an import is ready to use.&lt;/p&gt;

&lt;p&gt;I also tried the Chinese-language interface of &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt; as a browser comparison. On the notice with the mapping removed, enabling the option to recognize text inside images produced the same content-extraction output as leaving it off. Converting the file to HTML did not restore the Chinese text either. The HTML task reached a completed state, but completion described the conversion, not the correctness of the text I wanted to hand over.&lt;/p&gt;

&lt;p&gt;There was a working recovery step, with an important condition. Image OCR recovered readable content from a clear PNG that had already been rendered externally. The Chinese spacing differed from the source, so the result still needed review. ImgIng's own PDF-to-image operation failed on this sample at the tested single-page PNG setting. I could not describe the recovery as one successful chain inside a single application. The external image was a separate input with a separate origin.&lt;/p&gt;

&lt;p&gt;If I were accepting a similar document-import request, this is where I would ask whether an editable source or a confirmed text export was available. That is a proposed working choice based on this experiment, not a claim that I measured the cheapest process across customer projects. I did not measure support time or calculate a monetary saving. My reason for asking is narrower: another output container has not supplied missing semantic information, while source content may let me check the exact fields the requester needs.&lt;/p&gt;

&lt;p&gt;The request can be small. I would send the page number, the readable passage, and the extracted result, then ask for the corresponding source text. I would not automatically ask for an entire project archive when a title and a few dates are enough to evaluate the current problem. If the provider sends a freshly exported PDF, I would rerun the same comparison. A new file timestamp tells me the file changed; it does not establish that the text now matches.&lt;/p&gt;

&lt;p&gt;If the source no longer exists, I would describe the next deliverable as reviewed OCR text from selected page images. That wording leaves room for the work still required: obtaining a correct image, recognizing it, and checking the fields that matter. It also avoids claiming that the original PDF has been repaired. I would keep the failed direct extraction alongside the reviewed result so that a later correction can be traced to the route that produced it.&lt;/p&gt;

&lt;p&gt;The stopping rule I took from this test is simple enough to use without building a larger system: after a second extraction route reproduces the same important error, pause and identify what new evidence another attempt could provide. If I cannot name it, I would ask for source content or agree on a reviewed image-based route. I want to hand over text whose origin and remaining uncertainty I can explain.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>canvas.toBlob silently crops WebP past 16,383 px, and nothing tells you</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sun, 04 Oct 2026 08:05:23 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/canvastoblob-silently-crops-webp-past-16383-px-and-nothing-tells-you-1f02</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/canvastoblob-silently-crops-webp-past-16383-px-and-nothing-tells-you-1f02</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%2Fn0lj9upbfvo3hj6jqask.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%2Fn0lj9upbfvo3hj6jqask.png" alt=" " width="800" height="410"&gt;&lt;/a&gt;&lt;br&gt;
My little image tool site does its resizing and format conversion in the browser. The reason is boring: files that never reach my server never show up on my bandwidth bill. What I had never tested was what happens when someone drops a really large image into it. So I generated synthetic test images with a script (gradients, shapes and noise, no photos) and ran them through three engine builds: the Chromium 149 open-source build (not Chrome itself), a WebKit 26.5 build that is not Safari, and a Firefox 151 build that is not the release. None of this is real Safari, iOS or a release Chrome.&lt;/p&gt;

&lt;p&gt;Before touching my own code I wanted to know whether keeping big files on the client is realistic at all. I used imging (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), operating the Chinese version of its interface, on large synthetic JPEGs (the biggest common case was 10000×10000) in all three engines, and counted the requests in the network log: across 12 cases there were zero non-GET requests. So a 100-megapixel file can stay on the user's machine. Then I went back to my own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you export a canvas wider than 16,383 px to WebP?
&lt;/h2&gt;

&lt;p&gt;In Chromium 149, calling &lt;code&gt;toBlob&lt;/code&gt; with &lt;code&gt;'image/webp'&lt;/code&gt; on a 20000×64 canvas hands back a perfectly valid WebP. There is no error, no console warning, and the MIME type is right. The file is simply 16383×64. To tell cropping from scaling I painted a horizontal gradient with a 64 px blue bar on the right edge. In the output the blue bar is gone, and the last column has exactly the color of column 16,383 of the source gradient. The right 3,617 px were cut off. At 65535×64 the output is again 16,383 px wide, which means 75% of the image is gone. A 64×20000 canvas loses its bottom instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="A 65535 by 2400 canvas in Chromium 149 and the WebP it exported, which covers only the left quarter" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the small repro page I wrote, running a 65535×2400 canvas in Chromium 149. The top strip is the canvas: green to orange, ending in the blue bar. The strip under it is the exported WebP shown at the same scale. It stops about a quarter of the way in, and the red box is the part that never made it into the file. The line at the bottom reads export.webp · 16,383 × 2,400 · 81 KB. Nothing on the page says anything went wrong.&lt;/p&gt;

&lt;p&gt;The other two engines fail in different ways. The Firefox 151 build calls back with null for the same exports, which is at least something you can catch. The WebKit 26.5 build cannot encode WebP at all and returns a full-size PNG instead, so the type is wrong but the pixels are all there. JPEG has its own ceiling as well: Chromium 149 turns a 65535×64 canvas into a 65500×64 JPEG and drops the rightmost 35 px, again without a word. The Firefox build returns null there too.&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%2Fyf50pujgoxhhva71hj92.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%2Fyf50pujgoxhhva71hj92.png" alt="The last 300 columns of a 65535 by 64 source next to the same columns of the JPEG Chromium 149 exported, where the blue bar stops early" width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here the source and the exported file sit side by side, both zoomed 6× on the last 300 columns. The source ends in a full blue bar. The JPEG, 65500×64, keeps only part of it, and the red box is the 35 px that are simply not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I check canvas exports now
&lt;/h2&gt;

&lt;p&gt;The change in my uploader is not clever. After every export I read the width and height of the resulting image and compare them with the canvas I drew. If they differ, if the blob is null, or if &lt;code&gt;blob.type&lt;/code&gt; is not what I asked for, the job stops and the user gets a message instead of a quietly truncated file. For WebP output I also scale anything with an edge above 16,383 px down before drawing, because a check that fires after encoding has already spent the user's memory on a result I will throw away.&lt;/p&gt;

&lt;p&gt;A few things I still can't answer. I only ran these open-source engine builds, so I don't know if release Chrome, Chrome on Android or real Safari behave the same. I also didn't dig into the encoder source to see where 16,383 comes from; I only know where it bites. If your app exports WebP from a canvas, draw a 20000×64 test canvas with a colored strip on the right edge, export it, and check the width of what comes back.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Removing the white fringe from a cutout punched a hole in a variegated leaf</title>
      <dc:creator>AsyncMonk</dc:creator>
      <pubDate>Sat, 03 Oct 2026 08:05:36 +0000</pubDate>
      <link>https://dev.to/shu_jing_915fa287b22539ad/removing-the-white-fringe-from-a-cutout-punched-a-hole-in-a-variegated-leaf-3gol</link>
      <guid>https://dev.to/shu_jing_915fa287b22539ad/removing-the-white-fringe-from-a-cutout-punched-a-hole-in-a-variegated-leaf-3gol</guid>
      <description>&lt;p&gt;I build small sites for clients and run a couple of my own, and more of the product pages people ask for now use dark backgrounds. Dark backgrounds are unforgiving with cutouts, so before quoting that kind of job I ran a small trial with two white-background photos from Wikimedia Commons: a puppy by George Hodan (CC0) and a rooted cutting in a glass bottle by Biusch (CC BY-SA 3.0). No client, no incident, just me checking what breaks. The short version: the white fringe comes from the old white background baked into semi-transparent pixels, and the one switch that removes it also changes transparency inside the subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the fringe comes from
&lt;/h2&gt;

&lt;p&gt;I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) for the cutout, fast mode with the ISNet INT8 model, on those two white-background photos, then composited the exports onto black to look at the edges. The cutout runs in the browser; across six exports of the two images I saw zero non-GET requests in the network panel. The refine panel says that by default it uses the AI output pixel by pixel, without touching transparency or edge color. So what I got was the raw model edge.&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="Puppy cutout, default export, composited on black" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the default export of the puppy (source image CC0) placed on black. Look at the outline of the ears and front legs: the fur is dark brown, but there is a greyish light rim hugging it.&lt;/p&gt;

&lt;p&gt;I measured it. I treated alpha 1 to 254 as semi-transparent and used opaque pixels within 3 px of the edge as the subject's real color. The puppy's 113,438 semi-transparent pixels have an average stored luminance of 160.1, while the fur just inside the edge sits at 108.6. For the plant it's 200.8 against 123.6. On black, the puppy edge measures 75.4 where the subject color predicts about 64.3, and the plant measures 52.5 against 40.9. Both are roughly 11 luminance units too bright. That "expected" value is an approximation from nearby pixels, so I only use it to compare versions.&lt;/p&gt;

&lt;p&gt;My first guess was the export format, since WEBP is preselected. A PNG export had exactly the same number of semi-transparent pixels, and luminance differed by 0.2 at most. The format isn't it. The size is a different story: the puppy PNG is 1,338,889 bytes versus 109,722 bytes as WebP. For someone paying for bandwidth, that settled the format question on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toggle that fixes it, and what it costs
&lt;/h2&gt;

&lt;p&gt;There is a toggle to remove the background color fringe. It's off by default and marked as optional and as changing the original AI edge. On the puppy it works: the edge on black drops to 76.0 against an expected 75.6, and the only visible side effect is a small jagged patch where the front leg meets the belly.&lt;/p&gt;

&lt;p&gt;On the plant it went wrong. The original is from Wikimedia Commons by Biusch, CC BY-SA 3.0; the image below is a cutout composite and is likewise provided under CC BY-SA 3.0.&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%2Fould3cl9boamdlmjcbqi.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%2Fould3cl9boamdlmjcbqi.png" alt="Variegated leaf: original, default export on black, decontaminated on black" width="600" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Left is the original, middle is the default export on black, right is the same leaf with the toggle on. In the right panel the upper half of the leaf goes dark and the black background shows through it. I counted the leaf area: 208,058 pixels that aren't fully transparent, of which 151,728 were already semi-transparent in the default export. With the toggle on, 41,644 of them became fully transparent. Not a single opaque pixel changed color by more than 30. It rewrites alpha, not color, so pale parts of the subject that look like the old background get faded out with it.&lt;/p&gt;

&lt;p&gt;The two sliders didn't rescue anything. Shrinking the edge by +1 brought the puppy's excess from 11.1 down to 8.4, expanding it made things whiter, and 3 px of feathering was roughly a no-op. The glass bottle keeps its halo of about 20 px either way, and with the toggle on its outline turns into steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing about it
&lt;/h2&gt;

&lt;p&gt;I now sort cutouts by subject before touching the toggle. Dark, solid subjects get it, followed by a zoomed check of the joints. Anything with pale areas close to the old background, like variegated leaves, white packaging or labels, keeps the default export and its slight fringe. Glass I haven't solved; I'd rather give it a lighter panel. I only tested fast mode and white backgrounds, so other modes and grey or colored backdrops are open questions. If a batch of yours is headed for a dark page, pull out the images with pale parts first, and keep in mind there's a button to restore the original AI alpha if the toggle eats something.&lt;/p&gt;

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