<?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: Lank_M</title>
    <description>The latest articles on DEV Community by Lank_M (@pm_cheng_3f36acecfb9c59f5).</description>
    <link>https://dev.to/pm_cheng_3f36acecfb9c59f5</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%2F4086794%2F931fea88-28cd-4592-bb00-cbf315e37b06.png</url>
      <title>DEV Community: Lank_M</title>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pm_cheng_3f36acecfb9c59f5"/>
    <language>en</language>
    <item>
      <title>A moov box at byte 28 did not make this MP4 a faststart file</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:01:26 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-moov-box-at-byte-28-did-not-make-this-mp4-a-faststart-file-3176</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-moov-box-at-byte-28-did-not-make-this-mp4-a-faststart-file-3176</guid>
      <description>&lt;p&gt;Both MP4 exports put their &lt;code&gt;moov&lt;/code&gt; box at byte 28. That looked like a convenient answer to the question I was checking: would the player have to fetch the end of the file before it could start? But the next boxes changed the interpretation. These files contained repeated &lt;code&gt;moof&lt;/code&gt; and &lt;code&gt;mdat&lt;/code&gt; pairs, followed by &lt;code&gt;mfra&lt;/code&gt;. Calling them ordinary faststart MP4s would have discarded the most useful part of the inspection.&lt;/p&gt;

&lt;p&gt;I made the exports with &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;, using its Chinese video compression workbench for the actual test. The inputs were two existing WebM clips of ocean waves. I selected MP4 and the option prioritizing file size, ran both conversions, and captured the bytes written by the tool. Both operations completed. I did not recreate a plausible output with a separate encoder. The resulting files were 912,701 and 2,198,495 bytes.&lt;/p&gt;

&lt;p&gt;In each file, &lt;code&gt;ftyp&lt;/code&gt; occupied the first 28 bytes and &lt;code&gt;moov&lt;/code&gt; started immediately afterward. Looking only at that offset would tell me where the movie metadata began. It would not tell me how the rest of the media was organized. The recurring fragment boxes identified a fragmented MP4 structure. That matters because an early &lt;code&gt;moov&lt;/code&gt; does not, by itself, establish that it contains a complete index for every sample in the whole movie.&lt;/p&gt;

&lt;p&gt;Ordinary faststart processing and fragmentation answer different structural questions. In the conventional case, moving the movie metadata toward the front lets a player read it before downloading the remaining media payload. Fragmented output carries additional metadata alongside its media fragments. The &lt;a href="https://ffmpeg.org/ffmpeg-formats.html#mov_002c-mp4_002c-ismv" rel="noopener noreferrer"&gt;FFmpeg format documentation&lt;/a&gt; treats fragmentation separately from its faststart pass. Finding either layout in a file does not identify which software the application used internally. I have no evidence that these exports were produced by running FFmpeg faststart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="A decoded frame from the Iceland clip exported by ImgIng" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the exported Iceland clip in a local browser player after a frame appeared. It is not the product interface or a container inspection report. The picture establishes that the player decoded a visible frame at this point; the box inspection comes from the saved output bytes. Even the player’s displayed duration is a separate observation, not a substitute for checking the completed file.&lt;/p&gt;

&lt;p&gt;I also served the actual exports over a controlled local HTTP connection. Responses shared a 256 KiB/s write budget, with an 80 ms delay at the start of each request. This was application-level throttling, not a complete network simulation. For the larger fragmented export, Chromium’s first-frame median was 7.909 seconds with Range enabled and 1.403 seconds without it, with two runs per condition. Its requests scanned later fragments in the Range case. An early &lt;code&gt;moov&lt;/code&gt; clearly did not guarantee an immediate picture here.&lt;/p&gt;

&lt;p&gt;That observation is not a reason to disable Range. The other playback engines behaved differently, and startup does not settle whether a viewer can seek to an undownloaded position. I also kept these exports separate from the ordinary MP4 comparison files: those were eight-second, silent derivatives, while the complete product exports retained audio and had different durations. Comparing their startup times as a product speed ranking would mix different inputs and layouts.&lt;/p&gt;

&lt;p&gt;The Iceland footage is Alexander Grebenkov’s &lt;a href="https://commons.wikimedia.org/wiki/File:Ocean_waves_at_L%C3%A6kjavik_beach,_Iceland.webm" rel="noopener noreferrer"&gt;Ocean waves at Lækjavik beach, Iceland&lt;/a&gt;, used under &lt;a href="https://creativecommons.org/licenses/by/3.0/" rel="noopener noreferrer"&gt;CC BY 3.0&lt;/a&gt;; the image above is a player screenshot of the converted output. The second input was דוד שי’s &lt;a href="https://commons.wikimedia.org/wiki/File:Water_waves_in_Herzliya_beach.webm" rel="noopener noreferrer"&gt;Water waves in Herzliya beach&lt;/a&gt;, under &lt;a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noopener noreferrer"&gt;CC BY-SA 4.0&lt;/a&gt;. Neither was footage I shot.&lt;/p&gt;

&lt;p&gt;For the next export I inspect, I would record the box sequence before assigning a label. Then I would check first-frame playback and seeking separately against the actual HTTP behavior. This test used Chromium 149, Firefox 151, and a WebKit 26.5 test build, not a released Safari browser. It does not establish behavior on mobile devices or a production CDN. Byte 28 is a useful observation; the fragments after it determine which question to investigate next.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A readable PDF page is not a text extraction test</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:01:45 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-readable-pdf-page-is-not-a-text-extraction-test-5ccc</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-readable-pdf-page-is-not-a-text-extraction-test-5ccc</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%2F98ker9jmoymg62pei95w.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%2F98ker9jmoymg62pei95w.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A PDF screenshot can stay identical while the extracted words change. I tested that deliberately on September 30, 2026, using a small Chinese holiday notice. The page still said 放假, meaning a holiday or time off. Three text extractors returned 放真 after I changed one character mapping. That is a useful failure case for a document test suite: the output looks like ordinary text, so checks for empty strings and replacement characters will not catch it.&lt;/p&gt;

&lt;p&gt;These were controlled files, not documents from a customer incident. I kept a normal notice, removed its text mapping in a second version, and changed one mapping destination in a third. The original file was 29,878 bytes. Its five lines included Chinese, dates, and English, giving me several kinds of text to compare without needing a large fixture. I wanted to know which existing checks would approve a result that a reader would reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screenshot comparison passed
&lt;/h2&gt;

&lt;p&gt;I rendered all three versions with MuPDF 1.26.10 at a scale of 1.5, using the same color settings. Each produced an 893 by 1263 image, and their raw pixel hashes matched. This is a claim about one renderer and configuration. It does not establish that every viewer behaves identically. It does establish that, in this test, a pixel comparison could not distinguish the intact notice from either damaged variant.&lt;/p&gt;

&lt;p&gt;The changed mapping affected two occurrences of 假. Their glyphs still appeared correctly on the rendered page, but the mapping now pointed to 真. PyMuPDF 1.26.5, pypdf 6.10.2, and PDF.js 5.6.205 all extracted the wrong word. Agreement between the engines therefore did not settle correctness. They were given the same misleading information, and the visible original remained necessary to decide what the text should say.&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="Controlled comparison of PDF character mappings" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This image is a screenshot of my experiment report, not a product interface. The missing-mapping version lost its Chinese text. The incorrectly mapped version retained 56 Chinese characters, matching the normal version. Both the normal and incorrectly mapped outputs contained zero replacement characters. A test that accepts readable Chinese with no replacement symbols would pass the wrong words shown on the right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterexamples narrowed the rule
&lt;/h2&gt;

&lt;p&gt;The missing-mapping variant also exposed a weak length check. PyMuPDF returned 152 characters for both the normal and damaged notices, although the damaged output contained no Chinese characters. Some original positions became other characters or control codes. That makes total length useful as a diagnostic observation, but insufficient as an acceptance condition. The incorrect-mapping variant goes further: even the Chinese count remains unchanged while two words are wrong.&lt;/p&gt;

&lt;p&gt;I added a simple English counterexample before turning the mapping check into a gate. A Helvetica document using WinAnsiEncoding had no ToUnicode entry, yet all three engines recovered &lt;code&gt;Invoice A-1024: total 1280.50 CNY&lt;/code&gt; correctly. A rule that rejected every missing ToUnicode field would reject that valid result. In my test set, the structural observation belongs beside the text assertion; it cannot replace it in either direction.&lt;/p&gt;

&lt;p&gt;A two-page fixture supplied another boundary: its first page used the normal text mapping, while its second used the damaged one. Checking only the first page would miss the problem. I would keep page-specific references for such a fixture and report the failing page directly. This does not establish a statistically valid sampling policy for large collections. It provides a reproducible reason not to treat one good page as proof about the entire document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the product output
&lt;/h2&gt;

&lt;p&gt;I also tried the normal and missing-mapping notices in ImgIng. The extraction experiment used its Chinese site; the international entry point is &lt;a href="https://imging.ai" rel="noopener noreferrer"&gt;ImgIng&lt;/a&gt;. The normal result was readable. On the damaged notice, enabling and disabling the option for recognizing text inside images produced identical broken text. That observation concerns the returned output. Without internal traces, it does not tell me which recognition path the service executed.&lt;/p&gt;

&lt;p&gt;I did not build the PDF engine in that product. The checks described here are independent fixture tests, and the proposed additions to a regression suite are recommendations derived from them. They do not demonstrate a deployed monitoring system or guarantee arbitrary PDF correctness. In particular, I have not solved how to detect every plausible wrong word without a trusted reference, and changing extraction libraries did not resolve the deliberate wrong-mapping case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change in the test suite
&lt;/h2&gt;

&lt;p&gt;For an existing visual regression suite, I would start with a short, manually checked reference for a specific page. The holiday title is a useful assertion here because the deliberate error changes its meaning. A date by itself is weaker: the digits survived even when Chinese extraction failed. The reference should therefore include the title and a sentence connecting the dates to the holiday arrangement, with the expected text checked against the rendered original.&lt;/p&gt;

&lt;p&gt;The test should record where that reference came from. In this fixture, I have the source text because I generated the document. For an external PDF, someone would need to verify the selected passage against a trustworthy original. Merely copying text from the extractor under test would make the assertion circular. Saving the exact input file or its digest alongside the page number also prevents a later document revision from silently invalidating the reference.&lt;/p&gt;

&lt;p&gt;I would compare the expected words before accepting broader cleanup rules. Removing layout-only line breaks may be appropriate for one output contract, but replacing similar-looking Chinese characters would erase the failure this fixture is designed to expose. Punctuation, spacing, and paragraph order can have separate rules. The important choice is to write down which changes are permitted instead of treating any readable sentence as an acceptable reconstruction.&lt;/p&gt;

&lt;p&gt;For the next document test I add, I would keep the screenshot assertion and place a page-specific word assertion beside it. A failure report should show the expected passage, the actual passage, and the source page, so a reviewer can inspect the disagreement directly. The screenshot can answer whether the page still looks right. The reference text answers whether the selected words survived. This experiment needed both answers before I could call its output correct.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Moving canvas work into a Worker won't raise its size limit</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sun, 04 Oct 2026 08:01:27 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/moving-canvas-work-into-a-worker-wont-raise-its-size-limit-45l5</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/moving-canvas-work-into-a-worker-wont-raise-its-size-limit-45l5</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%2Fcjmgy01iydhs15ma3ddg.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%2Fcjmgy01iydhs15ma3ddg.png" alt=" " width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A review comment I keep seeing on image-upload PRs goes like this: the huge photo produces a blank export, so move the drawing into a Worker with OffscreenCanvas. It sounds reasonable. A Worker has its own event loop, so maybe it has its own budget too. I measured it, and the answer is no. The Worker changes where the work runs. It does not change how big a canvas can be.&lt;/p&gt;

&lt;p&gt;I tested three engine builds on a 16 GB M4 Mac: the Chromium 149 open-source build (not Chrome itself), a WebKit 26.5 build and a Firefox 151 build. The WebKit build is not Safari, the Firefox build is not release Firefox, and I tested no phones. Every canvas was filled programmatically with solid color and marker pixels. For scale, 100 megapixels sits well inside every limit below. I cross-checked that tier with the converter on imging (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), operating its Chinese-language UI: a 10000×10000 synthetic JPEG converted to WebP and JPG in all three engines, and the 12 cases in that run made zero non-GET requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does OffscreenCanvas in a Worker have a bigger size limit?
&lt;/h2&gt;

&lt;p&gt;I doubled each dimension until drawing failed, then bisected to the exact pixel, for three kinds of canvas: a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element, an OffscreenCanvas on the main thread, and an OffscreenCanvas inside a Worker. Every threshold matched across all three kinds, in all three engines. Chromium 149 and the WebKit build cap area at 268,435,456 pixels, so 16384×16384 draws and 16384×16385 does not. The Firefox build reaches 23168×23168. Single edges stop at 65,535 in Chromium and Firefox, and at 4,194,303 in WebKit.&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="Create, draw and export limits per engine; the last row shows OffscreenCanvas matching canvas" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the bottom row first: "Identical to " in every column. Then compare the Firefox column with the other two, because that's the only place where the ceiling actually moves. It moves by engine, never by thread.&lt;/p&gt;

&lt;p&gt;This is the Worker side of the check I ran for this post. It draws one pixel in the far corner and reads it back:&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;g2d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;g2d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#00f&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;g2d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g2d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getImageData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a height of 8, it returns true at width 65,535 and false at 65,536 in Chromium and Firefox. WebKit flips between 4,194,303 and 4,194,304. The same function on the main thread and on a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; flipped at exactly the same widths. The &lt;code&gt;try&lt;/code&gt; is there because the Firefox build throws &lt;code&gt;NS_ERROR_FAILURE&lt;/code&gt; on the read instead of returning zeros.&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%2Fjfh0ljvvbs0mn40rh3o8.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%2Fjfh0ljvvbs0mn40rh3o8.png" alt="A small repro page in the WebKit 26.5 build fills 4194303×8 and 4194304×8 canvases red; the wider one has an empty last column at the right end" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a small repro page I wrote, open in the WebKit 26.5 build. Two canvases are 8 px tall, one 4,194,303 px wide and one 4,194,304 px wide. After Fill red, the page magnifies a slice from the left end, the middle and the right end of each. The top canvas is red everywhere. The bottom one is red at the left and in the middle, but the red box shows its last column at the right end is empty, with the checkerboard showing through. That is why the probe reads the far corner: a top-left check would call this width a success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a thread can't buy you pixels
&lt;/h2&gt;

&lt;p&gt;My reading is that the limit is attached to the bitmap allocation, not to the thread that asks for it. Creating a canvas and calling &lt;code&gt;getContext&lt;/code&gt; without drawing added under 1 MiB in every engine. The memory shows up on the first draw: a filled 16384×16384 canvas cost exactly width × height × 4 bytes in Chromium and Firefox, 1025 MiB. Moving the call to another thread doesn't make that allocation any smaller. I didn't measure Worker memory separately, so I won't claim a Worker helps with memory pressure either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does change inside a Worker
&lt;/h2&gt;

&lt;p&gt;The failure looks different. The &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element in Chromium fires &lt;code&gt;contextlost&lt;/code&gt;, and there is no element in a Worker to listen on. What you get is a rejected &lt;code&gt;convertToBlob&lt;/code&gt;. Chromium rejects with &lt;code&gt;IndexSizeError&lt;/code&gt; saying the OffscreenCanvas size is zero, while &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; still read back 16384×16385. WebKit rejects with &lt;code&gt;EncodingError&lt;/code&gt; and Firefox with &lt;code&gt;NS_ERROR_FAILURE&lt;/code&gt;. WebKit's "Canvas area exceeds the maximum limit" console warning barely reaches you from a Worker: 1 out of 104 over-area probes produced it. Don't wait for it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4y9o4t8wdddoqbjjm7p.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%2Fm4y9o4t8wdddoqbjjm7p.png" alt="A small repro page in Chromium 149 draws one image onto a 4096×4096 canvas and a 16384×16385 canvas; the larger one stays blank and its export is 4 bytes" width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the element side of the same failure, on the same repro page in Chromium 149. One sample.jpg is drawn onto two canvases. The 4,096 × 4,096 one shows the picture and exports a 16.1 MB PNG. The 16,384 × 16,385 one, a single row over the area cap, stays a blank checkerboard inside the red box, and nothing on the page reports an error. The 4-byte export.png is what happens when &lt;code&gt;toBlob&lt;/code&gt; hands back &lt;code&gt;null&lt;/code&gt; and the page wraps it in a File without checking: you get the string "null". In a Worker there is no element to look at, so the rejected &lt;code&gt;convertToBlob&lt;/code&gt; is the only thing you will see.&lt;/p&gt;

&lt;p&gt;So I keep the Worker for what it's good at, keeping the page responsive. Before posting dimensions to it, I check them against a per-engine table and downscale anything over. Inside it, I treat a rejected &lt;code&gt;convertToBlob&lt;/code&gt; as "too big" and log the requested size next to the error text.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Premultiplied alpha didn't remove my cutout's white fringe</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sat, 03 Oct 2026 08:01:27 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/premultiplied-alpha-didnt-remove-my-cutouts-white-fringe-p6n</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/premultiplied-alpha-didnt-remove-my-cutouts-white-fringe-p6n</guid>
      <description>&lt;p&gt;A background-removed PNG dropped onto a dark page often shows a thin grey-white rim. The usual advice is to composite with premultiplied alpha. I expected that to help, measured it, and it changed nothing: on my test cutout the fringe averaged a luminance of 75.4 over black with the straight-alpha formula, and exactly 75.4 again after converting to premultiplied first. The white is not a compositing artifact. It is stored in the edge pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup and what I can and cannot speak to
&lt;/h2&gt;

&lt;p&gt;The samples are two white-background photos from Wikimedia Commons: a puppy by George Hodan (CC0) and ficus cuttings in a glass bottle by Biusch (CC BY-SA 3.0). For the cutouts I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) on its fast tier, ISNet INT8, exporting transparent PNGs and compositing them onto black for measurement. The model runs in the browser, and the six exports produced zero non-GET requests. On ImgIng my own work is on-device codecs and model loading. I did not build the matting model or its edge options, so everything below about them is inferred from exported files, not from reading their code. The refine panel states the default plainly: it uses the AI output pixel by pixel and does not change transparency or edge color.&lt;/p&gt;

&lt;h2&gt;
  
  
  Straight and premultiplied store different numbers
&lt;/h2&gt;

&lt;p&gt;Straight alpha keeps a pixel's color and its opacity as separate values. Premultiplied alpha stores the color already multiplied by opacity, so the same half-transparent pixel is darker on disk. PNG requires straight alpha. Canvas and GPU pipelines often work premultiplied internally. Paired with its own blend formula, each representation gives the same composite, because premultiplying just moves the multiply earlier. Mixing them is the real bug.&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;over&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;treat_as_premultiplied&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;treat_as_premultiplied&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &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="mf"&gt;0.2126&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="n"&gt;fringe&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;color&lt;/code&gt; and &lt;code&gt;alpha&lt;/code&gt; are the exported PNG scaled to 0..1, and &lt;code&gt;fringe&lt;/code&gt; selects the 113,438 pixels with alpha between 1 and 254. The correct straight composite gives 75.4. Treating the straight file as premultiplied gives 160.1, because the alpha multiply is skipped and the stored color shows through raw. On the ficus image that mistake gives 200.8 against a correct 52.5. This is the version a hand-rolled pixel loop or a WebGL texture with the wrong premultiply flag produces.&lt;/p&gt;

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

&lt;p&gt;The fringe pixels store an average luminance of 160.1, while the opaque fur just inside the edge sits at 108.6. I diffed the export against the source JPEG pixel by pixel. For the 67,624 fringe pixels with alpha from 128 to 254, every one is within 2 of the source color, as are all opaque pixels. So the export attaches an alpha and keeps the photo's color untouched. At a hair-thin edge that color was already fur mixed with white backdrop by the camera. Deviations cluster below alpha 32, where only 56.9% stay within 2. I have not pinned down why; rounding through a premultiplied step is my guess, not something I verified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Puppy belly edge: source, default export over black, color decontamination over black" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Puppy photo from Wikimedia Commons, CC0. Left to right: source crop, default export over black, and export with "remove background color fringe" over black, all at 3x. In the middle panel the fur below the belly carries a soft grey rim, which is the stored white. On the right the rim is gone, but a jagged hard edge appears where the front leg meets the belly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it means changing the data
&lt;/h2&gt;

&lt;p&gt;Removing the fringe requires separating the subject color from the mix, and ImgIng's decontamination toggle, off by default, does that by rewriting alpha. On the puppy 76,236 pixels changed alpha, and not one opaque pixel shifted color by more than 30. The fringe over black landed at 76.0 against an estimated 75.6. On the ficus the pale variegated leaf was close enough to white that it got pushed toward transparent and shows holes over black. That is a trade, not a fix.&lt;/p&gt;

&lt;p&gt;My order now: first check that the blend formula matches how the file is stored. If the math is right and the rim remains, look at the subject before enabling decontamination. Anything near the old background color, like white fur, pale leaves or glass, gets handled separately.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Progressive JPEG saved 4–6% of bytes and cost 2.5x decode time</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Fri, 02 Oct 2026 08:01:33 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/progressive-jpeg-saved-4-6-of-bytes-and-cost-25x-decode-time-g8c</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/progressive-jpeg-saved-4-6-of-bytes-and-cost-25x-decode-time-g8c</guid>
      <description>&lt;p&gt;"Just serve progressive JPEGs" is one of those tips that gets repeated without numbers. I work on the on-device codec side of an image tool, so I sat down and measured both halves of the trade on the same two images: how many bytes progressive saves, and how much extra time the decoder spends to get them back. On my samples it saved 3.9% to 6.3% and decoded roughly 2.3 to 2.6 times slower.&lt;/p&gt;

&lt;p&gt;The samples are two images I found online. One is a landscape photo from Wikimedia Commons, downscaled from 5472×3648 to 2736×1824 so it behaves like a hero image. The other is a holiday notice template from a Chinese design-template site, 1242×2688, mostly flat color and text. I encoded each one four times with Pillow 11.3 (libjpeg-turbo underneath, &lt;code&gt;optimize=True&lt;/code&gt; on every file): baseline and progressive, at quality 75 and 85. The machine is an Apple M4 with 16 GB, and the browser is an open-source build of Chromium 149.&lt;/p&gt;

&lt;p&gt;For decode time I did not trust a single call, because the first decode of anything tends to include warm-up. This is the function I ran in the page. It decodes the same blob seven times and keeps the median.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// runs inside the page: median createImageBitmap time for one JPEG blob&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;decodeMedianMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;bitmap&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="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runs&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I close each bitmap right away so seven full-size decodes of a 5-megapixel photo don't pile up in memory while the clock is running. The median of seven is also what the numbers below use.&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="File size and decode time for both samples at quality 75 and 85, baseline in gray and progressive in blue" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the chart as two separate panels. On the left, file size in KB (bytes divided by 1024): each gray baseline bar and its blue progressive twin are almost the same height. The landscape at quality 85 goes from 1,098,272 to 1,048,255 bytes, which is under 50 KB saved. On the right, decode time in milliseconds: every blue bar towers over its gray one. The same landscape file takes 11.1 ms as baseline and 28.3 ms as progressive. The template goes from 7.5 ms to 19.2 ms at quality 85.&lt;/p&gt;

&lt;p&gt;My understanding of why the gap goes this way is fairly plain. The quantized coefficients are identical in both files, so picture quality is identical. Progressive just splits those coefficients into several scans, each with its own Huffman table, and that squeezes out a few percent. The price is that the decoder has to hold the whole coefficient buffer and walk it once per scan, instead of finishing each 8×8 block in one pass. Why the flat-color template saves a point or two more than the photo, I have not taken apart scan by scan. My guess is its high-frequency coefficients are almost all zero, but it is a guess.&lt;/p&gt;

&lt;p&gt;What you buy with those milliseconds is a whole-picture preview on a slow connection. I simulated a partial download by truncating the files and decoding whatever was there. With half the bytes, the progressive landscape was already at 30.97 dB PSNR against the original, while the baseline one only had its top half painted. That is a decoder simulation, not a browser recording. I tried throttled screenshots too, and every frame came out as the fully loaded image, so I have no numbers for how much sooner anything shows up on screen.&lt;/p&gt;

&lt;p&gt;This also touches my own work. In ImgIng, JPG export goes through the browser's native encoder, and &lt;code&gt;canvas.toBlob&lt;/code&gt; only emits baseline. When I checked, I used two JPGs exported from ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) during an earlier poster test, read on the same M4: both headers are SOF0. So if progressive is what you want, it has to happen on a server or with a dedicated encoder library.&lt;/p&gt;

&lt;p&gt;My own rule after this: one large above-the-fold image on a slow network is worth converting server-side. A grid of thumbnails is not, since they download fast anyway and each one still pays the extra decode. To check your own images, drop your hero image into &lt;code&gt;decodeMedianMs&lt;/code&gt; as both variants and compare the two medians.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>JPEG quality 75 at 4:4:4 beat 0.99 at 4:2:0 on red text</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Thu, 01 Oct 2026 08:01:24 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/jpeg-quality-75-at-444-beat-099-at-420-on-red-text-57dd</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/jpeg-quality-75-at-444-beat-099-at-420-on-red-text-57dd</guid>
      <description>&lt;p&gt;I look after the client-side encoding path at ImgIng, and a few days ago I spent an afternoon on a sale poster with saturated red text. The poster is programmatic: 1600×900, a red (215, 0, 15) top half with a yellow headline, and a white card below with red lines at 72, 32 and 22 px. I exported it with &lt;code&gt;canvas.toBlob('image/jpeg', q)&lt;/code&gt; in a Chromium 149 open-source build on an Apple M4 with 16 GB, then measured the mean CIE76 ΔE in a 2 px band around the glyph edges. Flat areas barely change under JPEG, so a whole-image average hides the problem. The edge band doesn't.&lt;/p&gt;

&lt;p&gt;Raising quality from 0.80 to 0.99 made the file 2.25× bigger (120,501 → 270,978 bytes). Edge ΔE only went from 8.11 to 6.35. At 1.00 it dropped to 0.19, and the file jumped to 438,482 bytes, 3.5× the 123 KB source PNG. That cliff between 0.99 and 1.00 is not about quantization. It's the chroma subsampling changing from 4:2:0 to 4:4:4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subsampling is the knob that matters
&lt;/h2&gt;

&lt;p&gt;To isolate it I re-encoded the same poster with Pillow (libjpeg-turbo) and changed only the 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;4:2:0 bytes / ΔE&lt;/th&gt;
&lt;th&gt;4:2:2 bytes / ΔE&lt;/th&gt;
&lt;th&gt;4:4:4 bytes / ΔE&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;120,513 / 8.64&lt;/td&gt;
&lt;td&gt;137,543 / 6.27&lt;/td&gt;
&lt;td&gt;169,640 / 4.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;173,026 / 7.11&lt;/td&gt;
&lt;td&gt;198,266 / 4.46&lt;/td&gt;
&lt;td&gt;246,096 / 2.31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;221,333 / 6.66&lt;/td&gt;
&lt;td&gt;253,455 / 3.80&lt;/td&gt;
&lt;td&gt;315,356 / 1.31&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="n"&gt;im&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;poster.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;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q75-444.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="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;75&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4:4:4&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;p&gt;Quality 75 at 4:4:4 is 169,640 bytes with an edge ΔE of 4.47. The browser's 0.99 at 4:2:0 is 270,978 bytes with 6.35. The 4:4:4 file is 37% smaller and cleaner. Holding quality fixed, 4:4:4 costs you 41–42% more bytes than 4:2:0. That's a real cost, but it buys far more on red text than pushing quality ever does.&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="Calendar cells from a real holiday-notice template, zoomed 6x" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A programmatic poster is easy to dismiss as a hand-picked case, so I also ran a ready-made template I found online: a 2026 National Day holiday notice, one of Gaoding Design's template images, 1242×2688, with a red header, cream cards with red text and a red calendar grid. The picture above is a 6× crop of three calendar cells, the dates 1, 2 and 3 with their lunar-calendar labels underneath. It has no labels of its own. The top row is the original, the middle row is Pillow at quality 75 with 4:4:4, and the bottom row is the browser's 0.99 at 4:2:0. Look at the small characters under each date. In the bottom row they go darker and slightly brown, while the top two rows keep the same clean red. The middle file is 680,616 bytes, about a third of the browser's 2,004,878, and it's still the cleaner one. The trend matched the poster. Going from 0.80 to 0.99 in the browser made the template 3.2× bigger and only moved the edge ΔE on its red text block from 6.56 to 4.48.&lt;/p&gt;

&lt;p&gt;The reason is how JPEG stores colour. It converts RGB into one luma and two chroma planes, and 4:2:0 keeps a quarter of the chroma samples. A glyph's outline survives only as far as the text/background difference lives in luma. That red has a luma of about 66 out of 255. Against white, a good share of the edge is carried by chroma. On darker backgrounds almost all of it is, and those were the worst cases on my test board.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browsers don't expose it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;toBlob&lt;/code&gt; takes a type and a quality. There's no subsampling parameter. I read the SOF sampling factors from each exported file header to see what each engine picks. Chromium 149 and WebKit 26.5 stay at 4:2:0 through 0.99 and switch at 0.995, which rounds to 100. Firefox 151 switches at 0.895, which rounds to 90. At quality 0.9 Firefox wrote 246,096 bytes with ΔE 2.31, while Chromium wrote 157,626 bytes with 7.11. The Firefox file decodes pixel-for-pixel identical to Pillow's quality 90 at 4:4:4. It isn't a better encoder, just a different default. All three were open-source builds, and I haven't checked release Safari.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do on our side
&lt;/h2&gt;

&lt;p&gt;I tested this with ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) as it was live on 2026-09-29, default settings, the Compress + Convert entry, the same Chromium 149 build, and no upload requests in the network log. Our JPG export uses the browser's native encoder. I map the UI's 100 to 0.99 so browsers don't treat 1.0 as a special case, since WebP 1.0 in &lt;code&gt;toBlob&lt;/code&gt; flips to lossless. The side effect is that JPG from ImgIng in Chromium is always 4:2:0, and slider 100 gave the exact same 270,978-byte file as &lt;code&gt;toBlob&lt;/code&gt; at 0.99. For this poster, though, the default wasn't JPG at all. It was detected as line art and saved as an 8-bit PNG: 124 colours, 48,481 bytes, 62% smaller, edge ΔE 0.13. It isn't pixel-identical, since 2.03% of pixels differ by up to 6 levels. The detector and the quantizer aren't my code. That PNG-8 result only holds for flat graphics made of a few solid colours plus text. The real template has gradients and illustrations, and there lossless WebP came out at 2,359,014 bytes, 2.5× the size of the browser's JPEG at 0.9. ImgIng didn't pick PNG-8 for it either. It classified the template as a screenshot or UI image and defaulted to WebP at 84, which saved 82% but left an edge ΔE of 5.12, roughly where ordinary lossy WebP lands.&lt;/p&gt;

&lt;p&gt;If you have to ship JPEG with saturated text, encode it server-side with explicit 4:4:4 at a moderate quality instead of pushing quality up. Then read the SOF bytes of what you actually shipped. The main numbers come from flat programmatic graphics, with one real template as a cross-check. I haven't tested photos.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Same 200 KB, three formats: why WebP and AVIF look cleaner than JPEG</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Wed, 30 Sep 2026 08:01:38 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/same-200-kb-three-formats-why-webp-and-avif-look-cleaner-than-jpeg-2nff</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/same-200-kb-three-formats-why-webp-and-avif-look-cleaner-than-jpeg-2nff</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%2F5lm9zex6xchu3frmfrii.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%2F5lm9zex6xchu3frmfrii.png" alt=" " width="800" height="506"&gt;&lt;/a&gt;&lt;br&gt;
At ImgIng I look after the client-side encoding path, and the question I get most about our quality defaults is why JPEG gets a higher number than WebP. For photos the table says WebP 75, JPG 80, AVIF 52. It looks backwards if you read quality as a universal dial. It isn't one, and the cleanest way I know to show that is to fix the byte budget and let each format spend it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same budget, three encoders
&lt;/h2&gt;

&lt;p&gt;Each format was pushed to the highest integer quality that stays at or under 204,800 bytes (200 × 1024). JPEG and WebP came from the browser's own &lt;code&gt;canvas.toBlob&lt;/code&gt; on an Apple M4 machine with 16 GB, running Chromium 149 built from open source. I used that path on purpose, since it's the one ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) takes for those two formats. AVIF is a different story. Chromium 149's &lt;code&gt;toBlob('image/avif')&lt;/code&gt; quietly returned a 272-byte PNG, so the AVIF column comes from the AVIF encoder built into macOS (&lt;code&gt;sips&lt;/code&gt;), not from a browser. In our product AVIF is an on-demand WASM encoder, but these numbers are not from it. PSNR is over RGB, SSIM is on luma with an 11×11 window.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;PSNR&lt;/th&gt;
&lt;th&gt;SSIM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Photo 1920×1080&lt;/td&gt;
&lt;td&gt;JPEG&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;204,214&lt;/td&gt;
&lt;td&gt;35.43 dB&lt;/td&gt;
&lt;td&gt;0.9726&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;200,388&lt;/td&gt;
&lt;td&gt;37.18 dB&lt;/td&gt;
&lt;td&gt;0.9784&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;AVIF (sips)&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;203,578&lt;/td&gt;
&lt;td&gt;37.99 dB&lt;/td&gt;
&lt;td&gt;0.9842&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot 1440×2400&lt;/td&gt;
&lt;td&gt;JPEG&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;202,864&lt;/td&gt;
&lt;td&gt;39.85 dB&lt;/td&gt;
&lt;td&gt;0.9949&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;200,224&lt;/td&gt;
&lt;td&gt;45.66 dB&lt;/td&gt;
&lt;td&gt;0.9995&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;AVIF (sips)&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;200,493&lt;/td&gt;
&lt;td&gt;45.20 dB&lt;/td&gt;
&lt;td&gt;0.9997&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The photo is a landscape wallpaper that ships with the OS, downscaled to 1080p. The screenshot is one of our own help pages, text on flat UI. On the photo WebP is 1.75 dB ahead of JPEG and AVIF 2.56 dB. On the screenshot the gap is 5.8 dB, and WebP and AVIF are close to level.&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="Text crop from the screenshot at the same ≤200 KB: original, JPEG q78, WebP q97, AVIF via sips" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the edges of the letters and the flat background right next to them. JPEG leaves a faint haze and some specks around each stroke. WebP and AVIF keep the background clean up to the edge. The crop is enlarged so the pixels are visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why JPEG spends the same bytes worse
&lt;/h2&gt;

&lt;p&gt;Baseline JPEG cuts the image into fixed 8×8 blocks and codes each one with a DCT. Apart from the DC term, a block knows nothing about its neighbours. A letter stroke is a sharp step, and a sharp step inside an 8×8 block spreads energy into the high-frequency coefficients. Those are exactly the ones quantization cuts first. What survives is the step plus ripples around it, which is the haze in the crop. It also pays for every flat block on its own, even a block that's pure white.&lt;/p&gt;

&lt;p&gt;VP8, the lossy half of WebP, predicts each block from pixels it has already decoded above and to the left, and only codes the difference. On a flat UI background the prediction is nearly perfect, so the residual is close to zero and costs almost nothing. The bytes saved there go to the edges. It uses 4×4 transforms, so ringing stays in a smaller area, and it runs a deblocking filter inside the loop. AV1 takes the same idea further, with blocks that range from 4×4 up to 128×128, many more directional prediction modes, and extra filters (CDEF and loop restoration) after reconstruction. Large flat regions become a few very cheap blocks.&lt;/p&gt;

&lt;p&gt;You can see that difference in the screenshot numbers. WebP could afford quality 97 on it while JPEG stopped at 78, and my reading is that WebP spent almost nothing on the background. I can't fully explain one detail: AVIF has the best SSIM on the screenshot but a slightly lower PSNR than WebP. My guess is that its post-filters nudge many pixels by a small amount while keeping the structure intact, which SSIM forgives and PSNR doesn't. I haven't isolated it, so it stays a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for a quality table
&lt;/h2&gt;

&lt;p&gt;The quality numbers themselves don't carry across formats. At the same size the photo landed on JPEG 70, WebP 81 and AVIF 69, and the screenshot on 78, 97 and 96. That's why our table gives each format its own column and a separate row per content type: photo, smooth, texture and graphics/text, where graphics/text gets WebP 84, JPG 88, AVIF 60. The source comment says it was calibrated with SSIM measurements plus side-by-side viewing, and these results point the same way. All of this is one machine and two images, so I wouldn't treat the dB gaps as constants.&lt;/p&gt;

&lt;p&gt;If you keep one quality setting and apply it to every output format, try the fixed-budget test above on two of your own images, one photo and one screenshot. Then give each format its own default.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>In-browser AI cold start: the 6 MB ONNX Runtime WASM took longer than the 44 MB model</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:01:39 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/in-browser-ai-cold-start-the-6-mb-onnx-runtime-wasm-took-longer-than-the-44-mb-model-5bp9</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/in-browser-ai-cold-start-the-6-mb-onnx-runtime-wasm-took-longer-than-the-44-mb-model-5bp9</guid>
      <description>&lt;p&gt;At ImgIng I own the model loading path: which mirror a model comes from, the hash check, the cache. So when I timed a cold start of our fastest background removal tier, I expected the 44 MB model to be the long bar. It wasn't. The 5.95 MB onnxruntime-web WASM file finished about nine seconds after the model did, and nothing could run until it arrived.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I timed it
&lt;/h2&gt;

&lt;p&gt;I used ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;) with its quick AI tier (ISNet INT8), in a fresh browser context on an M4 Mac, Chromium 149, 16 GB, on the evening of 2026-09-28. I didn't click through the UI. I called the page's own &lt;code&gt;TYBG.segment(img, 'quick', onProgress)&lt;/code&gt; directly, which is the same function the start button calls, and logged every network request plus each progress event. The input was a synthetic bottle image I generated, and its content doesn't affect timing because the model resizes everything to 1024×1024. I ran two modes: hardware acceleration on with WebGPU on Metal, and a Chromium with no usable GPU adapter, the state you get with hardware acceleration switched off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the time went on a cold run
&lt;/h2&gt;

&lt;p&gt;With WebGPU, the runtime JS was ready at 32 ms. The model, 44,279,201 bytes from ModelScope's CDN, finished downloading at 4,188 ms, and the SHA-256 check was done by 4,207 ms. Session setup started at 4,250 ms. That is the moment ORT requested &lt;code&gt;ort-wasm-simd-threaded.asyncify.wasm&lt;/code&gt; (5,955,745 bytes gzipped) from the site's own origin. Download finished at about +13.2 s, inference started at 13,725 ms, and the result was back at 14,594 ms. Without a GPU adapter the shape was the same: the WebGPU attempt failed, the switch-to-WASM message appeared at 13,551 ms, and the run finished at 16,015 ms. Two things in that sequence are worth separating. The WASM file is requested only when a session is created, so it starts after the model is already downloaded, and the two never overlap. And the WebGPU run needed it too, because ORT's WebGPU execution provider is compiled into that same WASM file.&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="Repro page listing when the runtime WASM and the model each started and finished on a cold load" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a repro page that logs the start and end of the two downloads on one cold load. Look at the order of the two rows. The runtime request begins only after the model row ends, and it ends later even though the file is about a seventh of the size. It's a single run, so the times on screen differ a little from the ones in the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it the file or the network?
&lt;/h2&gt;

&lt;p&gt;I checked the per-file speed with curl at the same time of evening. The runtime file took 9.19 s and 10.78 s on two tries, 552 to 648 KB/s. The 44 MB model from ModelScope took 4.20 s, about 10.5 MB/s. That was an ordinary home connection that evening, and I'm not claiming it holds for other regions or other hours. A later cold run saw the runtime finish at +34.2 s with the model at +4.3 s, though that run shared the machine with other jobs, so I only take the ordering from it, not the numbers.&lt;/p&gt;

&lt;p&gt;The heavier tier shows the same order. For the professional tier (BEN2 FP16, 219,121,675 bytes) with WebGPU, the model was done at +20.5 s, the runtime at +29.8 s, and the whole call took 34.8 s.&lt;/p&gt;

&lt;p&gt;After the first visit none of this matters much. The runtime is served with &lt;code&gt;Cache-Control: max-age=31536000, immutable&lt;/code&gt; and isn't requested again, and the model comes out of Cache Storage. On a warm cache, a new page finished the whole call in 930 ms on WebGPU and 2,643 ms without an adapter.&lt;/p&gt;

&lt;p&gt;What stays with me is the asymmetry on the first visit. The model has a CDN, a backup domain and a hash check. The runtime is one request to our own origin, and it starts late. Those are the numbers as they stand on the 09-03 build.&lt;/p&gt;

&lt;p&gt;If you ship in-browser inference, open the Network waterfall on a cold load. Don't stop at the biggest file. Check when the runtime &lt;code&gt;.wasm&lt;/code&gt; request starts relative to the model, and how fast your own origin serves it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Base64-inlined WASM still costs 37% more after gzip</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:01:30 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/base64-inlined-wasm-still-costs-37-more-after-gzip-6c3</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/base64-inlined-wasm-still-costs-37-more-after-gzip-6c3</guid>
      <description>&lt;p&gt;Emscripten can bake your &lt;code&gt;.wasm&lt;/code&gt; into the JavaScript loader as a base64 string. One file, no MIME config, no &lt;code&gt;locateFile&lt;/code&gt; headaches. I work on the in-browser codec loading at imging, and our HEIC decoder ships exactly this way. This week I finally measured what that convenience costs after compression. The short answer: about 140 KB more on the first load, and gzip does not claw it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file in question
&lt;/h2&gt;

&lt;p&gt;The decoder is open-source libheif 1.19.8 plus libde265, compiled to WASM. We did not write the decoder. What we own is the loading path: try the browser's native decoder first, and only fetch the WASM when that fails. The raw WASM is 1,034,305 bytes. Inlined as base64 it becomes 1,379,076 characters, and the whole &lt;code&gt;libheif-bundle.mjs&lt;/code&gt; is 1,461,926 bytes. Production serves it gzipped at 520,705 bytes.&lt;/p&gt;

&lt;p&gt;The numbers below come from a local copy of the bundle that &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;imging&lt;/a&gt; serves in its 09-03 build. I measured it on an Apple M4 with Node 24's built-in zlib, comparing the inlined file against the same bytes split back into a bare &lt;code&gt;.wasm&lt;/code&gt; plus the leftover glue JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What base64 does to compression
&lt;/h2&gt;

&lt;p&gt;Base64 inflates size by 4/3 before compression, and most people assume gzip will squeeze that back out. I assumed so too. Here is the script I ran in the bundle's directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;fsys&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;brotliCompressSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gzipSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:zlib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libheif-bundle.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;latin1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;]{100000,}&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;latin1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gzip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gzipSync&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brotli&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brotliCompressSync&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;inline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;latin1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parts&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;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&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;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="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="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;gzip: { inline: 517971, split: 378166 }&lt;/code&gt; and &lt;code&gt;brotli: { inline: 388879, split: 287053 }&lt;/code&gt;. Split files are 139,805 bytes smaller under gzip, which means the inlined version costs 37% more on the wire. Brotli narrows the absolute gap to 101,826 bytes but does not close it. Production only serves gzip today (a &lt;code&gt;br&lt;/code&gt; request gets the uncompressed file back), so the brotli pair is hypothetical.&lt;/p&gt;

&lt;p&gt;The reason is alignment. Base64 maps every 3 input bytes to 4 characters. A repeated byte sequence in the WASM lands at one of three offsets relative to those 3-byte groups, so it turns into one of three different character strings. Deflate only matches literal repeats, and a lot of them stop being literal. Compressed alone, the base64 string is 489,570 bytes while the raw WASM is 350,473.&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="Bytes transferred when the HEIC decoder loads: unpacked size, inlined WASM, first transfer, and revisit" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the last two bars. The first load transfers 522,557 bytes across both files, and by my local math over 100 KB of that is the base64 tax. A reload costs 1,095 bytes, because the files are served with &lt;code&gt;max-age=0, must-revalidate&lt;/code&gt; and come back as two 304s. So the penalty lands once per user, not per visit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is streaming compilation the bigger loss?
&lt;/h2&gt;

&lt;p&gt;Inlining also rules out &lt;code&gt;WebAssembly.compileStreaming&lt;/code&gt;, since there is no separate response to stream. I expected that to matter. This bundle uses synchronous &lt;code&gt;new WebAssembly.Module&lt;/code&gt;, and in Chromium 149 that call took 0.9 to 1.2 ms over five runs. Most of that speed is V8's lazy compilation: with &lt;code&gt;--no-wasm-lazy-compilation&lt;/code&gt; the same call took 6.9 ms, because function bodies are otherwise compiled on first call. So it is not the full compile cost. It does suggest the bytes are the real bill here, not the compile. Decoding is heavier than either: a 12MP image took 377 ms on the main thread in the same headless Chromium build, on a synthetic sample I encoded with macOS sips.&lt;/p&gt;

&lt;p&gt;Two more caveats. My local gzip-6 output is 2,734 bytes smaller than what production actually sends, and I don't know which gzip level the server uses. I also have not deployed a split build, so "split saves 140 KB" is arithmetic, not a measured transfer.&lt;/p&gt;

&lt;p&gt;Only a subset of users pay this at all. The decoder downloads only when native decoding fails, which in my runs meant Playwright's Chromium 149 and Firefox 151 builds. Playwright's WebKit 26.5 build on macOS decoded the HEIC natively and never fetched it.&lt;/p&gt;

&lt;p&gt;If you ship an Emscripten single-file build, run the script above against your own bundle. Compare &lt;code&gt;inline&lt;/code&gt; to &lt;code&gt;split&lt;/code&gt; before deciding whether the deployment simplicity is worth the extra bytes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Stop UA-sniffing canvas encoders: probe toBlob once at page load</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Sat, 26 Sep 2026 08:01:23 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/stop-ua-sniffing-canvas-encoders-probe-toblob-once-at-page-load-4ok4</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/stop-ua-sniffing-canvas-encoders-probe-toblob-once-at-page-load-4ok4</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%2Fa250qxtefwbpuqw6dcgx.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%2Fa250qxtefwbpuqw6dcgx.png" alt=" " width="800" height="287"&gt;&lt;/a&gt;&lt;br&gt;
Here is a line I still find in image upload code: &lt;code&gt;if (!/Safari/.test(navigator.userAgent)) type = 'image/webp'&lt;/code&gt;. It is wrong in both directions. Chromium's user agent contains the word "Safari" too, so Chrome users lose WebP for no reason. And the UA string tells you nothing about what the canvas can actually encode.&lt;/p&gt;

&lt;p&gt;I work on the in-browser encoding side of imging, and we never picked output formats by UA. This week I re-checked how that holds up. The setup I used: the live build of &lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;imging&lt;/a&gt; from 09-03, run in the three engines bundled with Playwright 1.61.1 (Chromium 149, WebKit 26.5, Firefox 151, all headless on an M4 Mac), with a synthetic 2000×1500 test image.&lt;/p&gt;
&lt;h2&gt;
  
  
  Decoding a format is not encoding it
&lt;/h2&gt;

&lt;p&gt;The WebKit 26.5 build reports &lt;code&gt;Version/26.5 Safari/605.1.15&lt;/code&gt;. It displays WebP images fine. Its &lt;code&gt;canvas.toBlob('image/webp')&lt;/code&gt; cannot produce WebP. Meanwhile the same build encodes AVIF, BMP, GIF and HEIC, which Chromium 149 cannot. Chromium encodes PNG, JPEG and WebP only. Firefox 151 adds BMP. There is no version line you can draw.&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="Which formats toBlob can encode in each engine" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at where the red cells are: every column has some. The red cells also share one behavior that makes UA logic dangerous. An unsupported type silently falls back to PNG. No exception, no &lt;code&gt;null&lt;/code&gt;, no console warning. Asking this WebKit build for WebP from our photo sample returned a 7,993,002-byte PNG, 19.8 times the size of Chromium's default WebP. If your code names that file &lt;code&gt;photo.webp&lt;/code&gt;, the server receives a large file with a lying extension. Even the typo &lt;code&gt;image/jpg&lt;/code&gt; (missing the e) falls back to PNG in all three.&lt;/p&gt;
&lt;h2&gt;
  
  
  Probe once at load
&lt;/h2&gt;

&lt;p&gt;The fallback is honest in one place: the returned &lt;code&gt;blob.type&lt;/code&gt; says &lt;code&gt;image/png&lt;/code&gt;. So instead of asking which browser this is, ask the encoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dev.to: same idea, OffscreenCanvas flavour&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FORMATS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/avif&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&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;canEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&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;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;oc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&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;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;oc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mime&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;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// a silent PNG fallback fails this check&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran this in all three engines. Chromium and Firefox answered webp true, avif false, jpeg true. WebKit answered webp false, avif true, jpeg true. That matches the full table above. A 4×4 canvas is enough because the format decision does not depend on size. The one size to avoid is 0×0, which was the only case where &lt;code&gt;toBlob&lt;/code&gt; returned &lt;code&gt;null&lt;/code&gt; in my tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the probe decides in imging
&lt;/h2&gt;

&lt;p&gt;On load, imging draws nothing fancy: a 4×4 canvas, one &lt;code&gt;toBlob&lt;/code&gt; call each for png, jpeg, webp and avif. No WASM is loaded at that point. The answers pick a path per output format.&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%2Fu3zc8p0siurf3h5ys2gf.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%2Fu3zc8p0siurf3h5ys2gf.png" alt="imging picks an encoder from a live capability test" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two green cells sit in different columns, and that is the point. For WebP, Chromium and Firefox use &lt;code&gt;canvas.toBlob('image/webp', 0.8)&lt;/code&gt;, and the output is byte-identical to a direct canvas export (404,244 and 404,558 bytes). WebKit fails the probe, so the page fetches a self-hosted libwebp WASM encoder on demand, which produced 500,178 bytes. AVIF goes the other way: Chromium gets libavif in WASM, WebKit uses its native encoder. The same code takes opposite routes in two engines with zero per-browser branches. We did not write these encoders. The native ones ship with the browser, the WASM ones are open-source libwebp and libavif. Our part is the probe, the routing and the lazy loading.&lt;/p&gt;

&lt;p&gt;Across 37 test runs in the three engines, there were zero non-GET requests. The only extra traffic was the GET for an encoder file the first time it was needed. That covers the common formats in the main app, not every feature on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the probe does not tell you
&lt;/h2&gt;

&lt;p&gt;It answers "can this engine encode X". It does not answer "what will X look like". At the same JPEG quality 0.8, the WebKit build produced a file 2.53 times larger than Chromium's, and both passed the probe. Quality mapping needs separate calibration per engine, and I have not found a cheaper way than measuring bytes. I also did not run the AVIF path in Firefox, and none of this was tested on real Safari or iOS, only the Playwright builds.&lt;/p&gt;

&lt;p&gt;If you have UA-based export code today, add one log line first: record the requested type next to the returned &lt;code&gt;blob.type&lt;/code&gt;. Every row where they differ is a user who got a file that is not what its name says.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your HTML-to-PDF export has zero selectable characters</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Fri, 25 Sep 2026 08:01:30 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/your-html-to-pdf-export-has-zero-selectable-characters-4gld</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/your-html-to-pdf-export-has-zero-selectable-characters-4gld</guid>
      <description>&lt;p&gt;Someone sent me a PDF exported from an internal report page and asked me to check one SKU in it. Ctrl+F found nothing. Dragging across the table produced no selection. At 400% the glyph edges were jagged, so the whole page was a bitmap — and the person who exported it had no idea, because on their side it was just a button labelled Export PDF.&lt;/p&gt;

&lt;p&gt;PDF generation is not my lane. I work on codec and model loading on the client side, so everything below comes from measuring artifacts, not from reading anyone's source.&lt;/p&gt;

&lt;p&gt;I hand-wrote seven HTML samples shaped like a cross-border warehouse report. The brand in them, Tidebox, does not exist; the SKUs, warehouses and amounts are all made up, and none of it corresponds to a real company or a real report. Then I exported each sample three ways: Playwright calling &lt;code&gt;page.pdf()&lt;/code&gt;, which is the browser print path; the HTML-to-PDF tool on ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;); and a control I built myself — screenshot the full page at 2x and drop that single PNG into a PDF of the same size. That third one matches what html2canvas plus jsPDF produces in shape. It is not a stand-in for any product, it only exists to put a number on what "export the page as an image" costs.&lt;/p&gt;

&lt;p&gt;About where the second path runs, since the rest of my measurements depend on it: importing, directory reading, resource mapping, instant preview, and the lossless minification applied after the PDF comes back all happen locally in the browser, with zero upstream traffic during that stretch. Only after you click convert does it take a self-contained HTML snapshot — scripts stripped, every resource inlined — and submit it with a single POST to the same-origin imging.cn endpoint, where server-side Chromium / Skia returns the real PDF. This is the one document capability there that goes through a server. I wrapped &lt;code&gt;fetch&lt;/code&gt;, XHR and &lt;code&gt;sendBeacon&lt;/code&gt; to keep a ledger: zero non-GET requests before the click, exactly one POST after it, 1,604,339 bytes of request body for the illustrated sample, zero &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags inside that body, and one domain touched the whole session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting what survives
&lt;/h2&gt;

&lt;p&gt;The blunt number first. Same 46-row report: the laid-out PDF is 156,370 bytes with 2,996 extractable characters; my screenshot export is 995,232 bytes with zero. Here is the whole measurement, which is short enough to paste:&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&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;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;laid-out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;screenshot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]):&lt;/span&gt;
    &lt;span class="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="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;chars&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&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;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;chars&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chars&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;p&gt;It prints &lt;code&gt;laid-out 156,370 bytes 2,996 chars&lt;/code&gt; and &lt;code&gt;screenshot 995,232 bytes 0 chars&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;6.4x the bytes for zero selectable text is the part people notice. The parts they notice later cost more. The SVG chart in the illustrated sample survives as 819 vector drawing objects — I rendered that region at 900 dpi and the axis labels still have clean edges. In the screenshot export the vector object count is 0. The four link annotations in the body (three external, one in-page anchor) come through intact with identical URIs on both laid-out paths, and come through as 0 on the screenshot path. A link in a PDF is a separate object bound to a rectangle; photographing blue text does not create one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="Same region magnified: the laid-out path keeps 2,996 selectable characters at 156,370 bytes, the screenshot export has 0 characters at 995,232 bytes" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The bitmap was the one thing that did not degrade. The 960x600 PNG has the same sha256 in the source file and in the laid-out artifact, with a maximum per-pixel difference of 0. It does grow from 817,313 to 1,432,687 bytes once embedded, but that is Chromium rewrapping the PNG and both laid-out paths grow identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paper size is a print-pipeline decision
&lt;/h2&gt;

&lt;p&gt;The other half of the complaint — tiny type, wrong page count — has nothing to do with screenshots.&lt;/p&gt;

&lt;p&gt;With no print CSS at all, the 46-row report prints to 2 pages at 612x792pt. That is Letter, not A4; I had assumed A4 for years and this build of Chromium defaults to Letter. To fit an 1180px-wide table onto an 816px-wide sheet it shrinks the page, and 13px table text lands at 6.74pt. The path that reads the full rendered width and height instead emits one custom 1080x1536pt page, and the same text lands at 9.75pt. That gap is scaling, not rendering quality — I could not measure any difference in glyph sharpness between the two.&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%2Fiaq3lkxk51pkh9m03g4x.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%2Fiaq3lkxk51pkh9m03g4x.png" alt="Same 46-row report: browser print gives 2 Letter pages at 6.74pt, following the real page size gives 1 page at 9.75pt" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once the page declares &lt;code&gt;@page&lt;/code&gt;, the two paths converge. I added &lt;code&gt;@page&lt;/code&gt;, repeating headers and no in-row breaks to a longer table, and both exports came out at 7 A4 pages with the header repeated 7 times, matching item by item on page count, page size, type size and extractable characters. &lt;code&gt;@page&lt;/code&gt; inside &lt;code&gt;@media print { }&lt;/code&gt; is picked up just as well as a bare one — my two samples differing only in that detail produced artifacts 30 bytes apart.&lt;/p&gt;

&lt;p&gt;One thing I had prepared for did not happen: table rows cut in half across a page boundary. I tagged every row twice and checked whether a tag ever landed on two pages. Across seven samples and every path the count was 0. This build pushes a row whole to the next page and repeats &lt;code&gt;thead&lt;/code&gt; on its own, so that &lt;code&gt;thead { display: table-header-group }&lt;/code&gt; line everyone copies is redundant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one place data actually disappears
&lt;/h2&gt;

&lt;p&gt;A table inside &lt;code&gt;height: 520px; overflow: auto&lt;/code&gt; exported 8 of its 46 rows. Both paths, same 8 rows, no warning in either UI. Component libraries put tables in scroll containers by default, so this is the common case in admin dashboards, and no exporter saves you from it. Release the container before exporting and let the table reach its real height.&lt;/p&gt;

&lt;p&gt;Still unsolved on my side: Chinese line breaks differ between local printing and server-side rendering, because the renderer does not have my machine's CJK fonts and substitutes its own metrics. Four characters in one lead paragraph moved to the next line. The Latin face was inlined in the page, so that part matches character for character. Inlining the CJK font should fix it; two attempts did not work and I left it there.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A .docx does not store where its pages end</title>
      <dc:creator>Lank_M</dc:creator>
      <pubDate>Thu, 24 Sep 2026 08:01:51 +0000</pubDate>
      <link>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-docx-does-not-store-where-its-pages-end-pee</link>
      <guid>https://dev.to/pm_cheng_3f36acecfb9c59f5/a-docx-does-not-store-where-its-pages-end-pee</guid>
      <description>&lt;p&gt;Someone handed me a Word proposal last week and asked whether converting it to HTML would look exactly like Word. I said no. They thought I was dodging. All I had at the time was "the formats are different," which explains nothing, so I sat down and built a sample to measure what actually changes.&lt;/p&gt;

&lt;p&gt;I hand-wrote the OOXML instead of using a template: A4 page setup, six hard page breaks, one 36-row six-column table with &lt;code&gt;w:tblHeader&lt;/code&gt; on the first row, a 22-item three-level numbered list, three images, four footnotes, header and footer with PAGE and NUMPAGES fields, and a VML WordArt title on the cover. Company name, order numbers and amounts are all made up — this is not anyone's real document. I ran it through the DOCX-to-HTML tool on ImgIng (&lt;a href="https://imging.ai/" rel="noopener noreferrer"&gt;https://imging.ai/&lt;/a&gt;), which does the work inside the browser. Six runs with the Network panel open produced zero non-GET requests, which matters here: with no server in the loop, every difference I see belongs to parsing and layout rebuilding, and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  A .docx does not store where the pages end
&lt;/h2&gt;

&lt;p&gt;Unzip it, read &lt;code&gt;word/document.xml&lt;/code&gt;, and count what is actually there.&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A_proposal.docx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;word/document.xml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;w:pgSz w:w=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; w:h=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+)&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;groups&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;page:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;twips =&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;56.7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;56.7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mm&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cached page-end markers:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;lastRenderedPageBreak&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;p&gt;That prints &lt;code&gt;page: 11906 x 16838 twips = 210 x 297 mm&lt;/code&gt; and &lt;code&gt;cached page-end markers: 0&lt;/code&gt;. The section properties know the paper is A4. Six &lt;code&gt;w:type="page"&lt;/code&gt; breaks are in there too — the ones I inserted by hand. What is missing is the third thing: &lt;code&gt;lastRenderedPageBreak&lt;/code&gt;, the marker Word writes back after it renders — a cache of where the lines fell last time — and my hand-written package has none of them. Even when it is there, it is a record, not a contract. &lt;code&gt;word/settings.xml&lt;/code&gt; also carries a &lt;code&gt;compatibilityMode&lt;/code&gt; value (15 in my file) that decides which generation of layout rules to apply; change it and the same text breaks elsewhere.&lt;/p&gt;

&lt;p&gt;So what a .docx hands you is paper size, margins, font names, spacing, and the few places that must break. Which sentence ends page 3 is computed at open time by whatever program opens it, from glyph metrics that live on that machine — and Word folds printer metrics into the same calculation. Font availability and printer metrics are the two things least likely to match between two computers, which is why the same .docx already paginates differently on your colleague's laptop. HTML conversion just changes who is doing the arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three differences you can see without a reference
&lt;/h2&gt;

&lt;p&gt;The product is one 85,903-byte HTML file with zero external resources and three images inlined as base64 at 31% of the file. Text fidelity is good: 286 of 286 source paragraphs matched, 3,178 characters selectable. But three things are plainly wrong, and none of them needs a Word baseline to spot.&lt;/p&gt;

&lt;p&gt;Page numbers first. The output has seven pages, and pages 2 through 7 all print the same footer — "第 1 页 共 10 页", page 1 of 10. The source stores cached values of 1 and 10 next to the PAGE and NUMPAGES fields; the fields were never evaluated, so the cached values were carried straight through — while the viewer's own toolbar reads 01 / 07.&lt;/p&gt;

&lt;p&gt;Then the long table. Six of the seven page shells are &lt;code&gt;794 x 1123&lt;/code&gt; (A4 at 96 dpi); page four is &lt;code&gt;794 x 1733&lt;/code&gt;. The 36-row table was not split across pages — it was kept whole and the page was stretched around it, from 1,123 px to 1,733 px. The &lt;code&gt;w:tblHeader&lt;/code&gt; hint is gone too: zero &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt;, no &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt;. Print that page and it overflows A4.&lt;/p&gt;

&lt;p&gt;Third, the WordArt cover title renders as &lt;code&gt;&amp;lt;svg style="width:430pt;height:46pt" width="0" height="0"&amp;gt;&lt;/code&gt; — 573×61 px of layout occupied by zero text nodes and zero paths. A second sample with an embedded Excel OLE object behaved the same way: the object and its 520×300 static preview both vanished, leaving two empty &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; elements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same engine is steady on PPTX
&lt;/h2&gt;

&lt;p&gt;I built an 8-slide quarterly review with a bar chart, a pie chart, SmartArt, a 7×5 table, four rotated shapes and a two-second embedded video. The source declares &lt;code&gt;p:sldSz&lt;/code&gt; as 12191695×6858000 EMU, ratio 1.7777. The output keeps all eight slides at &lt;code&gt;data-width=1280 data-height=720&lt;/code&gt;, ratio 1.7778. The four rotations come out at −22°, 15°, −160°, −28° against 338°, 15°, 200°, 332° in the source — each one accounted for. The table is still a real &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt;, 7 rows and 35 cells.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/..." alt="The 8-slide PPTX sample converted: SmartArt stays vector and selectable, the bar chart is frozen into a PNG" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Not everything survives there either. Both charts are drawn into a canvas and frozen as PNGs — the axis categories and the data labels appear zero times in the output HTML, and only the HTML legend is selectable. SmartArt does stay vector, eight &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; elements with all five stage labels selectable. Animations and transitions do not run; &lt;code&gt;@keyframes&lt;/code&gt; count is 0.&lt;/p&gt;

&lt;p&gt;The difference is not effort, it is what each format stores. PPTX stores canvas size plus absolute coordinates and rotation per object — environment-independent numbers that survive any move. DOCX stores reflow parameters, and the final layout has to be recomputed from metrics that live on the reader's machine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3r0usjanv0dunofw999l.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%2F3r0usjanv0dunofw999l.png" alt="28,516 bytes of DOCX became an 85,903-byte single HTML file, zero external resources, zero parser traces" width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the line I use now: fixed-layout formats convert into fixed layout, reflowable formats only ever approximate. Before sending anything out, I check the footer page numbers, the WordArt title, and any embedded spreadsheet objects.&lt;/p&gt;

&lt;p&gt;One limit I have to state. There is no Word or LibreOffice on this machine, so I have no authoritative page count to compare against — every page-count claim above is measured against my own six explicit breaks. The three defects do not need a baseline, which is why those are the only ones I am willing to state flatly.&lt;/p&gt;

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