<?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: shaojie gong</title>
    <description>The latest articles on DEV Community by shaojie gong (@shaojie).</description>
    <link>https://dev.to/shaojie</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%2F4015883%2Ff495cde7-b3bb-432b-8da3-ff6aa5217ebe.png</url>
      <title>DEV Community: shaojie gong</title>
      <link>https://dev.to/shaojie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shaojie"/>
    <language>en</language>
    <item>
      <title>A complete NotebookLM research workflow</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Fri, 06 Nov 2026 13:07:39 +0000</pubDate>
      <link>https://dev.to/shaojie/a-complete-notebooklm-research-workflow-2483</link>
      <guid>https://dev.to/shaojie/a-complete-notebooklm-research-workflow-2483</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%2Fsfby4atztnglh23blj8h.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%2Fsfby4atztnglh23blj8h.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
Research tools become difficult to use when collection, organization, citation and study are treated as one giant feature.&lt;/p&gt;

&lt;p&gt;The first stage is Collect. Bring source links into a notebook and check what NotebookLM was able to load. The second stage is Organize. Use predictable notebook names, folders, tags and saved records so useful material can be found later.&lt;/p&gt;

&lt;p&gt;The third stage is Cite. Export structured source information for Zotero or generate APA, MLA, Chicago and BibTeX references, then verify the metadata. The fourth stage is Study. Export selected flashcards to Anki while preserving the source on each card.&lt;/p&gt;

&lt;p&gt;Each stage has a visible result: a source list, an organized saved record, a verified citation or an Anki deck. That makes the workflow easier to debug and easier to learn.&lt;/p&gt;

&lt;p&gt;NotebookBloom brings these actions into one panel, but the operating rule stays simple: finish one useful job before opening the next tab.&lt;/p&gt;

&lt;p&gt;Practical checklist&lt;/p&gt;

&lt;p&gt;Use a public or non-sensitive notebook for the first test. Verify the output before deleting the intermediate file, and keep the product limitation visible.&lt;/p&gt;

&lt;p&gt;Related guide: &lt;a href="https://notebookbloom.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_research_workflow" rel="noopener noreferrer"&gt;https://notebookbloom.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_research_workflow&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>notebooklm</category>
      <category>anki</category>
      <category>zotero</category>
    </item>
    <item>
      <title>Resizing images on a canvas without jagged edges: halving steps, fit-to-box math and Safari's canvas limit</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:09:50 +0000</pubDate>
      <link>https://dev.to/shaojie/resizing-images-on-a-canvas-without-jagged-edges-halving-steps-fit-to-box-math-and-safaris-5dcg</link>
      <guid>https://dev.to/shaojie/resizing-images-on-a-canvas-without-jagged-edges-halving-steps-fit-to-box-math-and-safaris-5dcg</guid>
      <description>&lt;p&gt;&lt;em&gt;I build MakeQuickWork, a small set of free browser tools, on my own. This post is about the Resize Image tool and the three things that took longer than the resize itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffu2z2tdvasbo4q6jri8j.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%2Ffu2z2tdvasbo4q6jri8j.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Resizing an image in the browser looks like one call: draw the image onto a smaller canvas with &lt;code&gt;drawImage&lt;/code&gt;. That works, and on a big photo it also looks bad. The edges go jagged and fine texture turns into noise.&lt;/p&gt;

&lt;p&gt;Getting it to look right took one loop. Most of the rest of the time went into the size fields: what "keep aspect ratio" should mean when someone enters both a width and a height, and how big a canvas a phone will actually agree to create.&lt;/p&gt;

&lt;p&gt;The tool is here: &lt;a href="https://makequickwork.com/image/resize-image/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/resize-image/&lt;/a&gt;. Everything runs in the tab. The image is decoded, resized and encoded locally, and the result is a normal download.&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%2Ftp36ipcvy88hnqerx9sl.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%2Ftp36ipcvy88hnqerx9sl.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding: let the browser rotate it
&lt;/h2&gt;

&lt;p&gt;Phone photos are often stored sideways with an EXIF Orientation tag. If you ignore it, a portrait photo comes out lying down. &lt;code&gt;createImageBitmap&lt;/code&gt; can apply it for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bmp&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;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;imageOrientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HEIC is the exception. Safari decodes it natively; Chrome, Edge and Firefox don't. So the decoder tries &lt;code&gt;createImageBitmap&lt;/code&gt; first and falls back to libheif compiled to WebAssembly, which is only downloaded the first time someone actually opens a HEIC file. The page itself loads about 11 KB of JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  Downscaling: halve, then finish
&lt;/h2&gt;

&lt;p&gt;A single &lt;code&gt;drawImage&lt;/code&gt; from 3200 px wide to 1080 px samples too few source pixels per output pixel, and the result aliases. Raising &lt;code&gt;imageSmoothingQuality&lt;/code&gt; to &lt;code&gt;"high"&lt;/code&gt; helps, but browsers differ in how much. Halving repeatedly until the next halving would undershoot, and doing the last step at the exact size, gives consistently clean results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Resizes with repeated halving, which stays sharp where a single large downscale gets jagged. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resize&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="nx"&gt;HTMLCanvasElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&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;cur&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;half&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;half&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="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageSmoothingQuality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&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="nx"&gt;half&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;half&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&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="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// free the intermediate&lt;/span&gt;
    &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;half&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&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="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageSmoothingQuality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&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="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&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="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;out&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;Setting &lt;code&gt;width = height = 0&lt;/code&gt; on canvases you're done with matters more than it looks. On iOS, canvas memory is tight and isn't always released promptly, so a batch of large photos can fail halfway if intermediates hang around.&lt;/p&gt;

&lt;p&gt;Enlarging goes through the same function. The loop simply doesn't run, and you get a single high-quality &lt;code&gt;drawImage&lt;/code&gt;. It's allowed, but it can't invent detail. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Keep aspect ratio" means a box
&lt;/h2&gt;

&lt;p&gt;This was the design question I spent the most time on. If keep-ratio is on and someone types width 1080 and height 1080 for a 4:3 photo, there are three reasonable answers: crop to a square, stretch to a square, or fit inside the square. I picked fit. Width × height is a box, and the image is scaled by the smaller of the two factors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&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;sy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter a width or a height.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty field becomes &lt;code&gt;Infinity&lt;/code&gt;, so leaving the height blank means "width 1080, height whatever it needs to be". It also makes batches behave: a landscape and a portrait image in the same 1080 × 1080 box each keep their own proportions. There is no cropping anywhere in the tool. If you need an exact size, turn keep-ratio off and the image is stretched to it.&lt;/p&gt;

&lt;p&gt;The width and height fields are prefilled from the first image's dimensions, and while keep-ratio is on, typing one side recalculates the other from that image's ratio. That way the numbers you see always match what the first image will become.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canvas size ceiling
&lt;/h2&gt;

&lt;p&gt;iPhone Safari refuses to create canvases above roughly 16 megapixels. It doesn't throw a helpful error; you just get a blank image. So every target size is clamped before anything is drawn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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;MAX_SIDE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_SIDE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;th&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;MAX_SIDE = 16_000&lt;/code&gt;, output stays within 16,000 px per side and about 16 MP total in every browser. If you ask for 8000 × 6000, you get less than that, scaled down proportionally. I'd rather be honest about the limit than have the tool work on a desktop and silently fail on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encoding and the batch
&lt;/h2&gt;

&lt;p&gt;JPG and WebP are saved at 92% quality; PNG stays lossless. WebP needs a detour: Safari's &lt;code&gt;canvas.toBlob&lt;/code&gt; can't write WebP, so WebP output goes through libwebp compiled to WebAssembly in every browser. Multiple results are zipped with fflate. Output names carry the size, like &lt;code&gt;photo-1080x720.jpg&lt;/code&gt;, so a folder of exports stays readable.&lt;/p&gt;

&lt;p&gt;EXIF doesn't survive the round trip through a canvas, so GPS location and camera details are gone from the output. For most uses that's a bonus, but if you need the metadata, keep the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like on real files
&lt;/h2&gt;

&lt;p&gt;On my test images, with width 1080, height empty and keep-ratio on:&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%2Fc3tj0mjhfoctsormc5ne.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%2Fc3tj0mjhfoctsormc5ne.png" alt=" " width="800" height="324"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;beach-sunset.jpg&lt;/code&gt;: 3200 × 2400, 2.9 MB → 1080 × 810, 68.8 KB, saved as &lt;code&gt;beach-sunset-1080x810.jpg&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;receipt.jpg&lt;/code&gt;: 900 × 1200, 41.3 KB → 1080 × 1440, 63.8 KB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is the case I want to point out. The receipt was narrower than 1080, so the box enlarged it. The file got bigger and the text got slightly softer, because upscaling interpolates pixels; it doesn't recover anything. If you only want to shrink, check the source dimensions first or use the percentage mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I check nothing is uploaded
&lt;/h2&gt;

&lt;p&gt;The site is static (Astro on Cloudflare Pages), and there is an automated test that processes a file in every tool and fails on any POST, or any third-party request other than Google Analytics, during processing. It also fails if an analytics event contains a file name. You can verify it yourself in the DevTools Network tab. You do need a connection to load the page and the WebAssembly modules.&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%2F7mvlhym1oz9zkx69anig.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%2F7mvlhym1oz9zkx69anig.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've solved the downscale problem a different way on canvas, or think crop-to-fill should be an option next to fit, I'd like to hear about it in the comments.&lt;/p&gt;

</description>
      <category>makequickwork</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
    </item>
    <item>
      <title>Bulk-import playlists, channels, RSS and links</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 05 Nov 2026 12:26:51 +0000</pubDate>
      <link>https://dev.to/shaojie/bulk-import-playlists-channels-rss-and-links-49df</link>
      <guid>https://dev.to/shaojie/bulk-import-playlists-channels-rss-and-links-49df</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%2Fiun0fvv53mg0549gcw67.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%2Fiun0fvv53mg0549gcw67.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
Bulk import is useful when the input already has structure: a YouTube playlist, a channel, an RSS feed or a prepared list of links.&lt;/p&gt;

&lt;p&gt;Open the target NotebookLM notebook first. Choose the matching import mode, paste the URL or list, and start with a small batch. This makes it easier to see whether the source type is supported before reaching a notebook limit.&lt;/p&gt;

&lt;p&gt;After import, inspect the final source list. A video may appear without useful text when no transcript is available. A private or restricted page may fail. A large playlist can exceed the remaining source capacity.&lt;/p&gt;

&lt;p&gt;The result should show how many sources were imported and how many failed. A silent partial import is harder to correct than an explicit failure count.&lt;/p&gt;

&lt;p&gt;NotebookBloom automates the repeated Add source steps. It does not remove NotebookLM's own source limits or guarantee that every external page exposes usable content.&lt;/p&gt;

&lt;p&gt;Practical checklist&lt;/p&gt;

&lt;p&gt;Use a public or non-sensitive notebook for the first test. Verify the output before deleting the intermediate file, and keep the product limitation visible.&lt;/p&gt;

&lt;p&gt;Related guide: &lt;a href="https://notebookbloom.com/blog/notebooklm-bulk-import-folders/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_bulk_import" rel="noopener noreferrer"&gt;https://notebookbloom.com/blog/notebooklm-bulk-import-folders/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_bulk_import&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>notebooklm</category>
      <category>anki</category>
      <category>zotero</category>
    </item>
    <item>
      <title>My 100 KB image compressor produced 36 KB files. The bug was using a different encoder to search.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 05 Nov 2026 02:15:09 +0000</pubDate>
      <link>https://dev.to/shaojie/my-100-kb-image-compressor-produced-36-kb-files-the-bug-was-using-a-different-encoder-to-search-2nib</link>
      <guid>https://dev.to/shaojie/my-100-kb-image-compressor-produced-36-kb-files-the-bug-was-using-a-different-encoder-to-search-2nib</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm the developer of MakeQuickWork, a small site of free browser tools. This is how its Compress Image tool hits a file-size target, and the bug that made the first version miss it by two thirds.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F94zq1h3vo8t27s65jilk.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%2F94zq1h3vo8t27s65jilk.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Plenty of upload forms say "photo must be under 100 KB". So besides the usual Light / Recommended / Strong levels, the tool has a &lt;strong&gt;Target size&lt;/strong&gt; mode: type a number of KB and get the best-looking file that fits.&lt;/p&gt;

&lt;p&gt;The first version worked in the sense that every output was under 100 KB. On my 3200×2400 test photo it produced a 36 KB file at quality 31%. Under the limit, technically. Also a third of the budget, and visibly worse than it needed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Everything runs in the tab. Encoders are WebAssembly builds via jSquash: MozJPEG for JPG, libwebp for WebP, OxiPNG for PNG. None of them ship with the page. The tool page loads roughly 10 KB of JavaScript, and each encoder is fetched the first time you actually compress something in that format. Files are never uploaded, which you can check in the Network tab: &lt;a href="https://makequickwork.com/image/compress-image/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/compress-image/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipzewap57vn6otqa1161.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%2Fipzewap57vn6otqa1161.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quality mode is straightforward: decode to a canvas, encode at 85%, 75% or 60%. Target mode is a search problem: find the highest quality &lt;code&gt;q&lt;/code&gt; where &lt;code&gt;encode(q).size &amp;lt;= target&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug: searching with a stand-in encoder
&lt;/h2&gt;

&lt;p&gt;MozJPEG through WASM isn't instant on a 7.7-megapixel image, and a search needs several encodes. The browser's &lt;code&gt;canvas.toBlob(..., "image/jpeg", q)&lt;/code&gt; is much faster. So version one did the search with canvas, found the quality that landed just under 100 KB, then wrote the final file with MozJPEG at that same quality.&lt;/p&gt;

&lt;p&gt;That assumes "quality 31%" means the same thing in both encoders. It doesn't. MozJPEG is built to squeeze harder than a typical encoder, and at low quality the gap gets large. Canvas needed 31% to fit in 100 KB. MozJPEG at 31% came out at 36 KB.&lt;/p&gt;

&lt;p&gt;The tempting fix is a correction factor. I didn't trust one: the gap between encoders depends on the image content, so any constant is wrong for some photo. The fix that's actually correct is boring. Search with the same encoder that writes the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Search with the same encoder that writes the file: a faster stand-in (the browser's JPG encoder)&lt;/span&gt;
&lt;span class="c1"&gt;// compresses very differently and would pick a much lower quality than needed.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLCanvasElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;optimize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Making the real encoder affordable
&lt;/h2&gt;

&lt;p&gt;That made each search step slower, so I needed fewer steps. Plain bisection on &lt;code&gt;q&lt;/code&gt; between 0.30 and 0.92 takes five or six encodes to get close. But file size isn't random in quality: it grows smoothly and roughly exponentially. So I interpolate on &lt;code&gt;log(size)&lt;/code&gt; between the two ends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&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;enc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;floor&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;enc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// too big even at low quality: try a smaller size&lt;/span&gt;
  &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&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;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;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="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;best&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;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.03&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;t&lt;/span&gt; &lt;span class="o"&gt;=&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.97&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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="nb"&gt;Math&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="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.02&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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&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="nf"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&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;q&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&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;size&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&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;size&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It aims at 97% of the target, not 100%, so the guess lands under more often than over.&lt;/li&gt;
&lt;li&gt;It stops as soon as the best result is within 8% of the target. 92 KB vs 97 KB isn't visible; another encode is.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;±0.02&lt;/code&gt; clamp keeps a bad guess from collapsing onto an endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;best&lt;/code&gt; only ever holds a blob that fit, so the result can never be bigger than the target.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Usually that's two or three encodes after the endpoints, which roughly halved the time on a large photo compared with bisecting. It still takes a few seconds on a big image, because it really does encode several times. Quality mode is a single encode.&lt;/p&gt;

&lt;p&gt;Same photo, same 100 KB target, after the fix: &lt;strong&gt;96.1 KB at quality 56%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz9z39ls31befv0krsnx5.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%2Fz9z39ls31befv0krsnx5.png" alt=" " width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When quality isn't enough
&lt;/h2&gt;

&lt;p&gt;If even quality 30% doesn't fit, the image is too big in pixels for the budget. Only then does it shrink dimensions, stepping down through scales (0.85, 0.7, 0.55 and so on) and re-running the search at each. Quality first, pixels last, because most people uploading a photo to a form care more about it not looking smeared than about its exact resolution. If the image would have to drop below 48 px on its short side, it gives up and says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other rules
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PNG can't target a size.&lt;/strong&gt; PNG is lossless, so there's no quality knob to turn. In target mode a PNG is saved as JPG, or as WebP if it has transparency, and the result row says which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never return a bigger file.&lt;/strong&gt; In quality mode, if re-encoding in the same format produces something larger (common with already-optimised JPGs), the original is kept and labelled "already well compressed".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG colour reduction&lt;/strong&gt; is on by default: UPNG.js quantises to at most 256 colours, then OxiPNG repacks. That's a big win for screenshots and icons, but it can band on photos and gradients. Off means lossless repacking only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 KB = 1024 bytes&lt;/strong&gt;, because when a form says 100 KB I'd rather err on the strict side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EXIF is removed&lt;/strong&gt;, since the image is re-encoded from a canvas. Rotation is applied first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Recommended mode, on my test photos: the 2.9 MB photo went to 258.6 KB (91% smaller), and a 123.0 KB phone-style photo to 53.1 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the "no upload" part
&lt;/h2&gt;

&lt;p&gt;The site is static (Astro on Cloudflare Pages), so there's no endpoint to receive a file. On top of that, an automated test processes files with each tool and fails if there's any POST or any third-party request other than Google Analytics, or if an analytics event contains a file name. GA4 is there for anonymous page and "tool used" counts. You need a connection to load the page and the encoders.&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%2F0g7uhdd1q4hbdo4winzf.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%2F0g7uhdd1q4hbdo4winzf.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: a fast proxy for the real computation is fine for a progress bar, not for a decision. If you've done size targeting differently (a model of size vs quality per image, maybe), I'd like to hear about it.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://makequickwork.com/image/compress-image/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/compress-image/&lt;/a&gt; or with 100 KB preset: &lt;a href="https://makequickwork.com/image/compress-image-to-100kb/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/compress-image-to-100kb/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>makequickwork</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Search across indexed NotebookLM notebooks</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:41:09 +0000</pubDate>
      <link>https://dev.to/shaojie/search-across-indexed-notebooklm-notebooks-6j5</link>
      <guid>https://dev.to/shaojie/search-across-indexed-notebooklm-notebooks-6j5</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%2Fjx4s599rw1gj94z7efnk.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%2Fjx4s599rw1gj94z7efnk.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
Cross-notebook search needs a clear coverage model.&lt;/p&gt;

&lt;p&gt;NotebookBloom indexes the chat and Studio content loaded when Search opens inside a notebook. Once indexed, that notebook can appear beside other indexed notebooks and saved source records, citations, cards and prompts.&lt;/p&gt;

&lt;p&gt;The extension does not silently crawl every notebook in the account. An unopened notebook is not searchable, and source text that the page never loaded is not available to the local index.&lt;/p&gt;

&lt;p&gt;A practical first test is small. Open one notebook, let indexing finish, and search for a distinctive phrase from the current conversation. Use Go to result and confirm that the notebook returns to the matching context.&lt;/p&gt;

&lt;p&gt;The limitation is part of the privacy design: the index and query stay in the browser rather than being uploaded to a central search service.&lt;/p&gt;

&lt;p&gt;Practical checklist&lt;/p&gt;

&lt;p&gt;Use a public or non-sensitive notebook for the first test. Verify the output before deleting the intermediate file, and keep the product limitation visible.&lt;/p&gt;

&lt;p&gt;Related guide: &lt;a href="https://notebookbloom.com/blog/search-across-notebooklm-notebooks/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_cross_notebook_search" rel="noopener noreferrer"&gt;https://notebookbloom.com/blog/search-across-notebooklm-notebooks/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_cross_notebook_search&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>notebooklm</category>
      <category>anki</category>
      <category>zotero</category>
    </item>
    <item>
      <title>Safari's canvas can't write WebP: notes from building a browser-only image converter</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Wed, 04 Nov 2026 01:19:07 +0000</pubDate>
      <link>https://dev.to/shaojie/safaris-canvas-cant-write-webp-notes-from-building-a-browser-only-image-converter-22eo</link>
      <guid>https://dev.to/shaojie/safaris-canvas-cant-write-webp-notes-from-building-a-browser-only-image-converter-22eo</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm the developer of MakeQuickWork, a small set of free browser tools. This post is about its Image Converter: what I expected to be a 20-line canvas wrapper, and the parts that weren't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0bo0vlotd4u9w2tnop8h.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%2F0bo0vlotd4u9w2tnop8h.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The naive version of an image converter in the browser fits in a tweet: &lt;code&gt;createImageBitmap(file)&lt;/code&gt;, draw it on a canvas, call &lt;code&gt;canvas.toBlob(cb, "image/webp")&lt;/code&gt;. In Chrome that works. Then you open it on an iPhone and the "WebP" file you get back is a PNG with the wrong extension, because Safari's canvas can't encode WebP and silently falls back to PNG.&lt;/p&gt;

&lt;p&gt;That was the first of a handful of things that shaped the tool. It converts to JPG, PNG or WebP, opens JPG, PNG, WebP, GIF, BMP, AVIF and HEIC, and everything happens in the tab. No upload: &lt;a href="https://makequickwork.com/image/image-converter/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/image-converter/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkw2qsnlew7rdalmaire9.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%2Fkw2qsnlew7rdalmaire9.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't trust the extension
&lt;/h2&gt;

&lt;p&gt;Files saved from the web lie about themselves all the time. A CDN serves WebP to a browser that asked for it, the user hits "Save image as", and they end up with &lt;code&gt;photo.jpg&lt;/code&gt; that is actually WebP. So the first thing the pipeline does is read the first bytes and decide for itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sniff&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="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SourceFormat&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;ascii&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0xd8&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0xff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x89&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;ascii&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;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PNG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ascii&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="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RIFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WEBP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ascii&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;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GIF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ascii&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;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bmp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ascii&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;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ftyp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ISO-BMFF: major brand plus a list of compatible brands.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;24&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;brands&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&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;16&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="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;brands&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="nf"&gt;ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&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;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;avif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;avis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;avif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heic&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="s2"&gt;heix&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="s2"&gt;hevc&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="s2"&gt;hevx&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="s2"&gt;heim&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="s2"&gt;heis&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="s2"&gt;mif1&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="s2"&gt;msf1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AVIF and HEIC are both ISO-BMFF containers, so they share the &lt;code&gt;ftyp&lt;/code&gt; box. Checking only the major brand isn't enough: some files put &lt;code&gt;mif1&lt;/code&gt; up front and the interesting brand in the compatible list. AVIF is checked first because an AVIF file can also list &lt;code&gt;mif1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The sniffed format decides the decode path and a couple of later details, like whether a "transparent areas filled with white" note makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding: let the browser do it, with one fallback
&lt;/h2&gt;

&lt;p&gt;For almost everything, decoding is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bmp&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;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;imageOrientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;imageOrientation: "from-image"&lt;/code&gt; applies the EXIF Orientation tag, so phone photos come out upright instead of sideways.&lt;/p&gt;

&lt;p&gt;HEIC is the exception. Safari decodes it natively, so I try &lt;code&gt;createImageBitmap&lt;/code&gt; first. When that throws (Chrome, Edge, Firefox), the tool loads libheif compiled to WebAssembly and decodes the bytes itself. AVIF has no fallback: current Chrome, Edge, Firefox and Safari decode it, and if one doesn't, the error message says to try a current browser instead of failing silently.&lt;/p&gt;

&lt;p&gt;After decoding, everything is drawn onto a canvas. The canvas is capped at about 16 megapixels, because iPhone Safari refuses to allocate canvases above roughly 16.7M pixels. If an image is bigger, it is scaled down to fit and the result row says so (&lt;code&gt;scaled to W × H (browser size limit)&lt;/code&gt;), rather than quietly handing back a smaller file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encoding: three formats, three paths
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PNG&lt;/strong&gt; is the easy one: &lt;code&gt;canvas.toBlob(cb, "image/png")&lt;/code&gt;. Transparency is kept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JPG&lt;/strong&gt; has no alpha channel, and if you encode a transparent canvas as JPEG, the transparent pixels typically come out black. So the canvas is first drawn onto a white-filled copy, then encoded with &lt;code&gt;canvas.toBlob(cb, "image/jpeg", quality)&lt;/code&gt;. If the source actually had transparent pixels, the result row says "transparent areas filled with white". I'd rather tell people than let them discover a white box around their logo later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebP&lt;/strong&gt; never goes through the canvas encoder. It always uses libwebp compiled to WebAssembly, via jSquash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Safari's canvas cannot write WebP, so always use the WASM encoder.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;webp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@jsquash/webp/encode&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;out&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;webp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&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="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="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="na"&gt;quality&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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 could have used the native encoder where it exists and WASM only on Safari. I chose WASM everywhere so that the same input and the same quality setting give the same file in every browser. Debugging "it's 20 KB bigger on my machine" across two different encoders didn't sound fun.&lt;/p&gt;

&lt;p&gt;Quality is a select with three values: Maximum (0.95), High (0.9, default) and Medium (0.8). PNG hides the select, since it's lossless.&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%2Fuaur99we9kke2l7jdi29.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%2Fuaur99we9kke2l7jdi29.png" alt=" " width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On my test images (three small JPGs at High) the WebP versions came out at 41.3 → 24.6 KB, 91.7 → 57.8 KB and 37.9 → 22.1 KB. That's three files, not a benchmark; photos and screenshots behave very differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Animation.&lt;/strong&gt; GIF and animated WebP give you the first frame only. Encoding animated WebP is a different project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EXIF.&lt;/strong&gt; Metadata isn't copied. Orientation is applied to the pixels, and GPS, camera model and date are gone. For a converter that's usually what people want, but it's a choice, so it's listed on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparency in JPG.&lt;/strong&gt; It can't exist, hence the white fill.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Batch, ZIP and stale results
&lt;/h2&gt;

&lt;p&gt;You can drop many files at once. One result downloads directly; several are zipped with fflate using &lt;code&gt;level: 0&lt;/code&gt;. Images are already compressed, so store-only zipping is basically a memory copy and doesn't stall a phone.&lt;/p&gt;

&lt;p&gt;One small rule that took me a while to settle: any change to the options (format or quality) throws away earlier results. Without that, you could convert to WebP, switch to JPG, click Download, and get WebP files. Now clicking the button after a change reconverts everything with the new settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the page light
&lt;/h2&gt;

&lt;p&gt;The tool page ships about 10 KB of JavaScript. The decode/encode module is a dynamic import, and the WebP encoder (and libheif, if needed) only downloads the first time you actually convert something. Someone who lands on the page from search and leaves doesn't pay for a WASM binary.&lt;/p&gt;

&lt;p&gt;The site is static (Astro on Cloudflare Pages), so there's no upload endpoint at all. There's also an automated test that processes files in every tool and fails on any POST or any third-party request other than Google Analytics (used for anonymous page counts; the test also fails if an analytics event contains a file name). You do need a connection to open the page and fetch the code.&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%2F49080yet6gfwd9392z6h.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%2F49080yet6gfwd9392z6h.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've hit other browser quirks with canvas encoding, or have an opinion on "same encoder everywhere" vs "native where possible", I'd like to hear it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://makequickwork.com/image/image-converter/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/image-converter/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>makequickwork</category>
      <category>images</category>
      <category>webp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Flashcards not captured? Check these five things</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:59:47 +0000</pubDate>
      <link>https://dev.to/shaojie/flashcards-not-captured-check-these-five-things-1jp6</link>
      <guid>https://dev.to/shaojie/flashcards-not-captured-check-these-five-things-1jp6</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%2Fn7e4xokwskgu85ldy34f.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%2Fn7e4xokwskgu85ldy34f.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A zero-card import can come from two different stages: capturing the cards from NotebookLM or sending the captured cards to Anki.&lt;/p&gt;

&lt;p&gt;Start with capture. Let NotebookLM finish generating the set. Confirm that the current notebook owns the visible cards. If the extension was updated while the page stayed open, refresh the page so the new content scripts load.&lt;/p&gt;

&lt;p&gt;Use NotebookBloom 1.1.6 or newer. Version 1.1.5 added support for NotebookLM's Blob iframe and structured card content. Reopen the Cards panel and run Import from notebook again.&lt;/p&gt;

&lt;p&gt;Only after the cards appear inside NotebookBloom should you troubleshoot APKG or AnkiConnect. APKG does not require a running local bridge. AnkiConnect does.&lt;/p&gt;

&lt;p&gt;The failure-event decline after the parser fix is encouraging, but a troubleshooting guide remains useful for users who learned that the old button returned zero and stopped trying.&lt;/p&gt;

&lt;p&gt;Practical checklist&lt;/p&gt;

&lt;p&gt;Use a public or non-sensitive notebook for the first test. Verify the output before deleting the intermediate file, and keep the product limitation visible.&lt;/p&gt;

&lt;p&gt;Related guide: &lt;a href="https://notebookbloom.com/blog/notebooklm-flashcards-to-anki/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_flashcard_troubleshooting" rel="noopener noreferrer"&gt;https://notebookbloom.com/blog/notebooklm-flashcards-to-anki/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_flashcard_troubleshooting&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>notebooklm</category>
      <category>anki</category>
      <category>zotero</category>
    </item>
    <item>
      <title>Decoding iPhone HEIC photos in Chrome: libheif as WebAssembly, loaded on demand and kept out of the bundle</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:48:14 +0000</pubDate>
      <link>https://dev.to/shaojie/decoding-iphone-heic-photos-in-chrome-libheif-as-webassembly-loaded-on-demand-and-kept-out-of-the-3i1</link>
      <guid>https://dev.to/shaojie/decoding-iphone-heic-photos-in-chrome-libheif-as-webassembly-loaded-on-demand-and-kept-out-of-the-3i1</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm the developer of MakeQuickWork, a small set of free browser tools. This post is about its HEIC to JPG converter and the two or three things that took longer than the conversion itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm2pabux459k1lh5v6np4.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%2Fm2pabux459k1lh5v6np4.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;iPhones save photos as HEIC by default. Copy a few of them to a Windows laptop, or try to attach one to a form that only takes &lt;code&gt;.jpg&lt;/code&gt;, and you find out how unevenly that format is supported. I wanted a converter that runs in the tab, so the photos are never uploaded: &lt;a href="https://makequickwork.com/image/heic-to-jpg/" rel="noopener noreferrer"&gt;https://makequickwork.com/image/heic-to-jpg/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting part is that the browser you're in decides how much work there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two decode paths
&lt;/h2&gt;

&lt;p&gt;Safari can decode HEIC itself. Chrome, Edge and Firefox can't, which covers most Windows and Android users, i.e. most of the people who actually have the problem. So the decoder tries the native path first and falls back to libheif compiled to WebAssembly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Safari decodes HEIC natively, rotation included.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bmp&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;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;imageOrientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bmp&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="c1"&gt;// Everyone else: libheif (WebAssembly), loaded on first use.&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;decodeHeic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./heic&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;data&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;decodeHeic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newCanvas&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="nx"&gt;width&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="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;putImageData&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;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="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;Both paths end on a canvas. From there it's &lt;code&gt;canvas.toBlob()&lt;/code&gt; as JPG (quality Maximum, High or Medium) or PNG. Rotation is applied on both paths, so photos come out the right way up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;format&lt;/code&gt; doesn't come from the file extension or the MIME type. Windows often reports HEIC files with an empty MIME type, so the file input accepts &lt;code&gt;.heic,.heif&lt;/code&gt; by extension, and the code then reads the &lt;code&gt;ftyp&lt;/code&gt; box and checks the brands (&lt;code&gt;heic&lt;/code&gt;, &lt;code&gt;heix&lt;/code&gt;, &lt;code&gt;mif1&lt;/code&gt; and friends). A JPG renamed to &lt;code&gt;.heic&lt;/code&gt; gets a clear "this is not a HEIC photo" message instead of a decoder crash.&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%2Fjdxljhibdn447heap99v.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%2Fjdxljhibdn447heap99v.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the decoder isn't in the bundle
&lt;/h2&gt;

&lt;p&gt;libheif is LGPL-3.0. I'm not a lawyer, but the spirit of the LGPL is that users should be able to swap the library for their own build. If I let the bundler inline it into a hashed chunk, that's hard to do in any practical sense.&lt;/p&gt;

&lt;p&gt;So it's not bundled. A build script copies the unmodified &lt;code&gt;libheif.js&lt;/code&gt; and &lt;code&gt;libheif.wasm&lt;/code&gt; from libheif-js 1.23.5 to &lt;code&gt;/vendor/libheif/&lt;/code&gt;, next to the licence text and a &lt;code&gt;SOURCE.txt&lt;/code&gt; that points to the corresponding source and says you may replace the two files. The app loads them from that fixed path with a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;A side effect I like: the decoder is about 1.4 MB, and it's a separate request that only happens when it's needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas with the Emscripten build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It couldn't find its own &lt;code&gt;.wasm&lt;/code&gt;.&lt;/strong&gt; Loaded via a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag on the main thread, this build failed to locate and fetch &lt;code&gt;libheif.wasm&lt;/code&gt; by itself. The fix was to fetch it myself and hand it over as &lt;code&gt;wasmBinary&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/vendor/libheif/libheif.wasm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wasmBinary&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;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Emscripten writes its exports onto the options object.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;wasmBinary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// HeifDecoder is attached right after onRuntimeInitialized, so wait one tick.&lt;/span&gt;
  &lt;span class="na"&gt;onRuntimeInitialized&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="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;HeifDecoder&lt;/code&gt; wasn't there yet.&lt;/strong&gt; &lt;code&gt;onRuntimeInitialized&lt;/code&gt; fires, you reach for &lt;code&gt;mod.HeifDecoder&lt;/code&gt;, and it's &lt;code&gt;undefined&lt;/code&gt;. The JS wrapper attaches it right after that callback returns. A &lt;code&gt;setTimeout&lt;/code&gt; with no delay was enough. It's an ugly line, so it has a comment.&lt;/p&gt;

&lt;p&gt;The loader promise is cached, and reset on failure, so a flaky connection gives a "check your connection and try again" message and the next attempt starts clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lazy loading, with a test that holds me to it
&lt;/h2&gt;

&lt;p&gt;The page itself loads only a few KB of JavaScript. The decoder is downloaded when the first HEIC is actually converted, and Safari, which decodes HEIC itself, doesn't need it. There's a Playwright test that opens the page and fails if any request to &lt;code&gt;libheif&lt;/code&gt; happens before a file is chosen, plus one that converts the sample photo and checks that both vendor files were fetched from &lt;code&gt;/vendor/libheif/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2kwmns98nbrqjmj97vor.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%2F2kwmns98nbrqjmj97vor.png" alt=" " width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(That screenshot is Chrome: it can't preview HEIC, so the rows show an icon instead of a thumbnail until they're converted.)&lt;/p&gt;

&lt;p&gt;On my test files, three 1280×854 HEIC samples of 701.3 KB each, the JPGs at High quality came out at 417.7 KB. Those are small samples, not full-size iPhone photos, so don't read much into the ratio. Several files come back as a ZIP; one file downloads directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the output doesn't keep
&lt;/h2&gt;

&lt;p&gt;Going through a canvas has consequences, and I'd rather list them than have someone find out later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No EXIF.&lt;/strong&gt; GPS location, camera model and date are not carried over. For photos you're sending to someone, I count that as a side benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Photos&lt;/strong&gt; give you the still image only. Depth and HDR extras are dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colour.&lt;/strong&gt; iPhones shoot in Display P3; the canvas saves sRGB, so very saturated colours can look slightly less intense on a wide-gamut screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size.&lt;/strong&gt; iPhone Safari refuses canvases above roughly 16.7 million pixels, so anything bigger is scaled to stay under ~16 MP, and the result row says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only HEIC/HEIF.&lt;/strong&gt; Other formats go to the separate Image Converter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checking that nothing is uploaded
&lt;/h2&gt;

&lt;p&gt;The site is static (Astro on Cloudflare Pages), so there's no endpoint to upload to. An automated test still processes a file with each tool and fails on any POST, or on any third-party request other than Google Analytics (used for anonymous page counts), and also if an analytics event contains a file name. You can watch the Network tab yourself while converting. You do need a connection to load the page and, outside Safari, the decoder.&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%2Fajcwqbnoz99ptpqfa7ji.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%2Fajcwqbnoz99ptpqfa7ji.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The open question for me is moving decoding into a Web Worker so a batch of large photos doesn't block the main thread. If you've run an Emscripten build like this in a worker, I'd like to hear what broke.&lt;/p&gt;

</description>
      <category>makequickwork</category>
      <category>images</category>
      <category>heic</category>
      <category>jpg</category>
    </item>
    <item>
      <title>Your first two minutes with NotebookBloom</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Mon, 02 Nov 2026 12:38:08 +0000</pubDate>
      <link>https://dev.to/shaojie/your-first-two-minutes-with-notebookbloom-2hhd</link>
      <guid>https://dev.to/shaojie/your-first-two-minutes-with-notebookbloom-2hhd</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%2Fvyjrh0wtgs6t42niehet.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%2Fvyjrh0wtgs6t42niehet.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
A feature tour is a poor first task. A new user needs one result they can recognize.&lt;/p&gt;

&lt;p&gt;For NotebookBloom, the shortest path is a small Anki export. Open a NotebookLM notebook that already contains flashcards. Open NotebookBloom, choose Cards, import the current notebook, and export APKG.&lt;/p&gt;

&lt;p&gt;Open the deck in Anki and inspect one card. If the front, back and source are present, the first workflow is complete. The user now knows where the panel lives and what a successful result looks like.&lt;/p&gt;

&lt;p&gt;Only then does it make sense to introduce citations, local search, bulk import, folders and saved prompts. Showing every feature before the first result increases reading without increasing confidence.&lt;/p&gt;

&lt;p&gt;The 30-day analytics window recorded 195 installation events and 54 first-value users. That is a same-period proxy rather than a cohort conversion rate, but it is enough to make first value the next product priority.&lt;/p&gt;

&lt;p&gt;Practical checklist&lt;/p&gt;

&lt;p&gt;Use a public or non-sensitive notebook for the first test. Verify the output before deleting the intermediate file, and keep the product limitation visible.&lt;/p&gt;

&lt;p&gt;Related guide: &lt;a href="https://notebookbloom.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_first_two_minutes" rel="noopener noreferrer"&gt;https://notebookbloom.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_first_two_minutes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>notebooklm</category>
      <category>anki</category>
      <category>zotero</category>
    </item>
    <item>
      <title>Compressing PDFs in the browser: most of the work is deciding which images not to touch</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Mon, 02 Nov 2026 01:16:08 +0000</pubDate>
      <link>https://dev.to/shaojie/compressing-pdfs-in-the-browser-most-of-the-work-is-deciding-which-images-not-to-touch-5cb7</link>
      <guid>https://dev.to/shaojie/compressing-pdfs-in-the-browser-most-of-the-work-is-deciding-which-images-not-to-touch-5cb7</guid>
      <description>&lt;p&gt;&lt;em&gt;I build MakeQuickWork, a small set of free browser tools, on my own. This is how its Compress PDF tool works and where the edge cases were.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flj8x50ltdyk73rhc4p2y.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%2Flj8x50ltdyk73rhc4p2y.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A PDF that's too big is almost always a PDF full of images. Scans from a phone app, a report with photos pasted in, a brochure exported at print resolution. The text and vector parts are usually a few hundred KB at most. So the plan for Compress PDF was narrow on purpose: find every image, make it smaller if that's safe, and copy everything else byte for byte.&lt;/p&gt;

&lt;p&gt;The "if that's safe" part ended up being most of the code.&lt;/p&gt;

&lt;p&gt;It all runs in the tab. The page itself loads about 3 KB of JS; the PDF library and the MozJPEG encoder (WebAssembly, via jSquash) are only fetched once you actually pick a file and compress. The PDF is never uploaded, which you can watch in the Network tab: &lt;a href="https://makequickwork.com/pdf/compress-pdf/" rel="noopener noreferrer"&gt;https://makequickwork.com/pdf/compress-pdf/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh1fbl531dq2dx5kuz6go.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%2Fh1fbl531dq2dx5kuz6go.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Walking the image XObjects
&lt;/h2&gt;

&lt;p&gt;I use a maintained fork of pdf-lib. After loading the document, I enumerate every indirect object and keep the raw streams whose &lt;code&gt;/Subtype&lt;/code&gt; is &lt;code&gt;/Image&lt;/code&gt;. That catches images regardless of which page or form XObject references them, and an image shared by ten pages gets compressed once.&lt;/p&gt;

&lt;p&gt;Before touching anything, a first pass collects every object referenced as &lt;code&gt;/SMask&lt;/code&gt; or &lt;code&gt;/Mask&lt;/code&gt;. Those are transparency masks. Lossy-compressing a soft mask gives you grey halos around logos, so they're off limits.&lt;/p&gt;

&lt;p&gt;Then each image goes through a list of reasons to skip it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;filter is not exactly &lt;code&gt;DCTDecode&lt;/code&gt; (JPEG) or &lt;code&gt;FlateDecode&lt;/code&gt; (lossless). That rules out JPEG 2000, JBIG2 and CCITT fax images&lt;/li&gt;
&lt;li&gt;colour space isn't DeviceRGB, DeviceGray or a 1- or 3-channel ICCBased profile. CMYK print images are skipped rather than risk a colour shift&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BitsPerComponent&lt;/code&gt; isn't 8, or the image has a &lt;code&gt;/Decode&lt;/code&gt; array, or it's an image mask&lt;/li&gt;
&lt;li&gt;smaller than 64 px on a side (icons, bullets: not worth it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything that survives gets decoded to a canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two decoders
&lt;/h2&gt;

&lt;p&gt;JPEG images are easy: the stream contents already are a JPEG file, so &lt;code&gt;createImageBitmap(new Blob([bytes], { type: "image/jpeg" }))&lt;/code&gt; does the work, with &lt;code&gt;imageOrientation: "none"&lt;/code&gt; so the browser doesn't apply EXIF rotation the PDF never asked for.&lt;/p&gt;

&lt;p&gt;Flate images are raw pixel rows, zlib-compressed. pdf-lib inflates them, but many PDFs also use a PNG predictor (&lt;code&gt;/DecodeParms &amp;lt;&amp;lt; /Predictor 15 &amp;gt;&amp;gt;&lt;/code&gt;), which means every row starts with a filter-type byte and is delta-encoded against its neighbours. The browser can't help there, so I undo the five PNG filters (None, Sub, Up, Average, Paeth) by hand in a small loop before putting the pixels into &lt;code&gt;ImageData&lt;/code&gt;. Any other predictor value: skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not every lossless image should become a JPEG
&lt;/h2&gt;

&lt;p&gt;A Flate image in a PDF is often a screenshot, a chart or a logo. Converting those to JPEG makes ringing around text and edges, and they're usually small as Flate anyway. I didn't want a classifier, so it's a cheap heuristic: sample every 7th pixel and check whether it's identical to the pixel before it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Flat colour graphics (screenshots, charts, logos) look bad as JPG and are usually small as Flate. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isFlatGraphic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8ClampedArray&lt;/span&gt;&lt;span class="p"&gt;)&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;same&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;total&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="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;4&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;rgba&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="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="nx"&gt;total&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rgba&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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="nx"&gt;same&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;If more than half the samples match their neighbour, the image stays lossless. Photos almost never pass that; flat UI screenshots almost always do. On the Light level, Flate images aren't re-encoded at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Downscale, encode, and only replace if it's clearly worth it
&lt;/h2&gt;

&lt;p&gt;Each level is a maximum long side and a MozJPEG quality: roughly 300 DPI / q82 for Light, 150 DPI / q72 for Recommended, 100 DPI / q55 for Strong, assuming the image covers a normal page. The canvas is downscaled, then encoded as baseline JPEG, because some older PDF readers don't handle progressive JPEG inside a PDF.&lt;/p&gt;

&lt;p&gt;Then the check that matters most for quality:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only replace when it is clearly smaller; re-encoding a small JPG for 2% is not worth the loss.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jpg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A JPEG has to come out at least 10% smaller to replace the original; a lossless image has to shrink by at least 30%, since that swap also changes it from lossless to lossy. When it does get replaced, the new stream keeps the original dictionary minus &lt;code&gt;Filter&lt;/code&gt;/&lt;code&gt;DecodeParms&lt;/code&gt;/&lt;code&gt;Length&lt;/code&gt;, with new width, height and a plain DeviceRGB/DeviceGray colour space.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lossless leftovers
&lt;/h2&gt;

&lt;p&gt;Some PDF generators write content streams with no filter at all. After the image pass, any unfiltered stream over 256 bytes gets deflated, kept only if that saves 10% or more. Then the document is saved with object streams on, which packs the many small objects together.&lt;/p&gt;

&lt;p&gt;Text, fonts, vector paths, links, bookmarks and form fields aren't touched by any of this, so text stays selectable and searchable. That's the main difference from approaches that rasterise whole pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does to a real scan
&lt;/h2&gt;

&lt;p&gt;On my test file, three phone-scanned pages, Recommended took it from 0.6 MB to 165 KB, 73% smaller. The page shows original size, compressed size and percent saved:&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%2Fi6ko43d84p73v2bamyv8.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%2Fi6ko43d84p73v2bamyv8.png" alt=" " width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the honest part: a mostly-text PDF barely shrinks, because there's nothing big to recompress. If the result isn't at least about 3% smaller, the tool doesn't download anything and tells you to keep the original. Strong makes photos visibly softer. Password-protected PDFs aren't supported yet, and it's one file at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking that nothing is uploaded
&lt;/h2&gt;

&lt;p&gt;The site is static (Astro on Cloudflare Pages), so there's no endpoint to upload to. There's also an automated test that processes a file in each tool and fails on any POST or any request to a third party other than Google Analytics, and fails if an analytics event contains a file name. GA4 is there for anonymous page views and a "tool used" event. You still need a connection to open the page and fetch the encoder the first time.&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%2F3ybyht3xwdnchbbkns86.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%2F3ybyht3xwdnchbbkns86.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've got a PDF that you expected to shrink and didn't, I'm curious what's inside it. CMYK and JBIG2 are the two skips I'm least sure about keeping.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://makequickwork.com/pdf/compress-pdf/" rel="noopener noreferrer"&gt;https://makequickwork.com/pdf/compress-pdf/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>makequickwork</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Why flashcard sources disappear on the way to Anki</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:28:14 +0000</pubDate>
      <link>https://dev.to/shaojie/why-flashcard-sources-disappear-on-the-way-to-anki-20g0</link>
      <guid>https://dev.to/shaojie/why-flashcard-sources-disappear-on-the-way-to-anki-20g0</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%2Fmp9kl68kop9f07sm82vz.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%2Fmp9kl68kop9f07sm82vz.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
A flashcard can look complete and still be hard to trust.&lt;/p&gt;

&lt;p&gt;The question and answer are visible, so a quick copy or TSV export often preserves them. The source may live somewhere else in the interface: a document title, a citation reference, a URL or the notebook context. Unless the export maps that information into a field, Anki never receives it.&lt;/p&gt;

&lt;p&gt;I prefer a dedicated Source field on the note type. It can be rendered on the back of the card below the answer. That keeps the study surface clean while leaving a path back to the evidence.&lt;/p&gt;

&lt;p&gt;Before studying, inspect three cards from different parts of the deck. Confirm that the source belongs to the correct card and that any URL opens the intended material.&lt;/p&gt;

&lt;p&gt;Full disclosure: I built NotebookBloom because I needed this workflow. The field-design advice applies whether the cards come from NotebookBloom, a spreadsheet or a custom script.&lt;/p&gt;

&lt;p&gt;Practical checklist&lt;/p&gt;

&lt;p&gt;Use a public or non-sensitive notebook for the first test. Verify the output before deleting the intermediate file, and keep the product limitation visible.&lt;/p&gt;

&lt;p&gt;Related guide: &lt;a href="https://notebookbloom.com/blog/notebooklm-flashcards-to-anki/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_keep_sources" rel="noopener noreferrer"&gt;https://notebookbloom.com/blog/notebooklm-flashcards-to-anki/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ops14_keep_sources&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>notebooklm</category>
      <category>anki</category>
      <category>zotero</category>
    </item>
    <item>
      <title>Splitting PDFs in the browser: a forgiving page-range parser, synced thumbnails and a ZIP that sorts correctly</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Fri, 30 Oct 2026 01:30:36 +0000</pubDate>
      <link>https://dev.to/shaojie/splitting-pdfs-in-the-browser-a-forgiving-page-range-parser-synced-thumbnails-and-a-zip-that-41mm</link>
      <guid>https://dev.to/shaojie/splitting-pdfs-in-the-browser-a-forgiving-page-range-parser-synced-thumbnails-and-a-zip-that-41mm</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm the developer of MakeQuickWork, a small set of free browser tools. This post walks through how its Split PDF tool works, with the parts that took longer than I expected.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7f5pf0o56oci0yz9p3rl.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%2F7f5pf0o56oci0yz9p3rl.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Split PDF" sounds like a one-liner: open the document, copy some pages, save. The copying really is the easy part. What took most of the time was everything around it: letting people describe pages the way they naturally write them, keeping a text field and clickable thumbnails in agreement, and producing output files that behave well once they land in someone's Downloads folder.&lt;/p&gt;

&lt;p&gt;The tool runs entirely in the browser. The PDF is read into memory, processed in the tab, and saved with a normal download. Nothing is uploaded, which you can check in the DevTools Network tab while you use it: &lt;a href="https://makequickwork.com/pdf/split-pdf/" rel="noopener noreferrer"&gt;https://makequickwork.com/pdf/split-pdf/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F317hdybwv0jmcjnf7qg6.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%2F317hdybwv0jmcjnf7qg6.png" alt=" " width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three modes, one engine
&lt;/h2&gt;

&lt;p&gt;Users ask for three different things when they say "split":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extract pages&lt;/strong&gt;: "give me pages 2–3 as a new PDF". Click thumbnails or type &lt;code&gt;1-3, 5&lt;/code&gt;. Output is one PDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every page separately&lt;/strong&gt;: one PDF per page, delivered as a ZIP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom ranges&lt;/strong&gt;: &lt;code&gt;1-3, 4-6, 7-&lt;/code&gt; gives one PDF per range, also in a ZIP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood all three reduce to a list of &lt;code&gt;[from, to]&lt;/code&gt; ranges. "Every page" is just &lt;code&gt;[1,1], [2,2], ...&lt;/code&gt;, and extract mode is a single output built from the expanded page list. So the core is a range parser and a "copy these pages into a new document" function.&lt;/p&gt;

&lt;p&gt;There's also no dedicated "delete pages" button. Removing pages is select all, deselect the ones you don't want, extract. I went back and forth on adding one, but it would be the same code path with an inverted selection, and one fewer mode to explain felt better.&lt;/p&gt;

&lt;h2&gt;
  
  
  A range parser that explains itself
&lt;/h2&gt;

&lt;p&gt;People type page ranges in many ways: &lt;code&gt;1-3,5&lt;/code&gt;, &lt;code&gt;1 - 3; 5&lt;/code&gt;, &lt;code&gt;8-&lt;/code&gt; for "to the end", &lt;code&gt;-3&lt;/code&gt; for "from the start". Some will paste an en dash from a Word document. And when the input is wrong, a red border tells you nothing, so every failure gets a sentence telling you how to fix it.&lt;/p&gt;

&lt;p&gt;Here is the parser, lightly trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="cm"&gt;/** Parses "1-3, 5, 8-" into ranges. `-3` means 1–3, `8-` means 8 to the end. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseRanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "2 - 6" means 2-6, so join the dash to its numbers before splitting on spaces.&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="nx"&gt;text&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;-–&lt;/span&gt;&lt;span class="se"&gt;])\s&lt;/span&gt;&lt;span class="sr"&gt;*/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;,;&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parts&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter the pages you want, for example 1-3, 5.&lt;/span&gt;&lt;span class="dl"&gt;"&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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;part&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;-–&lt;/span&gt;&lt;span class="se"&gt;]\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is not a page or range. Use numbers like 4 or 2-6.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&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="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="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;const&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Page numbers start at 1.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pageCount&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`This PDF has &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; page&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pageCount&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="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RangeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" goes backwards. Write it as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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;to&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separators are generous.&lt;/strong&gt; Commas, semicolons and whitespace all separate parts, and both &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;–&lt;/code&gt; work as the range dash. Because whitespace is also a separator, an early version split &lt;code&gt;2 - 6&lt;/code&gt; into three tokens. Now the input is normalized first with &lt;code&gt;.replace(/\s*([-–])\s*/g, "$1")&lt;/code&gt;, which pulls the dash onto its numbers, so &lt;code&gt;2 - 6&lt;/code&gt; works exactly like &lt;code&gt;2-6&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error names the bad token.&lt;/strong&gt; &lt;code&gt;"6-3" goes backwards. Write it as 3-6.&lt;/code&gt; is more useful than "invalid range", and it costs one template string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page count is part of the error.&lt;/strong&gt; If you type &lt;code&gt;12&lt;/code&gt; on a 10-page file, you get &lt;code&gt;This PDF has 10 pages.&lt;/code&gt; That answers the next question before you ask it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-ended ranges resolve immediately.&lt;/strong&gt; &lt;code&gt;7-&lt;/code&gt; becomes &lt;code&gt;[7, pageCount]&lt;/code&gt; at parse time, so the rest of the code never deals with "infinity".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keeping thumbnails and the text field in sync
&lt;/h2&gt;

&lt;p&gt;In extract mode there are two inputs for the same state: you can click page thumbnails or type in the "Pages to extract" field. Both have to agree at all times.&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%2Fhut1hw7f5e1ma1ganckg.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%2Fhut1hw7f5e1ma1ganckg.png" alt=" " width="799" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The source of truth is a &lt;code&gt;Set&amp;lt;number&amp;gt;&lt;/code&gt; of selected pages. The two directions work differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thumbnail click → text.&lt;/strong&gt; Toggle the page in the set, then rewrite the text field from the set with a small formatter that collapses runs: &lt;code&gt;{1,2,3,5}&lt;/code&gt; becomes &lt;code&gt;1-3, 5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text → thumbnails.&lt;/strong&gt; Parse on every input event and update the set, but &lt;em&gt;do not&lt;/em&gt; rewrite the text field. If you reformat while someone is typing &lt;code&gt;1-&lt;/code&gt;, the cursor jumps and their half-finished range disappears. So typing only flows one way until the next click.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the text doesn't parse, the selection clears, the error sentence appears under the field, and the button is disabled. The button label follows the set too ("Extract 2 pages"), which turned out to be the clearest confirmation that the parser understood you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copying pages without touching them
&lt;/h2&gt;

&lt;p&gt;For the actual work I use a maintained fork of pdf-lib. Copying pages from one document into a new one keeps their content streams, fonts and images as they are, so nothing is re-rendered or re-compressed. Text stays selectable and vector graphics stay vector. Each page also keeps its own size.&lt;/p&gt;

&lt;p&gt;PDF.js (Mozilla) is used separately, only to draw the thumbnails. Both libraries are loaded lazily after you pick a file, so the page itself stays light.&lt;/p&gt;

&lt;p&gt;The main known limit: password-protected PDFs aren't supported yet. One more behavior worth knowing: in extract mode the pages are written in page order, so typing &lt;code&gt;5, 2&lt;/code&gt; gives you page 2 then page 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  A ZIP that sorts correctly
&lt;/h2&gt;

&lt;p&gt;The two multi-file modes return a ZIP. Two small details made a visible difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-padded names.&lt;/strong&gt; If a 120-page file is split per page and named &lt;code&gt;page-1.pdf ... page-120.pdf&lt;/code&gt;, most file managers sort &lt;code&gt;page-10&lt;/code&gt; before &lt;code&gt;page-2&lt;/code&gt;. Padding to the width of the page count fixes that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pageCount&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="c1"&gt;// 120 → 3&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&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="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// report-page-001.pdf, report-page-002.pdf, ..., report-pages-004-006.pdf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone types the same range twice, the second file gets a &lt;code&gt;-2&lt;/code&gt; suffix instead of silently overwriting the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No compression.&lt;/strong&gt; The ZIP is built with fflate's &lt;code&gt;zipSync(files, { level: 0 })&lt;/code&gt;. PDFs are already compressed internally, so deflating them again burns CPU for a few percent at most. Store-only zipping is close to a memory copy, which matters on phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I check that nothing is uploaded
&lt;/h2&gt;

&lt;p&gt;"Runs in your browser" is easy to claim, so there is an automated test that loads each tool, processes a file, and fails if the page makes any POST or any request to a third-party host other than Google Analytics during processing (the site uses GA4 for anonymous visit counts, and the test also fails if an analytics event contains the file name). The site is static (Astro on Cloudflare Pages), so there is no upload endpoint to call in the first place. You do need a connection to open the page and load the tool code.&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%2F8b435necv320o61il9nr.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%2F8b435necv320o61il9nr.png" alt=" " width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The PDF part was maybe a fifth of the work. The rest was input handling, state sync and file naming, which is the part users actually touch. If you have opinions on range syntax (should &lt;code&gt;5-&lt;/code&gt; on a 5-page file be allowed? it is today) or ideas for edge cases I missed, I'd like to hear them in the comments.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://makequickwork.com/pdf/split-pdf/" rel="noopener noreferrer"&gt;https://makequickwork.com/pdf/split-pdf/&lt;/a&gt;&lt;/p&gt;

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