<?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: Toolsy Hub</title>
    <description>The latest articles on DEV Community by Toolsy Hub (@toolsyhub).</description>
    <link>https://dev.to/toolsyhub</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%2F4199854%2Fa847f63d-5033-4d13-9bd8-2895096bc323.png</url>
      <title>DEV Community: Toolsy Hub</title>
      <link>https://dev.to/toolsyhub</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toolsyhub"/>
    <language>en</language>
    <item>
      <title>Is this timestamp in seconds or milliseconds? Guessing the unit from the digit count</title>
      <dc:creator>Toolsy Hub</dc:creator>
      <pubDate>Thu, 12 Nov 2026 03:48:48 +0000</pubDate>
      <link>https://dev.to/toolsyhub/is-this-timestamp-in-seconds-or-milliseconds-guessing-the-unit-from-the-digit-count-3jnc</link>
      <guid>https://dev.to/toolsyhub/is-this-timestamp-in-seconds-or-milliseconds-guessing-the-unit-from-the-digit-count-3jnc</guid>
      <description>&lt;p&gt;You paste 1700000000 into a date converter and get November 2023. You paste 1700000000123 and get the same day, but only if the tool knows the second number is milliseconds. Feed that value to code that expects seconds and you get a year about 53,000 years from now. Feed the first value to &lt;code&gt;new Date()&lt;/code&gt; and you get January 1970.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1700000000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// "2023-11-14T22:13:20.000Z"&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1700000000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// "1970-01-20T16:13:20.000Z"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line is the classic bug: seconds passed where JavaScript wants milliseconds. The date lands 19 days after the epoch, which looks plausible enough to survive a code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Count the digits
&lt;/h2&gt;

&lt;p&gt;Today a timestamp in seconds has 10 digits, milliseconds has 13, microseconds 16 and nanoseconds 19. That is enough to guess the unit when you cannot ask where the number came from. The converter on Toolsy Hub uses this rule on the whole part of the number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;up to 11 digits: seconds (this holds until the year 5138)&lt;/li&gt;
&lt;li&gt;12 to 14 digits: milliseconds&lt;/li&gt;
&lt;li&gt;15 to 17 digits: microseconds&lt;/li&gt;
&lt;li&gt;18 or more: nanoseconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cut points sit between the common lengths, so a 12 or 14 digit value still gets a sensible unit. It is a guess, so the tool also lets you pick the unit by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nanoseconds need BigInt
&lt;/h2&gt;

&lt;p&gt;A nanosecond timestamp for today is around 1.7 x 10^18. JavaScript numbers are 64-bit floats and only hold whole numbers exactly up to 9,007,199,254,740,991. Past that, digits get rounded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1700000000123456789n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// 1700000000123456800&lt;/span&gt;
&lt;span class="mi"&gt;1700000000123456789n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// "1700000000123456789"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the converter keeps every input as whole nanoseconds in a BigInt and only turns it into a Date at the last step, after dividing down to milliseconds. Microsecond and nanosecond input comes out exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Negative timestamps round the other way
&lt;/h2&gt;

&lt;p&gt;Dates before 1970 are negative. Truncating division gets them wrong by a second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// -1  (rounds toward zero)&lt;/span&gt;
&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// -2  (rounds toward the past)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;-1500 ms is 1.5 seconds before the epoch, which falls inside the second that starts at -2. BigInt division truncates (&lt;code&gt;-1500n / 1000n&lt;/code&gt; is &lt;code&gt;-1n&lt;/code&gt;), so the converter has its own floor division that steps down by one when the remainder is not zero and the value is negative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The range limit
&lt;/h2&gt;

&lt;p&gt;A JavaScript Date stops at 8,640,000,000,000,000 ms either side of 1970. One millisecond past that is an invalid date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;8.64e15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "+275760-09-13T00:00:00.000Z"&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;8.64e15&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// NaN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A converter should say "out of range" and not print "Invalid Date". The one on Toolsy Hub does, and it also rejects text that is not a number with its own message.&lt;/p&gt;

&lt;p&gt;If you want to check a value without writing code, the converter is at &lt;a href="https://www.toolsy-hub.com/tools/unix-timestamp-converter" rel="noopener noreferrer"&gt;https://www.toolsy-hub.com/tools/unix-timestamp-converter&lt;/a&gt;. It runs in the browser tab, shows seconds, milliseconds, microseconds, nanoseconds, ISO 8601 and UTC for any input, and converts a pasted list one value per line.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to shrink a PDF to a target size in the browser: render pages, split a byte budget, retry three times</title>
      <dc:creator>Toolsy Hub</dc:creator>
      <pubDate>Thu, 05 Nov 2026 09:36:33 +0000</pubDate>
      <link>https://dev.to/toolsyhub/how-to-shrink-a-pdf-to-a-target-size-in-the-browser-render-pages-split-a-byte-budget-retry-three-2l28</link>
      <guid>https://dev.to/toolsyhub/how-to-shrink-a-pdf-to-a-target-size-in-the-browser-render-pages-split-a-byte-budget-retry-three-2l28</guid>
      <description>&lt;p&gt;Court portals, job sites and university forms often cap uploads at a number like 200 KB or 2 MB. Most PDF compressors give you a quality slider and leave you to guess. If you know the target, you can make the code hit it.&lt;/p&gt;

&lt;p&gt;Here is how the Compress PDF tool on Toolsy Hub does it, all in the browser with pdf-lib and pdf.js.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes, because they trade different things
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Light&lt;/strong&gt; re-saves the file with pdf-lib and object streams turned on. Text stays selectable and nothing is re-encoded, so the saving is small. If the PDF has pictures that are the real weight, light mode will not help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images&lt;/strong&gt; renders every page to a JPEG with pdf.js and rebuilds the PDF from those pictures. A scanned document shrinks a lot. The cost is that text becomes part of the picture, so it is no longer selectable. The tool says this before you pick it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render once at the sharpest setting
&lt;/h2&gt;

&lt;p&gt;There are three presets: 150 dpi at JPEG quality 0.8, 120 dpi at 0.65, and 96 dpi at 0.5. When you type a target size, the tool ignores the preset and starts every page at the sharpest one, 150 dpi at 0.8. It lowers quality only as far as the target needs. Starting low and going up would leave a sharp document blurry for no reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split the byte budget by pixel area
&lt;/h2&gt;

&lt;p&gt;If the first PDF is already under the target, you are done. If not, the target in bytes is divided across the pages in proportion to each page's pixel area (width times height). A big page gets a bigger share than a small one, so one large scan does not eat the whole budget.&lt;/p&gt;

&lt;p&gt;Each page image is then recompressed to its share with a quality search, and the PDF is rebuilt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry, but only three times
&lt;/h2&gt;

&lt;p&gt;Page shares add up, but PDF structure adds overhead on top of the images, so the rebuilt file can land a little over. The tool then tightens the budget by &lt;code&gt;target / size * 0.95&lt;/code&gt;, never by more than half in one step, and tries again. Three attempts at most. If it still misses, it hands back the smallest result and says the target was not reached, rather than looping or silently returning a file that breaks your limit.&lt;/p&gt;

&lt;p&gt;The "say so" part matters. A file that is 30 KB over a 200 KB limit gets rejected by the portal, and the person finds out only after the upload fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays in the browser
&lt;/h2&gt;

&lt;p&gt;Nothing is sent to a server for this. pdf-lib is loaded on demand (about 180 KB) so the page itself stays light. Files you add stay in your browser; Google Analytics counts visits to the site.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://www.toolsy-hub.com/tools/compress-pdf" rel="noopener noreferrer"&gt;https://www.toolsy-hub.com/tools/compress-pdf&lt;/a&gt;. For a fixed limit there is a page with the size already set: &lt;a href="https://www.toolsy-hub.com/tools/compress-pdf/200kb" rel="noopener noreferrer"&gt;https://www.toolsy-hub.com/tools/compress-pdf/200kb&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What I would improve: the retry factor is a heuristic. A proper search over per-page quality would hit the target more closely on files with mixed pages. If you have a PDF that misses its target, I would like to hear about it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>pdf</category>
    </item>
    <item>
      <title>How to compress an image to an exact KB limit in the browser (canvas, binary search, and when to stop lowering quality)</title>
      <dc:creator>Toolsy Hub</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:52:26 +0000</pubDate>
      <link>https://dev.to/toolsyhub/how-to-compress-an-image-to-an-exact-kb-limit-in-the-browser-canvas-binary-search-and-when-to-2d7i</link>
      <guid>https://dev.to/toolsyhub/how-to-compress-an-image-to-an-exact-kb-limit-in-the-browser-canvas-binary-search-and-when-to-2d7i</guid>
      <description>&lt;p&gt;Upload forms for visas, exams and job portals often say "photo must be under 100 KB". A phone photo is 3 to 5 MB, and the usual advice is to drag a quality slider until the number looks right. You can automate that in the browser with the canvas API, and the part that matters is knowing when to stop lowering quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Encode at a quality and read the size&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;canvas.toBlob(callback, "image/jpeg", quality)&lt;/code&gt; gives you a Blob. &lt;code&gt;blob.size&lt;/code&gt; is the file size in bytes. That is all you need to measure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="nx"&gt;quality&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;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&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;width&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&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;height&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;canvas&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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;// JPEG has no alpha; transparent PNGs turn black without this&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;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="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="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;bitmap&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&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;Load the bitmap with &lt;code&gt;createImageBitmap(file, { imageOrientation: "from-image" })&lt;/code&gt;, otherwise phone photos come out sideways because the EXIF rotation is ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Binary search on quality&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;File size grows with quality, so you can search for the highest quality that still fits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&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="nx"&gt;high&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&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="kc"&gt;null&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;6&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;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;low&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;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;targetBytes&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="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;low&lt;/span&gt; &lt;span class="o"&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six rounds narrows the range to about 1% of quality, which is more precision than the file size needs. Count 1 KB as 1000 bytes, not 1024, so the file passes whether the form counts KB one way or the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know when to stop&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the part the sliders do not tell you. Below roughly 30% JPEG quality a photo breaks into visible blocks and the colours shift. A file that is technically under the limit but looks like that gets rejected by a human reviewer or looks bad on an ID. So the search never goes below 0.3. First it encodes once at 0.3 for the current size. If that is still over the limit, it makes the picture smaller instead: width and height times 0.8, and try again. It tries up to 12 sizes, which is down to about a tenth of the original width. Only at the last size does it allow quality all the way down to 0.05, because a file that fits beats no file.&lt;/p&gt;

&lt;p&gt;PNG has no quality setting, so for PNG only the dimensions change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would do differently&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Offer a "keep exact width and height" switch for forms that demand fixed pixels, and then accept a lower quality or a missed target. The tool I built has one, and it tells the user when the target was missed.&lt;/li&gt;
&lt;li&gt;Test the search with a fake encoder. The function takes the encoder as a parameter, so unit tests can pass a function that returns a Blob of a size you choose, with no canvas involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool built on this, with nothing uploaded anywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Image compressor with a target size: &lt;a href="https://www.toolsy-hub.com/tools/image-compressor" rel="noopener noreferrer"&gt;https://www.toolsy-hub.com/tools/image-compressor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Photo resizer for exact KB and pixel presets: &lt;a href="https://www.toolsy-hub.com/tools/photo-signature-resizer" rel="noopener noreferrer"&gt;https://www.toolsy-hub.com/tools/photo-signature-resizer&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google Analytics counts visits on the site; image files are never sent anywhere.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>performance</category>
    </item>
    <item>
      <title>Building PDF tools that never upload your file: pdf-lib, pdf.js and qpdf in WebAssembly</title>
      <dc:creator>Toolsy Hub</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:16:17 +0000</pubDate>
      <link>https://dev.to/toolsyhub/building-pdf-tools-that-never-upload-your-file-pdf-lib-pdfjs-and-qpdf-in-webassembly-16eh</link>
      <guid>https://dev.to/toolsyhub/building-pdf-tools-that-never-upload-your-file-pdf-lib-pdfjs-and-qpdf-in-webassembly-16eh</guid>
      <description>&lt;p&gt;Most "free PDF tools" upload your file to a server, process it there and send it back. For a bank statement or a CNIC scan that is a lot of trust to hand a site you found a minute ago. I wanted merge, split, compress and unlock to work with no upload at all, so I built them to run entirely in the browser. This post is what that took, and where it falls short.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three engines
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pdf-lib&lt;/strong&gt; (1.17) reads and writes the PDF structure: merging, splitting, picking pages, building a PDF from JPGs. It is plain JavaScript and works the same in every modern browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pdf.js&lt;/strong&gt; (Mozilla's renderer) draws a page onto a canvas. I use it to turn pages into images for "PDF to JPG" and for the stronger compress mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;qpdf&lt;/strong&gt;, compiled to WebAssembly (about 1.3 MB), removes the password and restrictions from a PDF when the user types the password they already know. pdf-lib cannot decrypt, so this is the one job that needed a native tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of them load with the page. Each is imported inside the click handler with &lt;code&gt;await import(...)&lt;/code&gt;, so someone who only wants the word counter never downloads a PDF engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching the engines for a year
&lt;/h2&gt;

&lt;p&gt;The WASM and worker files are copied at build time into a folder named after the package version, for example &lt;code&gt;/engines/qpdf-&amp;lt;version&amp;gt;/&lt;/code&gt;, and served with &lt;code&gt;Cache-Control: public, max-age=31536000, immutable&lt;/code&gt;. A new version gets a new folder, so a browser never mixes an old worker with new code, and a returning visitor does not download 1-2 MB again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compress: two modes, and why
&lt;/h2&gt;

&lt;p&gt;There is no magic "compress PDF" call. What shrinks a PDF depends on what is inside it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Light mode&lt;/strong&gt; re-saves the file with pdf-lib using compressed object streams. Text stays selectable. The saving is small, often a few percent, because the pictures inside are untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image mode&lt;/strong&gt; renders every page with pdf.js to a JPEG (150 dpi at quality 0.8 for "high", down to 96 dpi at 0.5 for "low") and builds a new PDF from those images. For scanned documents, which are pictures anyway, this is where the big savings are. The cost: text becomes part of the picture and is no longer selectable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tool says which mode does what before you click, rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hitting an exact size
&lt;/h2&gt;

&lt;p&gt;Upload forms in Pakistan ask for "PDF under 200 KB" or "under 100 KB", so a slider is not enough. With a target set, the pages are rendered at the sharpest level first, and the packer lowers JPEG quality only as far as it has to until the whole PDF fits. If it cannot get there, it says so instead of quietly handing back a file that is still too big.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does not work well
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Very large files on phones.&lt;/strong&gt; Rendering 60 pages at 150 dpi in a phone browser uses a lot of memory. Server tools win here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image mode on text PDFs.&lt;/strong&gt; A text-only PDF can come out bigger as images. Light mode is the right choice there. Image mode is still the default, because most files people bring to a "PDF under 200 KB" form are scans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password cracking.&lt;/strong&gt; The unlock tool opens a file with the password you type. It does not guess passwords, on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The tools are free with no sign-up: &lt;a href="https://www.toolsy-hub.com/tools/compress-pdf" rel="noopener noreferrer"&gt;Compress PDF&lt;/a&gt;, &lt;a href="https://www.toolsy-hub.com/tools/merge-pdf" rel="noopener noreferrer"&gt;Merge PDF&lt;/a&gt;, &lt;a href="https://www.toolsy-hub.com/tools/unlock-pdf" rel="noopener noreferrer"&gt;Unlock PDF&lt;/a&gt;. Open the network tab while you use one and you will see no request carrying your file.&lt;/p&gt;

&lt;p&gt;If you have built something similar, I would like to hear how you dealt with memory on phones.&lt;/p&gt;

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