<?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: Enlh NG</title>
    <description>The latest articles on DEV Community by Enlh NG (@enlh).</description>
    <link>https://dev.to/enlh</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%2F3973436%2F42caeb4c-5582-4928-a079-e383a519e065.jpg</url>
      <title>DEV Community: Enlh NG</title>
      <link>https://dev.to/enlh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/enlh"/>
    <language>en</language>
    <item>
      <title>Migrating legacy .doc/.xls/.ppt files: a runbook with acceptance tests (28 of 29 checks survived, plus one trap)</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Thu, 01 Oct 2026 05:45:07 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-tools-to-convert-legacy-docxlsppt-to-docxxlsxpptx-batch-compared-4ke3</link>
      <guid>https://dev.to/enlh/top-5-free-tools-to-convert-legacy-docxlsppt-to-docxxlsxpptx-batch-compared-4ke3</guid>
      <description>&lt;p&gt;Sooner or later someone hands you a shared drive full of &lt;code&gt;.doc&lt;/code&gt;, &lt;code&gt;.xls&lt;/code&gt; and &lt;code&gt;.ppt&lt;/code&gt; files and says "move it all to the new formats". Converting one file is easy. Converting 4,000 and knowing nothing silently broke is a migration. This is the runbook I'd use, with the two scripts I tested: a &lt;strong&gt;pre-flight / batch / post-flight converter&lt;/strong&gt; and a &lt;strong&gt;round-trip experiment&lt;/strong&gt; that shows what a LibreOffice-based pipeline keeps and loses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: know what "conversion" is in your pipeline
&lt;/h2&gt;

&lt;p&gt;Most free converters (online or self-hosted) call LibreOffice in headless mode. One online converter I checked says so on its own page (conversion uses LibreOffice, so "the output may differ from the original in some details"). Whatever tool you pick, the fidelity of your migration is the fidelity of its engine, so test the engine on a sample of your own files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: pre-flight, because the converter will say "success" on junk
&lt;/h2&gt;

&lt;p&gt;Legacy binary Office files are OLE2 containers and begin with the bytes &lt;code&gt;D0 CF 11 E0 A1 B1 1A E1&lt;/code&gt;. Modern files are ZIP packages and begin with &lt;code&gt;PK&lt;/code&gt;. Plenty of "&lt;code&gt;.xls&lt;/code&gt;" files in the wild are really HTML exports, and plenty of "&lt;code&gt;.doc&lt;/code&gt;" files are plain text or already &lt;code&gt;.docx&lt;/code&gt;. I tested what LibreOffice does with them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a plain-text file named &lt;code&gt;fake.doc&lt;/code&gt; → converted without complaint into a 4,994-byte &lt;code&gt;fake.docx&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;an HTML file named &lt;code&gt;web.xls&lt;/code&gt; → converted without complaint into a 5,536-byte &lt;code&gt;web.xlsx&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both runs reported success. You can end up with a folder of "converted" files that were never Office documents. So check the magic bytes first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;OLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromhex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;D0CF11E0A1B11AE1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ZIP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;PK&lt;/span&gt;&lt;span class="se"&gt;\x03\x04&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;preflight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getsize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;empty file&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZIP&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;already a zip-based file (renamed .docx/.xlsx/.pptx?)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;OLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;not an OLE2 file (renamed text/HTML/RTF?)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One online converter's page says the same thing in its own words: it accepts "genuine binary DOC, XLS, and PPT files only" and rejects renamed text files. My pre-flight is deliberately strict; HTML-as-&lt;code&gt;.xls&lt;/code&gt; exports are a legitimate case, but they deserve a separate, reviewed path, not the same batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: convert with a private profile and a timeout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdtemp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# own profile: no lock collisions between runs
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;soffice&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-env:UserInstallation=file://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--headless&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--convert-to&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TARGET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--outdir&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                           &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutExpired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timeout after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rmtree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate &lt;code&gt;-env:UserInstallation&lt;/code&gt; per call avoids the stale-lock problems you meet when one headless instance is already running; the timeout makes one bad file unable to hang the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: post-flight, because exit code 0 is not proof
&lt;/h2&gt;

&lt;p&gt;The output must exist, be a valid ZIP and contain &lt;code&gt;[Content_Types].xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;postflight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no output file&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;testzip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;corrupt zip&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[Content_Types].xml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;namelist&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;not an OOXML package&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BadZipFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;not a zip&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran the full script (in the package) on a folder with 3 genuine legacy files and 4 bad ones. Result: the 3 real files were &lt;code&gt;OK&lt;/code&gt; (0.2–0.4 s each), and the empty file, the text file, the HTML file and the renamed &lt;code&gt;.docx&lt;/code&gt; were all &lt;code&gt;SKIPPED&lt;/code&gt; with a reason, written to &lt;code&gt;report.csv&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: acceptance tests: what does a conversion keep?
&lt;/h2&gt;

&lt;p&gt;I did not want to guess, so I built a fixture per format with known features, converted it to the legacy format and back to the modern one (LibreOffice 24.2), and read each property back with &lt;code&gt;openpyxl&lt;/code&gt;, &lt;code&gt;python-docx&lt;/code&gt; and &lt;code&gt;python-pptx&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%2Fibzfoar5aouhxmpddgz6.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%2Fibzfoar5aouhxmpddgz6.png" alt="29 properties checked after a round trip, 28 kept" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Excel&lt;/strong&gt;: all 20 formulas, three number formats, merged cells, a comment, conditional formatting, data validation, a defined name, column width, a chart, an image and a hidden sheet came back. Every cell value and formula text compared equal. &lt;strong&gt;Freeze panes did not&lt;/strong&gt; (&lt;code&gt;B2&lt;/code&gt; became none).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word&lt;/strong&gt;: headings, table, image, header, footer, a bold run, a comment, a tracked insertion, a tracked deletion and a bullet list all survived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PowerPoint&lt;/strong&gt;: 3 slides, speaker notes, a picture, a chart and a table survived.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two caveats matter more than the 28 greens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;My legacy files were written by LibreOffice, not by Microsoft Office.&lt;/strong&gt; A round trip through the same engine is friendlier than converting a file that Office 2003 wrote. Treat this as a harness and a baseline, not a guarantee. The way to get a real answer is to run it on a sample of your own files.&lt;/li&gt;
&lt;li&gt;I did not isolate whether the freeze panes were lost on the way to &lt;code&gt;.xls&lt;/code&gt; or on the way back. A direct &lt;code&gt;.xlsx&lt;/code&gt; → &lt;code&gt;.xlsx&lt;/code&gt; pass through the same engine kept them (&lt;code&gt;B2&lt;/code&gt;), so it is specific to the &lt;code&gt;.xls&lt;/code&gt; leg in my setup.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The trap: converting back loses data silently
&lt;/h2&gt;

&lt;p&gt;Legacy &lt;code&gt;.xls&lt;/code&gt; has a hard limit of 65,536 rows per sheet. I wrote a 70,000-row &lt;code&gt;.xlsx&lt;/code&gt;, converted it to &lt;code&gt;.xls&lt;/code&gt; and back, and the sheet came back with &lt;strong&gt;65,536&lt;/strong&gt; rows. No error. Nothing about it was in the script's output. If any step of your workflow goes through &lt;code&gt;.xls&lt;/code&gt; (for a partner who "needs the old format"), add a row count to the acceptance test.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Modern files are smaller": not always
&lt;/h2&gt;

&lt;p&gt;A common claim is that the modern formats are much smaller. In my fixtures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format pair&lt;/th&gt;
&lt;th&gt;Original modern&lt;/th&gt;
&lt;th&gt;Legacy&lt;/th&gt;
&lt;th&gt;Converted back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;xlsx / xls&lt;/td&gt;
&lt;td&gt;159,266 B&lt;/td&gt;
&lt;td&gt;166,400 B&lt;/td&gt;
&lt;td&gt;162,797 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docx / doc&lt;/td&gt;
&lt;td&gt;188,530 B&lt;/td&gt;
&lt;td&gt;176,128 B&lt;/td&gt;
&lt;td&gt;160,774 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pptx / ppt&lt;/td&gt;
&lt;td&gt;191,124 B&lt;/td&gt;
&lt;td&gt;815,616 B&lt;/td&gt;
&lt;td&gt;196,474 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the PowerPoint pair showed a big gap, which comes from how &lt;code&gt;.ppt&lt;/code&gt; stores embedded pictures, and my picture was incompressible noise, so treat the sizes as illustrative. Do not promise "50% smaller" to anyone before you have measured their files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an online converter says about itself
&lt;/h2&gt;

&lt;p&gt;For a few files you do not need a script. For comparison, here is one converter page, from its own text (Nov 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Batch&lt;/th&gt;
&lt;th&gt;Limits&lt;/th&gt;
&lt;th&gt;Not preserved / caveats&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The script above&lt;/td&gt;
&lt;td&gt;any folder&lt;/td&gt;
&lt;td&gt;your timeout&lt;/td&gt;
&lt;td&gt;macros are not carried over by this pipeline; you decide on pre- and post-flight&lt;/td&gt;
&lt;td&gt;your machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ToolTiny &lt;a href="https://tooltiny.com/office-format-converter.html" rel="noopener noreferrer"&gt;Office Format Converter&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;up to 10 files, mixed types; several inputs return a ZIP&lt;/td&gt;
&lt;td&gt;20 MB total; 90 s per file; 5 min per job; one failed file fails the whole batch&lt;/td&gt;
&lt;td&gt;macros not retained; password-protected files unsupported; fonts, pagination, formulas, charts and presentation effects may differ&lt;/td&gt;
&lt;td&gt;uploaded to its server, LibreOffice converts, temp files removed after download or failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I did not re-check other converters for this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist for your own migration
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pre-flight: size, magic bytes, passwords, macros (VBA is not converted; keep the originals).&lt;/li&gt;
&lt;li&gt;Convert in an isolated profile with a timeout.&lt;/li&gt;
&lt;li&gt;Post-flight: valid package, then your feature checks (formulas, row counts, headings, slide counts).&lt;/li&gt;
&lt;li&gt;Spot-check by eye: 5% of files, the ugliest ones (fonts, pagination and animations are the usual drift; I did not test them).&lt;/li&gt;
&lt;li&gt;Keep the originals until someone has opened the converted file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits of this write-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One engine (LibreOffice 24.2.7.2) and synthetic fixtures. Real files, especially ones saved by old Office versions, will find problems mine did not.&lt;/li&gt;
&lt;li&gt;I did not test macros, passwords, fonts, page breaks, animations or transitions.&lt;/li&gt;
&lt;li&gt;The converter row is what the vendor's page states, not a test of its output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your migration found a property that did not survive, tell me which one and I will add it to the acceptance test.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Same contract, seven variants: what should a Word diff flag, and what do three methods actually flag?</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Sun, 27 Sep 2026 15:46:25 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-tools-to-compare-word-documents-redline-diff-compared-1fbp</link>
      <guid>https://dev.to/enlh/top-5-free-tools-to-compare-word-documents-redline-diff-compared-1fbp</guid>
      <description>&lt;p&gt;"The client sent a final version, did anything change?" is a question a diff should answer. But "changed" is not one thing in a &lt;code&gt;.docx&lt;/code&gt;. A file can differ in thousands of bytes and not at all in words, or differ in the one word that matters and leave the XML almost untouched. I built one small contract and seven variants of it, then ran three comparison methods and wrote down what each one flags. This is the lab notebook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A synthetic services agreement: 12 body paragraphs, a 2×2 fee table, and a footer. Built with &lt;code&gt;python-docx&lt;/code&gt; 1.2.0 on Python 3.13.&lt;/li&gt;
&lt;li&gt;Seven variants, each compared to the original:

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-saved&lt;/strong&gt;: the same file converted docx → docx by LibreOffice headless (not Word), no edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cosmetic&lt;/strong&gt;: curly apostrophes, a non-breaking space, and a double space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real edit&lt;/strong&gt;: "30 days" → "45 days".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moved&lt;/strong&gt;: one paragraph moved to the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending tracked change&lt;/strong&gt;: "30" deleted and "45" inserted as real &lt;code&gt;w:del&lt;/code&gt; / &lt;code&gt;w:ins&lt;/code&gt; revisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table&lt;/strong&gt;: the fee cell changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Footer&lt;/strong&gt;: only the footer text changed.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;Three methods, all using &lt;code&gt;difflib.SequenceMatcher&lt;/code&gt; and counting non-equal opcodes:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A&lt;/strong&gt;: raw lines of pretty-printed &lt;code&gt;word/document.xml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B&lt;/strong&gt;: text passages (non-empty paragraphs and table cells, in reading order), compared exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C&lt;/strong&gt;: the same passages, normalized first (Unicode NFKC, curly quotes to straight, whitespace collapsed).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&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%2Flf3qpycnrrk6l7bmqztr.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%2Flf3qpycnrrk6l7bmqztr.png" alt="Changes flagged by three comparison methods across seven variants" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Should flag&lt;/th&gt;
&lt;th&gt;A: XML lines&lt;/th&gt;
&lt;th&gt;B: passages&lt;/th&gt;
&lt;th&gt;C: normalized&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Re-saved, no edits&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;43&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cosmetic whitespace and quotes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real edit (30 → 45)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paragraph moved&lt;/td&gt;
&lt;td&gt;2 (a removal and an addition)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending tracked change&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fee table cell&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Footer only&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I learned from each row
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Re-saving is noise for method A and invisible to B.&lt;/strong&gt; The LibreOffice re-save produced a file of 21,769 bytes from the original's 37,562, and 43 changed XML lines, yet every body paragraph was word-for-word identical. If your "diff" looks at XML or bytes, a document that went through another editor looks heavily changed. Compare text, not markup. (This was LibreOffice, not Word; I did not test a Word re-save.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cosmetic differences need a normalizer.&lt;/strong&gt; Smart quotes, a non-breaking space and a double space made three paragraphs "different" under exact text comparison. After normalization the count dropped to zero. The cost is that a normalizer can also hide a real change: if a contract's meaning depends on a double space, which is rare, you need the strict mode. That is why the better tools expose this as an option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A moved paragraph is two changes, by design.&lt;/strong&gt; A sequence diff has no "move" operation, so the paragraph is reported as removed in one place and added in another. That is a good reminder that "2 changes" can mean "1 paragraph went somewhere else".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tracked changes can make a naive reader lose a number.&lt;/strong&gt; In variant 5 the paragraph reads "within 45 days" once the revision is accepted. But &lt;code&gt;python-docx&lt;/code&gt;'s &lt;code&gt;paragraph.text&lt;/code&gt; returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The Supplier shall deliver the Services within  days of the Effective Date.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number vanished, because the inserted run lives under &lt;code&gt;w:ins&lt;/code&gt; instead of directly under the paragraph and &lt;code&gt;w:del&lt;/code&gt; text is stored as &lt;code&gt;w:delText&lt;/code&gt;. My passage extractor walks all &lt;code&gt;w:t&lt;/code&gt; descendants instead, which gives the accepted view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iterchildren&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w:p&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w:t&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;   &lt;span class="c1"&gt;# includes w:ins text, skips w:delText
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w:tbl&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w:tc&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
                &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;qn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w:t&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever way a tool treats pending revisions, you want to know it, because the same file can read as "30" or "45" depending on the tool. (A comparison page I checked says that for existing tracked changes, inserted text is included and deleted text is excluded, which is the accepted view.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope is a decision.&lt;/strong&gt; The footer row is zero for all three methods because the footer lives in a different part of the package. That is not a bug in &lt;code&gt;difflib&lt;/code&gt;, it is a scope choice: my extractor only reads the body. Text boxes, footnotes, endnotes, comments and headers are separate parts too. Before trusting any "no differences found", look at the tool's list of what it does not compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison functions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;’&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt;&lt;span class="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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;‘&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt;&lt;span class="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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;“&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;”&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;nchanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;difflib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SequenceMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autojunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_opcodes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;op&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;equal&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;nchanges&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v0.docx&lt;/span&gt;&lt;span class="sh"&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;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v3_edit.docx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;   &lt;span class="c1"&gt;# 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;autojunk=False&lt;/code&gt; matters: with the default heuristic, &lt;code&gt;difflib&lt;/code&gt; may treat frequent items as junk in long sequences and give odd matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a tool: the questions come from the table above
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;What is compared / not&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;This script (&lt;code&gt;difflib&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;whatever you print&lt;/td&gt;
&lt;td&gt;body paragraphs and table cells (my choice)&lt;/td&gt;
&lt;td&gt;your machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ToolTiny &lt;a href="https://tooltiny.com/compare-word.html" rel="noopener noreferrer"&gt;Compare Word Documents&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;standalone HTML report (removed red, added green); not a Word tracked-changes file&lt;/td&gt;
&lt;td&gt;non-empty body paragraphs and table-cell text; not fonts, images, numbering, headers, footers, text boxes, footnotes, endnotes or comments; moved text may show as a removal plus an addition; options to ignore whitespace and case&lt;/td&gt;
&lt;td&gt;uploaded to its server and removed after the request; up to 20 MB per file, 200,000 characters and 2,000 passages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;diff.tools&lt;/td&gt;
&lt;td&gt;its page lists "tracked changes between two .docx"; output format not stated&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;td&gt;page says processing is local in the browser, nothing uploaded; limits not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Draftable (free web tool)&lt;/td&gt;
&lt;td&gt;side-by-side view and change list; PDF export; its page lists several redline types including Word redlines&lt;/td&gt;
&lt;td&gt;Word, PDF, Excel, PowerPoint and text per its FAQ&lt;/td&gt;
&lt;td&gt;not stated for the free tool; free-tier limits not stated on the page I read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Things I could not check: Microsoft's own Word "Compare" documentation did not load for me, so I make no claims about it. The old post's other two tools (FreeRedline, Jubarte) I did not re-verify, so they are not listed. All vendor statements are as read in Nov 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits of this experiment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One synthetic document, built with python-docx. Real contracts have nested tables, fields, content controls, and styles that change text indirectly.&lt;/li&gt;
&lt;li&gt;The re-save came from LibreOffice, not Word, and Word may produce different XML for the same text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;difflib&lt;/code&gt; is a line/sequence diff over passages. It does not show intra-paragraph word changes; tools that highlight "30 → 45" inside a sentence run a second, word-level pass.&lt;/li&gt;
&lt;li&gt;I tested three methods of my own, not the vendors' algorithms. The tool table lists what each page says, not what I measured.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Diff &lt;strong&gt;text&lt;/strong&gt;, not bytes or XML.&lt;/li&gt;
&lt;li&gt;Decide whether quotes, spaces and case count, and make that visible as an option.&lt;/li&gt;
&lt;li&gt;Decide what happens to pending tracked changes, and read the tool's note on it.&lt;/li&gt;
&lt;li&gt;Read the "not compared" list (headers, footers, text boxes, comments) before you say "nothing changed".&lt;/li&gt;
&lt;li&gt;Treat "2 changes" for a moved block as one change in disguise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a document type that breaks these rules, send it over and I will add a row.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Top 10 ways merging and splitting Excel files goes wrong (each one reproduced)</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Thu, 24 Sep 2026 15:44:03 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-tools-to-merge-and-split-excel-workbooks-cnc</link>
      <guid>https://dev.to/enlh/top-5-free-tools-to-merge-and-split-excel-workbooks-cnc</guid>
      <description>&lt;p&gt;Most advice about merging Excel files is a list of tools. The failures people actually hit are not about the tool, they are about what a merge silently does to formulas, IDs, dates and formatting. So here is a top 10 of those failures. Each one comes from a small test I ran (openpyxl 3.1.5, pandas 3.0.5, LibreOffice for recalculation), and the full script is in the last section.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Rewriting through pandas keeps the values and nothing else
&lt;/h2&gt;

&lt;p&gt;The most common "merge" script is &lt;code&gt;pd.read_excel(...)&lt;/code&gt; then &lt;code&gt;to_excel(...)&lt;/code&gt;. I built a sheet with conditional formatting, a data-validation list, a comment, an image, a defined name, a custom column width and frozen panes, then copied it two ways:&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%2Fcsxwko5grkp24vcmlzz0.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%2Fcsxwko5grkp24vcmlzz0.png" alt="Same workbook copied with openpyxl and with pandas" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;openpyxl's &lt;code&gt;load_workbook&lt;/code&gt; then &lt;code&gt;save&lt;/code&gt; kept all seven. A pandas read-then-write kept none of them, and renamed the sheet to &lt;code&gt;Sheet1&lt;/code&gt;. A chart also survived the openpyxl round trip in a separate test (I did not test the pandas path for charts). Things I did &lt;strong&gt;not&lt;/strong&gt; test: pivot tables, slicers, shapes, form controls. Test your own file types before trusting a round trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A file nobody has opened in Excel has no formula results
&lt;/h2&gt;

&lt;p&gt;I wrote a file with openpyxl containing &lt;code&gt;=B2+C2&lt;/code&gt;, then read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;f1.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sheet_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Sales&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# [nan, nan]
&lt;/span&gt;&lt;span class="n"&gt;openpyxl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_workbook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;f1.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Sales&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;D2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;  &lt;span class="c1"&gt;# None
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;openpyxl stores the formula but no result, so any reader that wants values gets nothing. After I opened and re-saved the file through LibreOffice headless (&lt;code&gt;soffice --headless --convert-to xlsx&lt;/code&gt;), the same reads returned &lt;code&gt;[30, 70]&lt;/code&gt; and &lt;code&gt;30&lt;/code&gt;. Files saved by Excel carry cached values; files generated by code often do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;data_only=True&lt;/code&gt; plus &lt;code&gt;save()&lt;/code&gt; deletes your formulas for good
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;wb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openpyxl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_workbook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recalculated.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;out.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;openpyxl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_workbook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;out.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Sales&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;D2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;   &lt;span class="c1"&gt;# 30, not "=B2+C2"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data_only=True&lt;/code&gt; is a read mode. If you save that workbook, the formulas are replaced by their cached values. That is exactly what you want for a "values only" export and exactly what you do not want for a working file.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Formulas that point at a sheet the new workbook does not have
&lt;/h2&gt;

&lt;p&gt;Merging by copying cells from one workbook into another carries the formula text along:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Summary&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;A1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;   &lt;span class="c1"&gt;# '=SUM(Sales!B2:C3)'
&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sheetnames&lt;/span&gt;                           &lt;span class="c1"&gt;# ['Sheet'] — there is no 'Sales'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The text still names &lt;code&gt;Sales&lt;/code&gt;, which no longer exists in the destination. The same thing happens when you split a workbook and one sheet's formulas refer to another. Also, &lt;code&gt;copy_worksheet&lt;/code&gt; only works inside one workbook; across workbooks it raises &lt;code&gt;ValueError: Cannot copy between worksheets from different workbooks&lt;/code&gt;. Decide per formula: keep the references (and keep the sheets together) or freeze to values.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Sheet names have rules, and some are enforced quietly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Duplicate names: creating a second sheet called &lt;code&gt;Sales&lt;/code&gt; quietly gives you &lt;code&gt;Sales1&lt;/code&gt;. When you merge ten files that each have &lt;code&gt;Sheet1&lt;/code&gt;, you get &lt;code&gt;Sheet11&lt;/code&gt;, &lt;code&gt;Sheet12&lt;/code&gt;… unless you rename them yourself.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;/&lt;/code&gt; in a name raises &lt;code&gt;ValueError: Invalid character / found in sheet title&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A 32-character title is accepted by openpyxl, with a warning that "some applications may not be able to read the file". Excel's own limit is 31 characters, so prefix names with the source file name and truncate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bonus: openpyxl will save a workbook under an &lt;code&gt;.xlsm&lt;/code&gt; file name without the macro parts, and the file's workbook content type stays the plain &lt;code&gt;.xlsx&lt;/code&gt; one. I did not open it in Excel; macros need &lt;code&gt;keep_vba=True&lt;/code&gt; on load, and I did not test that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. IDs with leading zeros become numbers
&lt;/h2&gt;

&lt;p&gt;A text cell &lt;code&gt;00123&lt;/code&gt; next to a numeric &lt;code&gt;456&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;f5.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;              &lt;span class="c1"&gt;# [123, 456]
&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;f5.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# ['00123', '456']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the default read happens first and you then write a CSV, the zeros are gone for good (my round trip printed &lt;code&gt;123&lt;/code&gt;). Read ID columns as &lt;code&gt;str&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Merged cells only keep one value
&lt;/h2&gt;

&lt;p&gt;A vertical merge &lt;code&gt;A2:A4&lt;/code&gt; holding "Ops" reads like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;f6.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Dept&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# ['Ops', nan, nan]
&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;f6.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Dept&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;ffill&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# ['Ops', 'Ops', 'Ops']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Excel stores the value only in the top-left cell. &lt;code&gt;ffill()&lt;/code&gt; is the fix for stacking, but it also fills genuine blanks, so apply it only to the merged column.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Stacking rows needs normalized headers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;   &lt;span class="c1"&gt;# ['Region', 'Sales', 'region ']
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Region&lt;/code&gt; and &lt;code&gt;region&lt;/code&gt; (lowercase, trailing space) are two different columns, so you get a third, half-empty one. Strip and lowercase headers before &lt;code&gt;concat&lt;/code&gt;, and print the resulting columns before you trust the merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The sheet has a hard row limit
&lt;/h2&gt;

&lt;p&gt;Writing row 1,048,577 raises &lt;code&gt;ValueError: Row numbers must be between 1 and 1048576&lt;/code&gt;. Stacking ten large exports can cross that line, so check the total row count first and split the output into several sheets or files.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Two workbooks can use different date systems
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.xlsx&lt;/code&gt; format stores dates as serial numbers with a base date that can be 1900 or 1904 (a workbook setting). I wrote 2026-11-06 into a 1904-based workbook. The file holds the serial &lt;code&gt;44870&lt;/code&gt;; openpyxl reads it back correctly as 2026-11-06 because it honors the workbook's epoch. Read the same serial as a 1900-system date and you get 2022-11-05, which is 1,462 days off. Copy raw serials between workbooks that use different systems and every date moves by four years and a day. Compare &lt;code&gt;wb.epoch&lt;/code&gt; before merging.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not code
&lt;/h2&gt;

&lt;p&gt;For one-off merges, a tool saves the script. The questions to ask a tool are the ten above: does it keep formatting, what does it do with formulas, how does it name sheets, and where does your file go? What I could check from the vendors' own pages in Nov 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Merge style&lt;/th&gt;
&lt;th&gt;Formulas&lt;/th&gt;
&lt;th&gt;Unsupported content&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;openpyxl script (above)&lt;/td&gt;
&lt;td&gt;whatever you write&lt;/td&gt;
&lt;td&gt;you choose (items 2–4)&lt;/td&gt;
&lt;td&gt;pivots/shapes/slicers untested here&lt;/td&gt;
&lt;td&gt;your machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ToolTiny &lt;a href="https://tooltiny.com/merge-split-excel.html" rel="noopener noreferrer"&gt;Merge &amp;amp; Split Excel&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;each source sheet becomes a tab (rows are not appended); split gives one XLSX per sheet in a ZIP&lt;/td&gt;
&lt;td&gt;choice of "use last saved values" or "keep formulas"; the keep option does not support named ranges, structured table references, INDIRECT, OFFSET or external references&lt;/td&gt;
&lt;td&gt;charts, images, shapes, pivots, external links: the file is refused, not silently dropped; XLSM unsupported&lt;/td&gt;
&lt;td&gt;its server; up to 10 XLSX files and 20 MB per merge, 100 sheets and 250,000 cells per job; temp files removed after the download or a failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Excel itself (Move or Copy sheet, Power Query "Combine")&lt;/td&gt;
&lt;td&gt;tabs, or stacked rows with Power Query&lt;/td&gt;
&lt;td&gt;native&lt;/td&gt;
&lt;td&gt;native&lt;/td&gt;
&lt;td&gt;your machine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I could not open Microsoft's documentation pages with my tooling, so I make no claims about Excel's own limits beyond what you can try in the app yourself. I did not re-verify other online mergers for this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reproduction script
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;test_pitfalls.py&lt;/code&gt; builds every fixture above and prints the results shown here. It needs &lt;code&gt;openpyxl&lt;/code&gt;, &lt;code&gt;pandas&lt;/code&gt; and &lt;code&gt;Pillow&lt;/code&gt;; item 2's cached-value check uses LibreOffice's &lt;code&gt;soffice&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Versions: openpyxl 3.1.5, pandas 3.0.5, Python 3.13. Other versions may behave differently.&lt;/li&gt;
&lt;li&gt;I did not open the outputs in desktop Excel, so claims about what Excel shows stay at the file level (what is stored), not what the Excel UI displays.&lt;/li&gt;
&lt;li&gt;The fixtures are small and synthetic; real workbooks have more ways to break.&lt;/li&gt;
&lt;li&gt;Vendor statements are as read in Nov 2026.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is the ugliest merge failure you have seen? I would add it as number 11.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>excel</category>
    </item>
    <item>
      <title>A free AI background remover is three questions: which model, which license, where do the pixels go</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Tue, 22 Sep 2026 08:52:24 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-ai-background-remover-tools-local-vs-cloud-compared-3c46</link>
      <guid>https://dev.to/enlh/top-5-free-ai-background-remover-tools-local-vs-cloud-compared-3c46</guid>
      <description>&lt;p&gt;I wrote a comparison of free background removers a while ago and praised one tool for naming its model. A name is checkable, I said. Then I did the checking I had skipped: I read the model card, and I measured what a page sends. This post is that audit, with a script you can reuse.&lt;/p&gt;

&lt;p&gt;"Free" and "AI" tell you almost nothing. Three questions do:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which model runs, and what is its license?&lt;/li&gt;
&lt;li&gt;Where do the pixels go?&lt;/li&gt;
&lt;li&gt;Will the service still exist next month?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. Which model, and under what license
&lt;/h2&gt;

&lt;p&gt;Many "AI" background removers are a thin page around an open-weight model. The weights have their own license, separate from any code around them. From the model cards and repositories, read in Nov 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model / package&lt;/th&gt;
&lt;th&gt;Stated license&lt;/th&gt;
&lt;th&gt;Size / base (as stated)&lt;/th&gt;
&lt;th&gt;Commercial use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BRIA RMBG-1.4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bria-rmbg-1.4&lt;/code&gt;, "source-available model for non-commercial use"&lt;/td&gt;
&lt;td&gt;44.1M parameters; "developed on the IS-Net"; input resized to 1024×1024&lt;/td&gt;
&lt;td&gt;"subject to a commercial agreement with BRIA"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BRIA RMBG-2.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bria-rmbg-2.0&lt;/code&gt;, CC BY-NC 4.0&lt;/td&gt;
&lt;td&gt;0.2B parameters; built on BiRefNet; 1024×1024&lt;/td&gt;
&lt;td&gt;"subject to a commercial agreement with BRIA"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rembg (the Python tool)&lt;/td&gt;
&lt;td&gt;MIT for the code&lt;/td&gt;
&lt;td&gt;lists u2net, isnet, BiRefNet variants, SAM, and &lt;code&gt;bria-rmbg&lt;/code&gt; (RMBG-2.0) as the default&lt;/td&gt;
&lt;td&gt;its README says weights carry their own licenses, so check each; &lt;code&gt;bria-rmbg&lt;/code&gt; "requires a paid agreement"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a class="mentioned-user" href="https://dev.to/imgly"&gt;@imgly&lt;/a&gt;/background-removal (JS)&lt;/td&gt;
&lt;td&gt;AGPL-3.0 for the library&lt;/td&gt;
&lt;td&gt;model not named on the README page&lt;/td&gt;
&lt;td&gt;README points to &lt;a href="mailto:support@img.ly"&gt;support@img.ly&lt;/a&gt; for other licensing; model license not stated there&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The takeaway is not "avoid these". It is that &lt;strong&gt;"free to try in a browser" and "free to use in a product" are different grants.&lt;/strong&gt; If you pull RMBG-1.4 or 2.0 into a commercial app, the card says you need a deal with BRIA. If you pick the AGPL JS library, the AGPL's obligations apply to what you ship. I am not a lawyer; read the license for your case.&lt;/p&gt;

&lt;p&gt;Notice also what &lt;em&gt;tools&lt;/em&gt; leave out. A page that says "powered by AI" and names nothing gives you nothing to check. A page that names RMBG-1.4 gives you a model card to read, including this license line. That is the honest value of naming the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Where do the pixels go: a script instead of a promise
&lt;/h2&gt;

&lt;p&gt;Every vendor says "private". Your browser can tell you. I wrote a ~25-line Playwright script that opens a page, picks a file in its &lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;, waits, and lists every POST/PUT/PATCH with its size. If a request body is at least half the size of your image, it flags it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// node upload-check.js &amp;lt;url&amp;gt; &amp;lt;image&amp;gt; [fileInputSelector]&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;chromium&lt;/span&gt; &lt;span class="p"&gt;}&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="s1"&gt;playwright&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;fs&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="s1"&gt;fs&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;async &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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[type=file]&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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;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;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&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;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&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;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&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;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// Blob/FormData bodies are streamed, so postData() is null for them.&lt;/span&gt;
  &lt;span class="c1"&gt;// Content-Length is the reliable size, so read that.&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PATCH&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&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="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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allHeaders&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postDataBuffer&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="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&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="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;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="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;sent&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="na"&gt;method&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="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;url&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="nf"&gt;url&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setInputFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// let the tool finish&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image bytes:&lt;/span&gt;&lt;span class="dl"&gt;'&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;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sent&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;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LIKELY CONTAINS THE IMAGE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;small&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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="nx"&gt;url&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="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt;&lt;span class="p"&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;sent&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no request carried a body&lt;/span&gt;&lt;span class="dl"&gt;'&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first version read &lt;code&gt;request.postDataBuffer()&lt;/code&gt; and reported "no request carried a body" for an upload that was plainly happening. Playwright does not expose streamed Blob and FormData bodies that way. Reading &lt;code&gt;Content-Length&lt;/code&gt; fixed it, and that is the kind of bug that makes a "privacy checker" lie in the comfortable direction.&lt;/p&gt;

&lt;p&gt;I validated the script on three tiny pages of my own on localhost, using one 535 KB JPEG. These are mocks, not any vendor's site:&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%2Fed2c0j0x46oyi8wb8yqe.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%2Fed2c0j0x46oyi8wb8yqe.png" alt="Bytes sent by three mock pages for the same 535 KB photo" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mock page&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A: &lt;code&gt;fetch&lt;/code&gt; with the file as body&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /upload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;534,844&lt;/td&gt;
&lt;td&gt;likely contains the image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B: &lt;code&gt;FormData&lt;/code&gt; (multipart)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;535,034&lt;/td&gt;
&lt;td&gt;likely contains the image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C: processes locally, sends a small ping&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /analytics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;small&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Limits you should know before trusting a green result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A page could send a heavily downscaled or cropped copy. My 50% rule would miss it.&lt;/li&gt;
&lt;li&gt;A chunked upload has no &lt;code&gt;Content-Length&lt;/code&gt;, so the script prints &lt;code&gt;-1&lt;/code&gt;. Treat &lt;code&gt;-1&lt;/code&gt; as "look manually".&lt;/li&gt;
&lt;li&gt;WebSocket traffic and &lt;code&gt;sendBeacon&lt;/code&gt; are not covered.&lt;/li&gt;
&lt;li&gt;A model that runs locally still downloads from somewhere; a 40 MB model file is a GET, not a POST, so it will not show here. The first run being slower is normal for that design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did &lt;strong&gt;not&lt;/strong&gt; run this against any vendor's website for this post. The script is a tool for you, on your own images; run it yourself and trust what you see.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Will the service be there
&lt;/h2&gt;

&lt;p&gt;On the day I read it, remove.bg's own homepage carried a banner: background removal is moving to Canva, and "the standalone website will no longer be available from 1 December 2026 at 9:00am CET." The page did not say what happens to the API or accounts, so I won't guess. The moral is plain: a hosted service can be the right choice and still a dependency with an end date. A model you can run yourself (license permitting) does not have that problem, at the cost of the download and your own hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each service says about itself
&lt;/h2&gt;

&lt;p&gt;All from the vendors' own pages, as read in Nov 2026. "Not stated" means the page I read doesn't say, not that the answer is no.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Where it processes&lt;/th&gt;
&lt;th&gt;Retention&lt;/th&gt;
&lt;th&gt;Limits / pricing notes&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;remove.bg&lt;/td&gt;
&lt;td&gt;cloud (upload)&lt;/td&gt;
&lt;td&gt;not stated on the FAQ page; a feedback dialog says a negatively-rated image "will be used for future improvements" unless you decline&lt;/td&gt;
&lt;td&gt;website closing 1 Dec 2026; FAQ lists up to 50 megapixels / 22 MB per image, free-tier applicability not stated&lt;/td&gt;
&lt;td&gt;not named&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photoroom&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;td&gt;links to its privacy policy&lt;/td&gt;
&lt;td&gt;high-resolution export is a paid feature; "no signup required" in page metadata&lt;/td&gt;
&lt;td&gt;not named&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BgEraser&lt;/td&gt;
&lt;td&gt;not stated ("directly in your browser", yet files are "deleted within 2 hours")&lt;/td&gt;
&lt;td&gt;uploads and results deleted within 2 hours&lt;/td&gt;
&lt;td&gt;batches of up to 20; "no sign-up or usage limits"; FAQ says commercial use of results is allowed if you have the rights to the original&lt;/td&gt;
&lt;td&gt;not named&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://tooltiny.com/remove-background-image.html" rel="noopener noreferrer"&gt;ToolTiny&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;the page says in the browser, via WebAssembly; "nothing is ever uploaded"&lt;/td&gt;
&lt;td&gt;n/a (nothing uploaded, per the page)&lt;/td&gt;
&lt;td&gt;about 40 MB model downloaded and cached on first use; "No file size limit" (in-browser, so your device's memory is the real limit; I did not test that); JPG, PNG, WebP in, transparent PNG out&lt;/td&gt;
&lt;td&gt;RMBG-1.4 (BRIA AI), so the license above applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ToolPix&lt;/td&gt;
&lt;td&gt;not checked: its site blocks automated fetching&lt;/td&gt;
&lt;td&gt;not checked&lt;/td&gt;
&lt;td&gt;not checked&lt;/td&gt;
&lt;td&gt;not checked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows deserve a comment. BgEraser's page says you can use it "directly in your browser" and also says files are deleted within two hours; those two statements can both be true, but the page does not say where processing happens, and this is the case where the script above earns its keep. And for any tool built on RMBG-1.4, including the one in this table, "free" on the page and "non-commercial" on the model card are two different sentences; a site owner should read the second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five-minute checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Find the model name. No name, no checking.&lt;/li&gt;
&lt;li&gt;Open the model card and read the license line before a commercial use.&lt;/li&gt;
&lt;li&gt;Run the upload script on a throwaway photo; investigate any &lt;code&gt;-1&lt;/code&gt; or flagged request.&lt;/li&gt;
&lt;li&gt;Note any end-of-life banner or terms change, and keep an export of what you need.&lt;/li&gt;
&lt;li&gt;Read the privacy policy for retention, then compare it with what the network tab showed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits of this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vendor statements come from the pages as they read in Nov 2026 and may change; some pages were summarized by a fetch tool and I could not see their linked policies.&lt;/li&gt;
&lt;li&gt;I did not benchmark edge quality (hair, fur, glass) for any tool; that needs a labeled image set, and I did not make one.&lt;/li&gt;
&lt;li&gt;The upload check was tested on my own mock pages only.&lt;/li&gt;
&lt;li&gt;The license table reports what each page states; licenses of other models (U-2-Net, BiRefNet and others) were not verified here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you know of a tool that publishes both its model license and a verifiable no-upload design, tell me and I will add a row.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>images</category>
    </item>
    <item>
      <title>Is your "free" image converter uploading your photos? A 2-minute test, and 3 ways conversion silently breaks images</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:18:20 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-online-image-converters-jpg-png-webp-heic-privacy-compared-520m</link>
      <guid>https://dev.to/enlh/top-5-free-online-image-converters-jpg-png-webp-heic-privacy-compared-520m</guid>
      <description>&lt;p&gt;I convert images a lot, and two questions come up every time: &lt;em&gt;did that tool just upload my file somewhere?&lt;/em&gt; and &lt;em&gt;did the converted image come out the way it went in?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can answer the first one yourself in about two minutes with the browser's built-in tools. The second one has three classic traps. I reproduced each of them with a few lines of Python, so you can see exactly what goes wrong and how to avoid it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: the two-minute upload test
&lt;/h2&gt;

&lt;p&gt;You don't need to trust a privacy page. Watch what the page actually does.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the converter and &lt;strong&gt;load the page fully&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open DevTools (&lt;code&gt;F12&lt;/code&gt;) → &lt;strong&gt;Network&lt;/strong&gt; tab. Tick &lt;strong&gt;Preserve log&lt;/strong&gt;, and click the clear (🚫) icon so the list is empty.&lt;/li&gt;
&lt;li&gt;Drop in a test image. Use something unmistakable, like a photo around 3–5 MB.&lt;/li&gt;
&lt;li&gt;Look at the new rows. Sort by &lt;strong&gt;Size&lt;/strong&gt; or filter by &lt;strong&gt;Fetch/XHR&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;POST&lt;/code&gt; or &lt;code&gt;PUT&lt;/code&gt; whose &lt;em&gt;request payload&lt;/em&gt; is about the size of your file (check the &lt;strong&gt;Payload&lt;/strong&gt; tab, or the "Sent" size in some browsers) = &lt;strong&gt;your file left your machine.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Nothing sizeable, just small analytics pings or fonts = processing is probably local.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;For a second opinion, &lt;strong&gt;go offline&lt;/strong&gt; (Network tab → throttling → &lt;em&gt;Offline&lt;/em&gt;, or switch off Wi-Fi) &lt;em&gt;after the page has loaded&lt;/em&gt;, then convert again. If it still works, it ran in your browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Caveats, so you don't draw the wrong conclusion:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A page that downloads a big decoder or model on first use will show large &lt;em&gt;incoming&lt;/em&gt; transfers. That is not an upload. Look at what is &lt;em&gt;sent&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;"Offline works" proves local processing for that action. It doesn't prove the page never sends anything &lt;em&gt;at another time&lt;/em&gt; (analytics, error reports). If it matters, read the requests.&lt;/li&gt;
&lt;li&gt;A tool can be local for some formats and server-side for others. HEIC in particular needs a decoder that most browsers don't ship, so some tools quietly fall back to a server for it. Test each format you care about.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the tools themselves say
&lt;/h3&gt;

&lt;p&gt;Here's what I could verify from the sources themselves (checked Nov 2026; policies change, so re-check before relying on them):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Where processing happens&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Squoosh (Google Chrome Labs, Apache-2.0)&lt;/td&gt;
&lt;td&gt;In your browser. README: "Squoosh does not send your image to a server. All image compression processes locally."&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/GoogleChromeLabs/squoosh" rel="noopener noreferrer"&gt;GitHub README&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudConvert&lt;/td&gt;
&lt;td&gt;Server-side. Privacy page: uploaded files "are automatically deleted after 24 hours at the latest"; you can delete earlier by hand.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://cloudconvert.com/privacy" rel="noopener noreferrer"&gt;Privacy page&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TinyPNG&lt;/td&gt;
&lt;td&gt;Home page states "up to 20 images, max 5 MB each." The privacy heading on the page had no answer under it, and I found no retention statement there.&lt;/td&gt;
&lt;td&gt;&lt;a href="https://tinypng.com/" rel="noopener noreferrer"&gt;tinypng.com&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm not ranking these. The point is that "browser-based" and "deleted within a day" are very different promises, and the Network tab lets you check which one you got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: three ways conversion silently breaks an image
&lt;/h2&gt;

&lt;p&gt;These are bugs in &lt;em&gt;pipelines&lt;/em&gt;, not in any one website. They show up in scripts and tools alike.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The sideways photo (EXIF orientation)
&lt;/h3&gt;

&lt;p&gt;Phones often store pixels in the sensor's orientation and add an EXIF tag that says "rotate this when you display it." A converter that copies the pixels and drops the tag gives you a sideways image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;

&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone-photo.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getexif&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x0112&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# (427, 640) 6  -&amp;gt; "rotate 90°"
&lt;/span&gt;
&lt;span class="n"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                   &lt;span class="c1"&gt;# pixels only; the tag is gone
&lt;/span&gt;&lt;span class="n"&gt;naive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;naive.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# displays sideways
&lt;/span&gt;
&lt;span class="n"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exif_transpose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# apply the rotation first
&lt;/span&gt;&lt;span class="n"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fixed.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;![Left: correctly rotated. Right: pixels copied, EXIF dropped(&lt;a href="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ui7eahw4t8shv3bmr3cm.png" rel="noopener noreferrer"&gt;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ui7eahw4t8shv3bmr3cm.png&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;I started from a sample photo and wrote it with orientation tag 6, which is what a portrait shot from a phone typically carries. After the naive conversion the tag is &lt;code&gt;None&lt;/code&gt; and the picture displays on its side. (Modern browsers honour the tag when &lt;em&gt;displaying&lt;/em&gt; a JPEG, which is why this bug often only appears after conversion.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check yours:&lt;/strong&gt; convert one portrait photo straight from your phone and look at it in a viewer that doesn't auto-rotate. If it's sideways, the tool isn't applying orientation.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The black logo (transparency → JPEG)
&lt;/h3&gt;

&lt;p&gt;JPEG has no alpha channel. What happens to transparent pixels depends on the code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rgba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logo-transparent.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# RGBA
&lt;/span&gt;
&lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# alpha just discarded
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpixel&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;                      &lt;span class="c1"&gt;# (0, 0, 0)  -&amp;gt; black
&lt;/span&gt;
&lt;span class="n"&gt;bg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rgba&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;             &lt;span class="c1"&gt;# flatten onto white first
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpixel&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;                       &lt;span class="c1"&gt;# (255, 255, 255)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F1wat9l1p0cx9r10gb6eq.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%2F1wat9l1p0cx9r10gb6eq.png" alt="PNG with transparency, naive JPEG, flattened on white" width="800" height="626"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In my test the transparent corner pixel became &lt;code&gt;(0, 0, 0)&lt;/code&gt;, so a dark logo on a transparent background turns into a black blob. A good converter either lets you pick the background colour or warns you. If you're converting logos to JPEG, flatten onto the colour the page will actually use.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The disappearing metadata
&lt;/h3&gt;

&lt;p&gt;When I re-saved that phone photo with only the pixels, the whole EXIF block was gone. That cuts both ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Good for privacy:&lt;/strong&gt; camera model, timestamps and GPS coordinates don't travel with the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bad for fidelity:&lt;/strong&gt; orientation goes too (see above), and so can the embedded colour profile unless it's explicitly copied. Wide-gamut photos can look duller afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check what's in a file before and after with &lt;code&gt;exiftool photo.jpg&lt;/code&gt; (or the Properties dialog on your OS). Decide on purpose whether you want the metadata kept, and don't let a converter decide for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safe conversion function
&lt;/h2&gt;

&lt;p&gt;Here's the whole thing in a few lines, handling orientation and transparency, and &lt;em&gt;deliberately&lt;/em&gt; stripping metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageOps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exif_transpose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;      &lt;span class="c1"&gt;# 1. apply orientation
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGBA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# 2. flatten alpha
&lt;/span&gt;        &lt;span class="n"&gt;flat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;flat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paste&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;img&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flat&lt;/span&gt;
    &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# 3. no EXIF carried over
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HEIC support is one &lt;code&gt;pip install pillow-heif&lt;/code&gt; away (&lt;code&gt;pillow_heif.register_heif_opener()&lt;/code&gt;), and in my test the library applied the rotation itself when opening, so it wasn't a source of sideways images.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you'd rather not script it
&lt;/h2&gt;

&lt;p&gt;For one-off files, a browser-based converter is fine, as long as you run the two-minute test above. I run a free &lt;a href="https://dev.toYOUR-IMAGE-CONVERTER-URL"&gt;image converter&lt;/a&gt; at ToolTiny, so apply the same test to mine as you would to any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits of what I showed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The orientation and transparency demos use Pillow. Other libraries differ in their defaults, and that's the point: &lt;em&gt;test the tool you use.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;I did not audit any specific converter's network traffic for this post; the Network-tab steps are how you can do that yourself. Policies in the table were read from the pages named, on the date shown.&lt;/li&gt;
&lt;li&gt;EXIF behaviour varies by browser and viewer. When in doubt, compare in two apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've hit a conversion bug that isn't on this list, drop it in the comments. I'm collecting them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>images</category>
    </item>
    <item>
      <title>I compressed 7 images 77 different ways. Here's what actually shrank (and what got ugly)</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Thu, 10 Sep 2026 15:14:46 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-image-compressors-online-browser-based-no-upload-3b96</link>
      <guid>https://dev.to/enlh/top-5-free-image-compressors-online-browser-based-no-upload-3b96</guid>
      <description>&lt;p&gt;Every "how to compress images" article tells you to "use WebP." I wanted to see how much that advice is worth, so I ran the same seven images through JPEG, WebP, AVIF and PNG at several quality settings, and measured two things: file size and how close the result is to the original.&lt;/p&gt;

&lt;p&gt;Short version: the format matters less than &lt;em&gt;which kind of image&lt;/em&gt; you have. Photos and graphics behave in opposite ways, and picking the wrong format for a graphic costs you more than any quality slider can save.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images (7):&lt;/strong&gt; five photos (a real fundus photo at 1411×1411, plus four standard sample photos at 512–640 px wide), one flat-colour bar chart (800×500) and one UI screenshot (1440×900).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoders:&lt;/strong&gt; Pillow 12.3 (libjpeg, libwebp, libavif). JPEG at q95/85/75/60 (optimized + progressive), WebP at q85/75/60 (&lt;code&gt;method=6&lt;/code&gt;), AVIF at q70/50, PNG lossless, and PNG reduced to 256 colours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality metric:&lt;/strong&gt; SSIM against the decoded original (1.0 = identical). It's a rough proxy for "looks the same", not a verdict from human eyes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Units:&lt;/strong&gt; 1 KB = 1,024 bytes throughout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baseline:&lt;/strong&gt; every size below is shown as a percentage of the same image saved as JPEG q95, which is roughly what an export dialog gives you by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested the encoder libraries, not any website's pipeline. Online tools choose their own presets, so treat these numbers as "what the codecs can do", not "what tool X will give you".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="n"&gt;im&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;photo.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RGB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;optimize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;progressive&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.webp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&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="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.avif&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Result 1: for photos, WebP q75 was smaller than JPEG q75 every time
&lt;/h2&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%2F701vvztu7xjhqonf2k3b.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%2F701vvztu7xjhqonf2k3b.png" alt="Photos: size and SSIM by format" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(the SSIM axis starts at 0.88 to make differences visible)&lt;/em&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Photo&lt;/th&gt;
&lt;th&gt;JPEG q75&lt;/th&gt;
&lt;th&gt;WebP q75&lt;/th&gt;
&lt;th&gt;WebP vs JPEG size&lt;/th&gt;
&lt;th&gt;SSIM JPEG / WebP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;astronaut 512²&lt;/td&gt;
&lt;td&gt;38.2 KB&lt;/td&gt;
&lt;td&gt;24.0 KB&lt;/td&gt;
&lt;td&gt;−37%&lt;/td&gt;
&lt;td&gt;0.9405 / 0.9395&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;china 640×427&lt;/td&gt;
&lt;td&gt;56.4 KB&lt;/td&gt;
&lt;td&gt;49.0 KB&lt;/td&gt;
&lt;td&gt;−13%&lt;/td&gt;
&lt;td&gt;0.9414 / 0.9566&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coffee 600×400&lt;/td&gt;
&lt;td&gt;39.5 KB&lt;/td&gt;
&lt;td&gt;28.7 KB&lt;/td&gt;
&lt;td&gt;−27%&lt;/td&gt;
&lt;td&gt;0.9083 / 0.9144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flower 640×427&lt;/td&gt;
&lt;td&gt;25.5 KB&lt;/td&gt;
&lt;td&gt;15.3 KB&lt;/td&gt;
&lt;td&gt;−40%&lt;/td&gt;
&lt;td&gt;0.9369 / 0.9368&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fundus 1411²&lt;/td&gt;
&lt;td&gt;104.3 KB&lt;/td&gt;
&lt;td&gt;41.0 KB&lt;/td&gt;
&lt;td&gt;−61%&lt;/td&gt;
&lt;td&gt;0.9751 / 0.9568&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On two photos WebP matched JPEG's SSIM almost exactly while being 37–40% smaller; on two it scored higher and was smaller; on the large photo it was 61% smaller but visibly scored lower (0.957 vs 0.975). So "WebP is 30% smaller" is a fair rule of thumb, but at the same &lt;em&gt;number&lt;/em&gt; on the quality slider you don't get the same quality. Compare at matched SSIM, or just look at the result.&lt;/p&gt;

&lt;p&gt;AVIF at q70 was often the closest to the original (highest SSIM on four of five photos) at a size that varied from smaller than JPEG q75 to slightly larger. At q50 it got very small, for example 29.0 KB against 138.5 KB for JPEG q95 on the china photo, but SSIM dropped to 0.92. I would not claim AVIF is "always smallest": with these default Pillow settings, it was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: for graphics and screenshots, JPEG was the worst choice
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;PNG lossless&lt;/th&gt;
&lt;th&gt;JPEG q75&lt;/th&gt;
&lt;th&gt;WebP q75&lt;/th&gt;
&lt;th&gt;PNG 256 colours&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flat bar chart&lt;/td&gt;
&lt;td&gt;9.7 KB&lt;/td&gt;
&lt;td&gt;14.4 KB&lt;/td&gt;
&lt;td&gt;4.1 KB&lt;/td&gt;
&lt;td&gt;4.4 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI screenshot&lt;/td&gt;
&lt;td&gt;86.0 KB&lt;/td&gt;
&lt;td&gt;52.1 KB&lt;/td&gt;
&lt;td&gt;26.9 KB&lt;/td&gt;
&lt;td&gt;24.9 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the flat chart, a &lt;strong&gt;lossless PNG (9.7 KB) beat JPEG q75 (14.4 KB)&lt;/strong&gt;, and JPEG q95 was 28.8 KB, three times the PNG. JPEG is built for noisy, continuous-tone photos. On sharp edges and flat colour it adds ringing artifacts &lt;em&gt;and&lt;/em&gt; gets bigger.&lt;/p&gt;

&lt;p&gt;The 256-colour PNG was the surprise winner for the screenshot: 24.9 KB at SSIM 0.994, with sharp text, roughly 29% of the lossless PNG size. WebP q75 was about the same size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: 256-colour PNG on a photo is a trap
&lt;/h2&gt;

&lt;p&gt;Quantizing a photo to 256 colours gave files 95–116% the size of JPEG q95, with an SSIM of only 0.89–0.96, plus colour banding. Palette PNG is great for graphics and pointless for photos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 4: resizing beat every codec setting
&lt;/h2&gt;

&lt;p&gt;Same fundus photo, WebP q75:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimensions&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1411×1411&lt;/td&gt;
&lt;td&gt;41.0 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000×1000&lt;/td&gt;
&lt;td&gt;24.6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;700×700&lt;/td&gt;
&lt;td&gt;15.0 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dropping from 1411 to 1000 px saved more (−40%) than switching from JPEG q75 to WebP q75 did on three of the five photos (and the same as the best case). If your page displays an image at 700 px wide, shipping it at 3000 px is the real waste, and no quality slider fixes that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Resize first&lt;/strong&gt; to the largest size the page will display (×2 if you care about high-DPI screens).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos:&lt;/strong&gt; WebP q75–80, or AVIF if your audience's browsers support it. Keep a JPEG fallback if you need one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots, charts, logos, UI:&lt;/strong&gt; PNG with a reduced palette, or lossless WebP. Never JPEG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the result&lt;/strong&gt; at 100% zoom. A single metric (even SSIM) won't catch every artifact.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Tools, if you don't want to script this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Squoosh&lt;/strong&gt; (Google Chrome Labs, Apache-2.0) is the one to reach for if you want to compare codecs side by side. Its README says it "does not send your image to a server."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TinyPNG&lt;/strong&gt; is simple and fast; its page currently states "up to 20 images, max 5 MB each" (checked Nov 2026, so verify before relying on it), and I couldn't find a data-retention statement on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ImageMagick/cwebp&lt;/strong&gt; if you're comfortable on the command line: &lt;code&gt;cwebp -q 75 -m 6 in.png -o out.webp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;I also run a free browser-based &lt;a href="https://tooltiny.com/image-compress.html" rel="noopener noreferrer"&gt;image compressor&lt;/a&gt; on ToolTiny with a live size preview, which is handy for one-off files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits of this experiment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The photos are small by modern camera standards. Ratios usually hold on larger images, but I didn't test 12-megapixel files.&lt;/li&gt;
&lt;li&gt;Seven images is a small sample. Results on your own images will vary, so run the script above on yours.&lt;/li&gt;
&lt;li&gt;SSIM isn't perception. AVIF and WebP can blur fine texture in ways SSIM undervalues.&lt;/li&gt;
&lt;li&gt;One encoder version (Pillow 12.3 / libwebp / libavif defaults). mozjpeg or a tuned &lt;code&gt;avifenc&lt;/code&gt; would change the JPEG and AVIF rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try the script on your own images and get different numbers, I'd like to see them in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>images</category>
    </item>
    <item>
      <title>Top 5 Free Text Diff &amp; Compare Tools Online (Privacy-First Options)</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Mon, 07 Sep 2026 06:42:57 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-text-diff-compare-tools-online-privacy-first-options-4nnl</link>
      <guid>https://dev.to/enlh/top-5-free-text-diff-compare-tools-online-privacy-first-options-4nnl</guid>
      <description>&lt;p&gt;The question that actually matters before pasting anything into an online diff checker: does this tool process the comparison on their servers, or entirely in your browser? If you're diffing API keys, a client contract, or proprietary code, that's not a minor detail — several popular diff tools upload your text to compare it, some even generate shareable public links by default. I compared 5 tools specifically on that axis, plus what each one actually does once the diff is computed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. ToolTiny — fully local, with a merge workspace and JSON-aware diffing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tooltiny.com/text-compare.html" rel="noopener noreferrer"&gt;ToolTiny's Text Compare tool&lt;/a&gt; runs the entire diff — built on the jsdiff library — inside your browser tab, so nothing you paste or upload ever reaches a server. Word-by-Word mode handles contracts and prose; Line-by-Line handles config files, logs, and source code. You can paste directly or upload &lt;code&gt;.txt&lt;/code&gt;, &lt;code&gt;.log&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt;, &lt;code&gt;.py&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;, and similar files without copy-pasting first.&lt;/p&gt;

&lt;p&gt;Past the basic highlight-the-difference stage, it now covers what you'd expect from a full comparison workspace: a JSON-normalize mode that parses both inputs and sorts keys recursively before comparing, so two JSON files that only differ in key order or indentation correctly show as identical rather than flagging false differences; ignore-whitespace and ignore-case toggles; a live stats bar showing characters/lines added, removed, and modified alongside an overall similarity percentage; a minimap sidebar for jumping straight to a change in a long document; a merge panel where each changed segment gets its own "Keep Original" / "Keep New" choice, building a combined result you can copy out; and one-click export of the whole comparison as a self-contained HTML file. All of it still runs client-side — the feature list grew, the privacy model didn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Diffchecker — the most polished workspace, with a privacy trade-off to know about
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.diffchecker.com/" rel="noopener noreferrer"&gt;Diffchecker&lt;/a&gt; offers switchable views, a rule system to ignore timestamps, build numbers, or session IDs before comparing, and merge functionality with a genuinely refined interface. Free to try, unlimited on the paid tier. Worth knowing explicitly: Diffchecker's core convenience feature — shareable diff links — means comparisons can be processed and stored server-side rather than staying local to your browser. For non-sensitive comparisons where sharing a link matters more than privacy, that's a reasonable trade. For anything confidential, it's the one thing to check before pasting.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Mergely — built for developers who want to edit while diffing
&lt;/h2&gt;

&lt;p&gt;The most code-editor-like interface &lt;a href="https://editor.mergely.com/" rel="noopener noreferrer"&gt;here&lt;/a&gt;: syntax highlighting, line numbers, and inline editing directly within the diff view — you can fix either side of the comparison without leaving the tool and re-diff immediately. If your workflow is "compare, then fix what's wrong," this saves a copy-out-edit-copy-back cycle that even merge-panel tools like ToolTiny still involve.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. EveryTool — the other fully-local option with a deep feature set
&lt;/h2&gt;

&lt;p&gt;Also 100% browser-based using the Myers diff algorithm &lt;a href="https://everytool.in/" rel="noopener noreferrer"&gt;EveryTool&lt;/a&gt; (the same one Git uses): JSON mode, whitespace/casing filters, a minimap, live change counts with similarity percentage, and merge-by-accepting-individual-changes. It's a close match to ToolTiny feature-for-feature at this point — the meaningful difference is that ToolTiny also takes direct file uploads for common code and data formats, where EveryTool's flow is paste-based.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OnlineTextCompare — simplest two-way merge tool, fully local
&lt;/h2&gt;

&lt;p&gt;A lighter-weight alternative to &lt;a href="https://text-compare.com/" rel="noopener noreferrer"&gt;Mergely&lt;/a&gt;'s full-editor approach: local processing, no signup, no usage limits, with arrow-based merging between the two versions. Good middle ground if you want basic merge capability without the deeper feature set of the tools above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Fully local&lt;/th&gt;
&lt;th&gt;Merge function&lt;/th&gt;
&lt;th&gt;JSON-aware diff&lt;/th&gt;
&lt;th&gt;Notable feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ToolTiny&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ (key-order normalized)&lt;/td&gt;
&lt;td&gt;Direct file upload + HTML export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diffchecker&lt;/td&gt;
&lt;td&gt;Partial (shareable links processed server-side)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Not specified&lt;/td&gt;
&lt;td&gt;Ignore-rules for timestamps/build numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mergely&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Not specified&lt;/td&gt;
&lt;td&gt;Inline editing within the diff view&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EveryTool&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Minimap, similarity %, HTML export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OnlineTextCompare&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Not specified&lt;/td&gt;
&lt;td&gt;Simple arrow-based merge, unlimited use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one for which situation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comparing something confidential — contracts, API keys, proprietary code?&lt;/strong&gt; ToolTiny, Mergely, EveryTool, or OnlineTextCompare — all fully local, nothing leaves your device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need to share a diff with a non-technical stakeholder via link?&lt;/strong&gt; Diffchecker, understanding that means server-side processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want to fix issues while comparing, not after?&lt;/strong&gt; Mergely's inline editing is still the one dedicated tool for that specific workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparing JSON files that might just be reordered or reformatted?&lt;/strong&gt; ToolTiny or EveryTool — both normalize before diffing so formatting noise doesn't show up as a false change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want the result as a standalone file to attach to a ticket or email?&lt;/strong&gt; ToolTiny or EveryTool's HTML export.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest note
&lt;/h2&gt;

&lt;p&gt;"Runs in your browser" is doing real work as a phrase, but it's worth verifying rather than assuming for any tool not on this list. A diff tool with a "share this comparison" button is, by necessity, sending your text somewhere to generate that link — the convenience and the privacy guarantee are in direct tension, and a tool can't honestly offer both for the same feature. Before pasting anything you wouldn't want on a stranger's server, check specifically whether the tool's sharing or export features require a round-trip through their backend, not just whether the initial diff computation happens locally.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>git</category>
    </item>
    <item>
      <title>Paraphrasing tools without the hype: four ways text gets rewritten, and a short script that catches the damage</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Thu, 03 Sep 2026 14:51:31 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-paraphrasing-tools-online-no-signup-no-125-word-wall-53c6</link>
      <guid>https://dev.to/enlh/top-5-free-paraphrasing-tools-online-no-signup-no-125-word-wall-53c6</guid>
      <description>&lt;p&gt;Paraphrasing tools get compared by word limits and price. That's useful, but it skips the question that matters more: &lt;em&gt;what did the rewrite do to my meaning?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Different tools rewrite text in fundamentally different ways, and each way breaks in its own way. Below I go through four approaches, what each one is good for, what free tiers currently allow (read from the vendors' own pages), and a small Python script that flags the most common damage: dropped numbers, lost names and flipped negations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways text gets rewritten
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Good at&lt;/th&gt;
&lt;th&gt;Typical failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Synonym swap&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Replace words using a thesaurus or rules&lt;/td&gt;
&lt;td&gt;Fast, predictable, keeps structure&lt;/td&gt;
&lt;td&gt;Wrong sense of a word ("bank" the river vs the institution), awkward phrasing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Back-translation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Translate to another language and back&lt;/td&gt;
&lt;td&gt;Produces genuinely different phrasing without a language model rewriting your content&lt;/td&gt;
&lt;td&gt;Can drift on idioms, jargon and tone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM rewrite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A language model rewrites to a requested style (formal, shorter, simpler)&lt;/td&gt;
&lt;td&gt;Fluent output, controllable tone and length&lt;/td&gt;
&lt;td&gt;Can silently change facts, drop qualifiers or invent detail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manual&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You rewrite it&lt;/td&gt;
&lt;td&gt;Full control&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these is "the best". They suit different jobs: tightening an email (LLM or manual), simplifying a dense paragraph (LLM), getting a second phrasing of a short sentence (back-translation or synonym swap), preserving exact technical wording (usually manual, or don't paraphrase at all).&lt;/p&gt;

&lt;p&gt;Here are illustrative failures I wrote to show &lt;em&gt;the kinds&lt;/em&gt; of damage to look for (they're examples, not outputs from any specific product):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Rewrite&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"The API is &lt;strong&gt;not&lt;/strong&gt; free; it costs $20 per month for 5 users."&lt;/td&gt;
&lt;td&gt;"The API costs $20 monthly for 5 users."&lt;/td&gt;
&lt;td&gt;The negation vanished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"In March 2024, Dr. Alvarez found the drug did &lt;strong&gt;not&lt;/strong&gt; reduce symptoms in &lt;strong&gt;62%&lt;/strong&gt; of patients."&lt;/td&gt;
&lt;td&gt;"Dr. Alvarez found in early 2024 that the medication reduced symptoms in most patients."&lt;/td&gt;
&lt;td&gt;The meaning inverted and the number was lost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"The meeting moves to &lt;strong&gt;14:30&lt;/strong&gt; on Friday."&lt;/td&gt;
&lt;td&gt;"The session is now at &lt;strong&gt;15:30&lt;/strong&gt; on Friday."&lt;/td&gt;
&lt;td&gt;A number was quietly altered&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fluent output hides these errors, because the rewritten sentence reads smoothly. That's why a mechanical check is worth having.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short "did the meaning survive?" checker
&lt;/h2&gt;

&lt;p&gt;It compares three things between your original and the rewrite: &lt;strong&gt;numbers&lt;/strong&gt;, &lt;strong&gt;names&lt;/strong&gt; (capitalised words in the middle of a sentence) and &lt;strong&gt;negations&lt;/strong&gt;. It won't understand meaning. It only catches the cheap, high-impact mistakes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;NEGATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;never&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cannot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;won&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aren&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;don&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;didn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;without&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neither&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;’]s\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;          &lt;span class="c1"&gt;# France's -&amp;gt; france
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[a-z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.,:;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\d[\d,.:%/-]*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mid_sentence_caps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;=[.!?])\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[A-Za-z][A-Za-z&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;’-]*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;isupper&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lost&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numbers&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lost&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;added&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;mid_sentence_caps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;names&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;ot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lost_n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ot&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;NEGATIONS&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="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;NEGATIONS&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;rt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;NEGATIONS&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="n"&gt;ot&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;NEGATIONS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lost_n&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;new_n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;negations&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lost_n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;added&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;new_n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it on the examples above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt; check("The API is not free; it costs $20 per month for 5 users.",
...       "The API costs $20 monthly for 5 users.")
{'negations': {'lost': ['not'], 'added': []}}

&amp;gt;&amp;gt;&amp;gt; check("In March 2024, Dr. Alvarez found that the drug did not reduce symptoms in 62% of 1,200 patients.",
...       "Dr. Alvarez found in early 2024 that the medication reduced symptoms in most of 1,200 patients.")
{'numbers': {'lost': ['62%'], 'added': []},
 'names': {'lost': ['March']},
 'negations': {'lost': ['not'], 'added': []}}

&amp;gt;&amp;gt;&amp;gt; check("The meeting moves to 14:30 on Friday, and Maria will attend.",
...       "The session is now at 15:30 on Friday; Maria will be there.")
{'numbers': {'lost': ['14:30'], 'added': ['15:30']}}

&amp;gt;&amp;gt;&amp;gt; check("Paris is the capital of France.", "France's capital city is Paris.")
{}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last case is the check working as intended: a valid rewrite produces an empty report. &lt;strong&gt;Limits:&lt;/strong&gt; it's English-only, it can't see a changed verb ("increase" → "decrease"), and it will flag harmless changes such as "March 2024" → "early 2024". Treat it as a smoke alarm, not a judge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What free tiers allow (read from the vendors' pages, Nov 2026)
&lt;/h2&gt;

&lt;p&gt;Limits change often, so check the page before relying on any of this.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Free input limit&lt;/th&gt;
&lt;th&gt;Free modes&lt;/th&gt;
&lt;th&gt;Other stated details&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;QuillBot&lt;/td&gt;
&lt;td&gt;"up to 125 words at a time"&lt;/td&gt;
&lt;td&gt;Standard and Fluency; eight other modes are marked Premium&lt;/td&gt;
&lt;td&gt;Page says no daily limit on free paraphrases&lt;/td&gt;
&lt;td&gt;&lt;a href="https://quillbot.com/paraphrasing-tool" rel="noopener noreferrer"&gt;quillbot.com&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paraphraser.io&lt;/td&gt;
&lt;td&gt;The page gives two different figures: "600 words limit free of cost" in the price section, and a "0 / 400" counter in the tool&lt;/td&gt;
&lt;td&gt;Word changer, fluency, standard, formal and smooth, per the price section&lt;/td&gt;
&lt;td&gt;"Unlimited text without any daily limit"; 20+ languages; the page has no statement on how your text is stored&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.paraphraser.io/" rel="noopener noreferrer"&gt;paraphraser.io&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two observations, both from reading those pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A per-input cap and "no daily limit" can both be true: you can run unlimited chunks of 125 words. For a long article, that means splitting and re-checking transitions between chunks, which is exactly where meaning and tone drift.&lt;/li&gt;
&lt;li&gt;Where a page doesn't state data handling, I haven't assumed anything. If you're pasting confidential text into &lt;em&gt;any&lt;/em&gt; online tool, read its privacy policy, or use the DevTools Network tab to see whether your text leaves the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run a free paraphraser at ToolTiny, so I'm not going to rank it against these. Read its limits on the &lt;a href="https://tooltiny.com/paraphrase.html" rel="noopener noreferrer"&gt;page itself&lt;/a&gt; like you would for any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  When paraphrasing is the wrong tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Academic work:&lt;/strong&gt; rewriting a source doesn't remove the need to cite it. The University of Virginia's honor committee puts it plainly: "paraphrasing without citing the original source is considered plagiarism" (&lt;a href="https://honor.virginia.edu/understanding-fraud" rel="noopener noreferrer"&gt;source&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detection is moving too.&lt;/strong&gt; In September 2025 Turnitin announced "AI bypasser detection," aimed at text that tools have modified to hide AI use. Coverage at the time noted it was limited to English and gave no accuracy figures (&lt;a href="https://thejournal.com/articles/2025/09/02/new-turnitin-bypasser-detection-feature-helps-identify-use-of-ai-humanizer-tools.aspx" rel="noopener noreferrer"&gt;THE Journal&lt;/a&gt;). I'm not suggesting any tool should be used to evade that. The point is that a rewrite is not a substitute for your own understanding of the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal, medical and technical text:&lt;/strong&gt; precise wording often &lt;em&gt;is&lt;/em&gt; the content. Rephrase the explanation around it, and quote the exact sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A simple workflow that holds up
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Paste &lt;strong&gt;short chunks&lt;/strong&gt; (a paragraph at most), not whole documents.&lt;/li&gt;
&lt;li&gt;Pick the approach that matches the job (table above).&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;check(original, rewrite)&lt;/code&gt;, then read the rewrite aloud once.&lt;/li&gt;
&lt;li&gt;Reread numbers, names and negations by hand. The script is a filter, not a proof.&lt;/li&gt;
&lt;li&gt;Cite the source if the ideas aren't yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits of this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The failure examples are illustrative and I wrote them myself; I did not benchmark any named tool's rewrite quality.&lt;/li&gt;
&lt;li&gt;The checker looks at three surface features of English text. It will miss subtle meaning changes and produce some false alarms.&lt;/li&gt;
&lt;li&gt;Tool limits are as stated on the vendor pages on the date I read them, and they change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a rewrite that fooled the checker, paste it in the comments and I'll see whether the script can be improved.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>writing</category>
    </item>
    <item>
      <title>My lorem ipsum generator promised an exact character count and hit it 15% of the time</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:20:05 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-lorem-ipsum-generators-classic-hipster-corporate-beyond-p33</link>
      <guid>https://dev.to/enlh/top-5-free-lorem-ipsum-generators-classic-hipster-corporate-beyond-p33</guid>
      <description>&lt;p&gt;I run a small lorem ipsum generator. It has a "Characters" mode, and the page's FAQ said this about it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Produces text trimmed to the exact character count, without cutting off in the middle of a word.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I never tested that sentence. Last week I did, with 2,000 runs per setting. The function hit the requested length about &lt;strong&gt;15%&lt;/strong&gt; of the time. That is not a rounding error, it is a broken promise, so this post is the test, the numbers, and the fix, including the part of the fix that I think is still a compromise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why anyone needs an exact count
&lt;/h2&gt;

&lt;p&gt;Lorem ipsum by paragraph is fine for layouts. Exact characters matter when the container is strict: a meta description field, a 160-character SMS preview, a database column with &lt;code&gt;VARCHAR(100)&lt;/code&gt;, a tweet-sized card, a form validation test that needs a string of exactly the limit and one over it. "About 160" is useless there, because the bug you are hunting lives at 160 and 161.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to produce N characters
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Cut at the limit.&lt;/strong&gt;&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="nx"&gt;text&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="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always the right length. But the last word is cut in the middle most of the time, and the text rarely ends in punctuation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Trim back to the last space (what my page did).&lt;/strong&gt; Generate more text than needed, cut at &lt;code&gt;n + 1&lt;/code&gt;, move back to the last space, strip trailing &lt;code&gt;,;:&lt;/code&gt;, add a period.&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;cut&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;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="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cut&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="nx"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;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;[&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="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="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;]&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&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;It never cuts a word. It also lands on the target only when a word boundary happens to sit at exactly position &lt;code&gt;n&lt;/code&gt;, and that is luck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Search, then fall back to a cut.&lt;/strong&gt; Repeat approach 2 on freshly generated text until the length equals &lt;code&gt;n&lt;/code&gt; (up to 80 attempts). If none fits, hard-cut to &lt;code&gt;n - 1&lt;/code&gt; characters and append a period, swapping a trailing space or comma for a letter so the output doesn't end in &lt;code&gt;, .&lt;/code&gt;.&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;hardCut&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="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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&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;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="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e&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;s&lt;/span&gt; &lt;span class="o"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;The generator builds sentences from a fixed vocabulary, so I could check three properties on every output:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;length === n&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;it ends with &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;!&lt;/code&gt; or &lt;code&gt;?&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the last token is a whole word from the vocabulary (not a fragment)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I loaded the original function and the patched one into Node with &lt;code&gt;vm&lt;/code&gt;, and ran each for targets 20, 50, 100, 160, 280, 500, 1000 and 5000, in three styles, 2,000 runs per cell. Classic style, n = 160:&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;exact length&lt;/th&gt;
&lt;th&gt;whole last word&lt;/th&gt;
&lt;th&gt;when not exact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;slice(0, n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;19%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trim to last space (my original)&lt;/td&gt;
&lt;td&gt;14.8%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;short by 4 on average, up to 13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search, then hard cut&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2Fhhlok3hw103cxzb07o5c.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%2Fhhlok3hw103cxzb07o5c.png" alt="Three ways to ask for exactly N characters, results at n = 160" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other targets from 100 to 5,000 look the same: the original is exact 14–16% of the time in classic style. Hipster was 11–14% and corporate 10% in my runs. It never overshoots (it always cuts back), so every miss is &lt;em&gt;short&lt;/em&gt;, never long. The first user-visible symptom would be "I asked for 160 and got 156", which is easy to blame on the user's counting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case where exactness and clean words disagree
&lt;/h2&gt;

&lt;p&gt;The page has a "Start with &lt;em&gt;Lorem ipsum dolor sit amet…&lt;/em&gt;" option, and that prefix is 55 characters. With the prefix on and a small target, there is no sentence structure that both ends on a whole word and totals exactly &lt;code&gt;n&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;target&lt;/th&gt;
&lt;th&gt;original&lt;/th&gt;
&lt;th&gt;new&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Lorem ipsum dolor.&lt;/code&gt; (18 chars, never 20)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Lorem ipsum dolor s.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Lorem ipsum dolor sit amet, consectetur.&lt;/code&gt; (40)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Lorem ipsum dolor sit amet, consectetur adipiscin.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In these two cases (n = 20 and 50 with the prefix on) the new code falls back to the hard cut in 100% of runs. So the contract became: &lt;strong&gt;exact length always; whole words whenever the arithmetic allows it.&lt;/strong&gt; With the prefix off, targets 20 and 50 fit cleanly. For n ≥ 100 in classic style the fallback fired in 0 of 2,000 runs per target, and at most 0.1% in hipster and corporate.&lt;/p&gt;

&lt;p&gt;Is &lt;code&gt;adipiscin.&lt;/code&gt; acceptable lorem ipsum? For a length test, I think yes, and the alternative is a wrong length, which is the whole thing the mode exists to prevent. But it is a trade-off, and the FAQ should say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a search loop cost anything?
&lt;/h2&gt;

&lt;p&gt;I counted attempts before a clean fit over 5,000 samples per target in classic style:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mean about 6.8, median 5&lt;/li&gt;
&lt;li&gt;95th percentile about 20, 99th about 30&lt;/li&gt;
&lt;li&gt;max 47–66 depending on target; never reached the 80-attempt cap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each attempt generates a few hundred characters of text; I did not time it in a browser, so check that on your own target. If your vocabulary has longer words, the odds of an exact fit per attempt drop and the cap matters more. Measure rather than copy my 80.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short detour: where the text comes from
&lt;/h2&gt;

&lt;p&gt;The classic text is not random Latin. It is scrambled text from Cicero's &lt;em&gt;De finibus bonorum et malorum&lt;/em&gt; (sections 1.10.32–33, 1st century BC), and it was traced there in the 1990s by Richard McClintock of Hampden–Sydney College, who looked up the rare word &lt;em&gt;consectetur&lt;/em&gt;. Per Wikipedia, in a 2026 interview he said his earlier claim that the passage dates to the 1500s was a guess. Letraset sheets used it from 1966. It reads like Latin and carries no meaning, so reviewers look at the layout instead of the copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the other generators say about exactness
&lt;/h2&gt;

&lt;p&gt;Everything below is from each vendor's own page, as read in Nov 2026. "Not stated" means the page does not say.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Generator&lt;/th&gt;
&lt;th&gt;Units offered&lt;/th&gt;
&lt;th&gt;Exact characters&lt;/th&gt;
&lt;th&gt;Runs where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lipsum.com&lt;/td&gt;
&lt;td&gt;paragraphs, words, bytes, lists&lt;/td&gt;
&lt;td&gt;not stated (has bytes)&lt;/td&gt;
&lt;td&gt;not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FlipMyCase&lt;/td&gt;
&lt;td&gt;up to 20 paragraphs, 50 sentences or 500 words; 5 styles&lt;/td&gt;
&lt;td&gt;not a unit (shows a character count)&lt;/td&gt;
&lt;td&gt;browser, text "never sent to a server"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://tooltiny.com/lorem-ipsum.html" rel="noopener noreferrer"&gt;ToolTiny&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;paragraphs 1–50, sentences 1–100, words 1–500, characters 20–5,000; Classic, Hipster, Corporate, Standard&lt;/td&gt;
&lt;td&gt;yes, per the tests above&lt;/td&gt;
&lt;td&gt;browser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I could not read LoremGen's and Genie9's pages (their &lt;code&gt;robots.txt&lt;/code&gt; blocked my fetch), so I make no claim about them. Note that "bytes" and "characters" differ for non-ASCII text, which does not matter for lorem ipsum but does matter if you use the counts for other scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist if you write your own
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Say in the UI which property wins when two conflict (exact length vs whole words).&lt;/li&gt;
&lt;li&gt;Test the boundaries: the smallest allowed target, and a target equal to your prefix length.&lt;/li&gt;
&lt;li&gt;Assert &lt;code&gt;length === n&lt;/code&gt; in a loop with many runs, not one run.&lt;/li&gt;
&lt;li&gt;Remember that JavaScript's &lt;code&gt;length&lt;/code&gt; counts UTF-16 code units. It is exact for this ASCII vocabulary, not in general.&lt;/li&gt;
&lt;li&gt;Cap the retry loop and have a deterministic fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limits of this test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The vocabulary is my own, synthetic, with its own word-length distribution; a different one changes the hit rate of the trim approach.&lt;/li&gt;
&lt;li&gt;One JavaScript engine (Node 22). The string operations are standard, but I did not run other engines.&lt;/li&gt;
&lt;li&gt;"Original" means the function as it was when I started this test, and the page now uses the new one.&lt;/li&gt;
&lt;li&gt;Vendor statements are as of Nov 2026 and may change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a different approach to hitting an exact length without a mid-word cut (padding with a short word, choosing the vocabulary words to fit the remainder), I would like to compare it with the same harness.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>design</category>
    </item>
    <item>
      <title>Top 5 Free Online Case Converters (camelCase, snake_case, PascalCase &amp; More)</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Sun, 23 Aug 2026 15:26:02 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-online-case-converters-camelcase-snakecase-pascalcase-more-h4n</link>
      <guid>https://dev.to/enlh/top-5-free-online-case-converters-camelcase-snakecase-pascalcase-more-h4n</guid>
      <description>&lt;p&gt;Renaming a Python &lt;code&gt;snake_case&lt;/code&gt; API response into &lt;code&gt;camelCase&lt;/code&gt; for your JavaScript model, or turning a blog title into a URL slug, is exactly tedious enough to be annoying and exactly simple enough that firing up a script feels like overkill. A browser-based case converter fixes it in one paste. I compared 5, specifically for format coverage and — the detail that actually separates the good ones from the rest — whether they correctly parse text that's already in some other case format.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. ToolTiny — 12 formats, with word-boundary detection that actually works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tooltiny.com/case-converter.html" rel="noopener noreferrer"&gt;ToolTiny's Case Converter&lt;/a&gt; covers Sentence case, lowercase, UPPERCASE, Title Case, camelCase, PascalCase, snake_case, CONSTANT_CASE, kebab-case, dot.case, URL slug, and alternating case — 12 formats, all client-side, no character limit, nothing you paste ever leaves your browser.&lt;/p&gt;

&lt;p&gt;The detail worth actually testing rather than taking on faith: paste in &lt;code&gt;XMLHttpRequest&lt;/code&gt; and ask it for snake_case. A naive converter either treats the whole string as one word or garbles the acronym into &lt;code&gt;x_m_l_http_request&lt;/code&gt;. ToolTiny splits it correctly into &lt;code&gt;xml_http_request&lt;/code&gt; — it detects camelCase/PascalCase boundaries &lt;em&gt;and&lt;/em&gt; acronym runs before converting, so renaming an existing identifier between conventions doesn't require you to manually add spaces first. Same logic applies going the other direction: paste &lt;code&gt;user_registration_date&lt;/code&gt; and hit camelCase, you get &lt;code&gt;userRegistrationDate&lt;/code&gt;, not &lt;code&gt;Userregistrationdate&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. FWD Tools — 14 formats with the same boundary-splitting approach
&lt;/h2&gt;

&lt;p&gt;Covers everything ToolTiny does plus Train-Case and COBOL-CASE, converting all 14 simultaneously so you paste once and copy whichever result you need. Also splits on existing camelCase boundaries before reformatting — &lt;code&gt;getUserProfileData&lt;/code&gt; correctly becomes four separate words rather than one blob. If you want every result visible at once instead of clicking between buttons, this is the more convenient layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. App DevTools — curated specifically around programming conventions
&lt;/h2&gt;

&lt;p&gt;12 formats, deliberately scoped toward code rather than general writing: PascalCase, CONSTANT_CASE, dot.case, kebab-case, path/case, Train-Case, and Pascal_Snake_Case alongside the standard set. If you want a tool that isn't cluttered with novelty formats and stays focused on what you'd actually use while coding, this is the most tightly curated option here.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. toolk.site — the other tool that gets acronyms right
&lt;/h2&gt;

&lt;p&gt;13 formats, and specifically calls out the same acronym-boundary handling as ToolTiny: &lt;code&gt;XMLHttpRequest&lt;/code&gt; correctly becomes &lt;code&gt;xml_http_request&lt;/code&gt; rather than the garbled character-by-character version. Worth knowing this isn't universal — several case converters on the market still get acronyms wrong, so if you're evaluating one not on this list, that's the one thing worth testing before you trust it with real code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. OnlineWebToolkit — broadest coverage at 17 formats
&lt;/h2&gt;

&lt;p&gt;The largest format list here, split into three groups: human-readable (Title Case, Sentence case), creative/novelty (alternating case, inverse case), and developer conventions (camelCase, snake_case, PascalCase, kebab-case, and the rest). If you want one tool that covers essentially every case format you'll ever encounter without switching tools, this is the widest net, even if you'll rarely touch most of the novelty formats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Format count&lt;/th&gt;
&lt;th&gt;Handles acronyms correctly&lt;/th&gt;
&lt;th&gt;Notable feature&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ToolTiny&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;No character limit, URL slug generator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FWD Tools&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;All formats shown simultaneously&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App DevTools&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Not specified&lt;/td&gt;
&lt;td&gt;Curated for programming use only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;toolk.site&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Same acronym handling as ToolTiny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OnlineWebToolkit&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;Not specified&lt;/td&gt;
&lt;td&gt;Broadest format coverage overall&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one for which situation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Renaming identifiers between languages&lt;/strong&gt; (Python snake_case ↔ JavaScript camelCase, database columns ↔ API fields)? ToolTiny, FWD Tools, or toolk.site — all three parse existing case boundaries and acronyms correctly, which matters more than the format count once you're past the basics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want every format visible at once instead of switching buttons?&lt;/strong&gt; FWD Tools' simultaneous-output layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text has heavy acronym use&lt;/strong&gt; (API, XML, HTTP mixed into identifiers)? Specifically verify with a test paste — ToolTiny and toolk.site both handle it; not every converter does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want one bookmark that covers every format you'll ever need, novelty formats included?&lt;/strong&gt; OnlineWebToolkit's 17-format spread.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest note
&lt;/h2&gt;

&lt;p&gt;"Case converter" sounds like a solved, interchangeable-tool category, and mostly the basic formats are — but acronym and boundary handling genuinely isn't universal, and it's the thing that actually breaks in practice. A converter that treats &lt;code&gt;XMLHttpRequest&lt;/code&gt; as one indivisible word will silently produce garbage the moment you feed it a real codebase identifier instead of a demo string. Before trusting any case converter with a bulk rename — database columns, API fields, anything at scale — paste in one identifier you know contains an acronym and check the output by hand. It takes ten seconds and it's the actual test that separates a converter built for prose from one built to handle code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>tools</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My "one-time token" handed out the real API key in two requests</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:37:42 +0000</pubDate>
      <link>https://dev.to/enlh/top-5-free-speech-to-text-tools-online-no-signup-file-upload-supported-4bn2</link>
      <guid>https://dev.to/enlh/top-5-free-speech-to-text-tools-online-no-signup-file-upload-supported-4bn2</guid>
      <description>&lt;p&gt;I run a free speech-to-text page that calls a paid transcription API. The API key must never reach the browser, so I added what I thought was a careful design: the page asks my server for a temporary token, the token lasts 30 seconds, and it works only once.&lt;/p&gt;

&lt;p&gt;Then I wrote a test for it, and the test showed that anyone could get the real key in two requests. This post shows the old code, the measurements, the proxy that replaced it, and the ten tests I use now. All keys in the examples are fake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design that felt safe
&lt;/h2&gt;

&lt;p&gt;This is the old code, shortened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_stt_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;            &lt;span class="c1"&gt;# { token: {"key": "gsk_...", "expires": ts} }
&lt;/span&gt;
&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/stt-token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_stt_token&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Token holds the real API key and expires after 30 seconds.
    The key never appears in the HTML/JS source.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_urlsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_stt_tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;token&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;next_key&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires_in&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groq_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.groq.com/openai/v1/audio/transcriptions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whisper-large-v3-turbo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/stt-token/validate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Form&lt;/span&gt;&lt;span class="p"&gt;(...)):&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_stt_tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# single use
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid or expired token&lt;/span&gt;&lt;span class="sh"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;              &lt;span class="c1"&gt;# &amp;lt;- the real key
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page would fetch a token, exchange it for the key, and call the transcription API straight from the browser. The docstring I wrote even argued why this was safe: the key is not in the source, the token is random, it expires, it is single-use, and I can rate-limit the token endpoint.&lt;/p&gt;

&lt;p&gt;Every one of those statements was true. None of them protected the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two requests
&lt;/h2&gt;

&lt;p&gt;I rebuilt just those two endpoints in a test app with a &lt;strong&gt;fake&lt;/strong&gt; key and called them as any visitor could:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1) GET  /api/stt-token          -&amp;gt; 200 ['token', 'expires_in', 'groq_url', 'model']
2) POST /api/stt-token/validate -&amp;gt; 200 {'key': 'gsk_FAKE_KEY_FOR_DEMO_0000...'}
   same token again             -&amp;gt; 401 {'detail': 'Invalid or expired token'}
3) 1000 token-&amp;gt;key exchanges in 3.13s, no rate limit, distinct keys obtained: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is single-use, as promised. But the thing it unlocks is the key itself, and nothing stops a visitor from asking for a new token. A thousand exchanges took 3.1 seconds in my sandbox, and I had not written the rate limit the docstring said I could add. Two &lt;code&gt;curl&lt;/code&gt; calls are enough, and you do not even need the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the token actually protected
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I wrote&lt;/th&gt;
&lt;th&gt;What it protects against&lt;/th&gt;
&lt;th&gt;What the attacker wanted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Key is not in the source&lt;/td&gt;
&lt;td&gt;Someone grepping my HTML&lt;/td&gt;
&lt;td&gt;The key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token is random&lt;/td&gt;
&lt;td&gt;Guessing a token&lt;/td&gt;
&lt;td&gt;The key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expires in 30 seconds&lt;/td&gt;
&lt;td&gt;Re-using an old token&lt;/td&gt;
&lt;td&gt;The key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single use&lt;/td&gt;
&lt;td&gt;Replaying a token&lt;/td&gt;
&lt;td&gt;The key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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%2F45o44e92o94llbcfkaa5.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%2F45o44e92o94llbcfkaa5.png" alt="Four controls on the token, none on the key" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All four controls guard the &lt;em&gt;token&lt;/em&gt;. The asset was the key, and the design had a door that exchanged tokens for it. If the browser is going to use the key, then the visitor has the key. A short lifetime only helps when what you hand out is itself a short-lived credential (for example a signed upload URL) and not the long-lived secret.&lt;/p&gt;

&lt;p&gt;Any key that was ever served by such an endpoint has to be treated as leaked, whatever the token lifetime was. Rotate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: the browser never sees a key
&lt;/h2&gt;

&lt;p&gt;Instead of giving the browser a credential, the server now does the call. The page posts the audio file to &lt;code&gt;/api/stt&lt;/code&gt; on my own server, and the server forwards it to the transcription API with the key attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/stt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;speech_to_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UploadFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt;
                         &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;_stt_rate_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_stt_client_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_stt_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rate limit reached. Please wait a minute and try again.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&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="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_STT_EXTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_stt_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsupported format. Please upload MP3, MP4, WAV, M4A, OGG, FLAC or WebM.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[a-z]{2,3}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_stt_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid language.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several details matter more than the happy path:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate before you spend anything.&lt;/strong&gt; Rate limit first, then file extension, then the language code (a 2 or 3 letter string; the transcription API documents ISO-639-1 codes, and I map &lt;code&gt;nb&lt;/code&gt; to &lt;code&gt;no&lt;/code&gt;). Nothing is forwarded until all three pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap what you load into memory.&lt;/strong&gt; The upload is read in 1 MB chunks, and the request is refused with 413 as soon as the running total passes 25 MB, because that is the limit of the free tier of the API I call. This bounds what my code holds in RAM. It does not stop the web framework from receiving and spooling a huge upload before my function runs, so the real size limit also belongs in the reverse proxy (in Nginx, &lt;code&gt;client_max_body_size&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit concurrency.&lt;/strong&gt; A semaphore allows two files in flight per worker, and a request that cannot get a slot within 20 seconds gets a 503. Without it, a burst of 25 MB uploads can exhaust memory, because each file is held in RAM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry once on the next key.&lt;/strong&gt; When the provider answers 429, the code retries once with the next key in a round-robin list. The test below checks that the second attempt really uses a different key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never forward upstream error text blindly.&lt;/strong&gt; Only 400, 413 and 422 from the provider pass a message on, because those describe the user's file. A 401, a 5xx or a network error becomes a generic 502 ("service unavailable"), so a provider error that mentions a credential cannot reach the visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten tests
&lt;/h2&gt;

&lt;p&gt;I run these against a fake HTTP client, so no real API is called:&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;Case&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Valid file, &lt;code&gt;language=nb&lt;/code&gt;, &lt;code&gt;verbose_json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;200, upstream gets &lt;code&gt;language=no&lt;/code&gt;, response has no &lt;code&gt;gsk_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.exe&lt;/code&gt; file&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Empty file&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Language &lt;code&gt;xx1!&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;25 MB + 1 byte&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Provider returns 429 once&lt;/td&gt;
&lt;td&gt;200, second call uses another key&lt;/td&gt;
&lt;td&gt;passed (&lt;code&gt;fake2&lt;/code&gt;, then &lt;code&gt;fake1&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Provider returns 400 "file is not valid audio"&lt;/td&gt;
&lt;td&gt;400 with that message&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Provider returns 401 "Invalid API Key gsk_secret"&lt;/td&gt;
&lt;td&gt;502, key text not in response&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Network error&lt;/td&gt;
&lt;td&gt;502&lt;/td&gt;
&lt;td&gt;passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Six requests from one IP&lt;/td&gt;
&lt;td&gt;200 four times, then 429&lt;/td&gt;
&lt;td&gt;passed (&lt;code&gt;[200, 200, 200, 200, 429, 429]&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After all ten, the semaphore had both slots free again, which is how I check that no code path leaks a slot.&lt;/p&gt;

&lt;p&gt;Test 8 is the one I care about most. It is the test that would have caught a naive proxy that returns the provider's error body.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still imperfect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rate limit is in memory and per worker.&lt;/strong&gt; With two workers the real limit is about double the number in the code (4 a minute and 30 a day per IP, per worker). That is fine for a free tool, but it is not a precise quota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The IP comes from &lt;code&gt;X-Real-IP&lt;/code&gt;.&lt;/strong&gt; That is only trustworthy if Nginx sets it and the app port is closed to the internet. Otherwise a client can invent a new IP on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole file sits in memory&lt;/strong&gt; while it is forwarded. The size cap and the semaphore bound it, but they do not remove it, and the size check in my function runs after the framework has already parsed the upload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audio does leave my server.&lt;/strong&gt; It goes to a third-party API (Whisper large-v3-turbo, hosted by the provider). My code does not write it to disk, and the provider's docs page that I read does not state a retention period, so I cannot promise anything about what happens on their side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the options differ (read Nov 2026)
&lt;/h2&gt;

&lt;p&gt;If you only need a transcript, you may not want to build any of this. I tested my own code, not these sites; this is what each page states, and "not stated" means I did not find it on the page I read.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it takes&lt;/th&gt;
&lt;th&gt;Limits&lt;/th&gt;
&lt;th&gt;Where the audio goes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://console.groq.com/docs/speech-to-text" rel="noopener noreferrer"&gt;Groq speech-to-text API&lt;/a&gt; (the engine behind my proxy)&lt;/td&gt;
&lt;td&gt;An API key&lt;/td&gt;
&lt;td&gt;25 MB per file on the free tier, 100 MB on the dev tier; flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm&lt;/td&gt;
&lt;td&gt;Their servers; retention not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://fast-transcriber.com/" rel="noopener noreferrer"&gt;FastTranscriber&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A guest can upload without an account&lt;/td&gt;
&lt;td&gt;2 uploads per day; TXT and SRT export; 98+ languages; file size not stated for the free tier; "20-minute transcript previews"&lt;/td&gt;
&lt;td&gt;"Private &amp;amp; Secure" badge; retention not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://speechnotes.co/" rel="noopener noreferrer"&gt;Speechnotes&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Dictation needs no account&lt;/td&gt;
&lt;td&gt;Dictation free; file transcription $0.1 per minute; dictation engines are "Google &amp;amp; Microsoft"&lt;/td&gt;
&lt;td&gt;Recordings "deleted from our servers as soon as the transcription is done"; dictation notes stay in the browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://otter.ai/pricing" rel="noopener noreferrer"&gt;Otter.ai&lt;/a&gt; free plan&lt;/td&gt;
&lt;td&gt;An account&lt;/td&gt;
&lt;td&gt;300 minutes per month, 30 minutes per conversation, 3 lifetime file imports, six languages&lt;/td&gt;
&lt;td&gt;Not stated on the pricing page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://huggingface.co/spaces/Xenova/whisper-web" rel="noopener noreferrer"&gt;Whisper Web&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Nothing to sign up for&lt;/td&gt;
&lt;td&gt;Not stated&lt;/td&gt;
&lt;td&gt;The page does not say whether the work happens on your device, so I do not claim it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://tooltiny.com/speech-to-text.html" rel="noopener noreferrer"&gt;ToolTiny&lt;/a&gt; (my page)&lt;/td&gt;
&lt;td&gt;No sign-up&lt;/td&gt;
&lt;td&gt;25 MB, the formats above; 4 requests a minute and 30 a day per IP per worker&lt;/td&gt;
&lt;td&gt;File goes through my server to the provider's API; not written to disk by my code; provider retention not stated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row I could not verify is Whisper Web. I used to describe it as running entirely on your device, but the page I read does not say that, so I am not repeating it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you proxy a paid API
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Never send a long-lived key to a browser, not even for 30 seconds. Hand out a credential only if it is itself short-lived and limited.&lt;/li&gt;
&lt;li&gt;Write the exploit as a test before you write the defence, and run it against the code you actually deploy.&lt;/li&gt;
&lt;li&gt;Validate cheap things first (rate, type, language), then read the body with a size cap, then spend money.&lt;/li&gt;
&lt;li&gt;Bound concurrency and memory, and test that a slot is released on every path.&lt;/li&gt;
&lt;li&gt;Map provider errors to generic ones unless the message is about the user's input.&lt;/li&gt;
&lt;li&gt;Rotate any key that was ever exposed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits of this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The old endpoints were rebuilt from my own code in a test app with a fake key, and nothing contacted a real service. The numbers (a thousand exchanges in 3.1 seconds) come from my sandbox, not from a production server.&lt;/li&gt;
&lt;li&gt;The ten tests use a fake HTTP client. They show how my code handles each response, not how the real provider behaves.&lt;/li&gt;
&lt;li&gt;I did not measure transcription accuracy or speed in this post.&lt;/li&gt;
&lt;li&gt;Memory use of the proxy was not measured. I only describe how it is bounded.&lt;/li&gt;
&lt;li&gt;Vendor statements are as they appeared on the day I read them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have found a safe way to let a browser call a paid API directly, I would like to hear what you hand out. Tell me in the comments.&lt;/p&gt;

</description>
      <category>tools</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Couldn't Finish Reading a 20-Page PDF Without Losing Focus. Listening to It Changed That</title>
      <dc:creator>Enlh NG</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:18:30 +0000</pubDate>
      <link>https://dev.to/enlh/i-couldnt-finish-reading-a-20-page-pdf-without-losing-focus-listening-to-it-changed-that-52jn</link>
      <guid>https://dev.to/enlh/i-couldnt-finish-reading-a-20-page-pdf-without-losing-focus-listening-to-it-changed-that-52jn</guid>
      <description>&lt;p&gt;Turns out the problem was never "I'm bad at reading." It was that I'm bad at reading silently, alone, for long stretches.&lt;/p&gt;

&lt;p&gt;Grad school assigned readings that were routinely 20-30 pages of dense academic PDFs, and I'd sit down with every intention of getting through one, and twenty minutes later realize I'd read the same paragraph four times without absorbing a word of it. I assumed this meant I just needed more willpower, more coffee, a quieter room. None of that fixed it.&lt;/p&gt;

&lt;p&gt;What actually fixed it, almost by accident, was a text-to-speech tool a classmate mentioned offhand. I pasted a reading into it, put in one earbud, and kept following along on the screen while it read to me. I finished the whole thing in one sitting for the first time in months. It wasn't that the reading got easier — it's that hearing it and seeing it at the same time gave my attention somewhere consistent to land.&lt;/p&gt;

&lt;p&gt;What actually changed&lt;/p&gt;

&lt;p&gt;I'm not diagnosed with anything, for what it's worth — I just apparently focus differently with combined audio and visual input than with silent reading alone. Turns out that's genuinely common, not some special trick, and it's the same reason text-to-speech is a standard accessibility tool for dyslexia, ADHD, and visual fatigue. I just hadn't tried it because I didn't think of myself as someone who "needed" it.&lt;/p&gt;

&lt;p&gt;What I use now&lt;/p&gt;

&lt;p&gt;ToolTiny — my default for anything I don't want uploaded anywhere, which turned out to be most of grad school (unpublished drafts, other people's unpublished work, personal notes). It runs entirely inside your browser using built-in voices, so the text never actually leaves your device — not "we delete it after," genuinely never sent anywhere. You get a real range of voices and languages, and you can slow the speed down, which I do constantly for denser material. The voices sound a little more robotic than the AI ones below, but for reading my own study material, I stopped noticing that within a few minutes.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://tooltiny.com/text-to-speech.html" rel="noopener noreferrer"&gt;tooltiny text to speech&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;FreeTTS — I use this when the audio itself is the point — recording something to actually share, not just for my own listening. The voices sound close to a real person, no signup, and you can generate as many times as you want without hitting a daily wall.&lt;/p&gt;

&lt;p&gt;Edge Read Aloud — if I'm reading an article directly on a website rather than something I've saved, this is built into the browser already and needs zero setup. I don't even think of it as a separate tool at this point, just a button I click.&lt;/p&gt;

&lt;p&gt;What I'd tell someone who assumes this isn't "for them"&lt;br&gt;
Struggling to get through long readings, no official diagnosis, just can't focus silently? Try it before assuming it's a willpower problem. It might just be how your attention works.&lt;br&gt;
Reading something private or unpublished? ToolTiny — it never uploads anything.&lt;br&gt;
Want the most natural-sounding voice for something you're sharing? FreeTTS.&lt;br&gt;
Reading directly on a webpage? Whatever your browser's built-in reader is — check for it before installing anything.&lt;br&gt;
The trade-off nobody explains upfront&lt;/p&gt;

&lt;p&gt;The tools that sound the most human — the ones that don't sound like a voice assistant at all — are cloud-based, meaning your text gets sent somewhere to generate that quality. The tools that keep everything fully private, running only on your own device, sound noticeably more synthetic in comparison. Neither one is "better" universally. I use the private, slightly robotic option for my own study material and drafts, and I'd use the more natural-sounding one if I were ever making something to actually share publicly. Once I understood that trade-off existed, picking got a lot easier.&lt;/p&gt;

&lt;p&gt;I still can't read a dense PDF silently in one sitting. I just don't have to anymore.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
