<?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: Hexloom Labs</title>
    <description>The latest articles on DEV Community by Hexloom Labs (@hexloomlabs).</description>
    <link>https://dev.to/hexloomlabs</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%2F4164355%2F23304b04-9132-4bf6-9622-8922c7ff738f.png</url>
      <title>DEV Community: Hexloom Labs</title>
      <link>https://dev.to/hexloomlabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hexloomlabs"/>
    <language>en</language>
    <item>
      <title>Is one client most of your income? Two SUMIFS formulas and a check row</title>
      <dc:creator>Hexloom Labs</dc:creator>
      <pubDate>Tue, 06 Oct 2026 00:38:41 +0000</pubDate>
      <link>https://dev.to/hexloomlabs/is-one-client-most-of-your-income-two-sumifs-formulas-and-a-check-row-npm</link>
      <guid>https://dev.to/hexloomlabs/is-one-client-most-of-your-income-two-sumifs-formulas-and-a-check-row-npm</guid>
      <description>&lt;p&gt;Your total income says how you are doing. Income per client says how fragile that is. If you freelance, two spreadsheet formulas and one check row show both, and they work the same in Excel and Google Sheets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;One row per payment, with a Type column ("Income" or "Expense"), a Client column and an Amount column. Here is a small example, with Type in column B, Client in E and Amount in F on a sheet called &lt;code&gt;Transactions&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;Northwind&lt;/td&gt;
&lt;td&gt;4200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;Northwind&lt;/td&gt;
&lt;td&gt;1800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;Fabrikam&lt;/td&gt;
&lt;td&gt;900&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;Contoso&lt;/td&gt;
&lt;td&gt;1300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expense&lt;/td&gt;
&lt;td&gt;(blank)&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;Northwind Cafe&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Income&lt;/td&gt;
&lt;td&gt;Fabrikam&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total income is 9,500. Keep that number in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: income per client
&lt;/h2&gt;

&lt;p&gt;On a second sheet, list the client names in column A from row 2 down. In B2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=SUMIFS(Transactions!$F:$F, Transactions!$B:$B, "Income", Transactions!$E:$E, $A2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy it down. To limit it to one year, add two more conditions on the date column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: share of total
&lt;/h2&gt;

&lt;p&gt;In C2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=IF(SUM($B$2:$B$30)=0, 0, B2/SUM($B$2:$B$30))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Format the column as a percentage. The &lt;code&gt;IF&lt;/code&gt; stops a divide-by-zero error before you have logged any income. Sort the table by income, largest first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the check row
&lt;/h2&gt;

&lt;p&gt;This is the part people skip. Put this in E2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=SUMIFS(Transactions!$F:$F, Transactions!$B:$B, "Income") - SUM(B2:B30)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should read 0. I ran the example above with three clients listed (Northwind, Fabrikam, Contoso) and recalculated it in LibreOffice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Income&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Northwind&lt;/td&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;67.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fabrikam&lt;/td&gt;
&lt;td&gt;1,600&lt;/td&gt;
&lt;td&gt;18.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contoso&lt;/td&gt;
&lt;td&gt;1,300&lt;/td&gt;
&lt;td&gt;14.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The check cell showed &lt;strong&gt;600&lt;/strong&gt;, which is the "Northwind Cafe" row. &lt;code&gt;SUMIFS&lt;/code&gt; matches the text exactly, so "Northwind" and "Northwind Cafe" are two different clients, and the second one was never listed.&lt;/p&gt;

&lt;p&gt;The check matters for a second reason. The share column divides by the sum of the clients you listed, not by your real total. With the 600 missing, Northwind shows 67.4%, but against the real 9,500 it is 63.2%. A missing client inflates every other share, so fix the check before you trust the percentages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it at zero
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add a new client to the list on the same day you log their first payment.&lt;/li&gt;
&lt;li&gt;Use a dropdown (data validation) on the Client column instead of typing names freehand. "northwind cafe" and "Northwind Cafe" then cannot drift apart.&lt;/li&gt;
&lt;li&gt;If the check is not zero, filter the Transactions sheet by Income and look for names that are not on your summary list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reading the result
&lt;/h2&gt;

&lt;p&gt;There is no universal safe percentage. The point is that you can now see your own number and decide whether you are comfortable with it. One client with a large share means a single late payment or lost contract hurts a lot. Many small clients usually means more admin per dollar, which you can compare against how many invoices each one takes. Run the same formula for each quarter side by side and a shrinking client shows up.&lt;/p&gt;

&lt;p&gt;This is general spreadsheet technique, not financial or tax advice. The full walkthrough, with the other tracker formulas, is here: &lt;a href="https://sheets.hexloomlabs.com/articles/track-freelance-income-by-client-spreadsheet" rel="noopener noreferrer"&gt;https://sheets.hexloomlabs.com/articles/track-freelance-income-by-client-spreadsheet&lt;/a&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>excel</category>
      <category>spreadsheet</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Resize before you compress: a four-step routine for lighter web images</title>
      <dc:creator>Hexloom Labs</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:39:07 +0000</pubDate>
      <link>https://dev.to/hexloomlabs/resize-before-you-compress-a-four-step-routine-for-lighter-web-images-1i5o</link>
      <guid>https://dev.to/hexloomlabs/resize-before-you-compress-a-four-step-routine-for-lighter-web-images-1i5o</guid>
      <description>&lt;p&gt;Images are usually the heaviest files a page loads, and a heavy page feels slow, especially on a phone. The most common cause is not a missing compression setting. It is a 4000 px phone photo sitting in an 800 px column. Here is the order I work in, and the one function that does the fitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Size the image to where it appears
&lt;/h2&gt;

&lt;p&gt;Find the widest the image is ever displayed: an 800 px article column, a full-width banner. Scale the longest side down to about that width, or up to double it if you want crisp results on high-density screens. Keep the proportions and never enlarge.&lt;/p&gt;

&lt;p&gt;Dropping pixels nobody will see is the biggest saving available. A phone photo that is 4000 px wide and shown in an 800 px column can go to 1600 px and still look sharp on a high-density screen, carrying far fewer pixels than the original. If you are unsure how wide a slot really is, check the layout in your browser's inspector.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Then set the quality
&lt;/h2&gt;

&lt;p&gt;For JPG and WebP, start at 75% and compare the before and after sizes. Most photos look nearly the same at 70 to 80% as at 100%, at a fraction of the size. Lower it a notch at a time, and always re-encode from the original file, so repeated tries do not stack up quality loss.&lt;/p&gt;

&lt;p&gt;PNG ignores a quality setting because it is lossless. For a PNG, resizing is the only lever. A photo saved as PNG is usually far bigger than the same photo as JPG, so convert photos; keep PNG for screenshots, text and logos where sharp edges and transparency matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Fit a size limit
&lt;/h2&gt;

&lt;p&gt;Forms and portals love a hard limit ("under 100 KB"). There is no magic setting, so search for it: try qualities from high to low and keep the first result that fits, and if even the lowest quality is too big, shrink the pixels and try again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fitToBytes&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="nx"&gt;maxBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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;scale&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.1&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;w&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;round&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&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;h&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;round&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;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;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="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;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="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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;&amp;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;q&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;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;type&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&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;maxBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mf"&gt;0.8&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// could not fit, say so instead of returning a postage stamp&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It always starts from the original bitmap, so nothing compounds. Two honest limits: a JPG has no transparency, so transparent areas of a PNG turn black or white depending on how you paint the canvas, and very detailed photos (foliage, crowds) need more shrinking than simple ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Finish in the page
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"hero-1600.webp"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1600"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"900"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"below-fold.webp"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"800"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"450"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Width and height stop the layout jumping while the image loads. &lt;code&gt;loading="lazy"&lt;/code&gt; belongs on images below the fold, not on the main image at the top. Use descriptive file names and alt text. Then reload on a phone-sized window; if it still feels heavy, shrink the largest remaining images first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which format
&lt;/h2&gt;

&lt;p&gt;For photos, WebP is usually smaller than JPG at similar quality and all current major browsers support it; JPG is the safe fallback. For screenshots, text and logos, PNG keeps edges sharp and handles transparency, and WebP supports transparency too.&lt;/p&gt;

&lt;p&gt;I build a small browser tool that follows this routine, so I can do it without installing anything: set a max width, a quality or a target size in KB, and it compresses in the page with nothing uploaded. The routine above is the same one, written out: &lt;a href="https://compress.hexloomlabs.com/compress-images-for-website/" rel="noopener noreferrer"&gt;compress images for your website&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Counting statute days in JavaScript: security deposit deadlines in five US states</title>
      <dc:creator>Hexloom Labs</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:29:43 +0000</pubDate>
      <link>https://dev.to/hexloomlabs/counting-statute-days-in-javascript-security-deposit-deadlines-in-five-us-states-34ce</link>
      <guid>https://dev.to/hexloomlabs/counting-statute-days-in-javascript-security-deposit-deadlines-in-five-us-states-34ce</guid>
      <description>&lt;p&gt;Statutes write deadlines as "within N days", and the two things that trip up code are what counts as a day and what happens when the last day lands on a weekend. Security-deposit return rules for US landlords are a clean example, because five states give five different answers to the same question. This is the date math, plus the traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The function
&lt;/h2&gt;

&lt;p&gt;Day 1 is the day after the start date. Optionally skip Saturdays and Sundays. Everything else (time zones, DST) goes away if you work in UTC dates.&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;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startISO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;weekdaysOnly&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&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="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;startISO&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&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="nb"&gt;Number&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUTCDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;weekdaysOnly&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="nf"&gt;getUTCDay&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&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="c1"&gt;// % 6 is 0 on Sunday and Saturday&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;t&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;Parsing &lt;code&gt;"2026-05-29"&lt;/code&gt; with &lt;code&gt;new Date(...)&lt;/code&gt; directly would be read as UTC midnight and then printed in local time, which can show the previous day in the Americas. Building the date with &lt;code&gt;Date.UTC&lt;/code&gt; and formatting with &lt;code&gt;timeZone: "UTC"&lt;/code&gt; avoids that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five states, same move-out date
&lt;/h2&gt;

&lt;p&gt;Move-out on Friday, May 29, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Rule (as written in the statute)&lt;/th&gt;
&lt;th&gt;Deadline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;California&lt;/td&gt;
&lt;td&gt;itemized statement within 21 calendar days after the tenant has vacated (Civ. Code 1950.5(h)(1))&lt;/td&gt;
&lt;td&gt;Friday, June 19, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Texas&lt;/td&gt;
&lt;td&gt;refund on or before the 30th day after the tenant surrenders the premises (Prop. Code 92.103(a))&lt;/td&gt;
&lt;td&gt;Sunday, June 28, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Florida&lt;/td&gt;
&lt;td&gt;15 days to return a deposit with no claim, 30 days to send notice of a claim (Fla. Stat. 83.49(3))&lt;/td&gt;
&lt;td&gt;Saturday, June 13 / Sunday, June 28, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arizona&lt;/td&gt;
&lt;td&gt;14 days, excluding Saturdays, Sundays and legal holidays (A.R.S. 33-1321(D))&lt;/td&gt;
&lt;td&gt;Thursday, June 18, 2026 (earliest possible, see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Michigan&lt;/td&gt;
&lt;td&gt;notice of damages within 30 days, suit or refund within 45 (MCL 554.609, 554.613)&lt;/td&gt;
&lt;td&gt;Sunday, June 28 / Monday, July 13, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Weekday-only counting is the exception.&lt;/strong&gt; Only Arizona's text excludes weekends; the other four have no such exclusion. Arizona skips them, so 14 "days" from May 29 is June 18, not June 12. Reusing a calendar-day function for Arizona gives a deadline six days too early; reusing a weekday function for the others gives one far too late.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Holidays are not in the math.&lt;/strong&gt; Arizona also excludes "other legal holidays", but the Act does not list them. A date picker cannot know which ones apply, so the honest output is "the earliest possible deadline": every holiday inside the window pushes the real one a weekday later, and mailing by the computed date is safe either way. Presenting the number as &lt;em&gt;the&lt;/em&gt; deadline would be a bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Weekend deadlines do not roll to Monday.&lt;/strong&gt; Several of the examples land on a Saturday or Sunday (Texas June 28, Florida June 13 and 28, Michigan June 28). The California, Texas and Florida texts have no rule moving a weekend deadline to the next business day, so the safe UI behaviour is to warn and say "act the business day before", not to silently show Monday. (I treat Michigan the same way, which is the cautious choice, not a claim about its text.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The start date is the hard part.&lt;/strong&gt; The statutes count from "the tenant has vacated" (California), "surrenders the premises" (Texas), "termination of the rental agreement" (Florida), "termination of the occupancy" (Michigan). Several do not say whether that is lease end, move-out or key return. Arizona's counts from the last of three events (tenancy terminated, possession delivered, tenant demanded the deposit), and the text does not say whether day 1 is the trigger date or the day after. The function above picks "the day after"; it is a labelled assumption in the tool, not a fact from the statute. When a rule is ambiguous, say which reading the code takes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Two clocks from one date.&lt;/strong&gt; Florida (15 and 30) and Michigan (30 and 45) run two deadlines from the same trigger, so model a list of rules per state rather than one number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MI&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="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mail the notice of damages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sue or return the balance&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;MI&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;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-05-29&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUTCString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A quick test
&lt;/h2&gt;

&lt;p&gt;The cheapest check that catches an off-by-one is a hand-computed table. Mine uses three start dates and all five states, compared against dates counted on a calendar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:assert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-05-29&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&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="nf"&gt;slice&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;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-06-19&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-05-29&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&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;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-06-18&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;If the weekday case matches by hand, the loop and the &lt;code&gt;% 6&lt;/code&gt; trick are right.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you just want the dates
&lt;/h2&gt;

&lt;p&gt;I put these rules in five free calculators (no sign-up, runs in the browser, each shows its statute citation and the caveats above): &lt;a href="https://hexloomlabs.com/guides/tools/" rel="noopener noreferrer"&gt;https://hexloomlabs.com/guides/tools/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is date arithmetic on statute text, not legal advice. Confirm the rule and the start date with the current statute or a local attorney before relying on a deadline.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>Merge PDFs in the browser with pdf-lib, and what it silently drops</title>
      <dc:creator>Hexloom Labs</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:26:25 +0000</pubDate>
      <link>https://dev.to/hexloomlabs/merge-pdfs-in-the-browser-with-pdf-lib-and-what-it-silently-drops-2kp3</link>
      <guid>https://dev.to/hexloomlabs/merge-pdfs-in-the-browser-with-pdf-lib-and-what-it-silently-drops-2kp3</guid>
      <description>&lt;p&gt;You can merge PDFs entirely in the browser with &lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt;. No server, no upload, about ten lines. We use exactly this in a small tool, so here is the whole thing, plus the two things it silently drops, which I checked rather than assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The merge
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                       &lt;span class="c1"&gt;// File objects from &amp;lt;input type="file" multiple&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&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;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&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;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPageIndices&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;a href={url} download="merged.pdf"&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order of the &lt;code&gt;files&lt;/code&gt; array is the order of the output. If you let people reorder, reorder that array.&lt;/p&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy the file list to an array before your first &lt;code&gt;await&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Array.from(input.files)&lt;/code&gt; (or &lt;code&gt;[...dataTransfer.files]&lt;/code&gt; in a drop handler) first, then loop: the list from a drop can be empty by the time an awaited call returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap the loop in try/catch.&lt;/strong&gt; &lt;code&gt;PDFDocument.load&lt;/code&gt; throws on files that are not valid PDFs and on password-protected ones. A readable message ("make sure these are valid, non-password-protected PDFs") beats a silent failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;copyPages&lt;/code&gt; does not carry over
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;copyPages&lt;/code&gt; copies pages, not the document around them. I built two small test files with pdf-lib, one with a bookmark (an outline entry) and a text form field, and merged them with the code above. Result from reading the merged file back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Merged&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages&lt;/td&gt;
&lt;td&gt;2 + 3&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outline (bookmarks)&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form fields&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So for a quick "stick these scans together" tool this is fine, but if your users merge fillable forms or long reports with a table of contents, say so on the page and tell them to check the result before deleting the originals. Rebuilding an outline by hand is possible with pdf-lib's low-level API, but it is not something &lt;code&gt;copyPages&lt;/code&gt; does for you.&lt;/p&gt;

&lt;p&gt;I only tested bookmarks and text fields. I did not test attachments, tagged-PDF structure or page labels, so check those yourself if you depend on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client-side is worth it
&lt;/h2&gt;

&lt;p&gt;The usual objection is "browsers are slow". I have not benchmarked it, but &lt;code&gt;copyPages&lt;/code&gt; moves PDF objects rather than re-rendering or re-encoding pages, so image quality is untouched. The limit that matters is the device's memory: the merged document grows with every file you add, so very large batches of scans are where a phone will struggle.&lt;/p&gt;

&lt;p&gt;The upside is privacy you can prove. Open the Network tab, run a merge, and you should see no request carrying your file. That check is easy to do on any tool, including ours.&lt;/p&gt;

&lt;p&gt;If you want to see this running, the tool built on the code above is here: &lt;a href="https://merge-pdf.hexloomlabs.com/" rel="noopener noreferrer"&gt;Merge PDF&lt;/a&gt;. It is free for up to 5 files and 20 MB; a paid tier lifts that limit.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>privacy</category>
    </item>
    <item>
      <title>What your photos say about you: the metadata fields to check before you share</title>
      <dc:creator>Hexloom Labs</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:22:31 +0000</pubDate>
      <link>https://dev.to/hexloomlabs/what-your-photos-say-about-you-the-metadata-fields-to-check-before-you-share-1nc3</link>
      <guid>https://dev.to/hexloomlabs/what-your-photos-say-about-you-the-metadata-fields-to-check-before-you-share-1nc3</guid>
      <description>&lt;p&gt;A phone photo is two things: the picture, and a hidden record of where, when and with what it was taken. The record is called EXIF metadata, and it travels with the file unless something removes it. Here is how to look at it, and which fields to care about first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to look, with what you already have
&lt;/h2&gt;

&lt;p&gt;Looking does not change the file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; right-click the file, choose Properties, open the Details tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac:&lt;/strong&gt; open the photo in Preview and choose Tools &amp;gt; Show Location Info. That shows location only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iPhone:&lt;/strong&gt; in Photos, tap the photo, then swipe up or tap the Info button. Apple says this shows the date and time, file name, iPhone model, camera settings and a map of where it was taken.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each built-in viewer chooses what to show, and none of them claims to list every field. If you want the full list for one file, use a tool that reads the file itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fields, ranked by how much they give away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GPS.&lt;/strong&gt; Latitude and longitude, stored as degrees, minutes and seconds plus N/S and E/W. A pair like 48° 51' 30.2" N, 2° 17' 40.2" E is 48.8584, 2.2945 in decimal form (south and west become negative numbers). GPS.gov says smartphones are typically accurate to within about 4.9 m (16 ft) under open sky, so assume the pair points at the building you were in or a few metres from it. Extra decimal places do not make it more accurate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date and time.&lt;/strong&gt; When the photo was taken, created and modified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serial numbers.&lt;/strong&gt; The camera body serial number and the lens serial number identify a particular device, not just a model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner, artist, copyright and software tags.&lt;/strong&gt; These can name you or the tools you use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded thumbnail.&lt;/strong&gt; A small second copy of the picture inside the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XMP and IPTC blocks.&lt;/strong&gt; Separate metadata that can hold titles, captions and keywords.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Camera make and model matter least on their own. Check GPS first, then the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What removing metadata does not fix
&lt;/h2&gt;

&lt;p&gt;It does not touch what is visible in the picture: a street sign, a house number or a face is still there. And it cannot clean what a website or app stores after you upload it. Removing metadata only helps for the copy you share.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the result
&lt;/h2&gt;

&lt;p&gt;Whatever you use to clean a photo, read the file again afterwards and confirm the fields are gone. Do not take the tool's word for it.&lt;/p&gt;

&lt;p&gt;We build a small browser tool for this, Photo Scrubber: it lists what a photo holds, cuts the metadata out of JPG, PNG and WebP files without re-saving the picture, and re-reads the clean copy so you can see what is left. It runs in your browser and nothing is uploaded. Our guide walks through the same lookup with sources: &lt;a href="https://hexloomlabs.com/exif/see-what-metadata-a-photo-has/" rel="noopener noreferrer"&gt;how to see what metadata a photo has&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to check that a PDF tool really doesn't upload your files</title>
      <dc:creator>Hexloom Labs</dc:creator>
      <pubDate>Mon, 05 Oct 2026 18:21:21 +0000</pubDate>
      <link>https://dev.to/hexloomlabs/how-to-check-that-a-pdf-tool-really-doesnt-upload-your-files-3kol</link>
      <guid>https://dev.to/hexloomlabs/how-to-check-that-a-pdf-tool-really-doesnt-upload-your-files-3kol</guid>
      <description>&lt;p&gt;Plenty of PDF tools say your files stay on your device. You can check that yourself in a couple of minutes, with the browser you already have and a throwaway test file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1: watch the Network tab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Open the tool's page. Press F12 (Cmd+Option+I on a Mac) and click the Network tab. Chrome, Edge and Firefox all have one.&lt;/li&gt;
&lt;li&gt;Click the clear button (the circle with a line through it) so the list is empty.&lt;/li&gt;
&lt;li&gt;Add a test file and run the tool: merge, compress, whatever it does.&lt;/li&gt;
&lt;li&gt;Look for requests that sent data out. Uploads show up as POST or PUT requests, often "multipart/form-data". Click one and read Content-Length under Request Headers, or open the Payload tab. A request about as big as your file is an upload. A request of a few hundred bytes (analytics, a licence check) carries no file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check 2: go offline halfway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Load the tool's page fully.&lt;/li&gt;
&lt;li&gt;In the Network tab, open the throttling menu (it says "No throttling") and choose Offline.&lt;/li&gt;
&lt;li&gt;Run the tool on your test file. If you still get your result, the work did not need a server.&lt;/li&gt;
&lt;li&gt;If it errors, some tools fetch part of their code the first time you use them. Go back online, run it once with the test file, then go offline and repeat.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the wording gives away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"Files are deleted after one hour" or "we never keep your files" describe a server that received the file.&lt;/li&gt;
&lt;li&gt;An "uploading" progress bar before the work starts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both checks show what a tool did in your session, not what it will do next month. For anything sensitive, run them once with a test file before you trust it with the real one.&lt;/p&gt;

&lt;p&gt;We build small browser tools. Our Merge PDF tool merges inside the tab. It loads its code from its own site and, when the page loads, makes one small request to check whether you have a Pro subscription, which carries no file data. Its page walks through the same check: &lt;a href="https://merge-pdf.hexloomlabs.com/merge-pdf-without-uploading/" rel="noopener noreferrer"&gt;merge PDFs without uploading them&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
  </channel>
</rss>
