<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: yue xing</title>
    <description>The latest articles on DEV Community by yue xing (@token111).</description>
    <link>https://dev.to/token111</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4087986%2F86b82555-4629-45b8-9c3a-6743e1932cb9.png</url>
      <title>DEV Community: yue xing</title>
      <link>https://dev.to/token111</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/token111"/>
    <language>en</language>
    <item>
      <title>Dithering made my GIF up to 50% bigger, and what to check first when a GIF will not shrink</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:09:08 +0000</pubDate>
      <link>https://dev.to/token111/dithering-made-my-gif-up-to-50-bigger-and-what-to-check-first-when-a-gif-will-not-shrink-koe</link>
      <guid>https://dev.to/token111/dithering-made-my-gif-up-to-50-bigger-and-what-to-check-first-when-a-gif-will-not-shrink-koe</guid>
      <description>&lt;p&gt;If a GIF refuses to get smaller, I would check the colour count and then whether dithering is switched on. Dithering sounds like a quality feature that should cost nothing, but on the animation I tested it made the file between 0.7% and 36.9% bigger in a browser tool and between 32% and 50% bigger in my own rebuild. I wanted to know why, so I counted what a GIF-style encoder actually writes.&lt;/p&gt;

&lt;p&gt;Everything below comes from one sample, one laptop and one run per setting. The sample is MORPH.gif, 675×599, 10 frames, 3,003,893 bytes, by Kowloonese on Wikimedia Commons, CC BY-SA 3.0. I re-encoded it for the tests. It is a photo-like fade with text, and almost every pixel changes in every frame. Sizes are plain bytes. A screen recording with flat colour blocks may behave very differently, and I did not test one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the palette size first
&lt;/h2&gt;

&lt;p&gt;For the first set of numbers I used the animation workshop in ImgIng &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; in a desktop browser, exporting GIF with the colour count and dithering set by hand. With dithering off, 256 colours gave 1,525,126 bytes, 128 gave 1,337,247, 64 gave 1,083,830 and 32 gave 811,310. Colour count is the cheapest lever to try, and going from 256 to 32 colours took the file down by 46.8%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then check the dithering switch
&lt;/h2&gt;

&lt;p&gt;Same tool, same four palette sizes, Floyd–Steinberg dithering on. At 32 colours the file was 1,110,431 bytes, which is 36.9% more than with it off. At 64 it was +23.6%, at 128 it was +10.4%, and at 256 it was 1,535,115 against 1,525,126, only +0.7%.&lt;/p&gt;

&lt;p&gt;That last row confuses me. My guess is that the source already looks like a 256-colour image, so there is little error left for dithering to spread around. I did not test that, so treat it as a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dithering breaks the repeats that LZW needs
&lt;/h2&gt;

&lt;p&gt;To see whether the tool's numbers reproduce, I rebuilt the test outside it. I blurred the frames slightly to approximate a true-colour source, built a median-cut palette with Pillow, and ran &lt;code&gt;gifsicle -O3&lt;/code&gt;. Across eight combinations (the original frames and the blurred copy, four palette sizes each), the dithered file was always bigger, between +32.0% and +50.4%. On the blurred copy at 32 colours the plain version was 572,768 bytes and the dithered one 861,321.&lt;/p&gt;

&lt;p&gt;Then I counted the codes a GIF-style LZW encoder writes for those blurred frames. At 32 colours, dithering raised the code count from 418,303 to 628,637, which is +50.3%, while the file grew +50.4%. The share of pixels equal to their left neighbour dropped from 85.8% to 55.8%. My reading, and it is an inference I did not verify further, is that LZW grows its dictionary from repeated pixel runs. Dithering sprinkles single-pixel noise through flat areas, the runs get cut short, and each code covers fewer pixels (9.67 on average down to 6.43). This is Floyd–Steinberg only. I did not try ordered dithering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try gifsicle on the original before blaming the file
&lt;/h2&gt;

&lt;p&gt;On the original GIF I also ran gifsicle's own colour reduction, once with dithering off and once with it forced on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;gifsicle &lt;span class="nt"&gt;--info&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.gif | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-4&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.gif 10 images
  logical screen 675x599
  global color table &lt;span class="o"&gt;[&lt;/span&gt;256]
  background 0
&lt;span class="nv"&gt;$ &lt;/span&gt;gifsicle &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;--colors&lt;/span&gt; 32 &lt;span class="nt"&gt;--no-dither&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.gif &lt;span class="nt"&gt;-o&lt;/span&gt; out.gif&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z out.gif
426315
&lt;span class="nv"&gt;$ &lt;/span&gt;gifsicle &lt;span class="nt"&gt;-O3&lt;/span&gt; &lt;span class="nt"&gt;--colors&lt;/span&gt; 32 &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;.gif &lt;span class="nt"&gt;-o&lt;/span&gt; out.gif&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z out.gif
1005103
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;426,315 against 1,005,103 is a bigger gap than anything above. These numbers are not comparable with the earlier tables, because here gifsicle merges colours directly on frames that already carry baked-in noise, so I only keep them as a command example.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lossy comes last
&lt;/h2&gt;

&lt;p&gt;I tried &lt;code&gt;--lossy&lt;/code&gt; only after the other two. On the 256-colour blurred source, lossy 200 took the plain file from 1,364,178 to 1,059,906 bytes, which is 22.3% less. The dithered file went from 1,800,055 to 1,430,275, and it was still 34.9% bigger than the plain one. Lossy did not close the gap that dithering opened.&lt;/p&gt;

&lt;p&gt;For a flat-colour screen capture the answer might be different, and that is the part I have not tried yet.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Reading the DPI back out of a PDF-to-image result: pixels 72 points</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sun, 01 Nov 2026 08:06:57 +0000</pubDate>
      <link>https://dev.to/token111/reading-the-dpi-back-out-of-a-pdf-to-image-result-pixels-x-72-points-3ekd</link>
      <guid>https://dev.to/token111/reading-the-dpi-back-out-of-a-pdf-to-image-result-pixels-x-72-points-3ekd</guid>
      <description>&lt;p&gt;I ran the same 4-page PDF through four PDF-to-image converters. At their defaults PDF24, iLovePDF and Smallpdf returned 1008×1440, 1050×1500 and 2100×3000 pixels, and ImgIng gave 1008×1440, 1512×2160 or 2100×3000 depending on the preset. None of the results said what DPI it had used. The word "point" is easy to leave vague, so I worked the DPI backwards from the pixel counts, and it came down to one line of arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PDF page has points, not pixels
&lt;/h2&gt;

&lt;p&gt;The page size inside a PDF is written in points, and one point is 1/72 of an inch. My sample page is 504 × 720 points, which is exactly 7 × 10 inches. There is no pixel grid until something renders the page, and DPI is the number you hand to the renderer to say how many pixels it should spend on each inch. So the formula is pixels = points × DPI ÷ 72, and turning it around gives DPI = pixels × 72 ÷ points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working the DPI out of each result
&lt;/h2&gt;

&lt;p&gt;I did the width first and then checked the height as a sanity test. 1008 × 72 ÷ 504 is 144, and the height agrees: 1440 × 72 ÷ 720 is also 144. The other results fall out the same way.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result (this 4-page sample)&lt;/th&gt;
&lt;th&gt;Width&lt;/th&gt;
&lt;th&gt;DPI worked out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PDF24, default&lt;/td&gt;
&lt;td&gt;1008&lt;/td&gt;
&lt;td&gt;144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImgIng, middle preset&lt;/td&gt;
&lt;td&gt;1512&lt;/td&gt;
&lt;td&gt;216&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iLovePDF, default&lt;/td&gt;
&lt;td&gt;1050&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smallpdf, no DPI option in the page&lt;/td&gt;
&lt;td&gt;2100&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these I could check against something else. The JPG files from iLovePDF and Smallpdf carry a DPI tag of 150 and 300, which is what the arithmetic gave me. PDF24 shows 144 as its default in the interface, and ImgIng offers 144, 216 and 300 as presets. So the formula is not just a nice story, it matches what the tools themselves say.&lt;/p&gt;

&lt;h2&gt;
  
  
  More pixels do not mean proportionally more bytes
&lt;/h2&gt;

&lt;p&gt;Going from 144 to 300 DPI multiplies the pixel count by 4.34, since 2100 × 3000 is 4.34 times 1008 × 1440. The JPG bytes at the 144 and 300 DPI presets went from 1,000,564 to 2,822,064, which is 2.82 times. Per megapixel that is roughly 689 KB at 144 DPI and 448 KB at 300 DPI (1 KB = 1000 bytes here). My reading is that big flat areas of the page compress well once there are more pixels covering them, but I only checked one booklet, so I would not trust that as a rule.&lt;/p&gt;

&lt;p&gt;Do not compare bytes across different tools, though. Reading the JPEG quantization tables suggests the four tools use different quality levels, so a smaller zip says nothing about which one compressed better. Compare bytes only inside one tool with one setting changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the arithmetic gets fuzzy
&lt;/h2&gt;

&lt;p&gt;My sample page is a clean 504 × 720, so every result is a whole number. An A4 page is 595.276 × 841.89 points, which gives a fractional pixel size at most DPIs. At 300 DPI I saw widths of 2480 and 2481 depending on the tool, because they round differently. When you work DPI backwards from an A4 result, expect to be one pixel off and round to the nearest sensible number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I use when I want to pick the DPI
&lt;/h2&gt;

&lt;p&gt;Smallpdf gave me no DPI choice and iLovePDF defaulted to 150. PDF24 and ImgIng both let me set the number in the page; the latter, at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; in a desktop browser, offers 144, 216 or 300, and in my runs I saw no request carrying the file out. On a phone it only does image compression. All numbers above are one sample, one machine and a single run each.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Blurry edge or white halo after resizing a cutout PNG? They are two different problems</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sat, 31 Oct 2026 08:06:57 +0000</pubDate>
      <link>https://dev.to/token111/blurry-edge-or-white-halo-after-resizing-a-cutout-png-they-are-two-different-problems-4if5</link>
      <guid>https://dev.to/token111/blurry-edge-or-white-halo-after-resizing-a-cutout-png-they-are-two-different-problems-4if5</guid>
      <description>&lt;p&gt;When I shrink a cutout PNG and blow it back up, two things go wrong at the edge, and it is easy to file them under one name: "resizing makes the edge blurry". After comparing the channels separately, I think they are two effects with two causes. The soft edge comes from interpolation. The bright or dark fringe comes from how the colour channels are averaged when alpha is not taken into account. Only the second one depends on the premultiplied-alpha convention.&lt;/p&gt;

&lt;p&gt;The test image is a portrait from Wikimedia Commons (CC BY-SA 4.0, by Dmitry Makeev), scaled to 1330×2000 and cut out with the AI background removal in ImgIng, which I ran in a desktop browser at &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt; . In the run where I watched the network tab, I saw only model file downloads. Every number below is one image per type, one Apple M4, one run, Chromium 149 and sharp 0.35.5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpha does not care which convention you use
&lt;/h2&gt;

&lt;p&gt;I shrank the exported PNG 4× with an area average and enlarged it back with bilinear, twice. One run scaled R, G, B and A as four independent channels (straight). The other multiplied RGB by alpha first, scaled, then divided back (premultiplied). The two alpha channels came out identical, maximum difference 0.&lt;/p&gt;

&lt;p&gt;That explains the soft edge. Averaging neighbours widened the transition band in both runs, and premultiplying does not sharpen anything. If your only complaint is a soft edge, no alpha setting will fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fringe comes from colour under transparent pixels
&lt;/h2&gt;

&lt;p&gt;A fully transparent pixel still stores an RGB value, and nobody looks at it. A straight resize averages that hidden colour into the visible neighbours, weighted by area, not by opacity.&lt;/p&gt;

&lt;p&gt;The smallest case I could build is eight pixels wide: four opaque red on the left, four fully transparent pixels on the right that happen to store blue. Shrunk to five pixels, the single transition pixel in the browser canvas came out as (255,0,0,128), pure red at half opacity. With &lt;code&gt;createImageBitmap&lt;/code&gt; set to &lt;code&gt;premultiplyAlpha: 'none'&lt;/code&gt; it came out as (128,0,128,128). Same alpha, but the blue leaked in.&lt;/p&gt;

&lt;p&gt;On the real portrait the hidden colour was not blue. Pixels with alpha 1 to 5 stored almost the original wall colour, averaging (221,218,219). Along row 1300 of the 4× run, at x=838 to 847, the source had alpha 1 to 5 and RGB 255 in those pixels. After the straight resize, the pixels at alpha 15 to 69 had RGB 195 to 244, a pale ring around dark clothing. The premultiplied run gave 76 to 113 at the same spot, which keeps the clothing dark.&lt;/p&gt;

&lt;p&gt;Over the whole edge band (original alpha 1 to 254, grown outward by 4 pixels), the straight run was off from the premultiplied baseline by 2.991 levels on average, 46.9 at worst, and 13.83% of the pixels differed by more than 8 levels. The premultiplied run scored near zero partly because the baseline is itself premultiplied, so read this as how much dirty colour the straight run adds, not as proof of which is closer to the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same test on a dog shows much less
&lt;/h2&gt;

&lt;p&gt;Same test on a white curly dog on a dark car seat. At 4× the straight run was off by 0.505 on average and only 0.12% of the edge pixels were more than 8 levels away. At 8× it was 1.560 and 4.98%, still far milder than the portrait at 3.879 and 19.27%. Here the hidden colour was dark, (30,31,32) on average at alpha 1 to 5. My guess is that the stored dark colour sits close to the fur's shadowed edge, so averaging it in costs little. One image per type, so not a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stored colour at low alpha is not reliable
&lt;/h2&gt;

&lt;p&gt;The canvas seems to keep colour premultiplied. I wrote (200,100,50) with &lt;code&gt;putImageData&lt;/code&gt; and read it back: at alpha 4 it returned (191,128,64), at alpha 2 it returned (255,128,0), and at alpha 0 it returned (0,0,0). The export I checked also had (0,0,0) under alpha 0. I have not looked at how the exporter writes pixels, so I only know the behaviour matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  A way to tell which one you have
&lt;/h2&gt;

&lt;p&gt;Compare alpha first. If an enlarged result has a wide soft edge but no colour fringe, that is interpolation and you are done. If there is a fringe, check whether your library resizes with alpha taken into account. In sharp 0.35.5, with Lanczos3, the default run and a run with RGB and alpha exported and resized separately gave identical alpha and a mean RGB difference of 4.218 in the edge band. The mean brightness of the semi-transparent pixels was 109.63 for the default and 125.1 for the split version, so the default behaves like premultiplied and the split like straight.&lt;/p&gt;

&lt;p&gt;One trap from my own attempt: calling &lt;code&gt;.removeAlpha().resize()&lt;/code&gt; is not a straight resize. libvips premultiplies before dropping alpha, so my first version came out darker and I had to redo it.&lt;/p&gt;

&lt;p&gt;The sharp numbers above are Lanczos3 only, and I did not test Safari or Firefox.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>One oxipng run, two honest numbers: 8.50% or 1.6 points</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Fri, 30 Oct 2026 08:07:50 +0000</pubDate>
      <link>https://dev.to/token111/one-oxipng-run-two-honest-numbers-850-or-16-points-2ib0</link>
      <guid>https://dev.to/token111/one-oxipng-run-two-honest-numbers-850-or-16-points-2ib0</guid>
      <description>&lt;p&gt;I ran oxipng on a palette PNG and the tool told me it saved 8.50%. When I wrote the same result in my notes as a share of the original file, it was 1.6 points. Both numbers are correct, and they answer different questions. This post is about keeping them apart. The files are 12 PNGs from Wikimedia Commons (CC licenses): three screenshots, three icons, three illustrations and three photos. One run each on an Apple M4, pngquant 3.0.3, oxipng 10.2.1, sizes in exact bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool prints a percentage of its own input
&lt;/h2&gt;

&lt;p&gt;The icon was 40,369 bytes. pngquant at quality 65–80 brought it to 7,600, then oxipng brought it to 6,954. Here is the full oxipng output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;Q.png Q_ox.png&lt;span class="p"&gt;;&lt;/span&gt; oxipng &lt;span class="nt"&gt;-o&lt;/span&gt; 4 &lt;span class="nt"&gt;--strip&lt;/span&gt; safe Q_ox.png

Files processed: 1/1
Input size: 7.42 KiB &lt;span class="o"&gt;(&lt;/span&gt;7600 bytes&lt;span class="o"&gt;)&lt;/span&gt;
Output size: 6.79 KiB &lt;span class="o"&gt;(&lt;/span&gt;6954 bytes&lt;span class="o"&gt;)&lt;/span&gt;
Total saved: 646 bytes &lt;span class="o"&gt;(&lt;/span&gt;8.50%&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 8.50% is 646 divided by 7,600, the quantized file that oxipng received. Divided by the original 40,369 it is 1.6 points. zopflipng does the same thing with different words: its "Percentage of original" line below is 6,923 divided by 7,600, so "original" there means its input as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;zopflipng &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;01234mepb Q.png Q_zop.png
&lt;span class="go"&gt;Optimizing Q.png
Input size: 7600 (7K)
Result size: 6923 (6K). Percentage of original: 91.092%
Result is smaller
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which base makes the gain look bigger
&lt;/h2&gt;

&lt;p&gt;Quoting the gain relative to the quantized file always looks larger, because that file is already small. On all 12 files together the originals add up to 4,454,411 bytes, pngquant leaves 1,411,877, and oxipng after it leaves 1,283,130. That is 2.9 points of the original, or 9.1% of what pngquant left. Neither is a lie, but "9.1% smaller" reads like a different kind of result than "2.9 points".&lt;/p&gt;

&lt;p&gt;The per-class numbers show a second problem, which is that the base can change the ranking.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Extra from oxipng, points of original&lt;/th&gt;
&lt;th&gt;Extra from oxipng, % of quantized file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Photos&lt;/td&gt;
&lt;td&gt;4.0&lt;/td&gt;
&lt;td&gt;11.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icons&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;15.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;1.9&lt;/td&gt;
&lt;td&gt;6.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Illustrations&lt;/td&gt;
&lt;td&gt;1.6&lt;/td&gt;
&lt;td&gt;6.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By points, photos lead and icons come second. By percent of the quantized file, icons lead. Screenshots and illustrations also swap places. If I had only kept the second column I would have written that icons benefit most, and someone who only saw the first column would write photos. Three files per class is too few to say which is right, and I did not look into why the classes differ, so I treat the table as a description of these 12 files and nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I settled on points of the original
&lt;/h2&gt;

&lt;p&gt;My rule now is to put the base next to every number. For a pipeline I use the original file as the base, since that is what a reader ships or downloads, and I say "points" so nobody reads 1.6 as a relative gain. The quantized-file figure only goes in when the point is how much one step adds on top of the previous one, and I label it that way.&lt;/p&gt;

&lt;p&gt;Timing needs the same care. oxipng added roughly 0.8 to 1.6 seconds per class of three files on this machine, and zopflipng added 3.5 to 19.5 seconds for 0.0 to 0.5 extra points. The zopflipng comparison only holds for the flags above, and I did not try higher iteration counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same check on someone else's palette PNGs
&lt;/h2&gt;

&lt;p&gt;To get palette PNGs I had not made myself, I fed the same 12 files to the batch compressor on ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser, output forced to PNG. Seven results were larger than their inputs and one file was left untouched. The other four were smaller palette PNGs, and those are the ones I ran oxipng on.&lt;/p&gt;

&lt;p&gt;On the icon, ImgIng gave 9,346 bytes and oxipng gave 6,301. That is 32.6% of the ImgIng output and 7.5 points of the 40,369-byte original. The biggest relative gain was a 120,393-byte star icon: 43,216 down to 23,571, which is 45.5% of the output and 16.3 points of the original. This is a batch run on four files, not the single-image page, so it shows that such output can still shrink and says nothing about how much a typical file would.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What the fsType field in a font means, and how to read it with fontTools</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Thu, 29 Oct 2026 08:07:31 +0000</pubDate>
      <link>https://dev.to/token111/what-the-fstype-field-in-a-font-means-and-how-to-read-it-with-fonttools-3063</link>
      <guid>https://dev.to/token111/what-the-fstype-field-in-a-font-means-and-how-to-read-it-with-fonttools-3063</guid>
      <description>&lt;p&gt;Font tooling keeps mentioning &lt;code&gt;fsType&lt;/code&gt;, and it is easy to treat it as a magic number where 0 is fine and anything else is a problem. The spec says something more specific, and a few things in it are easy to misread. So I read the field on fonts installed on my Mac, then on the fonts inside a PDF, and wrote down what each reading means.&lt;/p&gt;

&lt;h2&gt;
  
  
  fsType is one 16-bit number with three jobs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fsType&lt;/code&gt; lives in the OS/2 table of a TrueType or OpenType font. The OpenType spec (the OS/2 page on learn.microsoft.com, read on 2026-10-28) splits the 16 bits into parts. Bits 0 to 3 hold the usage permission. Bit 8 means the font must not be subsetted. Bit 9 means only bitmap embedding is allowed. The rest are reserved.&lt;/p&gt;

&lt;p&gt;The permission part is not a set of independent switches. It holds one value, and the valid ones are 0, 2, 4 and 8. Zero is installable embedding, 2 is restricted license embedding, 4 is preview and print embedding, and 8 is editable embedding. Value 2 is the most restrictive of the four.&lt;/p&gt;

&lt;h2&gt;
  
  
  A value of 8 is not bit 8
&lt;/h2&gt;

&lt;p&gt;This is the part I had to slow down for. The usage value 8 is what you get when bit 3 is set, so in binary it is &lt;code&gt;0000000000001000&lt;/code&gt;. Bit 8 is a different thing entirely: it is the value 256, &lt;code&gt;0000000100000000&lt;/code&gt;, and it says "no subsetting". Bit 9 is 512, "bitmap only". If you only look at the decimal number and never at the binary form, 8, 256 and 512 all feel like the same kind of flag, and they are not.&lt;/p&gt;

&lt;p&gt;That is why the snippet below prints the binary string and splits the value with masks instead of comparing against a list of numbers. &lt;code&gt;v &amp;amp; 0xF&lt;/code&gt; isolates the permission part, and &lt;code&gt;v &amp;amp; 0x100&lt;/code&gt; and &lt;code&gt;v &amp;amp; 0x200&lt;/code&gt; test the two flags.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading it with fontTools
&lt;/h2&gt;

&lt;p&gt;The script takes font paths and prints one line per font. It handles &lt;code&gt;.ttc&lt;/code&gt; collections by opening the first font inside, so that a collection does not need special handling. The names are placeholders for my own files: A is Noto Serif SC, B is Arial, C is Palatino, D is Helvetica.&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;python3&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FONT-A.otf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FONT-B.ttf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FONT-C.ttc&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FONT-D.ttc&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;P&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fontTools.ttLib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TTFont&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TTCollection&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TTCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lazy&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;fonts&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.ttc&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="nc"&gt;TTFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lazy&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OS/2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;fsType&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getBestFullName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; fsType=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;016&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  usage=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xF&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; no_subset=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bitmap_only=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;P&lt;/span&gt;

&lt;span class="n"&gt;Noto&lt;/span&gt; &lt;span class="n"&gt;Serif&lt;/span&gt; &lt;span class="n"&gt;SC&lt;/span&gt;      &lt;span class="n"&gt;fsType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;    &lt;span class="mi"&gt;0000000000000000&lt;/span&gt;  &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;no_subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt; &lt;span class="n"&gt;bitmap_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;Arial&lt;/span&gt;              &lt;span class="n"&gt;fsType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;    &lt;span class="mi"&gt;0000000000001000&lt;/span&gt;  &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="n"&gt;no_subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt; &lt;span class="n"&gt;bitmap_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;Palatino&lt;/span&gt;           &lt;span class="n"&gt;fsType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;    &lt;span class="mi"&gt;0000000000000100&lt;/span&gt;  &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="n"&gt;no_subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt; &lt;span class="n"&gt;bitmap_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="n"&gt;Helvetica&lt;/span&gt;          &lt;span class="n"&gt;fsType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;    &lt;span class="mi"&gt;0000000000000000&lt;/span&gt;  &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;no_subset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt; &lt;span class="n"&gt;bitmap_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across the fonts I scanned on this one machine, the values were only ever 0, 4 or 8. Pacifico, Liberation Sans, Noto Sans, Inter, Helvetica, Times and Menlo gave 0. Palatino, Avenir Book and PingFang SC gave 4. Arial, Georgia and Impact gave 8. I found no installed font with 2, 256 or 512. Anything I say below about those three values comes from copies where I changed the field myself, not from fonts I found in the wild.&lt;/p&gt;

&lt;h2&gt;
  
  
  A font inside a PDF can answer None
&lt;/h2&gt;

&lt;p&gt;Ask the same question of a PDF and there is one more trap. A PDF usually carries a subset of each font, and PyMuPDF can pull those bytes back out with &lt;code&gt;extract_font&lt;/code&gt;, after which fontTools reads the OS/2 table as above. My sample was the first four pages of the "Evaluating Wikipedia" brochure from Wiki Education Foundation and others, licensed CC BY-SA 3.0. Its embedded fonts were NewsGothicBT in three weights with fsType 4, ArialMT with 8, and PlantinMTStd, which printed &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;None&lt;/code&gt; is not a zero and it does not mean "no restriction". PlantinMTStd is a Type 1 font, and Type 1 fonts have no OS/2 table at all, so there is no field to read. If the script treats &lt;code&gt;None&lt;/code&gt; as 0, it claims a permission the file never stated. In my version I only call fontTools when the extracted font is a TrueType or OpenType file, and everything else stays &lt;code&gt;None&lt;/code&gt; on purpose. A Type 1 font in a PDF therefore tells you nothing about license through this route, so any restriction on it would have to be checked somewhere else, for example in the foundry's terms for that typeface.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a tool does with the value is a separate question
&lt;/h2&gt;

&lt;p&gt;The field is a statement of the font's license. What software does with it varies, and I only observed two cases. Chromium 149 printing to PDF, using my modified copies of Liberation Sans, with each value: for 2 and 512 it did not embed the font and wrote the glyphs as Type 3 outlines. For 256 it embedded the whole font without subsetting, and the PDF grew from 85,024 to 502,702 bytes. For 4 and 8 it embedded a normal subset. That is one machine and two TrueType fonts (Liberation Sans and Pacifico), and I did not read Chromium's source to see why.&lt;/p&gt;

&lt;p&gt;I also ran the PDFs through ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), a Beta tool that runs in a desktop browser and processes files locally. Its output turned TrueType subsets into inlined woff fonts, drew Type 3 glyphs as paths, and fell back to a system font for the Type 1 face. How it makes those choices internally I don't know, and I would not claim it reads fsType to do it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My transparent WebM turned black in the editor, and three checks that found out why</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:36:18 +0000</pubDate>
      <link>https://dev.to/token111/my-transparent-webm-turned-black-in-the-editor-and-three-checks-that-found-out-why-51fo</link>
      <guid>https://dev.to/token111/my-transparent-webm-turned-black-in-the-editor-and-three-checks-that-found-out-why-51fo</guid>
      <description>&lt;p&gt;Take an 8-second clip, cut the person out with the video background removal in ImgIng, export a transparent WebM, convert it for an editor, and the background comes out black. The first suspect is the export. In this case it wasn't the export: the black came from a later step. Below are three checks to run in order. Everything here is from one clip (540×960, 2,394,232 bytes), one machine, one run, and an FFmpeg 7.1 build. I did not try other FFmpeg versions, and I found no official import notes for any editor, so I say nothing about how editors treat the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check whether the file really has alpha
&lt;/h2&gt;

&lt;p&gt;The obvious first move is &lt;code&gt;ffmpeg -i&lt;/code&gt; and a look at the metadata. It prints &lt;code&gt;alpha_mode : 1&lt;/code&gt;, which looks like proof. It isn't. When I re-encoded the clip to plain yuv420p with FFmpeg as a test, the output still carried &lt;code&gt;ALPHA_MODE : 1&lt;/code&gt; (FFmpeg copies the stream tag over), and it had no alpha data at all. The reverse also happens: I stripped the tag from a file with &lt;code&gt;-c copy&lt;/code&gt; and the alpha data stayed inside, still readable. The tag is only a label.&lt;/p&gt;

&lt;p&gt;What I trust instead is whether each video block in the WebM carries a BlockAdditions element, since that is where VP9 alpha is stored. I didn't have ffprobe or mkvinfo on this machine, so I wrote a small EBML reader to count them. The original had 240 blocks with BlockAdditions, one per frame. The tag-stripped copy had 60 of 60, so it still had alpha. I haven't tried ffprobe's side-data output, so I can't say it would show the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check which decoder reads the file
&lt;/h2&gt;

&lt;p&gt;This is the step that explains the black background. FFmpeg's built-in &lt;code&gt;vp9&lt;/code&gt; decoder reads the picture but ignores the alpha plane, and it does so quietly. The Stream line just says yuv420p. Asking for the &lt;code&gt;libvpx-vp9&lt;/code&gt; decoder, placed before &lt;code&gt;-i&lt;/code&gt;, reads it as yuva420p. I ran both on frame 60 and printed the alpha statistics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; input.webm &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"select=eq(n&lt;/span&gt;&lt;span class="se"&gt;\,&lt;/span&gt;&lt;span class="s2"&gt;60),format=rgba,alphaextract,signalstats,metadata=print"&lt;/span&gt; &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; null -
lavfi.signalstats.YMIN&lt;span class="o"&gt;=&lt;/span&gt;255
lavfi.signalstats.YAVG&lt;span class="o"&gt;=&lt;/span&gt;255
lavfi.signalstats.YMAX&lt;span class="o"&gt;=&lt;/span&gt;255

&lt;span class="nv"&gt;$ &lt;/span&gt;ffmpeg &lt;span class="nt"&gt;-hide_banner&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;:v libvpx-vp9 &lt;span class="nt"&gt;-i&lt;/span&gt; input.webm &lt;span class="nt"&gt;-vf&lt;/span&gt; &lt;span class="s2"&gt;"select=eq(n&lt;/span&gt;&lt;span class="se"&gt;\,&lt;/span&gt;&lt;span class="s2"&gt;60),format=yuva420p,alphaextract,signalstats,metadata=print"&lt;/span&gt; &lt;span class="nt"&gt;-an&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; null -
lavfi.signalstats.YMIN&lt;span class="o"&gt;=&lt;/span&gt;0
lavfi.signalstats.YAVG&lt;span class="o"&gt;=&lt;/span&gt;46.7618
lavfi.signalstats.YMAX&lt;span class="o"&gt;=&lt;/span&gt;255
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the default decoder every pixel is fully opaque, so the person's cutout sits on a solid black frame. With libvpx-vp9 the minimum is 0 and the average 46.76, which is what a cutout should look like: roughly 79% of that frame is fully transparent and 16% fully opaque. The order matters too. Put &lt;code&gt;-c:v libvpx-vp9&lt;/code&gt; after &lt;code&gt;-i&lt;/code&gt; and the numbers came back as a constant 235, which is not real alpha. (I only know it is a constant, not why it is 235.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Check what the converted file really holds
&lt;/h2&gt;

&lt;p&gt;Editors usually want ProRes 4444, QuickTime Animation or a PNG sequence rather than WebM. The trap: if you convert with the default decoder, FFmpeg still writes the file, it plays, and it is smaller. It is also opaque. Read back, every pixel was 255.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Converted to&lt;/th&gt;
&lt;th&gt;Decoded with&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;Alpha kept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ProRes 4444&lt;/td&gt;
&lt;td&gt;libvpx-vp9&lt;/td&gt;
&lt;td&gt;62,034,554&lt;/td&gt;
&lt;td&gt;yes, same values as the source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QuickTime Animation (qtrle)&lt;/td&gt;
&lt;td&gt;libvpx-vp9&lt;/td&gt;
&lt;td&gt;92,176,076&lt;/td&gt;
&lt;td&gt;yes, same values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG sequence (240 files)&lt;/td&gt;
&lt;td&gt;libvpx-vp9&lt;/td&gt;
&lt;td&gt;63,857,616&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProRes 4444&lt;/td&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;41,105,728&lt;/td&gt;
&lt;td&gt;no, all 255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 MP4&lt;/td&gt;
&lt;td&gt;libvpx-vp9&lt;/td&gt;
&lt;td&gt;1,440,811&lt;/td&gt;
&lt;td&gt;no, H.264 has no alpha&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ProRes file from the default decoder is about 21 MB smaller than the good one, and FFmpeg printed no warning. So after any conversion, read the output back with the same alpha statistics instead of trusting that the file opens. The three good formats are 26 to 38 times the size of the WebM, which is the price of handing a clip to an editor.&lt;/p&gt;

&lt;p&gt;I don't know how a given editor treats a transparent WebM directly, so the safer hand-off on this evidence is ProRes 4444 converted from the libvpx-vp9 read, though that rests on FFmpeg read-backs, not on any editor. The WebM here came from ImgIng's video background removal, a Beta that runs in desktop Chrome or Edge, processes locally, and doesn't work on phones: &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Where do the bytes in an MP4 go? Counting video, audio and container with ffprobe</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:38:04 +0000</pubDate>
      <link>https://dev.to/token111/where-do-the-bytes-in-an-mp4-go-counting-video-audio-and-container-with-ffprobe-3l3g</link>
      <guid>https://dev.to/token111/where-do-the-bytes-in-an-mp4-go-counting-video-audio-and-container-with-ffprobe-3l3g</guid>
      <description>&lt;p&gt;It is easy to assume an MP4's size is "the video, plus a bit". Then you ask x264 for a 10 MB file, get 10.9 MB back, and the extra 0.9 MB has to be somewhere. ffprobe can answer that in about ten lines, so I counted.&lt;/p&gt;

&lt;p&gt;Sizes below are bytes, and 1 MB means 1,000,000 bytes. Some tools, browser compressors among them, use 1 MB = 1,048,576 bytes, so a "10 MB" target can differ by about 5% depending on whose definition you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  An MP4 is three piles of bytes
&lt;/h2&gt;

&lt;p&gt;Every file has video packets, audio packets, and everything else: the &lt;code&gt;moov&lt;/code&gt; index, the &lt;code&gt;ftyp&lt;/code&gt; header, the &lt;code&gt;mdat&lt;/code&gt; wrapper. ffprobe reports each packet's size, so the first two piles are a sum, and the third is whatever is left of the file size after you subtract them.&lt;/p&gt;

&lt;p&gt;My test clip is 60 seconds of 1080p street performance by Isak Zvegelj (CC BY 3.0, Wikimedia Commons). I cut it to 60 seconds and re-encoded it at phone-like bitrate, so it is not a phone original. Encoder: x264 medium, AAC 128 kbps, M4 Mac, FFmpeg 7.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sum the packets with ffprobe
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;size &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;csv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 a10.mp4
&lt;span class="k"&gt;for &lt;/span&gt;t &lt;span class="k"&gt;in &lt;/span&gt;v a&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ffprobe &lt;span class="nt"&gt;-v&lt;/span&gt; error &lt;span class="nt"&gt;-select_streams&lt;/span&gt; &lt;span class="nv"&gt;$t&lt;/span&gt;:0 &lt;span class="nt"&gt;-show_entries&lt;/span&gt; &lt;span class="nv"&gt;packet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;size &lt;span class="nt"&gt;-of&lt;/span&gt; &lt;span class="nv"&gt;csv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;p&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 a10.mp4 &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt; &lt;span class="s1"&gt;'{s+=$1} END{print t, NR, s}'&lt;/span&gt;
&lt;span class="k"&gt;done&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;9965325
v 1800 8926656
a 2814 971835
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is the file. The next two are packet count and total payload bytes for video and audio. Container overhead is 9,965,325 − 8,926,656 − 971,835 = 66,834 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three piles say
&lt;/h2&gt;

&lt;p&gt;For this clip, audio is 971,835 bytes, which is 9.7% of a 10 MB target. The container is 66,834 bytes, under 0.7%. In my own box parser, the &lt;code&gt;moov&lt;/code&gt; alone was 66,786 of those. Why &lt;code&gt;moov&lt;/code&gt; is that size, I did not look into; my guess is the per-packet tables, since there are 4,614 packets.&lt;/p&gt;

&lt;p&gt;The file above is the version where audio was budgeted for. The naive run asked for a mean video bitrate of 1,333,333 bit/s, which is 10,000,000 × 8 / 60, and that budget alone would give 10,000,000 bytes of video. Its video packets summed to 9,878,415 bytes, so x264 undershot its video target by 121,585 bytes, while audio added 971,835 and the container 66,834, for 10,917,084 in total. The overshoot did not come from the video encoder. It came from audio I forgot to budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subtract the audio first
&lt;/h2&gt;

&lt;p&gt;Naive version: set the video bitrate to target × 8 / duration. On this clip that produced 10,917,084 bytes, 9.17% over the 10 MB target. Subtract 128 kbps for audio first, so the video gets 1,205,333 bit/s, and the same encode lands at 9,965,325 bytes, 0.35% under. The sum above is that file.&lt;/p&gt;

&lt;p&gt;At 25 MB the same naive formula overshoots by 3.14% instead, because the same 0.97 MB of audio is a smaller share of a bigger target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two-pass did not land closer here
&lt;/h2&gt;

&lt;p&gt;Two-pass is usually expected to be tighter. With audio subtracted it ended 1.74% over at the 10 MB target and 0.28% over at 25 MB, which is 69,942 bytes past the limit. Single-pass was 0.35% and 0.66% under, and a CRF search over seven full encodes was 1.45% and 0.86% under, but each of those encodes took 157 to 217 seconds on this machine, against about 23 seconds for single-pass. If the cap is hard, I would leave a percent or two of room.&lt;/p&gt;

&lt;h2&gt;
  
  
  This only holds for this clip
&lt;/h2&gt;

&lt;p&gt;Everything above is one 60-second action clip, one machine, one run each. A synthetic 60-second screen recording I also tried behaved differently: at 10 MB, two-pass came out 12.0% over and single-pass 8.7% under. It was generated, so it is not evidence about real screen captures. Run the ffprobe sum on your own file before trusting my percentages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a browser tool fits
&lt;/h2&gt;

&lt;p&gt;If you only want a smaller file and do not need a byte target, a browser tool is enough. I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), which runs in a desktop browser and whose video page says processing happens locally. A network capture over 11 runs showed no upload request. It has presets and no "compress to X MB" field. Its smallest-size preset gave me 10,487,546 bytes on this clip, at 1080×608 and 24 fps, so picking a target size still means doing the arithmetic above yourself. I did not test batch mode or phones.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A tesseract TSV is a list of word boxes, not a table. Getting rows out of it</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:16:30 +0000</pubDate>
      <link>https://dev.to/token111/a-tesseract-tsv-is-a-list-of-word-boxes-not-a-table-getting-rows-out-of-it-c0f</link>
      <guid>https://dev.to/token111/a-tesseract-tsv-is-a-list-of-word-boxes-not-a-table-getting-rows-out-of-it-c0f</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%2Ferd26mzyruelokf0z3p0.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%2Ferd26mzyruelokf0z3p0.png" alt=" " width="799" height="238"&gt;&lt;/a&gt;&lt;br&gt;
I thought a tesseract TSV was a table. Then I ran it on a rendered price list with 9 rows and got a file with 70 word rows in it, plus a few structural ones on top. A TSV is a list of word boxes, and turning them into rows is up to you. Here is how far I got on one synthetic sample.&lt;/p&gt;

&lt;p&gt;The sample is a quote sheet I generated with a script: 7 columns, a header plus 8 data rows, 63 cells, no ruling lines, English and digits only. I ran tesseract 5.5.3 with the English pack and &lt;code&gt;--psm 6&lt;/code&gt;. Everything below is about this one sheet and tilted copies of it, so none of the numbers is an accuracy figure for tesseract in general.&lt;/p&gt;
&lt;h2&gt;
  
  
  The TSV has five levels and only one carries text
&lt;/h2&gt;

&lt;p&gt;The first lines of the file, trimmed to the columns I care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;level block par line word  left top width height conf   text
1     0     0   0    0     0    0   1352  556    -1
4     1     1   1    0     50   39  1191  37     -1
5     1     1   1    1     50   43  60    22     96.8   SKU
5     1     1   1    2     227  39  58    36     93.6   Item
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Levels 1 to 4 are page, block, paragraph and line, and they have a confidence of -1 and no text. Only level 5, the word, has text, a confidence and a box. The sample gave me 70 level-5 rows. So the 70 counts words, not table rows, and the rows I want are nowhere in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping by line_num works only while the table sits straight
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;line_num&lt;/code&gt; column looked like what I wanted. Grouping the 70 boxes by it gave 9 lines at 0 degrees, each a table row. After I rotated the sheet by 2 degrees it gave 8 groups, with the header merged into the first data row (15 words). At 3 degrees it was 8 groups again, at 5 degrees 7.&lt;/p&gt;

&lt;p&gt;I haven't read the source to see why tesseract fuses them. I stopped trusting &lt;code&gt;line_num&lt;/code&gt; and cut rows from the box coordinates myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A row is a run of boxes whose centres share a y
&lt;/h2&gt;

&lt;p&gt;A box's vertical centre is &lt;code&gt;top + height / 2&lt;/code&gt;. Sort the centres and start a new row when one is further than a tolerance from the average of the row so far.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;h&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="n"&gt;slope&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;txt&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;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&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="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;txt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;pts&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
        &lt;span class="n"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;tol&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;p&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;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row is then sorted by &lt;code&gt;left&lt;/code&gt; and joined. Columns are a separate problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tolerance has to be proportional to
&lt;/h2&gt;

&lt;p&gt;I tried three tolerances: 10 px, 25 px, and half the median word height. On the straight sheet all three gave 9 rows, which made the choice look unimportant. The tilted copies showed otherwise. At 2 degrees and the original size, where the median word height is 25 px, 10 px gave 26 rows, 25 px gave 9, and half the word height gave 18. At twice the image size 25 px gave 18 rows, and only 1.0 times the word height gave 9 at both sizes. I picked that 1.0 after seeing the result, so it is not a recommended constant.&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="Word-box centres coloured by detected row for a straight sheet and a 2-degree tilt" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each colour is one detected row, 70 boxes in every panel, from my generated sheet. The left panel is the straight sheet. In the middle the 2-degree tilt with half the word height as tolerance splits it into 18 rows. The right panel is the same tilt after correcting y, which is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tilt is a slope you can subtract first
&lt;/h2&gt;

&lt;p&gt;At 2 degrees the far end of a row has drifted about 43 px, more than half the 56 px between rows. Instead of hunting for a tolerance I estimated the slope as the median of dy over dx for neighbouring boxes more than 50 px apart in the same &lt;code&gt;line_num&lt;/code&gt; group. At 2 degrees I got -0.0352, against tan 2 degrees of 0.0349. Subtracting &lt;code&gt;slope * x&lt;/code&gt; from every centre (the &lt;code&gt;slope&lt;/code&gt; argument above) and clustering with half the word height gave 9 rows again, and 8 of them matched the ground truth line exactly. The ninth row has a misread &lt;code&gt;BT Earphones&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At 3 degrees the same method still gave 9 rows, but only 6 matched exactly, because tesseract itself began misreading digits: 196.10 came back as 496.10. That is a different problem from the slope, and I only checked the slope.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more tool I ran on the same kind of sheet
&lt;/h2&gt;

&lt;p&gt;For comparison I put three clean synthetic tables through ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser, default model. Each came out as 9 text lines with every cell's text present, but its Tabs split only some columns: 2 to 6 cells per row on the two 7-column sheets. I did not paste anything into a spreadsheet. It doesn't run on phones and doesn't read Korean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still haven't worked out
&lt;/h2&gt;

&lt;p&gt;Everything here is one English sheet, 63 cells, rotated by a script, not a photo, so there was no perspective and no glare. Columns and merged headers I haven't tested myself, so I have nothing to say about them yet.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A PDF page showed half an image. Every extractor I tried returned the whole thing</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:08:05 +0000</pubDate>
      <link>https://dev.to/token111/a-pdf-page-showed-half-an-image-every-extractor-i-tried-returned-the-whole-thing-mk3</link>
      <guid>https://dev.to/token111/a-pdf-page-showed-half-an-image-every-extractor-i-tried-returned-the-whole-thing-mk3</guid>
      <description>&lt;p&gt;I assumed that extracting an image from a PDF gives you what the page shows. Then I built a one-page PDF where the page shows only the left half of a picture, ran four extractors on it, and every one of them handed back the whole picture. The crop was never part of the image. It lives in the page.&lt;/p&gt;

&lt;p&gt;The test file is one page of raw PDF bytes I wrote by hand, with four lossless images. This post uses the first: a 240×160 RGB picture with a red square top left, green top right, blue bottom left and yellow bottom right, so I can tell which part I'm looking at. Sizes are in pixels, and pt means 1/72 inch. The extractors were pdfimages 26.08.0, PyMuPDF 1.26.5, the decoded bitmap that pdf.js 4.10.38 gives me in Chromium 149 (pdf.js has no public extract call, so that is the closest I could get), and ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser. One file, one sample, default settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page shows 90 pt of an image that is 180 pt wide
&lt;/h2&gt;

&lt;p&gt;Here is the line in the page's content stream that draws the picture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q 60 540 90 120 re W n  180 0 0 120 60 540 cm /ImA Do Q
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My reading, which took me a while: &lt;code&gt;re W n&lt;/code&gt; sets a clip rectangle 90 pt wide and 120 pt high, &lt;code&gt;cm&lt;/code&gt; stretches the image to 180×120 pt, and &lt;code&gt;Do&lt;/code&gt; paints it. Anything outside the rectangle is simply not drawn. The page keeps the left half, and the image data in the file is untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four tools, one answer
&lt;/h2&gt;

&lt;p&gt;All four returned the full 240×160 image. For pdfimages, PyMuPDF and the pdf.js bitmap the pixel hash matched the stored data exactly (5ce5032b49b6, the first 12 hex digits of a SHA-256 over the RGB bytes). ImgIng also returned the whole 240×160 image.&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="Page versus extraction for four images in one self-built PDF" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at column A only. The top picture is the page: red and blue squares on the left, the letter A sliced off at the right edge. The bottom picture is what pdfimages, PyMuPDF and pdf.js returned, with the green and yellow squares and the whole A. Columns B and C show the same thing for a rotated and a mirrored image, and D is a palette case I'm leaving for another day. The PDF is self-built, no third-party material.&lt;/p&gt;

&lt;p&gt;ImgIng finds image objects that are really painted on the page rather than screenshotting it, which fits what I saw. Its limits: one PDF per run, 0 results for a page with no images, and ZIP entries named only by number, with the page numbers left on its on-screen cards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking the page where the picture sits
&lt;/h2&gt;

&lt;p&gt;If the extractor won't tell me what is visible, the page has to. I asked PyMuPDF.&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;fitz&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;four_images.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get_image_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xrefs&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;width&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;240 160 (60.0, 132.0, 240.0, 252.0)
240
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bbox is 180 pt wide. That is the whole drawn picture, so the clip is not subtracted. pdf.js shows the same split: in &lt;code&gt;getOperatorList&lt;/code&gt; the image paint comes after a clip operation, and the transform is readable there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the half I could see
&lt;/h2&gt;

&lt;p&gt;As far as I can tell there are two ways to get "what the page shows": render the page and crop at the picture's position, or read the clip rectangle and crop the extracted image. The second works on this file. The rectangle is 90 pt wide and the image is drawn 180 pt wide for 240 pixels, which is 96 pixels per inch, so the visible part is the left 120 columns. Cropped to 120×160, the extracted image matches a 96 dpi render of the page exactly (average error 0.00 on a 0 to 255 scale). At 72, 150, 300 and 600 dpi the error is 0.98 to 2.51, from resampling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't worked out
&lt;/h2&gt;

&lt;p&gt;A hand-written PDF is a clean case. The real one I tried is the Creative Commons Indonesia brochure (CC BY 4.0, via Wikimedia Commons): PyMuPDF lists 9 image placements and 20 clip entries across its two pages. The list doesn't say which clip touches which image and I didn't match them, so I can't tell how many of its 8 images are partly hidden. I'd guess files from InDesign or Word behave like my hand-written one, but that is a guess.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why does a white background read 254 after I save it as JPEG? I counted the pixels</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:07:49 +0000</pubDate>
      <link>https://dev.to/token111/why-does-a-white-background-read-254-after-i-save-it-as-jpeg-i-counted-the-pixels-176d</link>
      <guid>https://dev.to/token111/why-does-a-white-background-read-254-after-i-save-it-as-jpeg-i-counted-the-pixels-176d</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%2Fwtyohet96rxbf6wy4or7.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%2Fwtyohet96rxbf6wy4or7.png" alt=" " width="800" height="803"&gt;&lt;/a&gt;&lt;br&gt;
I cut a pair of white sneakers out of a photo, put them on a pure white background and saved a PNG. Then I saved the same image as a JPEG and read one background pixel in both files. The PNG said 255, 255, 255. The JPEG said 254, 254, 254. Nothing is broken: JPEG never promised that a flat white area stays 255. On this photo 0.66% of the background came back different, nearly all of it right next to the shoe.&lt;/p&gt;

&lt;p&gt;The photo is from Wikimedia Commons (JustinLiang522, CC BY-SA 4.0). I cut it out with ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) in a desktop browser, set the background to white in the refine step and exported a 1,280×1,707 PNG. The export list has PNG, WebP, AVIF, TIFF and GIF but no JPEG, so I made the JPEG with Pillow 11.3.0 on an M4 Mac. ImgIng's compress and convert page writes JPEG too, and what I measured there was 4:2:0, the same chroma setting as my default case below. "Background" means the pixels the cut-out left fully transparent, 55.2% of the image, all 255 in the PNG. KB means 1024 bytes. One photo, one encoder.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reading the pixel without a picker
&lt;/h2&gt;

&lt;p&gt;A color picker shows one pixel's value. My first suspicion was my screen or the picker, so I read the pixel from the decoded file and counted how many background pixels changed.&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;plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shoe_white.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shoe_cutout.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))[...,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;bio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subsampling&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;back&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;empty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PNG &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;932&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;back&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;932&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;ring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromarray&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uint8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ImageFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MaxFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;close&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ring&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;wrong&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;back&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;within 8 px&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;beyond 8 px&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;close&lt;/span&gt;&lt;span class="p"&gt;)]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; px, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wrong&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not 255&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PNG  [255 255 255] JPEG [254 254 254]
all           1,206,652 px, 0.6560% not 255
within 8 px      66,781 px, 11.5766% not 255
beyond 8 px   1,139,871 px, 0.0162% not 255
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pixel, x=200 and y=932, sits within one pixel of the shoe's outline. The file really holds 254, so the picker told the truth. The count says where the rest went: of the 7,916 changed background pixels, 97.7% are in the 8-pixel band, which is only 5.5% of the background. Half of them read 254 in their darkest channel, a third 253, and the worst reads 239.&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="Heatmap of background pixels that are not 255 after saving as JPEG, with a 4x zoom of the shoe edge" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at where the red sits: only along the outline, never in the open white. In the zoom on the right the JPEG pixels look white to my eye, and the red marks the ones that are not 255. Photo: JustinLiang522, Wikimedia Commons, CC BY-SA 4.0; the figure is an adaptation under the same licence.&lt;/p&gt;

&lt;p&gt;I read this as JPEG working in 8×8 blocks: a block holding a hard dark edge spreads its rounding error over the whole block, white part included, and all-white blocks have nothing to spread. I haven't read the encoder source, so this is my interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality and chroma help but never reach zero
&lt;/h2&gt;

&lt;p&gt;I ran the same count over quality and chroma subsampling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Chroma&lt;/th&gt;
&lt;th&gt;Size (KB)&lt;/th&gt;
&lt;th&gt;Background not 255&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;4:2:0&lt;/td&gt;
&lt;td&gt;182&lt;/td&gt;
&lt;td&gt;0.61%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;4:4:4&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;td&gt;0.29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;4:2:0&lt;/td&gt;
&lt;td&gt;216&lt;/td&gt;
&lt;td&gt;0.66%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;4:4:4&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;0.29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;4:2:0&lt;/td&gt;
&lt;td&gt;324&lt;/td&gt;
&lt;td&gt;0.39%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;4:4:4&lt;/td&gt;
&lt;td&gt;365&lt;/td&gt;
&lt;td&gt;0.18%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;4:4:4&lt;/td&gt;
&lt;td&gt;625&lt;/td&gt;
&lt;td&gt;0.05%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Going from quality 75 to 95 at 4:2:0 cost 142 KB for a drop from 0.61% to 0.39%. Switching to 4:4:4 at quality 85 cost 30 KB and cut the count by more than half. Quality 100 with 4:4:4 reached 0.05% at 625 KB, about three times the default size. A plush toy and a stemmed glass gave the same pattern, and none of the three photos reached zero: quality 100 with 4:4:4 ended between 0.02% and 0.05%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do about a rule that says 255
&lt;/h2&gt;

&lt;p&gt;Amazon's seller central product image guide for the US marketplace (viewed 2026-10-23) asks for a pure white background, RGB 255, 255, 255, and recommends JPEG. On this photo a JPEG cannot meet both to the last pixel. I don't know how any marketplace checks, and I read only that page.&lt;/p&gt;

&lt;p&gt;For my own project I'll keep the PNG as the master and export JPEG at 85 or 95, with 4:4:4 if the size is fine. A check that only looks at the outer 3 pixels of the image would have said 99.99% here and missed everything around the shoe. Because most changed pixels read 253 or 254, I would count a background pixel as white when all three channels are at least 250. That threshold is my guess from one photo, not something I validated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still do not get
&lt;/h2&gt;

&lt;p&gt;I expected chroma to matter little for white, since white has no color to lose, yet 4:4:4 halved the count at the same quality. My guess is that the shoe's dark edge bleeds through the chroma blocks into the white beside it, but I only changed the setting and counted. Next I'll split the changed pixels into exactly 255, 250 to 254 and below 250 on a second photo.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>DevTools said 2 MB of JS heap. The browser was holding 5 GB</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:09:27 +0000</pubDate>
      <link>https://dev.to/token111/devtools-said-2-mb-of-js-heap-the-browser-was-holding-5-gb-3ce7</link>
      <guid>https://dev.to/token111/devtools-said-2-mb-of-js-heap-the-browser-was-holding-5-gb-3ce7</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%2Ft0pqvype7s047mrj3nfn.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%2Ft0pqvype7s047mrj3nfn.png" alt=" " width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm building a page for a personal project that compresses a whole folder of photos in the browser. Before picking a loop structure I wanted real numbers, so I generated 300 synthetic 3024×4032 JPEGs (a typical 12-megapixel phone size) and ran a few versions in desktop Chromium 149 on a 16 GB Mac. Every version does the same work per image: decode it with &lt;code&gt;createImageBitmap&lt;/code&gt;, draw it on an &lt;code&gt;OffscreenCanvas&lt;/code&gt;, re-encode it as JPEG at quality 0.8. The part that confused me was memory. Decoding 100 photos up front pushed the browser to 5,192 MB, and the JS heap peaked at 1.9 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JS heap stayed near 2 MB in every run
&lt;/h2&gt;

&lt;p&gt;For each run I tracked two numbers: the memory of all the browser's processes added together, which is what the operating system sees, and the JS heap size, which is the figure DevTools shows. In this post MB means 1024×1024 bytes, the same as MiB in the chart.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Images&lt;/th&gt;
&lt;th&gt;Browser memory peak&lt;/th&gt;
&lt;th&gt;JS heap peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Decode all first, then encode&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;1,531 MB&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode all first, then encode&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;5,192 MB&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One at a time, &lt;code&gt;close()&lt;/code&gt; after each&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;111–192 MB while the loop ran&lt;/td&gt;
&lt;td&gt;2.0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one-at-a-time row leaves out a single reading taken right at launch, before the first photo was decoded, because that is the browser starting up rather than the loop. If I had only watched DevTools, the decode-all version would have looked as harmless as the one-at-a-time loop. With 300 images it was already at 14,382 MB after 3.3 seconds, and I stopped it there because the machine only has 16 GB.&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="Peak browser memory when decoding 30, 100 and 300 photos up front, against the one-at-a-time loop" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bars are the decode-all runs. The dashed line is the highest reading of the one-at-a-time loop over all 300 photos once it got going, 192 MB. The bars grow with the image count while the line stays put, and the JS heap behind every one of them was about 2 MB. The chart comes from my own synthetic test images, no third-party material.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the missing gigabytes were
&lt;/h2&gt;

&lt;p&gt;A decoded image costs width × height × 4 bytes, one byte each for red, green, blue and alpha. For 3024×4032 that's about 46.5 MB, and the decode-all bars do grow by 46 to 50 MB per image. The arithmetic was easy. What I hadn't understood is that none of it counts as heap. My current understanding is that the &lt;code&gt;ImageBitmap&lt;/code&gt; object in JS is a small handle, and the pixels live in memory the browser manages itself, outside the V8 heap. I haven't worked out which process actually holds them, the page's renderer or the GPU process. My numbers just add every process together.&lt;/p&gt;

&lt;h2&gt;
  
  
  A console check you can repeat
&lt;/h2&gt;

&lt;p&gt;To see it on a smaller scale I decoded 20 of the test photos at once, logged the heap, then closed them all:&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;heapMB&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;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usedJSHeapSize&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;holdAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;picked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`before decoding: JS heap &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;heapMB&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; MB`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;picked&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;f&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&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;pixelMB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&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;sum&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;width&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;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`holding &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bitmaps&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="s2"&gt; bitmaps (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pixelMB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; MB of RGBA pixels): JS heap &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;heapMB&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; MB`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;bitmaps&lt;/span&gt;&lt;span class="p"&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;releaseAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmaps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bitmaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`after close(): JS heap &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;heapMB&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; MB, first bitmap is now &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bitmaps&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;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bitmaps&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;height&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before decoding: JS heap 9.5 MB
holding 20 bitmaps (930 MB of RGBA pixels): JS heap 9.5 MB
after close(): JS heap 9.5 MB, first bitmap is now 0x0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between those steps I sampled the browser's total memory again. It was 57 MB idle, 990 MB while the page held the 20 bitmaps, and 58 MB about 1.5 seconds after &lt;code&gt;close()&lt;/code&gt;. That 933 MB jump matches the 930 MB of pixels the snippet prints, and the heap line never moved. As far as I can tell Chrome rounds &lt;code&gt;performance.memory&lt;/code&gt; in a normal launch, which is why it sits at a flat 9.5 MB while DevTools read about 1.8 MB. Two runs gave identical output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I watch now
&lt;/h2&gt;

&lt;p&gt;For anything that decodes images I check the browser's task manager or the OS memory view, not the heap panel. I also keep only one decoded photo alive at a time: decode, draw, &lt;code&gt;close()&lt;/code&gt;, encode, save, then the next file. That's the 111–192 MB row above, 300 photos in 24.6 seconds. My decode-all version also closed every bitmap, just at the very end, so calling &lt;code&gt;close()&lt;/code&gt; wasn't the difference on its own. The peak followed how many decoded photos were alive at the same moment. I use ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) for my own photo batches, and its batch panel describes the same order: each photo is saved and released before the next one starts. I only measured Chromium 149 on one Mac, so I still don't know whether Firefox or Safari count decoded images the same way.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Making unchanged pixels transparent made my GIF 25% bigger</title>
      <dc:creator>yue xing</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:10:52 +0000</pubDate>
      <link>https://dev.to/token111/making-unchanged-pixels-transparent-made-my-gif-25-bigger-5d8b</link>
      <guid>https://dev.to/token111/making-unchanged-pixels-transparent-made-my-gif-25-bigger-5d8b</guid>
      <description>&lt;p&gt;For a multimedia course assignment I'm writing GIF frames byte by byte, and tutorials keep repeating one trick: store only the rectangle that changed since the last frame, then mark the unchanged pixels inside it as transparent so the previous frame shows through. Some posts say this halves the file. I tried it on a running-horse GIF and the file came out 25% bigger than just storing the rectangles. That second step is the part I had misunderstood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rectangle was mostly pixels that had not changed
&lt;/h2&gt;

&lt;p&gt;My main test file is a cartoon horse from Wikimedia Commons: 307×230, 8 frames, 16 colors. I first opened it and three other samples in ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), the browser tool I use for GIFs, in Chromium 149 on an M4 Mac, to check the frame timings it read from the files. The horse's original file already stores 7 of its 8 frames as partial rectangles.&lt;/p&gt;

&lt;p&gt;I re-encoded it with Python 3.9 and Pillow 11.3.0: one shared 255-color palette with the last slot as the transparent color, my own headers, Pillow's LZW for the pixels. Every output decoded back pixel-identical. My encoder is worse than whatever made the original file, so I only compare sizes within my own runs.&lt;/p&gt;

&lt;p&gt;The change rectangle covered 71.9% to 77.3% of the frame, yet only 11.2% to 14.2% of the pixels actually changed. Inside the box, 81% to 85% of the pixels were identical to the previous frame. On paper that's a lot of pixels I'd never have to store again.&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="Frames 2 to 4 of the horse GIF: blue box is the change rectangle, red marks the pixels that really changed" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Compare the white background and brown body inside each blue box with the thin red outlines. Tail and nose both moved, so the box drags in everything between them. Horse: Janke / Wikimedia Commons, CC BY-SA 2.5; this annotated version is shared under CC BY-SA 2.5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning those pixels transparent cost 25 percent
&lt;/h2&gt;

&lt;p&gt;Three versions, same palette, identical decoded frames:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How each frame is stored&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;vs full frames&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full frame every time&lt;/td&gt;
&lt;td&gt;27,733&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change rectangle only&lt;/td&gt;
&lt;td&gt;27,051&lt;/td&gt;
&lt;td&gt;97.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rectangle, unchanged pixels transparent&lt;/td&gt;
&lt;td&gt;33,935&lt;/td&gt;
&lt;td&gt;122.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Against rectangle-only that's +25%, and all 7 frames after the first got bigger. Here is the per-frame check, where &lt;code&gt;idx&lt;/code&gt; is the list of palette-index frames and &lt;code&gt;encode_lzw&lt;/code&gt; wraps Pillow's encoder:&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;cur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;
&lt;span class="n"&gt;rr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nonzero&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;rr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;rr&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="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;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;cc&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="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;kept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;holed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;holed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;                  &lt;span class="c1"&gt;# 255 = transparent index
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rect      &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kept&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rect+holes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holed&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;flat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ravel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;switches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frame &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;switches&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; switches &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;encode_lzw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;frame 1 rect        3227 switches  3109 bytes
frame 1 rect+holes  4149 switches  4013 bytes
frame 2 rect        3294 switches  3173 bytes
frame 2 rect+holes  4151 switches  4061 bytes
frame 3 rect        3736 switches  3432 bytes
frame 3 rect+holes  4617 switches  4346 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Frame numbers start at 0, so frame 1 is the second image.&lt;/p&gt;

&lt;h2&gt;
  
  
  My guess is that the holes chop up long runs
&lt;/h2&gt;

&lt;p&gt;LZW learns sequences of palette indexes it has already seen and gives repeats a short code, so long stretches of one color are cheap. The horse has plenty of those: flat white background and a flat brown body. The white mostly just turns into equally long runs of index 255. The trouble is at the edges. Take a pixel that moved from white to brown, sitting next to a pixel that was brown in both frames. In the plain rectangle they're one brown run. With holes, the unchanged neighbour becomes 255, and a new boundary appears right there. The moving legs and mane are covered in such boundaries.&lt;/p&gt;

&lt;p&gt;So I counted how often the index changes when you read each rectangle row by row. Over the 7 frames after the first, switches went from 24,192 to 30,859 (+27.6%), and the average run shrank from 15.03 to 11.79 pixels. That lines up with the size jump, but it's a correlation. I didn't open up the LZW dictionary to prove where the extra bytes go, and I'm not sure how much of it is the extra boundaries and how much is the new 255 symbol itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Panning and noisy footage saved nothing
&lt;/h2&gt;

&lt;p&gt;I made a 240×180, 40-frame clip that pans a window across the first frame of a cat GIF, 2 px right and 1 px down per frame. The rectangle was the whole frame every time, the rectangle-only file came out the same size as full frames, and marking unchanged pixels transparent added 23% (985,912 vs 800,084 bytes). All 39 frames after the first got bigger. The real cat GIF (320×240, 208 frames, phone footage in dim light) saved 0% too: with sensor noise only 13.7% of pixels match between neighbouring frames.&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%2Fpo7ed6wbz74e3ue7zn2d.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%2Fpo7ed6wbz74e3ue7zn2d.png" alt="Changed-pixel maps between neighbouring frames, white means changed" width="798" height="191"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Left is the pan clip, middle is the cat compared exactly, right is the cat when a pixel within 16 levels of the previous frame counts as unchanged. Only the right panel has big dark areas worth reusing. Cat: Mateus2019 / Wikimedia Commons, CC BY-SA 4.0; the pan clip is cut from it, and both maps are shared under CC BY-SA 4.0.&lt;/p&gt;

&lt;p&gt;That right panel is where a saving bigger than half finally showed up: with that tolerance the transparent version was 25.2% of full frames. Those full frames had grown too, so against the lossless full-frame file it was about 43%. But it isn't lossless anymore. Quality against the original dropped from 44.53 dB to 33.00 dB PSNR.&lt;/p&gt;

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

&lt;p&gt;Now I encode each frame both ways and keep the smaller one. On the horse that picked plain rectangles every time: 27,051 bytes. Without letting pixels drift, the best saving I got from frame differencing on these samples was 2.5%. I haven't tried a screen recording where most of the window stays perfectly still. My guess is that's where the holes pay off, since the unchanged parts would be big solid blocks instead of slivers next to moving edges.&lt;/p&gt;

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