<?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: yue xing</title>
    <description>The latest articles on DEV Community by yue xing (@token111).</description>
    <link>https://dev.to/token111</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%2F4087986%2F86b82555-4629-45b8-9c3a-6743e1932cb9.png</url>
      <title>DEV Community: yue xing</title>
      <link>https://dev.to/token111</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/token111"/>
    <language>en</language>
    <item>
      <title>I leaked 1,500 video frames on purpose and Chromium's memory barely moved</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:12:18 +0000</pubDate>
      <link>https://dev.to/token111/i-leaked-1500-video-frames-on-purpose-and-chromiums-memory-barely-moved-2a96</link>
      <guid>https://dev.to/token111/i-leaked-1500-video-frames-on-purpose-and-chromiums-memory-barely-moved-2a96</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%2Fhwnm48ous4fmo7o31qa5.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%2Fhwnm48ous4fmo7o31qa5.png" alt=" " width="800" height="1109"&gt;&lt;/a&gt;&lt;br&gt;
My side project walks through a video clip one frame at a time in the browser. Before going further I wanted to know what a mistake looks like, so I made one on purpose. I decoded 1,500 frames, kept every one of them, never released any, and watched the browser's memory. In Chromium 149 it went up by 138 MB. That was less than the run where I did everything right.&lt;/p&gt;

&lt;p&gt;Some words first. A video file stores compressed data, and decoding means turning that data back into full pictures. In the browser &lt;code&gt;VideoDecoder&lt;/code&gt; does this and hands you each picture as a &lt;code&gt;VideoFrame&lt;/code&gt;. A decoded 1920×1080 frame is big: &lt;code&gt;allocationSize()&lt;/code&gt; reported 3,110,400 bytes for one, about 3.1 MB. You are supposed to call &lt;code&gt;close()&lt;/code&gt; on a frame when you are done, which tells the browser it can have that memory back. My broken version skipped that line and pushed every frame into an array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VideoDecoder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// broken on purpose: never closed&lt;/span&gt;
    &lt;span class="c1"&gt;// frame.close();    // the well-behaved version&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clip is an example video of waves from Wikimedia Commons. I re-encoded it to H.264 using ffmpeg 7.1 and looped it end to end until it was 60 seconds long, 25 fps, 1,500 frames, so it is a stitched test clip and not a real long recording. mp4box.js 0.5.2 splits the file and feeds the decoder. I ran it on a 16 GB Apple M4 Mac in Chromium 149, Chrome 154, Firefox 151 and WebKit 26.5 (macOS), with a fresh browser for every run. By "process memory" I mean the resident memory of all the browser's processes added together, read from &lt;code&gt;ps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." 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="Bar chart of browser process memory growth after keeping 1,500 frames: Chromium 149 138 MB, Chrome 154 149 MB, Firefox 151 2,291 MB, WebKit 26.5 not measurable" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the two short blue bars. With 1,500 frames held, Chromium 149 grew by 138 MB and Chrome 154 by 149 MB. When I closed every frame straight away, Chromium grew by 186 MB, so going by this number alone the leaky version looks like the tidy one. Firefox 151 is the only bar that looks like what I expected, 2,291 MB against 163 MB when frames were closed. WebKit has no bar because its web process was outside what my &lt;code&gt;ps&lt;/code&gt; reading could see.&lt;/p&gt;

&lt;p&gt;So where did the frames go? It reminded me of the fridge in my dorm. My own shelf looked empty, because I had put everything on the shared shelf. On macOS there is a system-wide count of graphics buffers, called IOSurface, which are picture-sized blocks of memory. The count is for the whole system, not for one app. You can read that count from a terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ioclasscount IOSurface       &lt;span class="c"&gt;# graphics buffers, whole system&lt;/span&gt;
ps &lt;span class="nt"&gt;-axo&lt;/span&gt; &lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;,ppid&lt;span class="o"&gt;=&lt;/span&gt;,rss&lt;span class="o"&gt;=&lt;/span&gt;      &lt;span class="c"&gt;# per process, add up the browser's tree&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkek2655oc0k26gb4qos1.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%2Fkek2655oc0k26gb4qos1.png" alt="Bar chart of system graphics buffers added after keeping 1,500 frames: Chromium 149 1,508, Chrome 154 1,510, Firefox 151 7, WebKit 26.5 1,510" width="799" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This chart is the first one flipped. Chromium 149 added 1,508 graphics buffers, Chrome 154 and WebKit 26.5 added 1,510 each, which lines up with the 1,500 frames I was holding. Firefox added 7. With frames closed right away, Chromium added 12. So the frames were there the whole time, on a counter I hadn't been reading. Why Firefox keeps them in its own process and the other three don't, I haven't looked into.&lt;/p&gt;

&lt;p&gt;I also tried reading total system memory, and it was a much worse ruler. In the Chromium run it peaked 2,694 MB above the starting level, but other apps were open on the machine and the same scenario could land more than 1 GB apart between two runs. The buffer count was the steady one, so that is the number I trust here. One more thing I tried was dropping the frames without closing them and without keeping a reference. Six seconds later Chromium still showed 1,508 extra buffers. WebKit was down to 32 and printed a console warning about a frame destroyed without being closed. I only waited those six seconds.&lt;/p&gt;

&lt;p&gt;To see what a finished frame-by-frame pipeline does on the same counter, I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) and ran its video matting tool in Chromium 149 on the same 1,500-frame clip, with the compatibility model option (RVM MobileNetV3) and a solid colour background. The tool is marked Beta, and its notes say desktop Chrome and Edge are supported first. I sampled every 2 seconds, 104 samples in total, and the buffer count stayed between −2 and 79 above the starting level without climbing as the job went on. The clip is only waves, with no subject to cut out, so this says something 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%2F6fhm6b3i5921oenuzvy9.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%2F6fhm6b3i5921oenuzvy9.png" alt="Top: the imported clip. Bottom: the same clip in video matting (Beta) with the background replaced" width="799" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These two screenshots use a different clip. The waves have no person to cut out, so the screen stayed blank, and I tried 8 seconds of a street guitarist instead (video by Isak Zvegelj, Wikimedia Commons, CC BY 3.0). Top is the clip as imported, bottom is with the background swapped for white. The numbers above are still from the wave clip.&lt;/p&gt;

&lt;p&gt;What I do now is run &lt;code&gt;ioclasscount IOSurface&lt;/code&gt; in a terminal before and after a test and compare the two counts, and I stopped treating a flat process number as proof that I closed everything. All of this is one Mac. IOSurface is a macOS name, and I have not checked where frames end up on Windows, Linux or a phone, or whether hardware and software decoding differ. The wave footage is by David Shay (דוד שי) on Wikimedia Commons, CC BY-SA 4.0.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My vertical video played upright, but the thumbnail I grabbed was sideways</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:33:05 +0000</pubDate>
      <link>https://dev.to/token111/my-vertical-video-played-upright-but-the-thumbnail-i-grabbed-was-sideways-1ncc</link>
      <guid>https://dev.to/token111/my-vertical-video-played-upright-but-the-thumbnail-i-grabbed-was-sideways-1ncc</guid>
      <description>&lt;p&gt;I'm building a small clip-sharing site as a side project. You pick a video, the page grabs one frame as a thumbnail before uploading, and the list page shows a grid of those thumbnails. While testing in Firefox I opened a vertical clip and it played upright, the way it should. Then I went back to the list, and the thumbnail for that same clip was lying on its side. Same file both times.&lt;/p&gt;

&lt;p&gt;It turns out the picture in a vertical video file is often not stored standing up. The pixels are saved lying down, 1920 wide and 1080 tall, and the file carries a small note next to them that says "turn this 90 degrees when you play it". The proper name for that note is the rotation flag, and in MP4 and MOV files it lives in the track header, outside the picture data. A player reads the note and turns the picture. My thumbnail code got its frame a different way, and that way didn't read the note. Photos have a similar orientation story, but I'm staying with video here.&lt;/p&gt;

&lt;p&gt;To rule out my own app I made test clips from two example videos from Wikimedia Commons, a wind turbine and a snowy street, both vertical. With ffmpeg 7.1 I painted a red square in the top-left corner and a green one in the top-right, laid the pixels on their side, and wrote a rotation flag into the file. So these are stand-ins made with ffmpeg, not files straight off a phone. Then I loaded them in one test page in Chromium 149, Chrome 154, Firefox 151 and WebKit 26.5 on macOS. Playback was upright in all four, and &lt;code&gt;videoWidth&lt;/code&gt; × &lt;code&gt;videoHeight&lt;/code&gt; reported 1080×1920 in all four, so the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element already hands you the turned size.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Five ways of getting a frame from the flagged turbine clip in Firefox 151: video element and canvas drawImage are upright at 1080×1920, the other three are sideways at 1920×1080" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the turbine clip in Firefox 151. Follow the red square. In the two panels on the left, the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element and a canvas &lt;code&gt;drawImage&lt;/code&gt;, it sits in the top-left corner and the size is 1080×1920. In the other three it has dropped to the bottom-left and the size is 1920×1080, which means I got the stored pixels with the note ignored.&lt;/p&gt;

&lt;p&gt;The middle panel was my bug. I had used &lt;code&gt;createImageBitmap(video)&lt;/code&gt;, which turns a video's current frame into a bitmap, because it was one line. In Firefox 151 it returns the sideways 1920×1080 picture. Chromium 149 did something stranger: the bitmap was also 1920×1080, but the picture inside was upright and squashed to fit, so my 160×160 squares came out at roughly 284×90. Chrome 154 and WebKit 26.5 returned a correct 1080×1920 bitmap. I don't know why the two Chromium-family versions differ, and I didn't dig into it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before: sideways in Firefox 151, squashed in Chromium 149&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// after: upright in all four browsers I tried&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cnv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// 1080, already the turned size&lt;/span&gt;
&lt;span class="nx"&gt;cnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;videoHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 1920&lt;/span&gt;
&lt;span class="nx"&gt;cnv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcppqrzl7hg38u85n9vw7.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%2Fcppqrzl7hg38u85n9vw7.png" alt="The same comparison with the snow clip in Firefox 151: video element and drawImage are upright at 720×1280, createImageBitmap and mp4box plus VideoDecoder are sideways at 1280×720" width="800" height="218"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I repeated it with the snow clip in case it was something about one file. Same pattern, with the sizes swapped to 720×1280 and 1280×720: the canvas frame is upright and the bitmap is sideways. For a moment I thought about keeping the one-liner and just comparing the bitmap's width with &lt;code&gt;videoWidth&lt;/code&gt;. That catches the 90 and 270 degree clips, but a clip flagged 180 degrees keeps its size and simply comes out upside down in the paths that ignore the note. I kept the canvas.&lt;/p&gt;

&lt;p&gt;The two right-hand panels in the first picture are WebCodecs paths. &lt;code&gt;new VideoFrame(video)&lt;/code&gt; in the two Chromium-family browsers has a &lt;code&gt;rotation&lt;/code&gt; property that read 90 for this clip, and the frame draws upright. In Firefox 151 there was no &lt;code&gt;rotation&lt;/code&gt; property and it drew sideways. If you split the file yourself with mp4box.js and feed the pieces to &lt;code&gt;VideoDecoder&lt;/code&gt;, you get the sideways picture in all four browsers. The decoder only receives the picture data. The note stays behind in the container, so turning the frame becomes your job.&lt;/p&gt;

&lt;p&gt;My site also shrinks clips before upload, so I wanted to know which way the pixels sit after compression. I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) for that. I ran its video compressor in Chromium 149 on the size-first preset, and all three flagged turbine clips (90, 270 and 180 degrees) came out as 608×1080 files with the pixels stored upright and no rotation flag left. My unflagged upright samples were right in every path and every browser above, so a file like that doesn't depend on anyone reading a note. That is one browser, one preset and five-second clips, nothing wider.&lt;/p&gt;

&lt;p&gt;I haven't tested files straight from a real phone, HEVC, Safari on an iPhone, or anything on Android. The thumbnail in my project now goes through a canvas, and the red-and-green turbine clip stays in my test folder. The turbine footage is by Grendelkhan and the snow footage by Canbel, both from Wikimedia Commons under CC BY-SA 4.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%2Fapp495sme3m8c7dhddcw.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%2Fapp495sme3m8c7dhddcw.png" alt=" " width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The part you cropped off a Word image is still in the HTML export</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:09:22 +0000</pubDate>
      <link>https://dev.to/token111/the-part-you-cropped-off-a-word-image-is-still-in-the-html-export-8j3</link>
      <guid>https://dev.to/token111/the-part-you-cropped-off-a-word-image-is-still-in-the-html-export-8j3</guid>
      <description>&lt;p&gt;For a side project I'm turning .docx course notes into web pages with mammoth.js, and I wanted to know what happens to pictures cropped in Word. I don't have Word on this laptop, so I wrote a test .docx by hand following the OOXML structure. It has seven pictures made from two images: a landscape photo from Wikimedia Commons, and a screenshot of an order admin page I built myself with placeholder names, phone numbers and addresses. One case crops 35.484% off the bottom of the screenshot to hide the shipping card. mammoth 1.13.0 showed every cropped picture whole, shipping card included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The docx keeps the whole picture
&lt;/h2&gt;

&lt;p&gt;A .docx is a zip, so I renamed a copy and opened it. The crop doesn't touch the image at all. What sits in &lt;code&gt;word/media/&lt;/code&gt; is the full original, and the crop is a single &lt;code&gt;&amp;lt;a:srcRect&amp;gt;&lt;/code&gt; line in &lt;code&gt;document.xml&lt;/code&gt; with four edges in thousandths of a percent. Display size lives in &lt;code&gt;wp:extent&lt;/code&gt; and rotation in &lt;code&gt;a:xfrm rot&lt;/code&gt;. I wrote a few lines of Python to print every picture next to its crop:&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;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&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;zipfile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docx&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word/_rels/document.xml.rels&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Id=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(rId\d+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^&amp;gt;]*Target=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;([^&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rels&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docx&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word/document.xml&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;pic:pic\b.*?&amp;lt;/pic:pic&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;r:embed=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(rId\d+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;trim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;a:srcRect([^/]*)/&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;group&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;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(none)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docx&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;name&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
        &lt;span class="nf"&gt;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;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;22&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;width&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; srcRect &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;trim&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;span class="c1"&gt;# word/media/image2.jpeg 2736x1824   srcRect l="30000" t="20000" r="20000" b="25000"
# word/media/image4.png  1800x1240   srcRect l="0" t="0" r="0" b="35484"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;b="35484"&lt;/code&gt; hides 35.484% from the bottom, and the PNG behind it is still 1800x1240. To be fair, I put the full image there myself when I wrote the file, so this doesn't prove what Word does. Microsoft's support page does say that on macOS you can tick "Delete cropped areas of pictures" to shrink the file, so the cropped part stays unless you ask. I haven't checked 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="The whole photo stored in the file, with the red frame showing the part a Word crop would display" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's case B, cropped 30% left, 20% top, 20% right and 25% bottom. Readers should only see the red frame, but everything outside it is still in the file. The photo is by Dietmar Rabich on Wikimedia Commons, CC BY-SA 4.0, and this derivative figure uses the same licence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden part ships in the HTML too
&lt;/h2&gt;

&lt;p&gt;Next I tried docx-preview 0.4.1, and for the third run I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), its DOCX to HTML export, with the same seven-case .docx in a Chromium 149 open-source build. Both got the visible part right, and both made the same mistake with uneven crops. When only the bottom is cropped, the picture moves up 70.4 px in ImgIng's export (69.7 px in docx-preview) and covers the heading line above it. The case cropped 15% on all four sides didn't shift at all. ImgIng made 0 non-GET requests during conversion, and it showed no warning about crops either. Then I looked inside the output files. ImgIng's export was 9,665,338 bytes, and all seven inline images were the whole originals, byte for byte the same as the media in the docx. The shipping card wasn't visible on the page, but decoding the screenshot's data URI gave back the full 1800x1240 PNG with every placeholder phone number in it. mammoth's output has the full images too, and it shows them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfd1np2n8euj88a7sing.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%2Fhfd1np2n8euj88a7sing.png" alt="Left: what the document shows. Right: the full screenshot stored in the file, with the cropped shipping info in a red box" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bake the crop in before converting
&lt;/h2&gt;

&lt;p&gt;So the fix has to happen in the .docx, before any converter sees it. In Word that's the "Delete cropped areas of pictures" option, which I couldn't try. I did it with a script instead. For each picture with a srcRect, it crops the media file by those four fractions, writes it back and empties the srcRect, so there's nothing left for a converter to get wrong. JPEGs get re-encoded at quality 92 and PNGs stay lossless. I couldn't do this inside mammoth, because its &lt;code&gt;convertImage&lt;/code&gt; callback only gets the image bytes and type and never sees the crop. After baking, the docx went from 7,234,550 to 5,714,763 bytes, 21.0% smaller, and the screenshot's media became 1800x800, down from 143,480 to 84,507 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the offset and the hidden part again
&lt;/h2&gt;

&lt;p&gt;Then I converted again and checked two things. The bottom-cropped screenshot that had moved 70.4 px now sits 0.4 px off, and the heading above it is readable. ImgIng's export of the screenshot case alone dropped from 213,557 to 134,899 bytes, and none of the three converters left a shipping card to find, because it isn't in the file anymore.&lt;/p&gt;

&lt;p&gt;My script still skips a few cases. It doesn't split one media file used by two pictures with different crops, it ignores negative srcRect values, and I only tested inline pictures, not floating ones. I haven't tried Word, WPS, LibreOffice or pandoc either. For now, before a converted page leaves my laptop, I run that listing on the .docx first, and anything that isn't &lt;code&gt;(none)&lt;/code&gt; gets baked.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Boxes in my Chinese PDF export weren't an encoding bug</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:06:59 +0000</pubDate>
      <link>https://dev.to/token111/boxes-in-my-chinese-pdf-export-werent-an-encoding-bug-2g14</link>
      <guid>https://dev.to/token111/boxes-in-my-chinese-pdf-export-werent-an-encoding-bug-2g14</guid>
      <description>&lt;p&gt;My side project exports a monthly statement page to PDF, so I wrote a fake one to test with: a Chinese title, a short mixed Chinese and English intro, a three-row table and a "Party A (seal): Some Company" line, all placeholder text. When I searched for "Chinese boxes in HTML to PDF", half the answers said fix the charset and the other half said install a font. Those are different bugs, and I didn't want to change things at random. So I made both of them happen on purpose and looked at what each one does to the 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/..." 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 turns every Chinese character into a hollow box. 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 top block is a simulation, and I want to be upfront about that. My Mac has PingFang, so Chromium will always find some Chinese font. To fake a machine without one, I wrote a tiny font where every CJK code point points at the same box glyph. The boxes are neat, all the same size, and "Statement No. 2026-0930-001" stays perfect. The middle block is a file saved as GBK but declared as UTF-8, and you get black diamond question marks with stray letters like õ and λ. The bottom block is the reverse, UTF-8 declared as GBK. It turns into a line of real but wrong Chinese characters, and "Statement" quietly loses its S. So now I check the shape first. Tidy boxes with clean English means a missing glyph. Diamonds, wrong characters or damaged English means the bytes were decoded wrong.&lt;/p&gt;

&lt;p&gt;The text layer agrees, mostly. Out of 16 strings I check for, the GBK-as-UTF-8 PDF gave back 1 and the UTF-8-as-GBK one gave back 0. My fake box PDF still returned 15, but only because my font maps every character to one glyph, so I wouldn't assume a real missing font keeps the text.&lt;/p&gt;

&lt;p&gt;Simulated boxes only get you so far, so I wanted a real server. I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) HTML to PDF with the same placeholder statement, importing it once as a single file and once as a whole folder. Import and preview stay in the browser. When you click convert, it sends one POST with a script-free snapshot of the page to a server-side Chromium 151 on Linux. I counted 0 non-GET requests before the click and exactly 1 after. The fonts inside its PDF were Noto Sans CJK SC Regular and Bold plus Noto Color Emoji. The "PingFang SC", "Microsoft YaHei" list I had copied into my CSS wasn't on that machine, and it was swapped out with no warning. Common characters looked fine.&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%2Fwcgdz9piblrjfam31n1q.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%2Fwcgdz9piblrjfam31n1q.png" alt="Top: the rare-character line printed on my Mac. Bottom: the same line in the PDF from ImgIng's server" width="800" height="202"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My test page has one line of seven rare characters, and that's where the real boxes showed up. On the server, 𠀀 (U+20000) and 𪚥 (U+2A6A5) became crossed boxes, the other five were fine, and the emoji switched from Apple's to Noto's. My Mac printed 𪚥 through PingFang HK and left 𠀀 blank. What surprised me is that text extraction from the server PDF still returns 𪚥, so a "compare the extracted text" test passes while the page shows a box. This is the check I run now. Type3 fonts have an empty name, so it reads FontName from the FontDescriptor instead, and then it renders the page so I actually look at it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;xref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&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;get_fonts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xref_get_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FontDescriptor&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Type3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;null&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xref_get_key&lt;/span&gt;&lt;span class="p"&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;desc&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FontName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;most_common&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;doc&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;get_pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page1.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# look at this, don't diff the text
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server PDF it prints &lt;code&gt;Type3 BAAAAA+NotoSansCJKsc-Regular 40&lt;/code&gt;, then the Bold with 10 and the emoji font with 2.&lt;/p&gt;

&lt;p&gt;The fix is to bring the font with the page, but it has to be loaded before you print. In my local Chromium 149 build I called page.pdf() right after the load event, three times each. With a remote font and font-display swap, the title fell back to the system font 3 out of 3 times. Without swap, I got a blank page of 1,073 bytes 3 out of 3 times. Waiting for document.fonts.ready fixed both. On ImgIng's server, the full 3.27 MB title font I embedded wasn't used, but a 48 KB subset of the same font was. I don't know why, so for now I subset first.&lt;/p&gt;

&lt;p&gt;So when a PDF looks wrong, I go in this order: the shape of the broken text, then the embedded font list, then a rendered page, and only after that the charset. The one case I haven't tested is a real Linux machine with no Chinese fonts at all.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Upscaling screenshots before OCR helped tesseract.js, not PP-OCRv6</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sat, 10 Oct 2026 08:07:56 +0000</pubDate>
      <link>https://dev.to/token111/upscaling-screenshots-before-ocr-helped-tesseractjs-not-pp-ocrv6-432b</link>
      <guid>https://dev.to/token111/upscaling-screenshots-before-ocr-helped-tesseractjs-not-pp-ocrv6-432b</guid>
      <description>&lt;p&gt;For my capstone I want to pull text out of screenshots that people forward through chat apps, and almost every OCR tip I found says the same thing first: upscale the image 2x before you recognise it. Those screenshots are usually already shrunk by the app, so the tip sounded exactly right. I didn't want to add a resize step on faith, so I tested it. The samples are crops from a web page I wrote myself (a made-up "community service hours" notice in Chinese, with 16 px body text, a 12 px sidebar, an 11 px grey footer and a 13 px fee table), plus one crop of poster text from a holiday-notice template I found online. I shrank each crop to 75%, 60% and 50% to fake what a chat app does. At 50% the 12 px characters are only about 6 px tall.&lt;/p&gt;

&lt;p&gt;Then I ran every shrunk crop as-is, upscaled 2x and upscaled 3x (bicubic) through two engines. The reference engine is tesseract.js 5 with default parameters and chi_sim+eng. For the other one I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) OCR, which runs PP-OCRv6, with all three tiers (Fast OCR, Professional OCR, Ultimate OCR) on the same crops, in an open-source build of Chromium 149 on an Apple M4 with the WebGPU backend. The score is character error rate: edit distance between the output and the true text, divided by the length of the true text. The core of my checker is below. The real one also folds the different dash characters into one and skips a decorative star, which I left out here.&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;squash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;char_error_rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;squash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;squash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ca&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;diag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;diag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&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="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&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="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="n"&gt;diag&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ca&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;cb&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;dist&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;bigger&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;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interpolation&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="n"&gt;INTER_CUBIC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Character error rate of shrunk screenshots, as shrunk and after 2x and 3x upscaling, for three ImgIng OCR tiers and tesseract.js 5" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The chart combines all 15 shrunk crops, and the two groups go in opposite directions. The tip holds for tesseract.js. Its error rate went from 53.0% as shrunk to 25.8% after a 2x upscale, and 3x only brought it to 25.0%. For ImgIng it didn't hold. Professional OCR went from 2.2% to 2.5%, Fast OCR stayed at 6.6%, and Ultimate OCR moved from 3.1% to 3.3%. One crop got clearly worse. The 12 px sidebar shrunk to 50% went from 14.7% to 23.5% on Professional OCR after bicubic 2x, and 29.4% with Lanczos. I saw the same split on the 1x screenshots I hadn't shrunk at all. On the 11 px and 12 px crops combined, tesseract.js dropped from 7.8% to 2.4% with a 2x upscale, while Professional OCR went from 0.0% to 0.6%.&lt;/p&gt;

&lt;p&gt;I honestly don't know why ImgIng doesn't gain anything. My guess was that it resizes every image to some fixed size before detection, but I didn't check that, so all I can report is what came out. My reading is only that the upscale tip was written for engines like tesseract, and a newer model may not need the help.&lt;/p&gt;

&lt;p&gt;Two more things changed what I'll do. First, nothing rescues the 50% level. Professional OCR on the grey 11 px footer went from 12.2% to 11.2%, and tesseract.js on the 12 px sidebar from 100% to 92.7%, because those pixels are gone. Second, I compared a real 2x screenshot (taken at device pixel ratio 2, like a Retina screen) with a 1x screenshot upscaled 2x. On the grey footer, tesseract.js got 1.0% on the real one and 2.0% on the upscaled one. On the sidebar both came out at 2.9%, and Professional OCR read both at 0.0%. The gap is small, so I wouldn't lean on it much.&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%2Fw2h6vgskqn3eigiq9hl5.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%2Fw2h6vgskqn3eigiq9hl5.png" alt="12 px screenshot text before and after a 3x3 median filter, enlarged 4x" width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While I was at it I also tried the other classic, a denoise pass. The picture shows why that was a bad idea for screenshots. The top is the 12 px sidebar as captured, and the bottom is the same crop after a 3x3 median filter, and the strokes have melted into blobs. On Professional OCR that crop went from 0% to 32.4%. Recognition runs in a local Worker and made zero non-GET requests the whole time. The first run downloads the model, but that's a download, not an upload of my screenshots. The Scan enhancement toggle says "Gentle grayscale, contrast and sharpening; no forced binarization.", and on my unshrunk crops Professional OCR scored 0.2% with it on and 0.1% with it off, which I count as no change.&lt;/p&gt;

&lt;p&gt;So my capstone pipeline now sends the screenshot as it is, with no resize and no filter. When I control the capture I take it at 2x instead of enlarging later. I only keep a 2x upscale as a branch for the tesseract.js fallback, and I check the error rate on my own crops before trusting any preprocessing tip again.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A 256-colour PNG turned my sticker's soft shadow into a black band</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:20:18 +0000</pubDate>
      <link>https://dev.to/token111/a-256-colour-png-turned-my-stickers-soft-shadow-into-a-black-band-2902</link>
      <guid>https://dev.to/token111/a-256-colour-png-turned-my-stickers-soft-shadow-into-a-black-band-2902</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%2Fttsdhvsgx4kzucvkn5iq.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%2Fttsdhvsgx4kzucvkn5iq.png" alt=" " width="799" height="531"&gt;&lt;/a&gt;&lt;br&gt;
I was dropping a sticker onto a white page for a class project, and after I ran it through a 256-colour PNG reducer the soft grey shadow under it turned into a solid black band with a knife-cut outer edge. The test file is a pink ribbon sticker I found on Wikimedia Commons (by K B, CC BY 2.0), a third-party image and not my work, and the crops below only show its top loop. My first guess was that 256 colours were not enough. The palette was already at the maximum, though, so I opened both files in NumPy and looked at the alpha channel instead of the colours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alpha channel went from 256 levels to 2
&lt;/h2&gt;



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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;alpha_levels&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;soft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&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;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;soft&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="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;soft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&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="sh"&gt;"&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;a&lt;/span&gt;

&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;alpha_levels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ribbon.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# ribbon.png 256 225652 17.9%
&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;alpha_levels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ribbon_256.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ribbon_256.png 2 0 0.0%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The original is 760×1662 and uses all 256 alpha values. 225,652 pixels are semi-transparent, which is 17.9% of the image, and almost all of them sit in the shadow. The reduced file has exactly two alpha values left, fully transparent and fully opaque, with zero pixels in between. Every soft pixel had been forced to pick a side, and the colours had nothing to do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything was split at alpha 128
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;was_soft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&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;before&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;was_soft&lt;/span&gt;&lt;span class="p"&gt;]&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;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;was_soft&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# 63769 161883
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array_equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&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;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                     &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule turned out to be a plain threshold. Pixels with alpha 128 or more became fully opaque and the rest vanished, and a threshold at 128 matched the reducer's output on every pixel. Of the old soft pixels, 63,769 became opaque and 161,883 became transparent. The inner part of a drop shadow is dark and fairly solid, so it survives as pure black. The faint outer fade sits below 128 and gets deleted, which is exactly where the hard edge comes from.&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, in the middle a two-level copy, on the right a 34-level version. Look at the shadow under the loop: the middle panel has a black band and then nothing, while the right one fades out again with a few fine steps. Both copies in the figures come from a quick reducer I wrote myself to compare against, not from any tool. The 34-level file still has 256 palette entries, but 33 of them have an alpha below 255 in the tRNS chunk, and it is only 9.1% bigger than my own two-level file. To put a number on it, I composited each file onto a background and took the average RGB difference from the original over the semi-transparent pixels, on a 0 to 255 scale. On white my two-level copy is off by 44.17 and the 34-level one by 3.48.&lt;/p&gt;

&lt;h2&gt;
  
  
  A black background hides the problem
&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%2Foyt7uhpudk5xiqs87mgy.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%2Foyt7uhpudk5xiqs87mgy.png" alt="Top row original, bottom row two alpha levels, on white, black and blue" width="800" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the part that confused me the longest. Top row is the original and bottom row the two-level copy, on white, black and #1976D2 blue. On black the two rows look almost the same, and the numbers agree: the error is 2.33 on black, 20.79 on blue and 44.17 on white. Only 3.4% of the soft pixels differ by more than 24 on black, against 41.0% on blue and 56.6% on white. The shadow is nearly black, so a black backdrop swallows both the band and the missing fade. Had I only checked the export on a dark canvas, I would have called it fine.&lt;/p&gt;

&lt;p&gt;The reducer I had started with was the 8-bit PNG mode in ImgIng, run in Chromium 149 on the build I tested. Its help page says its PNG-8 keeps multi-level alpha, but that export had two levels and an error of 44.07 on white, within 0.3 of my copy. For stickers with soft shadows I now convert to WebP instead, and for that I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) at its default quality 84: the ribbon went from 430.7 KB to 98.8 KB, the alpha channel matched the original pixel for pixel, and the error was 0.61 on all three backgrounds. The conversion runs in the browser, and I saw zero non-GET requests during export. I keep PNG-8 for screenshots and flat icons with no soft edges. Before shipping a transparent export I run the alpha count above and look at the file on white and on a colour, not only on black.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A PDF that opens without a password can still be encrypted</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:07:26 +0000</pubDate>
      <link>https://dev.to/token111/a-pdf-that-opens-without-a-password-can-still-be-encrypted-44gi</link>
      <guid>https://dev.to/token111/a-pdf-that-opens-without-a-password-can-still-be-encrypted-44gi</guid>
      <description>&lt;p&gt;I used to think "encrypted PDF" and "password-protected PDF" meant the same thing. Then I made a handful of test PDFs for a side project and ended up with a file that opened instantly in my browser, no prompt at all, while a compression tool refused it for being encrypted. It took me a while to accept that both were telling the truth, because a PDF can carry two different passwords and only one of them is about opening the file.&lt;/p&gt;

&lt;p&gt;All my samples are two-page placeholder documents I generated with a script, titled "Sample Agreement (placeholder text, for testing only)". One has no encryption. Five have only a permission password, meaning the user password is empty and an owner password sets the restrictions. One has an open password. I inspected them with pypdf and PyMuPDF, then opened them in the viewers built into Chromium 149 (an open-source build) and Firefox 151.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sample type&lt;/th&gt;
&lt;th&gt;Opens without a password?&lt;/th&gt;
&lt;th&gt;Has an /Encrypt dictionary?&lt;/th&gt;
&lt;th&gt;What it restricts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No encryption&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permission password only&lt;/td&gt;
&lt;td&gt;Yes, with no prompt or warning&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Printing, copying, editing, per its permission flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open password&lt;/td&gt;
&lt;td&gt;No, the viewer asks first&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Opening, then the same flags once it's open&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle row is what had confused me. Those files really are encrypted. They carry a full /Encrypt dictionary, and their content streams are AES or RC4 encrypted. The user password is just empty, so any reader decrypts them with that empty password and shows the pages, which is why nothing asked me for anything. The libraries even word it differently. pypdf's &lt;code&gt;is_encrypted&lt;/code&gt; returned True for a permission-only file. PyMuPDF's &lt;code&gt;is_encrypted&lt;/code&gt; returned False for the same file, because there it means "still locked right now", yet its metadata listed "Standard V5 R6 256-bit AES". One boolean won't tell you which kind of file you're holding.&lt;/p&gt;

&lt;p&gt;So what does a permission password actually stop? In Chromium's viewer I dragged across four lines of a copy-restricted file, pressed Cmd+C and pasted into a text box. The selection looked completely normal, and nothing came out.&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="Drag-selecting text in Chromium's viewer, then pasting: unencrypted file on the left, copy-restricted file on the right" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both panels are an English copy of my placeholder layout in Chromium's built-in viewer, unencrypted on the left and copy-restricted on the right. Look at the blue highlight first. It shows up on both sides, so selecting text works either way. Then look at the paste boxes underneath. The left one holds the four lines. The right one, inside the red frame, still shows "Paste here" because nothing reached the clipboard. That empty box is the only difference between the two panels.&lt;/p&gt;

&lt;p&gt;Chromium also did nothing when I clicked print on the samples that forbid printing. It did let me draw on all seven samples, including the ones that forbid annotations. Firefox 151 on default settings didn't enforce print, copy or edit on any of them. The algorithm made no difference either. Files with the same flags behaved identically whether they used AES-256, RC4-128 or RC4-40. A permission password is a request written into the file, and each reader decides how much of it to honour. Chromium did block copying and printing, so the request does something. It still depends on the reader agreeing to it.&lt;/p&gt;

&lt;p&gt;An open password works differently, because the reader stops you before a single page appears.&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%2F4f4reoz76qrfued2dpig.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%2F4f4reoz76qrfued2dpig.png" alt="Firefox 151 asking for a password before showing the open-password sample" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is Firefox 151 opening my open-password sample. Behind "Enter the password to open this PDF file." there is only a grey page, and Chromium shows its own dialog for the same file. Without that password you get no content at all, which is what most people picture when they hear "encrypted". Even after I entered the correct open password, Chromium still blocked copying and printing. The permission flags simply apply on top.&lt;/p&gt;

&lt;p&gt;For the compression part I used ImgIng's PDF compressor (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in its Chinese interface, feeding it my own permission-only and open-password test files. Its page says processing stays on the device, and I saw zero non-GET requests during the run. The unencrypted sample shrank by 25.7%. Every encrypted one failed with the same message, which I'd translate as "This PDF has encryption or permission protection and needs that protection removed first." That included a file whose flags allow modifying, and the open-password file never got a password prompt. The interface doesn't say why. My guess, and it's only a guess, is that rewriting the file would mean dealing with the owner's restrictions.&lt;/p&gt;

&lt;p&gt;I didn't test Acrobat, macOS Preview or the released Safari, so I can't speak for them. For the viewers I did test, this is the check I now run first when a PDF opens fine and a tool still calls it encrypted. If the viewer asks for a password on open, you need that password from whoever sent the file. If it opens with no prompt, it's permission-only, and the clean fix is to ask the author for an unrestricted version or the source document. If the file is yours and you remember the owner password, open it in the software that made it, enter the owner password there and export a new copy.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Nine video captures looked correct because I had asked for the same frame</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:06:44 +0000</pubDate>
      <link>https://dev.to/token111/nine-video-captures-looked-correct-because-i-had-asked-for-the-same-frame-27a5</link>
      <guid>https://dev.to/token111/nine-video-captures-looked-correct-because-i-had-asked-for-the-same-frame-27a5</guid>
      <description>&lt;p&gt;Nine immediate video drawings looked correct in my test, even though I had not waited for seeking to finish. I could have kept one of those results and convinced myself the capture sequence was safe. Looking at which requests succeeded changed the interpretation: all nine moved from zero to 0.017 seconds in a 30 fps video. The previous frame was also the frame I needed. The test had given the shortcut an easy case.&lt;/p&gt;

&lt;p&gt;I used a generated clip containing 180 numbered frames at 640×360. The number was encoded in pixels so that drawing into a canvas could reveal the actual frame, independently of the video’s reported time. Three H.264 versions had GOP lengths of 15, 60, and 180 frames, with no B-frames. I tried twelve target positions in each version using Chromium 149, Firefox 151, and a WebKit 26.5 test build. That produced 108 immediate-drawing checks.&lt;/p&gt;

&lt;p&gt;In 99 of them, drawing straight after assigning &lt;code&gt;currentTime&lt;/code&gt; captured the previously presented frame instead of the target. In the other nine, the request still belonged to that previous frame’s interval. At 30 fps, each interval is about 0.033 seconds long. Asking for 0.017 seconds after starting at zero does not necessarily ask for new pixels. A canvas can therefore look correct without demonstrating that my waiting logic worked at all.&lt;/p&gt;

&lt;p&gt;That is why I would not turn the count into a general failure percentage for video capture. The matrix contains deliberately chosen positions, repeated across particular files and engines. The nine convenient results share one explanation. What I learned was about test selection: I need a request that crosses a frame boundary as well as one that remains inside the same frame. Otherwise a stale image can accidentally satisfy the check.&lt;/p&gt;

&lt;p&gt;The next position, 0.049 seconds, belongs to frame one in this fixture. Comparing it with 0.017 gave the test a meaningful change to observe. Seeking to 2.117 seconds after completion produced frame 63 across all three GOP versions; its start time is 2.100 seconds. The keyframe interval did not change that selected frame in this controlled sample. I stopped using a decimal time value as if every possible fraction of a second had its own picture.&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="Existing numbered fixtures show frame 63 after a completed seek to 2.117 seconds" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This illustration was captured later by loading the existing fixtures and drawing after &lt;code&gt;seeked&lt;/code&gt;. It shows generated measurement frames rather than filmed footage. It supports the frame-number and GOP comparison, not the original count of immediate drawings. The count came from the stored test records. Keeping that distinction helped me avoid asking one screenshot to prove every step of the experiment.&lt;/p&gt;

&lt;p&gt;There was another same-frame surprise in WebKit. Moving from zero to 0.017 seconds completed seeking, but no new frame callback arrived within my 1.7-second observation window for any of the three GOP versions. The canvas still held frame zero. A missing callback during that window did not mean the image had become black. I have not implemented and validated a general fallback; the result only tells me that another callback is not guaranteed by this particular request.&lt;/p&gt;

&lt;p&gt;For a separate product check, I used &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt; through its Chinese video-matting Beta workbench. A six-second waves clip displayed, and the current-frame preview at 2.117 seconds reported no obvious subject. That was not a test of the product’s frame accuracy or a thumbnail export. The footage came from Alexander Grebenkov’s &lt;a href="https://commons.wikimedia.org/wiki/File:Ocean_waves_at_L%C3%A6kjavik_beach,_Iceland.webm" rel="noopener noreferrer"&gt;Ocean waves at Lækjavik beach, Iceland&lt;/a&gt;, under CC BY 3.0, trimmed, resized, and transcoded. The numbered fixture was a separate generated input.&lt;/p&gt;

&lt;p&gt;My next regression check will include both a same-frame move and a different-frame move, and it will retain the actual canvas frame number. A same-frame success can check that I did not break an already usable picture; it cannot prove that a new frame has arrived. These desktop fixtures had constant frame rate and no B-frames, and the WebKit build was not released Safari. I would extend the inputs before making broader promises. First I want the test to require the change it claims to verify.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I stopped treating seeked as proof that a video reached six seconds</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:06:32 +0000</pubDate>
      <link>https://dev.to/token111/i-stopped-treating-seeked-as-proof-that-a-video-reached-six-seconds-5gk5</link>
      <guid>https://dev.to/token111/i-stopped-treating-seeked-as-proof-that-a-video-reached-six-seconds-5gk5</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%2F1pkcyx48zqt5max1dlwk.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%2F1pkcyx48zqt5max1dlwk.png" alt=" " width="800" height="396"&gt;&lt;/a&gt;&lt;br&gt;
I expected a video’s &lt;code&gt;seeked&lt;/code&gt; event to tell me that a jump to six seconds had worked. That was too much to ask of one event. In a small playback experiment, I could request that position and still end up at zero. My next check had to be the resulting position, followed by the presented frame’s time. Counting an event as a successful thumbnail would have hidden the difference.&lt;/p&gt;

&lt;p&gt;The test used two publicly available clips of ocean waves. I prepared eight-second H.264 MP4 files at 960×540, with no audio, then made front-metadata and end-metadata versions without changing their encoded media payloads. Those are controlled comparison files, not recordings of a problem on someone’s website. Each browser started in a fresh context. I deliberately sought early: 500 ms after its first video-frame callback.&lt;/p&gt;

&lt;p&gt;The delivery setup matters here. A local HTTP server gave all active responses for a test a shared 256 KiB/s write budget. Each request also started with an 80 ms delay. I switched between honoring Range requests and returning the whole file. This is an application-level delivery experiment, not a simulation of every property of a slow internet connection. In particular, that initial delay should not be read as a measured network round-trip time.&lt;/p&gt;

&lt;p&gt;The smaller clip’s front-metadata version looked encouraging in Chromium. With Range ignored, its first frame appeared after 0.479 and 0.483 seconds. Yet both early requests to jump to six seconds were clamped to zero. The recorded seekable range was also zero to zero. An early picture had told me that playback could start; it had not established that my chosen destination was currently available. I had been checking the easier half of the task.&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 front-metadata comparison clip has a visible picture" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This separate illustration shows the same front-metadata comparison file 1,250 ms into a local no-Range load. It is a native player view, not an ImgIng interface. The visible coast is useful evidence of a picture at that moment, but it cannot prove that the six-second jump in the timed runs succeeded. For that I used the event and frame records, keeping the screenshot’s timing separate.&lt;/p&gt;

&lt;p&gt;Firefox gave me another reason to keep the actual result. Under the smaller clip’s front-metadata, no-Range condition, the two requested six-second jumps landed at 1.16 and 0.36 seconds. They did not land at the same wrong number, so checking only for zero would have missed the problem. With Range supported, the same file reached the target frame in both runs, after about 3.919 seconds at the median. That wait belongs to seeking, not initial playback.&lt;/p&gt;

&lt;p&gt;I had also used &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt; to export the source videos as MP4, operating its Chinese compression workbench for the actual test. Those outputs were fragmented MP4s and were tested separately. They were not the eight-second silent files above. This distinction stopped me from attaching the comparison files’ timing to a tool’s export performance. I wanted to understand a playback check, not produce a compression speed ranking.&lt;/p&gt;

&lt;p&gt;The original 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. The illustrated test copy was trimmed, resized, and transcoded. I did not shoot it. The experiment used Chromium 149, Firefox 151, and a WebKit 26.5 test build on macOS; that last build is not released Safari. Each ordinary MP4 condition ran twice. These early-seek observations do not show that a fully downloaded file will remain unseekable.&lt;/p&gt;

&lt;p&gt;My next capture check will retain the requested time, the position reported after seeking, and the time attached to the presented frame. For this experiment, reaching six seconds meant a frame callback within 0.15 seconds of that target; it was not a test of exact pixels. I would keep a clamped destination as a separate outcome and inspect the available range before retrying. A fired event is worth recording, but the destination still needs its own check.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My missing ToUnicode test did not explain every PDF</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:07:38 +0000</pubDate>
      <link>https://dev.to/token111/my-missing-tounicode-test-did-not-explain-every-pdf-21bh</link>
      <guid>https://dev.to/token111/my-missing-tounicode-test-did-not-explain-every-pdf-21bh</guid>
      <description>&lt;p&gt;I made a PDF lose its Chinese text by removing one font mapping. That looked like a satisfying explanation of the problem, until I built another PDF without that mapping and its text extracted correctly. The second file was useful precisely because it did not fail. It forced me to turn a confident diagnosis into a narrower statement: the first file depended on information that the second file could obtain another way.&lt;/p&gt;

&lt;p&gt;This is a learning note from a controlled experiment on September 30, 2026. I generated the files with ReportLab 4.5.1 and inspected them with PyMuPDF 1.26.5, pypdf 6.10.2, and PDF.js 5.6.205. None of the damaged files came from a customer or a production incident. Having the source text was the point. I could tell whether an extraction was right without trusting another extraction as my answer key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first test supported too broad a guess
&lt;/h2&gt;

&lt;p&gt;The starting document was a one-page holiday notice with five lines of Chinese and English. I retained an untouched copy and removed the Chinese font's ToUnicode entry from another copy. In the original, each extractor returned 56 Han characters. In the modified file, all three returned zero. English and numbers remained, along with unwanted characters. The missing mapping was a strong lead for this particular document, but I had not established a rule about every PDF.&lt;/p&gt;

&lt;p&gt;I also rendered the original and modified files with MuPDF 1.26.10, using the same 1.5 scale and RGB settings. Both produced identical 893 by 1263 pixel images. I could still read the Chinese notice in the rendered page. This was the part I wanted to understand: displaying a glyph and recovering the character it represents are different jobs. My experiment changed the extraction result without changing the rendered image under those conditions.&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="Results from the controlled text mapping experiment" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshot is a report made from the saved extraction results, not the interface of a PDF application. The middle column has no Han characters even though the source page remains readable. Notice that the replacement-character count is also zero. Searching for U+FFFD would not have detected this failure. I needed a known sentence from the source, not just a search for a familiar symbol associated with broken text.&lt;/p&gt;

&lt;h2&gt;
  
  
  A counterexample changed the question
&lt;/h2&gt;

&lt;p&gt;For the second test, I generated a small English file using Helvetica and WinAnsiEncoding. It had no ToUnicode entry. Its single line was “Invoice A-1024: total 1280.50 CNY”, and all three extractors recovered that text correctly. This did not make the first test wrong. It showed that the presence of one dictionary entry was not the whole explanation. The font and encoding context mattered as well.&lt;/p&gt;

&lt;p&gt;I checked the distinction against a PDF Association technical presentation about text extraction. It describes several ways to obtain character information, including standard encodings and explicit Unicode mappings. I am keeping that explanation separate from my measurements: the presentation explains the available mechanisms, while my two files show one failing case and one successful counterexample. I did not test every fallback path, and I would not infer one from the output alone.&lt;/p&gt;

&lt;p&gt;A practical detour helped me keep that separation. I used the Chinese-language interface of &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt; as a browser-based comparison on the damaged notice. The native content extraction result still lost the Chinese text. Enabling its option for recognizing text inside images did not change that result. The option's name was not evidence that the existing text had been discarded and the entire page recognized again. I could describe what came out; I could not claim to know the internal decision that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A present mapping can be wrong too
&lt;/h2&gt;

&lt;p&gt;Removing information was only half the exercise. In another copy of the notice, I kept ToUnicode but changed the entry for 假 to 真. The altered mapping affected two occurrences. The page still rendered as the original notice, yet all three extractors returned the wrong Chinese character in those positions. The count stayed at 56 Han characters. No replacement characters appeared, and agreement between the extractors did not establish correctness.&lt;/p&gt;

&lt;p&gt;That example changed how I would write a test for my own small PDF project. I would keep the visible sentence and the expected extracted sentence as separate fixtures. Checking that extraction returns a nonempty string is useful for detecting an empty result, but it cannot detect a plausible wrong character. Even matching the expected character count leaves a gap. For this fixture, I can compare the actual words because I know what was written into the source document.&lt;/p&gt;

&lt;p&gt;I would also avoid silently normalizing the output before looking at it. Spaces and line breaks differ across extractors; that deserves its own decision. Replacing or removing control characters immediately can hide clues about the failure I am trying to understand. Keeping the raw string next to a cleaned copy lets me inspect both. A cleaned result should not become the only record of what the extractor actually returned.&lt;/p&gt;

&lt;p&gt;My next diagnostic step for an unfamiliar file would be modest: choose a short passage I can read in the page, save the raw extracted passage, and trace the font used by that passage. A missing ToUnicode entry would be a reason to investigate, not a complete verdict. I still need a real-world broken PDF to see how far this exercise transfers. For now, the successful Helvetica counterexample is staying beside the failing notice in my test folder. It stops the simplest explanation from becoming an unjustified rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pdfa.org/wp-content/uploads/2018/06/1530_Seggern.pdf" rel="noopener noreferrer"&gt;PDF Association technical presentation on text extraction&lt;/a&gt;, used to check the distinction between glyph selection and text semantics.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Creating a canvas costs nothing. Drawing a 16384 one costs 1 GiB</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sun, 04 Oct 2026 08:06:42 +0000</pubDate>
      <link>https://dev.to/token111/creating-a-canvas-costs-nothing-drawing-a-163842-one-costs-1-gib-38an</link>
      <guid>https://dev.to/token111/creating-a-canvas-costs-nothing-drawing-a-163842-one-costs-1-gib-38an</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%2Frje86iyw8cgw0zwz8g2b.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%2Frje86iyw8cgw0zwz8g2b.png" alt=" " width="800" height="418"&gt;&lt;/a&gt;&lt;br&gt;
My course project has a page where you pick an image, draw it onto a canvas, and export the result. When I fed it a huge test image, the preview came out blank. My first guess was memory: the image must be too big and the tab must be running out of RAM. So I spent an evening measuring how much memory a canvas actually takes. The numbers were interesting, and they also showed that my first guess was wrong.&lt;/p&gt;

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

&lt;p&gt;Everything ran in three engine builds: Chromium 149 as an open-source build rather than Chrome itself, a WebKit 26.5 build that is not Safari, and a Firefox 151 build that is not the release Firefox. The machine was an M4 Mac with 16 GB of RAM, and it was busy with other jobs at the time. All images were synthetic (gradients, shapes and noise generated by a script), not photos. For a reference point I also ran a 10000×10000 JPEG through the compress-and-convert page of ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), operating its Chinese UI, converting to WebP and JPG in all three engines; across 12 runs the network log showed zero non-GET requests.&lt;/p&gt;

&lt;p&gt;The first thing I learned is that the browser's own tools can't see canvas memory. The DevTools heap metric &lt;code&gt;JSHeapUsedSize&lt;/code&gt; stayed at 792,768 bytes whether the canvas was 1024² or 16384². &lt;code&gt;performance.measureUserAgentSpecificMemory()&lt;/code&gt; was rejected with a SecurityError in the Chromium build even though the page was cross-origin isolated. So I measured from the operating system instead, summing the memory footprint of every browser process before and after each step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a canvas is free, drawing is not
&lt;/h2&gt;

&lt;p&gt;Take one 8192×8192 run in Chromium 149 from 2026-09-28. The raw pixel data works out to 8192 × 8192 × 4 bytes, which is 256.0 MiB. Creating the canvas and calling &lt;code&gt;getContext&lt;/code&gt; added 0.3 MiB. One full &lt;code&gt;fillRect&lt;/code&gt; added 256.7 MiB, almost exactly the raw size. A full &lt;code&gt;getImageData&lt;/code&gt; then took the total to 512.9 MiB. Over the same steps, &lt;code&gt;JSHeapUsedSize&lt;/code&gt; only moved from 949,212 to 1,115,836 bytes. That was a separate run from the one above, so its baseline differs.&lt;/p&gt;

&lt;p&gt;Just creating a canvas and calling &lt;code&gt;getContext('2d')&lt;/code&gt; added less than 1 MiB in all three engines, all the way up to 16384×16384. Memory is allocated the first time you draw. After one full &lt;code&gt;fillRect&lt;/code&gt;, Chromium and Firefox grew by exactly width × height × 4 bytes. For 16384×16384 that is 1025 MiB. Reading the whole thing back with &lt;code&gt;getImageData&lt;/code&gt; doubled it to 2050 MiB, because the &lt;code&gt;ImageData&lt;/code&gt; is a second full copy. WebKit's build went further and reached 3265 MiB after the readback at the same size. That is why I now avoid full-canvas &lt;code&gt;getImageData&lt;/code&gt; unless I really need every pixel.&lt;/p&gt;

&lt;p&gt;Decoding is its own cost. A 13.4 MB JPEG with 100 million pixels pushed the summed process peaks to 1289 MiB in Chromium, 1550 MiB in Firefox and 3525 MiB in WebKit. Those peaks are per-process maximums added together and may not happen at the same moment, so treat them as an upper-bound feel rather than exact usage. Still, a file size in megabytes tells you almost nothing about the memory it needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  So was the blank preview a memory problem?
&lt;/h2&gt;

&lt;p&gt;In my tests, no. I kept creating 4096×4096 canvases and keeping references to all of them. Before my script hit its own safety cap and stopped, the browsers had grown by 6826–7119 MiB with solid fills and by 3690–4041 MiB with noise, depending on the engine. No canvas went blank or lost its context along the way, and nothing crashed. I stopped there on purpose, so I can't tell you where the real limit is.&lt;/p&gt;

&lt;p&gt;The blank preview came from somewhere else: the canvas size limit. In Chromium 149 and the WebKit 26.5 build, 16384×16384 draws fine but 16384×16385 silently draws nothing, and &lt;code&gt;toBlob&lt;/code&gt; hands you &lt;code&gt;null&lt;/code&gt;. My test image was bigger than that, and I never checked the callback value.&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 small image drawn onto two canvases in Chromium 149. The 4096 by 4096 one shows it and exports 16.1 MB, the 16384 by 16385 one stays empty and exports 4 bytes" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a small repro page I wrote, running in Chromium 149. I picked one ordinary image and drew it onto two canvases at once. On the left, the 4096×4096 canvas shows the picture, and Export PNG gives a 16.1 MB &lt;code&gt;export.png&lt;/code&gt;. On the right, the only change is one extra row: 16384×16385. Inside the red box it is all checkerboard, so not a single pixel landed. The line under it says the exported file is 4 bytes. That is the &lt;code&gt;null&lt;/code&gt; from the &lt;code&gt;toBlob&lt;/code&gt; callback, wrapped into a file without a check. The page showed no error at any point.&lt;/p&gt;

&lt;p&gt;If you want to see the memory side yourself, open Activity Monitor or Task Manager next to a test page, create one 8192×8192 canvas, then fill it, then call &lt;code&gt;getImageData&lt;/code&gt; on all of it. Watch the number jump twice, and check &lt;code&gt;toBlob&lt;/code&gt; for &lt;code&gt;null&lt;/code&gt; before you blame memory.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The white rim on my cutout was the old background, not a bad mask</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sat, 03 Oct 2026 08:07:04 +0000</pubDate>
      <link>https://dev.to/token111/the-white-rim-on-my-cutout-was-the-old-background-not-a-bad-mask-29g8</link>
      <guid>https://dev.to/token111/the-white-rim-on-my-cutout-was-the-old-background-not-a-bad-mask-29g8</guid>
      <description>&lt;p&gt;I was putting a few cutout photos onto dark cards for a side project, and every one of them came with a thin grey-white rim. My first guess was a bad mask, and I was ready to try a heavier model. Before doing that I loaded the exported PNG into NumPy and looked at what the edge pixels actually store. The outline was fine. The rim is the old white background, still sitting inside the colour of the semi-transparent pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the semi-transparent ring
&lt;/h2&gt;

&lt;p&gt;The test images are two white-background photos I found on Wikimedia Commons: a puppy (George Hodan, CC0) and a plant cutting in a glass bottle (Biusch, CC BY-SA 3.0). For the cutouts I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in its fast AI mode, ISNet INT8, running in the browser (I used the Chinese UI, so the labels quoted below are my translations); I watched the Network panel during export and there were zero non-GET requests. Its refine panel says the default uses the AI output pixel by pixel without changing transparency or edge colour, so whatever I read from the file is the raw model output. This is roughly what I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;puppy_cutout.png&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
&lt;span class="n"&gt;ring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&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;img&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="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;luma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.2126&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0722&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="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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;        &lt;span class="c1"&gt;# 113438, 160.1
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="n"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;              &lt;span class="c1"&gt;# on black: 75.4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The puppy has 113,438 pixels with alpha between 1 and 254. Their own RGB averages 160.1 in Rec.709 luma. For comparison I took the opaque pixels within 3 px inside the edge, which average 108.6. So the ring is about 50 luma brighter than the fur right next to it, and 39.9% of ring pixels are above 200. The plant is worse: 200.8 in the ring against 123.6 inside, with 57.8% above 200. The model assigned a "half subject, half background" alpha but kept the original pixel colour, which already had white mixed in.&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 edge: original, default export on black, colour decontamination on black" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This crop is the puppy's front leg and belly at 3x. Left is the original on white. In the middle, the default export on black, the lower edge of the leg brightens for a moment before going dark. On the right, with "remove background colour fringe" turned on, the grey rim is gone but the leg–belly boundary has picked up a jagged step. A WebP export had exactly the same semi-transparent pixel count and an average colour within 0.2, so the format is not the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The error does not depend on the background
&lt;/h2&gt;

&lt;p&gt;The straight-alpha formula is &lt;code&gt;rgb * a + bg * (1 - a)&lt;/code&gt;. On black the puppy ring comes out at 75.4, and on a dark navy #1A2A4A at 92.1. If the ring pixels held the subject's own colour (estimated from that inner band), they would be 64.3 and 81.0. That is about 11 too bright on both. The plant showed the same gap of about 11 on both backgrounds, and I first assumed that was a coincidence. It is not. Subtract the ideal composite from the actual one and the &lt;code&gt;bg * (1 - a)&lt;/code&gt; term cancels, leaving &lt;code&gt;a * (F_stored - F_subject)&lt;/code&gt;. The error lives in the pixel, not in the backdrop. The "ideal" value is my approximation from nearby opaque pixels, so I only use it for relative comparisons.&lt;/p&gt;

&lt;p&gt;So why does the same picture look fine on white? I did not composite onto white and measure it, so this part is reasoning from the formula. The extra light in the ring is white, and the background around it is white too, so it reads as a soft transition. On a dark background the backdrop term is close to zero, everything around the ring is dark, and that leftover white becomes the highest-contrast thing on screen. The rim was always there; the dark card just exposed it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9uypgjba9yn6cit55gj7.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%2F9uypgjba9yn6cit55gj7.png" alt="Glass bottle edge: original, default export on black, colour decontamination on black" width="800" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The glass bottle is the extreme case. The glass is itself semi-transparent and the white backdrop shows through it, so the default export leaves a grey-white halo about 20 px wide. With the fringe option on, the halo mostly disappears but the bottle outline turns into visible steps. Original by Biusch on Wikimedia Commons, CC BY-SA 3.0; the image above is a composite made from the cutout and is shared under CC BY-SA 3.0 as well.&lt;/p&gt;

&lt;p&gt;That option fixes the rim by rewriting alpha, not colour. On the plant it changed alpha on 1,369,864 pixels while no opaque pixel shifted colour by more than 30. A leaf with pale patches lost 41,644 pixels to full transparency and showed black holes on the dark background. I have not found a setting that keeps both the clean rim and the pale leaf, so for now I treat it as a trade-off. What I do now: composite the export on black and zoom in, compare the ring's average brightness with the pixels just inside it, and only turn on fringe removal after checking whether the subject has areas close to the old background colour.&lt;/p&gt;

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