<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hao jia</title>
    <description>The latest articles on DEV Community by hao jia (@iterandum).</description>
    <link>https://dev.to/iterandum</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4091913%2Fb40a7d0f-5ae5-45a5-99ab-68ae53a2cdb0.png</url>
      <title>DEV Community: hao jia</title>
      <link>https://dev.to/iterandum</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iterandum"/>
    <language>en</language>
    <item>
      <title>Three ffmpeg one-liners that broke a portrait video, and the check that caught each one</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Tue, 13 Oct 2026 08:05:21 +0000</pubDate>
      <link>https://dev.to/iterandum/three-ffmpeg-one-liners-that-broke-a-portrait-video-and-the-check-that-caught-each-one-541g</link>
      <guid>https://dev.to/iterandum/three-ffmpeg-one-liners-that-broke-a-portrait-video-and-the-check-that-caught-each-one-541g</guid>
      <description>&lt;p&gt;A portrait clip plays upright in a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; element. Then it goes through a transcode job, and what comes out is either a wide, flattened picture or a clip lying on its side. I rebuilt that failure this week with three ffmpeg one-liners. Each of them looks like a reasonable thing to paste into a job definition, each was wrong in a different way, and none of them printed an error.&lt;/p&gt;

&lt;p&gt;First, the test file. Portrait video is often stored with the pixels lying sideways, plus a rotation tag: a note in the container that tells the player to turn the picture at playback. My test file was built that way on purpose, and it is not a phone original. I took a public wind turbine clip, drew a red block in the top-left corner and a green one in the top-right so the orientation is readable at a glance, then used ffmpeg to store the pixels sideways at 1920×1080 with a rotation tag. The result is five seconds of H.264 with no audio. ffmpeg 7.1 prints the tag as &lt;code&gt;rotation of -90.00 degrees&lt;/code&gt;, and the sign surprised me, because this is the file that needs a clockwise quarter turn to look right. Chromium 149, Chrome 154, Firefox 151 and WebKit 26.5 (macOS) all played it upright and reported &lt;code&gt;videoWidth × videoHeight&lt;/code&gt; as 1080×1920. Playback was not where it broke.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.mp4 &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="nv"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1280:720 &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-crf&lt;/span&gt; 26 &lt;span class="nt"&gt;-an&lt;/span&gt; a.mp4
ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt; copy &lt;span class="nt"&gt;-metadata&lt;/span&gt;:s:v &lt;span class="nv"&gt;rotate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 b.mp4
ffmpeg &lt;span class="nt"&gt;-display_rotation&lt;/span&gt; 0 &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.mp4 &lt;span class="nt"&gt;-c&lt;/span&gt; copy c.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is the usual "make it 720p". The output was 1280×720 with no tag, and the picture was upright but squashed, a tall frame pressed into a wide one. What I had missed is that ffmpeg applies the rotation before my filter runs. The same input re-encoded with no filter at all comes out as 1080×1920 with no tag, so the scale filter was handed an upright portrait frame and told to become 1280×720. Changing the filter to &lt;code&gt;scale=-2:720&lt;/code&gt; gave 406×720 and a normal 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="Left: the 1280×720 output of the fixed-size scale, squashed. Right: the 406×720 output of scale=-2:720. Both playing in Chrome 154." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the red and green blocks on the left. They were squares in the source, and here they are flat bars, which is the quickest way to tell a squashed frame from a cropped one.&lt;/p&gt;

&lt;p&gt;The second line was my attempt to clear the tag without re-encoding. It changed nothing: &lt;code&gt;b.mp4&lt;/code&gt; was still 1920×1080 and still reported -90.00. This one is harmless and misleading at once, because the job goes green and I walk away believing the tag is gone. I only ran ffmpeg 7.1, so I can't say what other versions do with that metadata key.&lt;/p&gt;

&lt;p&gt;The third line does remove the tag, and it was the worst of the three. &lt;code&gt;c.mp4&lt;/code&gt; is 1920×1080 with no rotation line. Stream copy never touches pixels, though, so they are still lying sideways, and now nothing tells the player to turn them. All four browsers played it on its side and reported 1920×1080. A metadata check would pass this file, and every browser I tried showed it sideways.&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%2Ftqhvt9m0sb74uakpgmnx.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%2Ftqhvt9m0sb74uakpgmnx.png" alt="Left: the tagged source playing upright at 1080×1920. Right: the same file after -display_rotation 0 with stream copy, playing sideways at 1920×1080. Both in Chrome 154." width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The red block has moved from the top-left to the bottom-left. The top of the picture now points left, which is exactly how the pixels were stored all along.&lt;/p&gt;

&lt;p&gt;All three got past me the same way: I judged them by exit code. Now I read the output file back after every command, with two lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; out.mp4
ffmpeg &lt;span class="nt"&gt;-noautorotate&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; out.mp4 &lt;span class="nt"&gt;-frames&lt;/span&gt;:v 1 stored.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first prints the stream size and, when a tag exists, the rotation line. For a tagged file that size is the storage size, 1920×1080 here, not what a viewer sees. The second writes one frame without applying the tag, so I get the stored pixels as they are. I keep both because dimensions alone miss cases. My 180-degree sample is stored at 1080×1920 upside down, and its size looks perfectly normal. This machine has no ffprobe, so I haven't compared its output.&lt;/p&gt;

&lt;p&gt;To check a route that doesn't involve my own flags, I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in Chromium 149 with the preset that favors smaller files, and the three tagged samples, at 90, 270 and 180 degrees, each came back as 608×1080, stored upright, with no rotation tag. That is one browser, one preset and five-second clips, so I read it as a result for these files only.&lt;/p&gt;

&lt;p&gt;There are gaps. The tags were written by ffmpeg rather than by a phone, and I did not test phone originals, HEVC, iPhone Safari or Android. Within those limits, the command that came out right for a tagged portrait source was the plain re-encode with no rotation flags. When I need a smaller size, I fix one side and let ffmpeg compute the other. I look at both read-back lines before I call a job done. The turbine footage is by Grendelkhan on 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%2Fsbhgr5tny0ilw8udxbyt.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%2Fsbhgr5tny0ilw8udxbyt.png" alt=" " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Mapping Word's srcRect crop to CSS and why center scaling drifts</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:03:19 +0000</pubDate>
      <link>https://dev.to/iterandum/mapping-words-srcrect-crop-to-css-and-why-center-scaling-drifts-42fk</link>
      <guid>https://dev.to/iterandum/mapping-words-srcrect-crop-to-css-and-why-center-scaling-drifts-42fk</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%2F3t2w4sjtpmr4a6ljeatt.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%2F3t2w4sjtpmr4a6ljeatt.png" alt=" " width="800" height="298"&gt;&lt;/a&gt;&lt;br&gt;
Our internal doc portal turns uploaded DOCX files into HTML, and I was checking what happens to cropped pictures before we let anyone use it. One test picture is a screenshot of an order admin page with the shipping block cropped off the bottom. After conversion with docx-preview 0.4.1 the visible area was correct, but the whole picture sat about 70 px too high and covered the heading above it, so only "Scree" was left. The bigger problem showed up when I looked at what the HTML actually carried. A note on the sample first: the DOCX is one I wrote by hand following the OOXML structure, with seven picture cases, so it is not a file Word saved. The admin screenshot comes from a demo page I built myself, and every name, phone number and address on it is a placeholder.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three values describe a cropped picture
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;word/document.xml&lt;/code&gt; a picture carries three things. &lt;code&gt;wp:extent&lt;/code&gt; is the size of the frame on the page. &lt;code&gt;a:srcRect&lt;/code&gt; holds &lt;code&gt;l&lt;/code&gt;, &lt;code&gt;t&lt;/code&gt;, &lt;code&gt;r&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt;, each in thousandths of a percent, so &lt;code&gt;b="35484"&lt;/code&gt; means 35.484 % trimmed from the bottom. &lt;code&gt;a:xfrm rot&lt;/code&gt; is the rotation in 1/60000 of a degree. The file in &lt;code&gt;word/media/&lt;/code&gt; stays whole. In my sample that is by construction, so it proves nothing about Word itself. Microsoft's support page on reducing picture file size does describe a "Delete cropped areas of pictures" option in Word for Mac, which suggests the trimmed pixels stay unless you tick it. I have no Word on this machine and have not tried it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Overflow plus negative margins works in all three engines
&lt;/h2&gt;

&lt;p&gt;The mapping I trust is a fixed box at extent size with &lt;code&gt;overflow: hidden&lt;/code&gt;, holding the full image scaled up and pulled left and up:&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;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100000&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;imgW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;boxW&lt;/span&gt; &lt;span class="o"&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="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;imgH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;boxH&lt;/span&gt; &lt;span class="o"&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="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imgStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imgW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imgH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;imgW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;imgH&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I rendered the three cropped cases this way and compared each against a reference made by really cropping and resizing the image. Normalized correlation came out 0.967 / 0.998 / 0.958 in a Chromium 149 open-source build, 0.962 / 0.999 / 0.961 in Firefox 151 and 0.956 / 0.994 / 0.961 in WebKit 26.5, which is the engine build and not a Safari release. The shorter route, &lt;code&gt;object-view-box: inset(...)&lt;/code&gt;, only worked in Chromium. Firefox and WebKit returned false from &lt;code&gt;CSS.supports&lt;/code&gt; and showed the whole picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling from the center pushes the picture off its frame
&lt;/h2&gt;

&lt;p&gt;The converters I looked at do it differently. Judging from the exported markup, the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; is sized to extent, &lt;code&gt;clip-path&lt;/code&gt; keeps the visible rectangle, and &lt;code&gt;transform: scale()&lt;/code&gt; blows it back up. Since &lt;code&gt;transform-origin&lt;/code&gt; defaults to the center, the visible part lands off target whenever left and right, or top and bottom, are trimmed by different amounts. The helper takes the four trims as fractions, so 35.484 % is 0.35484:&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;drift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;L&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&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="nx"&gt;L&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="na"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&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="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// B -&amp;gt; dx 57.6, dy -19.2   D -&amp;gt; dx 0, dy -70.4   G (15 % each side) -&amp;gt; 0, 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured offsets matched: (58, −19) for case B, −70.4 px for case D. For the converter side I also used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) for DOCX to HTML, with the same seven-case sample DOCX in the Chromium 149 open-source build. It got the crop content and the scaling right and made 0 non-GET requests during conversion. It showed the same drift as docx-preview, and its interface gave no warning about crops or rotation. Cropping only the bottom, probably the most common way to trim a screenshot, is exactly the case that slides up over the text above 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/..." 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="Red frame is the Word frame. Left: unequal trims shift the picture right and up. Right: 15 % off every side fits exactly" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The photo in this figure is from Wikimedia Commons by Dietmar Rabich, CC BY-SA 4.0, and the figure is shared under the same license. On the left the picture spills past the red frame on the right and top edges. On the right, with all four sides trimmed equally, it sits flush.&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%2F4onz0d0z91sg9ajrulzh.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%2F4onz0d0z91sg9ajrulzh.png" alt="docx-preview output for case D: the cropped screenshot moved up and covers the heading" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS only hides the cropped part
&lt;/h2&gt;

&lt;p&gt;None of this removes anything. The ImgIng export was 9,665,338 bytes, and all seven data URIs were the full originals, byte-identical to the media in the DOCX. Decoding the case D PNG gave 1800×1240 with the shipping details, phone number, address and payment ID all there. The mammoth output carried the same full images and simply displayed them. I once spent three months with legal reviewing what left our network, so this part stopped me more than the drift did.&lt;/p&gt;

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

&lt;p&gt;What fixed both problems was cropping the media to match &lt;code&gt;srcRect&lt;/code&gt; and then clearing &lt;code&gt;srcRect&lt;/code&gt;, re-encoding JPEG at q92 and keeping PNG lossless. The DOCX went from 7,234,550 to 5,714,763 bytes, 21.0 % smaller. The ImgIng export dropped from 9.22 MB to 7.28 MB, drift for B and D went to (0, 0) and (0, −0.4), and the trimmed region could no longer be found on the page. My script does not handle one media file referenced with different crops, negative &lt;code&gt;srcRect&lt;/code&gt; or floating images. Word, WPS, LibreOffice and pandoc are untested, and I only ran the ImgIng export in Chromium 149. In our pipeline the check now runs before conversion: grep &lt;code&gt;document.xml&lt;/code&gt; for a non-zero &lt;code&gt;srcRect&lt;/code&gt;, bake those pictures, then compare one cropped picture's position against its frame.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The preview used my font, but the PDF's embedded font list didn't</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sun, 11 Oct 2026 08:03:04 +0000</pubDate>
      <link>https://dev.to/iterandum/the-preview-used-my-font-but-the-pdfs-embedded-font-list-didnt-32pn</link>
      <guid>https://dev.to/iterandum/the-preview-used-my-font-but-the-pdfs-embedded-font-list-didnt-32pn</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%2Fukuh9vydbik82gucp2p8.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%2Fukuh9vydbik82gucp2p8.png" alt=" " width="800" height="202"&gt;&lt;/a&gt;&lt;br&gt;
The admin console my team owns exports statements as PDF. I've sat through a compliance review that went over every data flow we had, so I don't sign off on an export pipeline because a screenshot looks right. So when we started shipping fonts with the page, I wanted proof that the PDF actually used them. I wrote a placeholder statement page myself, with a made-up company and invented amounts. The body is set in Noto Sans SC and the title in ZCOOL KuaiLe, a rounded display font that's easy to spot. Both are OFL fonts that were already on my machine. Then I stopped judging the PDF by eye and read its font table.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three font sources gave three font tables
&lt;/h2&gt;

&lt;p&gt;I printed the same page with &lt;code&gt;page.pdf()&lt;/code&gt; in an open-source Chromium 149 build on an M4 Mac, once per font source. With the system font, the PDF held 39 Type3 fonts and weighed 154,603 bytes, and the PingFang names only showed up inside the font descriptors. With the Google Fonts version, printed after &lt;code&gt;document.fonts.ready&lt;/code&gt;, the page fetched 16 woff2 slices (412,984 bytes) and the PDF held 15 Type0 subsets, roughly one per slice, at 117,799 bytes. With both full TTFs shipped next to the page, Chromium loaded 13,880,960 bytes of font and wrote a PDF of 71,102 bytes.&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="Font bytes loaded by the page vs final PDF size for the three font sources, local Chromium 149 print" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Blue is what the page loaded and orange is the PDF. Look at the bottom pair. The biggest font input produced the smallest file, because Chromium subsets fonts when it prints. The six-letter prefix in &lt;code&gt;AAAAAA+ZCOOLKuaiLe-Regular&lt;/code&gt; marks a subset, and the KuaiLe program inside was 48,088 bytes, down from 3,274,128. Two things almost fooled me here. Type3 fonts carry no usable BaseFont, so the real name has to come from FontName in the FontDescriptor. My first script skipped that and nearly told me the server PDF had no font names at all. And the Google Fonts Noto is a variable font, listed as &lt;code&gt;NotoSansSCThin&lt;/code&gt;, even though the page renders at regular weight.&lt;/p&gt;
&lt;h2&gt;
  
  
  The server kept the subsets but not my full fonts
&lt;/h2&gt;

&lt;p&gt;The setup I actually worry about is a generator I don't control. For that 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 imported once as a single file and once as a whole folder. Import and preview run in the browser. On convert, it sends one POST with a script-free snapshot to a server-side Chromium 151 on Linux. I counted 0 non-GET requests before the click and exactly 1 after. That request body is the whole snapshot, so every font you inline travels with it. Worth knowing if someone reviews what leaves your network. The folder import inlined both TTFs as data URLs, and the snapshot came to 17.65 MB. The preview title was in KuaiLe. The PDF title was Noto Sans CJK SC, and the quality report said it had waited for &lt;code&gt;document.fonts&lt;/code&gt; and the page images, with no font warning.&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%2Fikl3e79hnehgv94qfmmh.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%2Fikl3e79hnehgv94qfmmh.png" alt="Title line from four server PDFs: full ZCOOL KuaiLe, full Smiley Sans, a KuaiLe subset, and the same subset padded to full size" width="800" height="208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The top two rows had the full KuaiLe (3.27 MB) and the full Smiley Sans (2.64 MB), and both came out in the server's Noto Sans CJK SC. The bottom two had the 48 KB KuaiLe subset, and that same subset padded with a dummy table to the size of the full file. Both rendered in KuaiLe. The padded one is why I don't think upload size is the cause. A full 2.05 MB pixel font also worked. I can't see the reason from outside. The upload cost is plain, though. The two full-font runs sent about 18.5 MB each for fonts that never showed up, while a two-font subset sent 501,152 bytes and both fonts made it into a 44,084-byte PDF.&lt;/p&gt;
&lt;h2&gt;
  
  
  My export check reads the table first
&lt;/h2&gt;

&lt;p&gt;This check now runs on every PDF we generate. It lists every font per type, pulls the name from the descriptor for Type3, and reports which CSS families never got embedded.&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;PREFIX&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;compile&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;^[A-Z]{6}\+&lt;/span&gt;&lt;span class="sh"&gt;"&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;font_inventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inventory&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="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;),&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;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ftype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;basefont&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;page&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;basefont&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ftype&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="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Type3 keeps its real name in the descriptor
&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;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xref_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="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;xref&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;pdf&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;ref&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="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="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;size&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;pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_font&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PREFIX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&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;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;biggest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ftype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;ftype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;biggest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;size&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;inventory&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;families_not_embedded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&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;ftype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fam&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;font_inventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_path&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;ftype&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="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&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="sh"&gt;""&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;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;full-fonts.pdf
  Type3 NotoColorEmoji           x2         0 B
  Type3 NotoSansCJKsc-Bold       x4         0 B
  Type3 NotoSansCJKsc-Regular    x41        0 B
  not embedded: ['ZCOOL KuaiLe', 'Noto Sans SC']
subsets.pdf
  Type0 NotoSansSC-Regular       x1    326272 B
  Type0 ZCOOLKuaiLe-Regular      x1     48088 B
  not embedded: []
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code notes: &lt;code&gt;full-fonts.pdf&lt;/code&gt; is the folder-import server PDF and &lt;code&gt;subsets.pdf&lt;/code&gt; the two-subset one, and I cut the Type3 lines from the second listing. Server system fonts come back as Type3, so I only count Type0 fonts as embedded web fonts. The table can't catch everything, though. On the server, 𪚥 (U+2A6A5) was drawn as a crossed box, but text extraction still returns 𪚥. So the check also renders page one to PNG for a person to look at.&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%2Fc8dy79d06fv73fgovhxs.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%2Fc8dy79d06fv73fgovhxs.png" alt="The rare-character line printed locally on top, and from the server PDF below" width="800" height="589"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the bottom row, 𠀀 and 𪚥 are crossed boxes and the other five rare characters are fine. I haven't tested a Linux box with no CJK fonts at all, or WOFF2 fonts shipped with the page. Before you trust an HTML-to-PDF export, compare its embedded font names with your &lt;code&gt;@font-face&lt;/code&gt; families, and subset your fonts to the characters on the page before you hand them to a generator you don't run.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Measure ink and lighting before you binarize an image for OCR</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sat, 10 Oct 2026 08:03:34 +0000</pubDate>
      <link>https://dev.to/iterandum/measure-ink-and-lighting-before-you-binarize-an-image-for-ocr-34oa</link>
      <guid>https://dev.to/iterandum/measure-ink-and-lighting-before-you-binarize-an-image-for-ocr-34oa</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%2Fh8slo55hu7f4o5ury56h.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%2Fh8slo55hu7f4o5ury56h.png" alt=" " width="799" height="192"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My team is looking at client-side OCR for an internal tool where people attach screenshots and photos of printed notices. The first preprocessing snippet anyone pastes into that kind of pipeline is grayscale plus a fixed threshold at 128. It's one line of OpenCV, so before it got near our upload flow I spent an evening measuring what it does. It fails in two quiet ways. It deletes text lighter than the cutoff, and under uneven light a global threshold paints whole regions black. Neither one throws an error, the OCR call just returns fewer characters.&lt;/p&gt;

&lt;p&gt;The samples were a web page I wrote myself (a made-up Chinese notice), screenshotted at 1x and 2x and cut into blocks, including an 11 px footer in #999 gray on #f5f5f5, plus two crops from a holiday-notice template I found online. For recognition I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) with its default Professional OCR tier, plus Fast OCR and Ultimate OCR on the same ten crops, on an Apple M4 Mac in an open-source Chromium 149 build. The control was tesseract.js 5 (default parameters, chi_sim+eng). Errors are character error rate. Recognition ran in a Worker on the machine with zero non-GET requests. After three months of legal going through our data flows during a compliance cleanup, that is the first thing I check. The first run downloads the model, a download and not an upload of anyone's screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gray text was lighter than the cutoff
&lt;/h2&gt;

&lt;p&gt;The footer's darkest pixel is 153. With the cutoff at 128, or at 100, every pixel of that text lands on the background side and the crop turns blank white. All three tiers at both pixel ratios returned 0 characters, and so did tesseract.js. The progress panel said no reliable text was found and suggested rotating, turning off scan enhancement, or a higher tier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="The 11 px gray footer at 3x: original on top, thresholded at 128 in the middle (all white), thresholded at 200 at the bottom" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Raising the cutoff left only the stroke cores
&lt;/h2&gt;

&lt;p&gt;At 160 the cutoff sits just above those darkest pixels, so only the center of each stroke survives. Professional OCR read the 1x crop as gibberish, 75.5% wrong. At 200 it was 3.1%, with Otsu (which picked 208) 5.1%, and the untouched original 0.0%. Across all ten crops Professional OCR went from 0.1% on originals to 25.8% at 128, while the three tiers on originals were less than one point apart. Colored backgrounds fail too. The template's white words sit on red tags, and at threshold 100 the tags turned largely into black slabs that cut into their white words. Professional OCR scored 15.5%, and the "返岗时间" (back-to-work date) tag vanished.&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%2F4gt5y6qavl3ubknhl9yn.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%2F4gt5y6qavl3ubknhl9yn.png" alt="Template crop found online: red tags with white words on top, thresholded at 100 below, where the tags go black and cut into the label text" width="800" height="189"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Uneven light breaks any global threshold
&lt;/h2&gt;

&lt;p&gt;I don't have real phone photos yet, so I generated two fake ones with a script: an A4 notice warped about 2.5 degrees, lit brighter top-left than bottom-right, blurred and noised, and one with an extra shadow over the text. These show a mechanism on program-generated samples, not real-photo error rates. On the shadowed one Otsu picked 137 and the shadow went solid black. Professional OCR scored 31.0%, and the order-independent error was also 31.0%, so those characters were really gone. Local adaptive thresholding scored 19.0% with an order-independent error of 0.0%. Every character was there, and the rest came from the tilt reordering short lines. Straightening the page from its corners got all three tiers to 0.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%2Fdw37pocmj15gfiu51gb4.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%2Fdw37pocmj15gfiu51gb4.png" alt="Program-generated simulated photo: left the original with a shadow, middle Otsu with the shadow area solid black, right adaptive threshold with the text intact" width="800" height="674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check before any threshold
&lt;/h2&gt;

&lt;p&gt;So before a threshold I measure two things: how dark the ink really is, and how much paper brightness drifts across the image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cv&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;threshold_audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IMREAD_GRAYSCALE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;
    &lt;span class="n"&gt;core&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&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;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;trim&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;h&lt;/span&gt; &lt;span class="o"&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="n"&gt;trim&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;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;trim&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;w&lt;/span&gt; &lt;span class="o"&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="n"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
    &lt;span class="n"&gt;paper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dilate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;core&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;ones&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;41&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="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# local "paper brightness"
&lt;/span&gt;    &lt;span class="n"&gt;otsu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&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;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_BINARY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THRESH_OTSU&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ink&lt;/span&gt;&lt;span class="sh"&gt;"&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;core&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paper_low&lt;/span&gt;&lt;span class="sh"&gt;"&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otsu&lt;/span&gt;&lt;span class="sh"&gt;"&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;otsu&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;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ink&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixed &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; erases the text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paper_low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;otsu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;global threshold blackens paper, go adaptive or skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trim drops the desk border around the generated photos. On the gray footer it reports ink 153 and flags 128. On the shadowed sample the darkest paper is 100 against an Otsu of 137, so it flags the global threshold, while the unshadowed one reads 159 against 135 and passes. Two cases get through. A cutoff of 160 sits above the ink, so the stroke-core failure isn't flagged, and the red tags pass because the dilate picks up their white words. I haven't found a cheap check for that one yet.&lt;/p&gt;

&lt;p&gt;ImgIng's own "Scan enhancement" toggle is described as "Gentle grayscale, contrast and sharpening; no forced binarization." It's off by default, and turning it on gave 0.2% against 0.1%, so I'd call it neutral. Not binarizing lines up with everything above. Our default will be to send the original and only fix geometry. Before you ship a threshold, print the darkest pixel of your lightest text and the spread of your paper brightness.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>256-colour PNGs can keep soft shadows. tRNS shows whether yours did</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:08:03 +0000</pubDate>
      <link>https://dev.to/iterandum/256-colour-pngs-can-keep-soft-shadows-trns-shows-whether-yours-did-3j5o</link>
      <guid>https://dev.to/iterandum/256-colour-pngs-can-keep-soft-shadows-trns-shows-whether-yours-did-3j5o</guid>
      <description>&lt;p&gt;Before letting 256-colour PNGs into an image pipeline, I wanted to understand one failure. A sticker with a soft drop shadow goes through colour reduction, and on a white page the shadow comes back as a solid black band with a knife-cut edge. My test file is a pink ribbon sticker from Wikimedia Commons (by K B, CC BY 2.0), a third-party image, and the figures only show its top loop. 17.9% of its pixels are semi-transparent. 256 colours were not the problem. An indexed PNG can store soft alpha fine, and the result depends on how many distinct alpha values the encoder writes into one small chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Colour type 3 keeps alpha in its own chunk
&lt;/h2&gt;

&lt;p&gt;The original is colour type 6, four bytes per pixel with alpha stored next to RGB. A 256-colour PNG is colour type 3. Each pixel is an index into PLTE, which holds up to 256 RGB entries of three bytes. Transparency lives in the optional tRNS chunk, one alpha byte per palette entry in the same order, and entries past its end count as opaque. How many distinct values those bytes hold is up to the encoder. I wrote a small reader in Node with no image library:&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;function&lt;/span&gt; &lt;span class="nf"&gt;paletteAlpha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;view&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;DataView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteOffset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&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;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;colourType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;paletteSize&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="na"&gt;alphaBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUint32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&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;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IHDR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;colourType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PLTE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paletteSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tRNS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alphaBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paletteSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;alpha&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="mi"&gt;255&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;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;belowOpaque&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;widestGap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;report&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;It walks the chunks, pads the alpha list to palette size per the spec, and reports entries below 255, distinct alpha values and the widest gap between neighbouring values. Count distinct values, not entries. My 98-level file has 129 entries below 255 but only 98 distinct values, because several colours share one alpha. Output for six versions of the sticker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ribbon.png               type=6 tRNS=0B below255=0 steps=0 gap=0
ribbon-2.png             type=3 tRNS=256B below255=1 steps=2 gap=255
ribbon-10.png            type=3 tRNS=256B below255=9 steps=10 gap=43
ribbon-34.png            type=3 tRNS=256B below255=33 steps=34 gap=18
ribbon-98.png            type=3 tRNS=256B below255=129 steps=98 gap=12
ribbon-png8-export.png   type=3 tRNS=256B below255=1 steps=2 gap=255
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two-step file is the black band. Its tRNS is a full 256 bytes, yet the only entry below 255 is the fully transparent colour, so every pixel at alpha 128 or above went opaque and the rest vanished. The 10, 34 and 98 level files come from a quick reducer I wrote for comparison, not from pngquant or any product. The last line is the PNG-8 export from ImgIng's image compressor at its defaults, run on 2026-09-30, and it has the same two steps. Its PNG compression page says transparent PNG-8 keeps multi-level alpha. My file did not, and I am reporting bytes, not guessing why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browsers read alpha per palette entry
&lt;/h2&gt;

&lt;p&gt;A format feature only helps if decoders honour it. I decoded the 34-level file in the Chromium 149 open-source build, Firefox 151 and WebKit 26.5 (the engine, not a Safari release) and read the pixels back from a canvas. All three reported 34 alpha levels and matched Pillow's decode on semi-transparent pixel count and alpha sum.&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="Noto emoji outline at 4x on blue: original, two alpha steps, 20 alpha steps" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a Noto Color Emoji outline (Apache 2.0) at 4x on blue. The original has 17 alpha levels and only 1,548 semi-transparent pixels. With two steps the anti-aliased rim becomes a staircase, and with 20 steps it looks like the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fewer alpha steps show up as rings
&lt;/h2&gt;

&lt;p&gt;A shadow is a smooth ramp, and every gap between neighbouring alpha values becomes a visible ring. Error here is the mean RGB difference (0 to 255) from the original over semi-transparent pixels, both composited on white. Two steps gave 44.17, 10 steps 8.23, 34 steps 3.48 and 98 steps 1.75. In the same encoder the 34-level file was 121,346 bytes against 111,178 for two steps, only 9.1% more. At 10 steps, with a widest gap of 43, the shadow shows clear contour lines. At 34 steps (gap 18) they thin out, and at 98 steps (gap 12) the shadow is close to the original.&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%2F5obw2jzoknsp94ucdqrp.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%2F5obw2jzoknsp94ucdqrp.png" alt="Shadow at 4x on white with 10, 34 and 98 alpha steps" width="799" height="262"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Dithering adds grain but no alpha steps
&lt;/h2&gt;

&lt;p&gt;With two steps, dithering never touches alpha. ImgIng's exports with dithering on and off had identical alpha and the same white error (44.07 and 44.06), but the dithered file was 1.59 times larger, 148,242 bytes against 93,317. On my 34-level file, Floyd-Steinberg diffusion over colour and alpha did break the rings into grain. White error still rose from 3.48 to 4.29 and the file grew 65%. Opaque pixels pushed to semi-transparent went from 573 to 1,850, seen as white specks on the pink edge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpu95e3a97tjdf2wuppdq.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%2Fpu95e3a97tjdf2wuppdq.png" alt="Shadow at 4x on white: original, 34 steps without dithering, 34 steps with dithering" width="800" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check before shipping an indexed PNG
&lt;/h2&gt;

&lt;p&gt;I run the reader first. A type 3 file with one tRNS entry below 255 has lost every soft pixel, however fine it looks on a dark page. Then I look on white and on a colour, never only on black, since the two-step ribbon scored 2.33 on black against 44.17 on white. For assets with large soft shadows I skip PNG-8. For the ribbon I used ImgIng (&lt;a href="https://imging.ai" rel="noopener noreferrer"&gt;imging.ai&lt;/a&gt;) to convert to WebP at its default quality 84, and it went from 430.7 KB to 98.8 KB with alpha identical to the original pixel for pixel. The conversion ran in the browser and the export sent no non-GET requests.&lt;/p&gt;

&lt;p&gt;This covers two samples. Coloured shadows, glows, Chrome stable, Safari, display pipelines and chat apps that recompress images are untested. Before switching a batch of PNGs to 256 colours, run a tRNS count over the exports and reject any file whose source had soft pixels but whose output has just two alpha steps.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Detecting open passwords and permission locks in PDFs with pdf.js</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:03:03 +0000</pubDate>
      <link>https://dev.to/iterandum/detecting-open-passwords-and-permission-locks-in-pdfs-with-pdfjs-56id</link>
      <guid>https://dev.to/iterandum/detecting-open-passwords-and-permission-locks-in-pdfs-with-pdfjs-56id</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%2F1iijn3vqf0q3uesced5t.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%2F1iijn3vqf0q3uesced5t.png" alt=" " width="800" height="895"&gt;&lt;/a&gt;&lt;br&gt;
A PDF that opens without asking for anything can still be encrypted. I ran into this while thinking about upload validation for document tools, so I generated seven two-page test PDFs filled with placeholder contract text and checked them in the browser. Five of them had only a permissions password. Chromium 149's open-source build and Firefox 151 displayed all five straight away, with no prompt and no warning. Yet every one of those files carried a full encryption dictionary. The user password was simply empty, so any reader could decrypt the content on its own. Only one sample, the one with a real open password, stopped at a password box. For a frontend that receives these files, that means "encrypted" is not a single state. It covers files nobody can read without a password and files anyone can read but that ask the viewer to block printing, copying, or editing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Each viewer enforces permission limits differently
&lt;/h2&gt;

&lt;p&gt;That second group matters because permission flags only work when the viewer chooses to honor them. On the same all-restricted sample, Chromium's built-in viewer blocked printing and copying but still let me draw on the page. Firefox with default settings blocked none of the three. After I turned on its permission enforcement setting, it blocked printing and editing but still did not block copying. Changing the cipher from AES-256 to RC4-128 or RC4-40 changed nothing in those results. Only the permission bits did. So whatever your app shows the user should come from what the file declares, not from what one browser happened to do with it.&lt;/p&gt;

&lt;p&gt;Processing tools make their own call on top of that. To see what a real processing tool does, I used ImgIng's PDF compressor (&lt;a href="https://imging.ai" rel="noopener noreferrer"&gt;imging.ai&lt;/a&gt; is its international site, and my run was on the Chinese deployment) with the permission-only and open-password PDFs I generated. It failed all four encrypted samples I imported with a message that translates to "this PDF is encrypted or permission-protected and needs the protection removed first", and it offered no password field. The page made 46 requests, all of them GET. It did not say why it refuses, and I would only be guessing at the reason.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three pdf.js calls separate open and permission passwords
&lt;/h2&gt;

&lt;p&gt;I used pdf.js 6.3.289 as a page library. An open password shows up through the &lt;code&gt;onPassword&lt;/code&gt; callback. &lt;code&gt;getMetadata()&lt;/code&gt; exposes &lt;code&gt;info.EncryptFilterName&lt;/code&gt;, which was &lt;code&gt;"Standard"&lt;/code&gt; for every permission-only sample and empty for the plain one. &lt;code&gt;getPermissions()&lt;/code&gt; returns the granted flags, or &lt;code&gt;null&lt;/code&gt; when the file declares none. This is the detection function I ran against all seven files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classifyPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pdfjs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;askedForPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;askedForPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMetadata&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;perms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPermissions&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;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;perms&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;perms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EncryptFilterName&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;permission-only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plain&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;granted&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;askedForPassword&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open-password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I destroy the task inside &lt;code&gt;onPassword&lt;/code&gt; on purpose. The check never asks for a password, and it never holds one. The open-password file then rejects with a &lt;code&gt;PasswordException&lt;/code&gt;, and the flag tells the catch block why. Checking &lt;code&gt;PRINT&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, and &lt;code&gt;MODIFY_CONTENTS&lt;/code&gt; against the granted set gave this, one line per sample:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S0 plain -
S1 permission-only PRINT,COPY,MODIFY_CONTENTS
S3 permission-only COPY
S4 permission-only PRINT
S5 open-password -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;S2 and S6 matched S1. The &lt;code&gt;new Set(perms)&lt;/code&gt; line is there because of a version difference that bit me. Firefox's built-in pdf.js 5.7.195 returns an array from &lt;code&gt;getPermissions()&lt;/code&gt;, while 6.3.289 returns a &lt;code&gt;Set&lt;/code&gt;. Code that calls &lt;code&gt;.includes&lt;/code&gt; works on the old one and throws on the new one. Normalizing first means the same check runs on both.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="A permission check page reading three PDFs at once" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a check page I built with the same three calls, fed English placeholder copies of three samples. The first card is the all-restricted file, where every row is red except accessibility extraction. The second only blocks copying, so that is the one red row. The third card needs an open password, and the page shows nothing for it except that fact. The page only reads the files and never modifies them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Report the state and point to the owner
&lt;/h2&gt;

&lt;p&gt;After detection, the frontend should explain what it found and leave the file alone. I have sat through a compliance review, and quietly stripping limits from a file someone else sent is the kind of thing it asks about. I would show "opens normally, but the author restricted printing and copying" for the middle case and "needs the open password" for the last. Then point people to the proper route. They can ask whoever sent the file for an unrestricted copy, or, if the file is theirs and they know the owner password, re-export it from the software that created it. I did not test Acrobat, macOS Preview, or Safari itself, and the WebKit 26.5 run covered only the pdf.js page library. Before adding a PDF upload step, run your own sample files through &lt;code&gt;classifyPdf&lt;/code&gt; and write the message for each of the three states first.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A completed video seek did not always produce a new frame callback</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:02:48 +0000</pubDate>
      <link>https://dev.to/iterandum/a-completed-video-seek-did-not-always-produce-a-new-frame-callback-126</link>
      <guid>https://dev.to/iterandum/a-completed-video-seek-did-not-always-produce-a-new-frame-callback-126</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%2Fr79609zx08yoeaslubqe.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%2Fr79609zx08yoeaslubqe.png" alt=" " width="799" height="562"&gt;&lt;/a&gt;&lt;br&gt;
A paused video seek finished, but the next video-frame callback did not arrive within my observation window. Treating that as a failed capture would have thrown away a usable result: the target still belonged to the frame already on screen. This happened in a controlled WebKit test, and it made me separate seek completion from waiting for a newly submitted frame. Neither signal could stand in for the other.&lt;/p&gt;

&lt;p&gt;I tested a generated H.264 clip with an independently readable frame number encoded in its pixels. It contained 180 frames at 30 fps, with no B-frames. Three versions used fixed GOP lengths of 15, 60, and 180 frames. The pixel number was important: I could inspect what had actually been drawn instead of reading back the same &lt;code&gt;currentTime&lt;/code&gt; value I had just assigned. This was a test fixture, not footage of a production incident.&lt;/p&gt;

&lt;p&gt;After loading and pausing the clip, I let the initial frame callback finish. I then registered a new callback and a separate &lt;code&gt;seeked&lt;/code&gt; listener before assigning the next position. In the WebKit 26.5 test build, seeking from zero to 0.017 seconds produced &lt;code&gt;seeked&lt;/code&gt; for all three GOP versions. The canvas still contained frame zero. No new frame callback arrived within 1.7 seconds. That interval was my observation limit, not a measurement of decoding work.&lt;/p&gt;

&lt;p&gt;At 30 fps, the requested 0.017 seconds lies within the first frame’s interval. The unchanged pixels were therefore not automatically a stale result. Each version then continued through eleven other targets; WebKit produced a new callback for all of those. The next target, 0.049 seconds, drew frame one. Comparing a same-frame request with a different-frame request explained more than treating every missed callback as the same error.&lt;/p&gt;

&lt;p&gt;A separate sea-footage check showed why I would not fix this by accepting any existing canvas immediately. Here, assigning 5.017 seconds and drawing at once captured a different wave shape from drawing after seeking completed. Both displayed time values could already say 5.017. In the numbered test fixture, 99 of 108 immediate drawings still contained the previously presented frame. The remaining nine were the same-frame requests, where the old pixels also happened to satisfy the target.&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="Immediate drawing and drawing after seek completion show different wave frames" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This independent reproduction uses a six-second, resized copy of public waves footage. It illustrates that a changed time property is not proof of updated pixels. It does not illustrate the 1.7-second callback timeout, which came from the numbered fixture. Keeping those two observations separate is essential: otherwise an example of stale content could be mistaken for proof that every unchanged frame is wrong.&lt;/p&gt;

&lt;p&gt;I also ran a product-side check with &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, using the Chinese video-matting Beta workbench. I imported the waves, moved to 2.117 seconds, waited for seeking, and requested its current-frame preview. It returned a no-obvious-subject message. That confirmed only the observed preview path and result. I did not inspect its internal waiting strategy or test a complete thumbnail export, so the application cannot serve as evidence for a callback fix.&lt;/p&gt;

&lt;p&gt;For an implementation, I would retain the requested position, seek completion, available pixel evidence, and callback outcome as separate results. A timeout should end the wait and preserve those observations; it should not automatically label the canvas black. Deciding whether an existing frame satisfies the request needs a defined tolerance or another trustworthy reference. In this fixture I had an exact frame number. Arbitrary variable-frame-rate inputs need a different validation method, which I have not tested here.&lt;/p&gt;

&lt;p&gt;The waves are 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. WebKit here was a test build, not released Safari. Before adopting a capture helper, I would run both a same-frame target and a different-frame target against it, then check cleanup after a timeout or cancellation. Those last implementation paths remain work to verify; the bounded experiment establishes why a new callback alone is an incomplete completion condition.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Testing MP4 delivery with identical media payloads and 200 or 206 responses</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:02:42 +0000</pubDate>
      <link>https://dev.to/iterandum/testing-mp4-delivery-with-identical-media-payloads-and-200-or-206-responses-891</link>
      <guid>https://dev.to/iterandum/testing-mp4-delivery-with-identical-media-payloads-and-200-or-206-responses-891</guid>
      <description>&lt;p&gt;I wanted a video-delivery test in which a changed picture could not explain a changed loading time. Re-encoding a clip at a lower bitrate would have moved too many things at once. Instead, I kept the encoded media payload identical and changed two other inputs: where the ordinary MP4 stored its movie metadata, and whether the HTTP server honored byte-range requests. That gave the playback results a narrower meaning.&lt;/p&gt;

&lt;p&gt;The inputs were two public waves clips, each prepared as an eight-second H.264 MP4 at 960×540 without audio. The smaller comparison file was 1,433,114 bytes; the larger was 5,068,844 bytes. For each source, I made front-metadata and end-metadata versions by copying the encoded packets during remuxing. Their &lt;code&gt;mdat&lt;/code&gt; payload hashes matched within each pair. The complete files did not need to match, because their container layout was the variable being tested.&lt;/p&gt;

&lt;p&gt;I served the files from a local HTTP process with two modes. One honored a Range request and returned a partial response with status 206. The other ignored the requested range and returned the whole file with status 200. All active responses in a test shared a 256 KiB/s write budget, scheduled in 40 ms steps, and each request began with an 80 ms delay. Giving every simultaneous response its own full budget would have changed the total delivery capacity when a browser opened another request.&lt;/p&gt;

&lt;p&gt;That setup still has limits. It throttles application writes rather than network packets, so socket buffering, scheduling, and decoding affect the observations. The 80 ms initial wait is not a complete model of an 80 ms round trip. I used fresh browser contexts and no-store responses, then repeated each source, engine, layout, and server-mode combination twice. Across three engines, the ordinary MP4 matrix contained 48 runs.&lt;/p&gt;

&lt;p&gt;The smaller end-metadata file made the server-mode difference clear in Chromium. With Range supported, its first-frame times were 0.776 and 0.813 seconds. With Range ignored, they were 5.773 and 5.792 seconds. The media bytes were unchanged. In one supported run, requests moved from byte zero to byte 1,409,024 near the end, then back to byte 32,768. The browser had a way to obtain the late metadata before reading all intervening media.&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 larger end-metadata comparison file is still waiting at an early observation point" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a separate no-Range illustration of the larger file at 1,250 ms, not a screenshot of the smaller file’s measured runs. It comes from a native player in the local reproduction. The larger file’s two Chromium first-frame waits in the main matrix were 20.272 and 20.278 seconds. A screenshot can show the waiting state; it cannot replace the event record used to calculate that interval.&lt;/p&gt;

&lt;p&gt;I also made real exports with &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, operating the Chinese video compression workbench. Those outputs were fragmented MP4s from the complete originals, retaining audio and different durations. I kept them out of this paired ordinary-MP4 comparison. Otherwise a product export, a shortened silent derivative, and a changed container structure would all be competing as if only one variable had changed. Their playback checks are useful, but they answer a different question.&lt;/p&gt;

&lt;p&gt;The illustrated source is דוד שי’s &lt;a href="https://commons.wikimedia.org/wiki/File:Water_waves_in_Herzliya_beach.webm" rel="noopener noreferrer"&gt;Water waves in Herzliya beach&lt;/a&gt;, under CC BY-SA 4.0; the comparison copy was trimmed, resized, stripped of audio, transcoded, and remuxed. The test engines were Chromium 149, Firefox 151, and a WebKit 26.5 test build rather than released Safari. One larger-file WebKit condition failed before producing a frame, so I did not convert every non-success into a slow-loading measurement.&lt;/p&gt;

&lt;p&gt;For a repeatable check, I would keep the file-pair hashes, actual response mode, request ranges, and first-frame event together. Seeking should have its own result and target, rather than being inferred from startup. Only after that local comparison would I test the final hosted URL with its real cache and delivery behavior. These numbers isolate behavior in the test server; they do not certify a CDN configuration or predict public-network startup times.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Keep the original text when a PDF page falls back to OCR</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:03:22 +0000</pubDate>
      <link>https://dev.to/iterandum/keep-the-original-text-when-a-pdf-page-falls-back-to-ocr-4fkg</link>
      <guid>https://dev.to/iterandum/keep-the-original-text-when-a-pdf-page-falls-back-to-ocr-4fkg</guid>
      <description>&lt;p&gt;A PDF extraction failure becomes harder to investigate when the fallback result replaces the first output. You eventually have readable text, but cannot tell which page needed help or where the replacement came from. I tested a small recording approach using controlled PDF samples. The useful change was keeping each result attached to its page and source, including results that looked wrong. This was a local experiment, not a production deployment.&lt;/p&gt;

&lt;p&gt;My main sample was a notice generated with ReportLab. I kept the source wording before producing the PDF, then made separate versions with a missing Unicode mapping and an incorrect mapping. Neither was a document supplied by a customer. Under the same MuPDF rendering conditions, both altered versions looked identical to the original. Their extracted text did not. That gave me a way to examine the extraction result without also changing the visible wording.&lt;/p&gt;

&lt;p&gt;The missing mapping removed all 56 Chinese characters from the extracted output. With pypdf, the result still contained 152 characters, exactly the same total as the normal version. A nonempty string would have passed a basic import check. Counting replacement characters would not have helped either: this output contained no U+FFFD. I kept the actual returned string rather than deleting unusual characters to make the report easier to read.&lt;/p&gt;

&lt;p&gt;Next I combined a normal page and a damaged page into one PDF. This made the problem with a document-level result obvious. The complete extraction contained Chinese text because the first page was fine. The second page still needed investigation. Once both outputs were flattened into one string, that distinction was harder to maintain. Saving separate page records preserved it without requiring an OCR scheduler or a new document-processing service.&lt;/p&gt;

&lt;p&gt;Each record in my experiment carried the file name, page number, raw checks, font observations, and reference comparison. The reference was the original generated wording, not another extractor's output. A page without obvious character anomalies received &lt;code&gt;no_signal&lt;/code&gt;, which deliberately did not mean correct. Pages matching their complete known reference were marked &lt;code&gt;sample_checked&lt;/code&gt;. Pages without a complete reference remained unverified. These names describe evidence, rather than promising that every downstream use is safe.&lt;/p&gt;

&lt;p&gt;The distinction mattered for a second failure. I changed one mapping entry so that two occurrences of a Chinese character were extracted as another valid character. The output still contained 56 Chinese characters and no replacement symbol. PDF.js, pypdf, and PyMuPDF all returned the same wrong substitutions. My basic character checks missed them. Comparing against the independent source text found the mismatch. Agreement between extractors would have hidden it if I had used a majority vote as ground truth.&lt;/p&gt;

&lt;p&gt;I also needed a counterexample to avoid sending every missing mapping to recovery. A simple Helvetica document using WinAnsiEncoding contained an English invoice-style sentence and no ToUnicode mapping. All three extractors recovered that sentence correctly. Rejecting it merely because the mapping was absent would have introduced a false alarm. I therefore recorded missing mappings as an observation, while leaving the text evidence and the final decision separate.&lt;/p&gt;

&lt;p&gt;Across the eight pages I checked, the records ended with three needing review, three checked against their known source, and two unverified. The last two were pages from an existing copy of &lt;a href="https://arxiv.org/abs/2609.11758" rel="noopener noreferrer"&gt;RAG-Safety-Bench&lt;/a&gt;. Selected passages had been inspected, but I did not have a complete manually checked reference for either page. I did not count those pages as failures or passes. The controlled set also reused the notice, so these numbers are not an accuracy benchmark.&lt;/p&gt;

&lt;p&gt;I then tested ImgIng in the browser on its Chinese site, imging.cn. The link to &lt;a href="https://imging.ai" rel="noopener noreferrer"&gt;imging.ai&lt;/a&gt; is its overseas homepage; these trials did not retest that deployment. For the damaged text PDF, enabling and disabling image-text recognition inside PDF content extraction produced identical text. Converting that file to HTML finished but left the Chinese text damaged. Both outcomes belong in the record. A completed operation is useful evidence about execution; it does not tell us whether the resulting characters match the page.&lt;/p&gt;

&lt;p&gt;There was a successful image-recognition path, with an important boundary. The clear PNG came from an external renderer. ImgIng's PDF-to-image attempt on this sample had failed with &lt;code&gt;SVG render failed&lt;/code&gt;, so the PNG was not a successful output of that step. Feeding the existing clear image into the separate image OCR tool recovered the notice's five lines. Spaces inside the Chinese text differed from the original PDF. The interface count of 134 characters was not an accuracy measurement.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Actual image OCR result using an externally rendered page" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshot shows the image OCR result with an English interface. The notice itself remains Chinese because that was the tested input. It documents a different extraction source, not a repair to the original PDF's mapping. If I kept only this readable result, the failed direct extraction and failed conversion attempt would disappear from the explanation.&lt;/p&gt;

&lt;p&gt;For a fallback integration, I would retain the original page record and attach the OCR output as another candidate with its own source and comparison scope. That integration is a proposal; this experiment did not implement retries, queues, or automatic merging. Start by checking whether your current pipeline overwrites the first text result. Preserve it, keep the page number, and compare the replacement against a known passage before allowing the new output to stand in for the original.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Reading a canvas back doubles its memory: numbers from three engines</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sun, 04 Oct 2026 08:02:46 +0000</pubDate>
      <link>https://dev.to/iterandum/reading-a-canvas-back-doubles-its-memory-numbers-from-three-engines-4dmm</link>
      <guid>https://dev.to/iterandum/reading-a-canvas-back-doubles-its-memory-numbers-from-three-engines-4dmm</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%2Fcsdkpc31sj3s1k2kb422.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%2Fcsdkpc31sj3s1k2kb422.png" alt=" " width="800" height="455"&gt;&lt;/a&gt;&lt;br&gt;
Our product-image component crops, watermarks and samples a dominant color from every upload. All three steps call &lt;code&gt;getImageData&lt;/code&gt; on the full canvas. For a long time our memory estimate for a large image was "width × height × 4". That number is right for the canvas itself, and it is exactly half of what the component actually holds once it reads the pixels back.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a full-canvas getImageData really costs
&lt;/h2&gt;

&lt;p&gt;The numbers below come from a round of measurements on an Apple M4 with 16 GB, using three engine builds: the Chromium 149 open-source build, a WebKit 26.5 build and a Firefox 151 build. None of them is the browser you download, so read nothing here as Chrome, Safari or release Firefox behavior. Memory was measured at the OS level by summing &lt;code&gt;phys_footprint&lt;/code&gt; across every browser process of that launch, because nothing inside the page can see canvas memory.&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="Line chart of browser memory increase against canvas pixels for three engines, after fill and after a full read-back" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The x axis is canvas pixels in millions, the y axis is MiB above the idle page. Each engine has a dark line for "after fill" and a light line for "after full getImageData". Look at the 268 tick, which is 16384×16384. Chromium's dark line sits at 1025 MiB and its light line at 2050 MiB, so the second copy is the whole ImageData, 1,073,741,824 bytes. Firefox lands almost on top of it (1025.3 and 2035.6 in the raw data), and its lines keep going to 537 because that build can draw up to 23168×23168. The light orange WebKit line is the outlier: 1206.4 MiB after the fill and 3265.4 MiB after the read-back at the same size. The WebKit build also carries tens of MiB of extra overhead on small canvases, 44.6 MiB for a filled 1024×1024 where the formula says 4 MiB. I quote WebKit figures per size and do not turn them into a ratio.&lt;/p&gt;

&lt;p&gt;Two more details changed how I wrote the estimate. Creating a canvas and calling &lt;code&gt;getContext('2d')&lt;/code&gt; without drawing added less than 1 MiB in all three engines, so the pixel buffer is allocated on first draw, not at creation. And setting &lt;code&gt;width = height = 0&lt;/code&gt; and dropping the reference did not bring the number down within 0.8 s: Chromium still showed +1025 MiB at 16384×16384. No GC was forced, so this is not evidence of a leak, only a reminder that "released" and "returned to the OS" are different moments.&lt;/p&gt;

&lt;p&gt;I re-ran the three steps on one 8192×8192 canvas in Chromium 149 to see them in order. &lt;code&gt;createElement&lt;/code&gt; plus &lt;code&gt;getContext&lt;/code&gt; added +0.3 MiB. The fill brought it to +256.7 MiB, one frame of 256 MiB, and the read-back to +512.9 MiB. Meanwhile &lt;code&gt;JSHeapUsedSize&lt;/code&gt; only moved from 949,212 to 1,115,836 bytes while half a gigabyte is allocated. That is why the numbers above come from the OS and not from the page.&lt;/p&gt;
&lt;h2&gt;
  
  
  The estimate I use now
&lt;/h2&gt;

&lt;p&gt;The component computes a budget before anything is drawn:&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;function&lt;/span&gt; &lt;span class="nf"&gt;frameBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readBack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt; &lt;span class="o"&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;height&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;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;readBack&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;20&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;I ran it in Node for the sizes above. 16384×16384 gives 1024 MiB without read-back and 2048 MiB with it, within 1% of what Chromium and Firefox measured. 8192×8192 gives 256 and 512 MiB. The WebKit build measured 823.3 MiB after read-back at 8192×8192, well above the formula, so our ceiling is set lower than the formula would allow instead of adding a per-engine multiplier I cannot justify.&lt;/p&gt;

&lt;p&gt;I also wanted to know whether image tools that work in the browser keep large files local, since our compliance review cares about that more than about memory. I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), operating the Chinese interface, to convert a 10000×10000 JPEG to WebP and JPG on the same M4 across the same three engine builds. Across 12 cases there were zero non-GET requests. By the formula, that 100-megapixel image needs about 381 MiB just to be drawn once, and about 763 MiB if anything reads it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still don't know
&lt;/h2&gt;

&lt;p&gt;The measurements stopped at safety thresholds on a machine that was also running other work, so I have no crash point to report. GPU-backed canvases were not covered either, and I never repeated any of this in release browsers.&lt;/p&gt;

&lt;p&gt;If your pipeline calls &lt;code&gt;getImageData&lt;/code&gt; on the full canvas, count that second copy in your budget. Then draw your largest allowed size in the browsers you ship to and read one corner pixel back to confirm it actually rendered.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My cutout PNG looked fine in canvas 2D and glowed in WebGL</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Sat, 03 Oct 2026 08:02:50 +0000</pubDate>
      <link>https://dev.to/iterandum/my-cutout-png-looked-fine-in-canvas-2d-and-glowed-in-webgl-5d8d</link>
      <guid>https://dev.to/iterandum/my-cutout-png-looked-fine-in-canvas-2d-and-glowed-in-webgl-5d8d</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%2F80qa53y0cz7tjdiggvn5.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%2F80qa53y0cz7tjdiggvn5.png" alt=" " width="799" height="261"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I took one transparent PNG, drew it onto a black background with canvas 2D, and the edges looked right. I drew the same file with WebGL and a bright white band appeared under the dog's belly. The semi-transparent edge pixels averaged 54.9 in luminance with canvas 2D and 108.9 with WebGL. Same file, same browser. The only difference was two WebGL settings that disagreed with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part of the halo is already in the file
&lt;/h2&gt;

&lt;p&gt;For the cutout I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in its fast mode, ISNet INT8, on two white-background photos from Wikimedia Commons, then composited the exports onto black. The one I use here is a puppy photo by George Hodan, released under CC0. The model runs in the browser and I watched the network panel during export: zero non-GET requests. The refine panel says the default output uses the AI result pixel by pixel without changing alpha or edge colors, so whatever I measured came from the model output and my compositing, nothing in between.&lt;/p&gt;

&lt;p&gt;The default export has 113,438 semi-transparent pixels. Their stored color averages 160.1 in luminance, while the opaque subject within 3 px of the edge averages 108.6. So the soft edge still carries some of the original white background. Composited correctly onto black, the edge averages 75.4 against an estimated 64.3 if it only held subject color. That gap of about 11 is baked into the file. Exporting WebP instead of PNG gave identical edge pixels, so the format is not the cause. Your compositing code cannot remove this part. What it can do is double it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Straight alpha needs exactly one multiply
&lt;/h2&gt;

&lt;p&gt;PNG stores straight alpha: color as is, alpha in its own channel. Compositing has to multiply color by alpha exactly once. Premultiplied data has already been multiplied, so it must not be multiplied again. Canvas 2D handles this for you. WebGL splits the decision across two independent settings: &lt;code&gt;UNPACK_PREMULTIPLY_ALPHA_WEBGL&lt;/code&gt; at texture upload, and the source factor you pass to &lt;code&gt;blendFunc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I cropped a 480×480 px edge region with 43,740 semi-transparent pixels and ran all four combinations in Chromium 149, reading pixels back after drawing onto black. The correct answer for that crop is 54.9.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Canvas 2D: 54.9, max difference from the formula 0.&lt;/li&gt;
&lt;li&gt;Straight upload + &lt;code&gt;SRC_ALPHA&lt;/code&gt;: 54.9, mean difference 0.2.&lt;/li&gt;
&lt;li&gt;Premultiplied upload + &lt;code&gt;ONE&lt;/code&gt;: 54.9, mean difference 0.2.&lt;/li&gt;
&lt;li&gt;Straight upload + &lt;code&gt;ONE&lt;/code&gt;: 108.9. Color never gets multiplied, so the edge is twice as bright.&lt;/li&gt;
&lt;li&gt;Premultiplied upload + &lt;code&gt;SRC_ALPHA&lt;/code&gt;: 48.8. Color gets multiplied twice and the edge goes dark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make sure multiply count was the whole story, I computed &lt;code&gt;rgb*a&lt;/code&gt;, &lt;code&gt;rgb&lt;/code&gt; and &lt;code&gt;rgb*a*a&lt;/code&gt; on the CPU over the same 43,740 pixels. The means came out 54.9, 108.9 and 48.8, matching what WebGL returned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Same puppy edge: canvas 2D, straight upload with ONE, premultiplied upload with SRC_ALPHA" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the bottom edge of each panel. The left one is canvas 2D and fades cleanly into black. The middle one has a near-white stripe below the belly and every fur tip glows. The right one looks fine at first glance, but the edge is thinner and darker than on the left. That dark variant is the one I would miss in a screenshot review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tie both settings to one value
&lt;/h2&gt;

&lt;p&gt;What I do now is never set the two separately:&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;BLEND_FOR_UPLOAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;straight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SRC_ALPHA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;premultiplied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ONE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadCutout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pixelStorei&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNPACK_PREMULTIPLY_ALPHA_WEBGL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;premultiplied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;texImage2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TEXTURE_2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RGBA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNSIGNED_BYTE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blendFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;BLEND_FOR_UPLOAD&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="nx"&gt;webgl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ONE_MINUS_SRC_ALPHA&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;I ran both modes on the same crop and compared edge luminance against the canvas 2D result on black. The mean difference was 0 in both cases. Anyone who changes the upload mode later changes the blend factor with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still open
&lt;/h2&gt;

&lt;p&gt;Canvas 2D makes a good reference here: &lt;code&gt;getImageData&lt;/code&gt; on a transparent canvas returned the file's RGB with zero error, including pixels with alpha below 32. But I only tested Chromium 149, where WebGL may be running on a software renderer. Firefox and Safari are untested.&lt;/p&gt;

&lt;p&gt;The roughly 11 units baked into the file are a separate decision. ImgIng has a "remove background color fringe" toggle, off by default. On the puppy it brought the edge to 76.0 against an estimated 75.6, so the halo is basically gone. The cost is that it rewrites alpha: 76,236 pixels changed and a jagged patch appeared where the front leg meets the belly. I have left it off for now. Before shipping anything that draws cutouts with WebGL, I check that upload mode and blend factor come from the same variable, and compare one frame against canvas 2D.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Decoding a progressive JPEG took 14 MiB more memory in my test</title>
      <dc:creator>hao jia</dc:creator>
      <pubDate>Fri, 02 Oct 2026 08:02:53 +0000</pubDate>
      <link>https://dev.to/iterandum/decoding-a-progressive-jpeg-took-14-mib-more-memory-in-my-test-2bbi</link>
      <guid>https://dev.to/iterandum/decoding-a-progressive-jpeg-took-14-mib-more-memory-in-my-test-2bbi</guid>
      <description>&lt;p&gt;Our image worker runs in containers with a hard memory limit, and someone upstream wants to switch the product hero images to progressive JPEG. Before signing off I wanted one number I didn't have: how much more memory does a progressive file need while it's being decoded? The answer on my machine was about 14 MiB more for a 2736×1824 photo, and it lines up almost exactly with one buffer you can calculate on paper.&lt;/p&gt;

&lt;p&gt;The setup is small. Two images I found online: a landscape photo from Wikimedia Commons scaled to 2736×1824, and a holiday notice template from a Chinese design-template site, 1242×2688. Each was encoded at quality 85 with Pillow 11.3 (libjpeg-turbo underneath), once as baseline and once as progressive. Same pixels in, same quality, only the scan layout differs. The machine is an Apple M4 with 16 GB, macOS 26.5.2.&lt;/p&gt;

&lt;p&gt;To measure, I decode each file in a fresh Python process and look at how far the peak resident size moved:&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;CHILD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;
import resource, sys
from PIL import Image
before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
img = Image.open(sys.argv[1]); img.load()
after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(before, after)
&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new process per file matters. Peak RSS never goes down, so decoding two files in one process would hide the second one. On macOS &lt;code&gt;ru_maxrss&lt;/code&gt; is in bytes (on Linux it's kilobytes). I ran each file five times and took the median.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Baseline&lt;/th&gt;
&lt;th&gt;Progressive&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;landscape 2736×1824&lt;/td&gt;
&lt;td&gt;+20.6 MiB&lt;/td&gt;
&lt;td&gt;+35.0 MiB&lt;/td&gt;
&lt;td&gt;14.4 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;template 1242×2688&lt;/td&gt;
&lt;td&gt;+14.3 MiB&lt;/td&gt;
&lt;td&gt;+24.0 MiB&lt;/td&gt;
&lt;td&gt;9.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The baseline numbers are roughly the decoded RGB bitmap plus some overhead. 2736×1824×3 bytes is 14.3 MiB, and the process grew by 20.6.&lt;/p&gt;

&lt;p&gt;The extra part is the interesting bit. A baseline JPEG is stored block by block. The decoder reads one block's 64 DCT coefficients, runs the inverse transform, writes pixels, and forgets the coefficients. It only needs a few rows of blocks in flight. A progressive file is split into several scans (Pillow's default script produces 10): first the DC value of every block, then bands of AC coefficients, then refinement passes that add the low bits. No block is complete until the last scan arrives, so the decoder has to keep every coefficient of the whole image around until the end.&lt;/p&gt;

&lt;p&gt;You can size that buffer by hand. Both files use 4:2:0 chroma subsampling, and libjpeg stores each coefficient as 2 bytes. For the landscape image that's 2736×1824 luma coefficients plus two chroma planes of 1368×912, times 2 bytes: 14.3 MiB. For the template, with the width padded up to a multiple of 16, it comes to 9.6 MiB. The measured extras were 14.4 and 9.7. I didn't instrument libjpeg itself, so treat this as a match rather than proof, but it's close enough that I stopped looking for another explanation.&lt;/p&gt;

&lt;p&gt;So for capacity planning the rule I wrote down is: a progressive JPEG costs roughly one more bitmap-sized buffer at decode time, on top of the output image. In our case the worker occasionally handles several large images in parallel, and that doubles the worst case. That's the part that would have hit the container limit, not the decode time (which also went up, about 2.3 to 2.6 times in Chromium in a separate test, still just milliseconds per image).&lt;/p&gt;

&lt;p&gt;One thing that made this easier: files exported in the browser don't need this budget at all. I compress most of my front-end images with ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), and its JPG exports in the Chromium 149 open-source build I tested are baseline (SOF0), because it uses the browser's native encoder. In that build, &lt;code&gt;canvas.toBlob&lt;/code&gt; also returns baseline JPEGs. Progressive files only enter our pipeline through server-side scripts.&lt;/p&gt;

&lt;p&gt;I haven't measured browser memory for this, only Pillow. If you want to check your own worker, the snippet above plus a loop over five runs is all it takes. Run it on the largest image your service accepts, once as baseline and once as progressive, and compare the difference with width × height × 2 × 1.5 bytes for a 4:2:0 file.&lt;/p&gt;

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