<?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: Anuj Kabra</title>
    <description>The latest articles on DEV Community by Anuj Kabra (@glinck).</description>
    <link>https://dev.to/glinck</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%2F4187667%2F3db58725-6cb8-408f-bacb-8a1fc2c6f50b.jpg</url>
      <title>DEV Community: Anuj Kabra</title>
      <link>https://dev.to/glinck</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/glinck"/>
    <language>en</language>
    <item>
      <title>Your photo knows where you took it: stripping EXIF before you post</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Fri, 06 Nov 2026 04:22:21 +0000</pubDate>
      <link>https://dev.to/glinck/your-photo-knows-where-you-took-it-stripping-exif-before-you-post-50n5</link>
      <guid>https://dev.to/glinck/your-photo-knows-where-you-took-it-stripping-exif-before-you-post-50n5</guid>
      <description>&lt;p&gt;A JPEG can carry more than pixels. Phones and cameras often embed EXIF metadata: when the shot was taken, which device took it, exposure settings, and sometimes the exact GPS coordinates. That location tag is useful in your own library. It is less useful on a photo you email, upload to a form, or post publicly. Below are built-in ways to stop location leaving with the file, a command-line check that shows what is still there, and one gotcha: scrubbing metadata can also drop the capture date, which matters if the photo is evidence.&lt;/p&gt;

&lt;p&gt;I checked the share and remove steps against Apple's Personal Safety guide (published September 2026) and current Windows File Explorer docs, and I verified the ExifTool commands on a small test JPEG I tagged with dummy GPS coordinates (ExifTool 13.30).&lt;/p&gt;

&lt;h2&gt;
  
  
  What is usually in the file
&lt;/h2&gt;

&lt;p&gt;EXIF is a block of tags stored with the image. Common ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When&lt;/strong&gt;: DateTimeOriginal and related date fields&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What took it&lt;/strong&gt;: camera or phone make and model&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How&lt;/strong&gt;: aperture, shutter, ISO, lens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where&lt;/strong&gt;: GPS latitude, longitude, altitude, and related GPS tags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not every photo has GPS. Location only gets written if the camera app was allowed to use Location Services (or the equivalent on Android). The rest of the EXIF block can still be present without coordinates.&lt;/p&gt;

&lt;p&gt;To see what a file is carrying, ExifTool is the clearest check I know:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-GPS&lt;/span&gt;:all photo.jpg
exiftool &lt;span class="nt"&gt;-DateTimeOriginal&lt;/span&gt; &lt;span class="nt"&gt;-Make&lt;/span&gt; &lt;span class="nt"&gt;-Model&lt;/span&gt; photo.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my test file, &lt;code&gt;-GPS:all&lt;/code&gt; printed latitude 28.6139, longitude 77.209, altitude, and the N/E refs. After stripping GPS (next section), that same command printed nothing for GPS while Make, Model and DateTimeOriginal were still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  iPhone: leave location out of the shared copy
&lt;/h2&gt;

&lt;p&gt;Apple's guide covers two different jobs: exclude location from one share, or remove it from the photo in your library.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For one share (outgoing copy only):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the photo in Photos (or select it from Camera's roll).&lt;/li&gt;
&lt;li&gt;Tap Share, then tap &lt;strong&gt;Options&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Turn &lt;strong&gt;Location&lt;/strong&gt; off. The sheet should show &lt;strong&gt;Location Not Included&lt;/strong&gt; above Options.&lt;/li&gt;
&lt;li&gt;Choose Mail, Messages, AirDrop, Files, or whatever destination you want.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That does not rewrite the original in your library. It only controls what goes out with that share. Apple's iPhone share guide also notes that sharing a photo can include date, device and captions unless you adjust Options; turning Location off is specifically about the GPS metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To remove location from the photo itself:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the photo, tap the info / More control.&lt;/li&gt;
&lt;li&gt;Tap &lt;strong&gt;Adjust Info&lt;/strong&gt;, then &lt;strong&gt;Adjust Location&lt;/strong&gt;, then &lt;strong&gt;No Location&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To stop Camera writing location on new shots: Settings &amp;gt; Privacy &amp;amp; Security &amp;gt; Location Services &amp;gt; Camera &amp;gt; Never. That does not clean photos you already took.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windows: Remove Properties and Personal Information
&lt;/h2&gt;

&lt;p&gt;File Explorer can strip metadata without installing anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Right-click the image and open &lt;strong&gt;Properties&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;Details&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Remove Properties and Personal Information&lt;/strong&gt; at the bottom.&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;Create a copy with all possible properties removed&lt;/strong&gt; so the original stays intact. Or choose specific properties (GPS among them) if you only want some gone.&lt;/li&gt;
&lt;li&gt;Click OK, then share the copy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can confirm afterward on the Details tab: a GPS section should be gone if location was present.&lt;/p&gt;

&lt;h2&gt;
  
  
  macOS: honest limits
&lt;/h2&gt;

&lt;p&gt;macOS does not give you one "strip all EXIF" button the way Windows does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preview (location only):&lt;/strong&gt; open the image in Preview, choose Tools &amp;gt; Show Inspector (Cmd+I), open the GPS tab if it appears, click &lt;strong&gt;Remove Location Info&lt;/strong&gt;, then save. No GPS tab means there was no location to remove. Camera model, exposure and capture time stay in the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photos app:&lt;/strong&gt; Image &amp;gt; Location &amp;gt; Hide Location is a library flag; Apple's own Personal Safety page pairs it with Revert to Original Location, so do not treat Hide as destroying the coordinates in the file. When you export, use File &amp;gt; Export &amp;gt; Export Photos and untick &lt;strong&gt;Include Location Information&lt;/strong&gt;. That affects the exported copy's GPS. Camera model and timestamps can still travel with the export. Dragging a photo out of Photos, or Export Unmodified Original, keeps the embedded metadata.&lt;/p&gt;

&lt;p&gt;For a full scrub on a Mac, use ExifTool (next section) on a copy of the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Command line: strip GPS, or strip everything
&lt;/h2&gt;

&lt;p&gt;ExifTool (exiftool.org) reads and writes EXIF. Install it from the site or your package manager, then work on a copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Location only&lt;/strong&gt; (keeps dates and camera tags):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-gps&lt;/span&gt;:all&lt;span class="o"&gt;=&lt;/span&gt; photo.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default ExifTool keeps a &lt;code&gt;photo.jpg_original&lt;/code&gt; backup. To rewrite in place after you have checked the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-gps&lt;/span&gt;:all&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-overwrite_original&lt;/span&gt; photo.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Everything ExifTool can clear&lt;/strong&gt; (dates, camera model, GPS, and more):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; photo.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my test JPEG, &lt;code&gt;-gps:all=&lt;/code&gt; removed every GPS tag while DateTimeOriginal, Make and Model remained. &lt;code&gt;-all=&lt;/code&gt; left only basic JPEG file info: no GPS, no capture date, no camera model.&lt;/p&gt;

&lt;p&gt;Read after you write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-GPS&lt;/span&gt;:all photo.jpg
exiftool &lt;span class="nt"&gt;-DateTimeOriginal&lt;/span&gt; &lt;span class="nt"&gt;-Make&lt;/span&gt; &lt;span class="nt"&gt;-Model&lt;/span&gt; photo.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The gotcha: capture date goes with a full scrub
&lt;/h2&gt;

&lt;p&gt;If you need the photo as evidence of when something happened, do not run &lt;code&gt;-all=&lt;/code&gt; and do not re-encode through a path that drops all metadata, unless you have the original stored elsewhere. GPS is the privacy problem for most sharing. Capture time is often the thing you still want for yourself.&lt;/p&gt;

&lt;p&gt;Some websites strip EXIF on upload. Some do not. Relying on the destination is guessing; cleaning the copy you send is something you can verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser re-encode drops EXIF as a side effect
&lt;/h2&gt;

&lt;p&gt;Any path that decodes the image to raw pixels and encodes a new JPEG keeps the picture and leaves the EXIF block behind. That is how HTML canvas export behaves, and it is what a local image compressor that redraws through a canvas is doing.&lt;/p&gt;

&lt;p&gt;I built ToolOcean. Its compressor page says the image is decoded, drawn onto a canvas, and encoded again as JPEG at the quality you set, with nothing uploaded, and that metadata is not preserved: camera settings, timestamps and GPS do not survive. I did not drive that live UI for this post. I did decode and re-encode my GPS-tagged test JPEG with Pillow the same way a canvas pass would, and ExifTool afterward showed no GPS, no DateTimeOriginal, no Make or Model. Same trade-off the page warns about: convenient for privacy, inconvenient if you needed the capture date.&lt;/p&gt;

&lt;p&gt;The page accepts JPG, PNG and WebP; the download is always JPEG. Quality around 80 is what it suggests for photographs. The tool is at &lt;a href="https://toolocean.co/image-compressor" rel="noopener noreferrer"&gt;toolocean.co/image-compressor&lt;/a&gt;. The built-in and ExifTool steps above work whether you use that page or not.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>photography</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I check whether a "no upload" PDF tool really keeps my file local</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Wed, 04 Nov 2026 04:31:47 +0000</pubDate>
      <link>https://dev.to/glinck/how-i-check-whether-a-no-upload-pdf-tool-really-keeps-my-file-local-445g</link>
      <guid>https://dev.to/glinck/how-i-check-whether-a-no-upload-pdf-tool-really-keeps-my-file-local-445g</guid>
      <description>&lt;p&gt;Lots of PDF and image sites now say "your file never leaves your device". Some of them mean it, and some run the work on a server anyway. You don't have to take the label on trust: the browser can show you every request a page makes, and you can cut the network and see whether the tool still works. Below are the exact steps in Chrome or Edge (Firefox where it differs), what each check can and can't prove, and one trap with the obvious "look for a big request" approach. I checked the DevTools labels in Chrome 156 and against the Chrome and Firefox DevTools docs, and I ran the checks on a small test page of my own that does upload a file, to see what a real upload looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1: watch the Network panel while the tool runs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the tool's page, but don't pick a file yet.&lt;/li&gt;
&lt;li&gt;Open DevTools (F12, or Ctrl+Shift+I; Cmd+Option+I on a Mac) and click the &lt;strong&gt;Network&lt;/strong&gt; tab. DevTools only records requests while it's open, so open it before you pick the file.&lt;/li&gt;
&lt;li&gt;Tick &lt;strong&gt;Keep log&lt;/strong&gt;. Older Chrome versions call it &lt;strong&gt;Preserve log&lt;/strong&gt;. Without it the list is wiped if the page navigates, which is exactly when a "processing..." page might send your file.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Clear&lt;/strong&gt; (the circle with a line through it) so the list only holds what happens next.&lt;/li&gt;
&lt;li&gt;Now pick your file, run the tool, and wait until the download has finished. Then wait a bit longer before you read the list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What to look for is any request that sends data out: method &lt;strong&gt;POST&lt;/strong&gt; or &lt;strong&gt;PUT&lt;/strong&gt;. Type &lt;code&gt;method:POST&lt;/code&gt; in the Filter box to show only those (I checked that this filter works), or right-click a column header and add the &lt;strong&gt;Method&lt;/strong&gt; column. &lt;strong&gt;Fetch/XHR&lt;/strong&gt; in the type buttons is another useful view, since uploads from scripts usually show up there.&lt;/p&gt;

&lt;p&gt;Click a suspicious request and look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Headers&lt;/strong&gt;: Request URL tells you where it went, and Request Method confirms it's a POST.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload&lt;/strong&gt;: a normal form upload shows up here. On my test page, a file sent as form data appeared under "Form data" as &lt;code&gt;file (binary)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initiator&lt;/strong&gt;: which script started it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The trap: the Size column is about the download
&lt;/h3&gt;

&lt;p&gt;The obvious advice is "look for a request the size of your file". The Size column won't show it. Chrome's docs define Size as "the combined size of the response headers plus the response body, as delivered by the server", which is the download, not what was sent. On my test page, a 509,728-byte PDF uploaded with &lt;code&gt;fetch&lt;/code&gt; showed up as &lt;strong&gt;0.1 kB&lt;/strong&gt; in the Size column, because the server only answered "ok". The status bar said "96 B transferred".&lt;/p&gt;

&lt;p&gt;The size filter has the same problem. &lt;code&gt;larger-than:100k&lt;/code&gt; hid that half-megabyte upload completely. So don't filter by size to find uploads; filter by method, then open each POST and see what's in it. In my test, the request that sent the file as a raw blob had no Payload tab at all. The Headers tab only showed "Provisional headers are shown" with &lt;code&gt;Content-Type: application/pdf&lt;/code&gt;, which was enough to give it away.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gotcha: workers and delays
&lt;/h3&gt;

&lt;p&gt;An upload doesn't have to happen when you click the button, and it doesn't have to come from the page's main script. My test page hands the file to a Web Worker, which waits 3 seconds and then POSTs it. Chrome still listed it in the same Network panel, with a small gear icon in front of the name and &lt;code&gt;worker.js:1&lt;/code&gt; as the Initiator. But it only appeared 3 seconds after I picked the file. If I'd closed DevTools when the download finished, or cleared the list, I'd have missed it. Keep the panel open until well after the result is saved, and look again before you close the tab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Firefox
&lt;/h3&gt;

&lt;p&gt;The Network tab works the same way, with different names. The setting that keeps the log is &lt;strong&gt;Persist Logs&lt;/strong&gt;, in the toolbar's menu of other actions (the docs also call it "Enable persistent logs" in Settings). The type buttons have &lt;strong&gt;XHR&lt;/strong&gt; instead of Fetch/XHR. &lt;code&gt;method:post&lt;/code&gt; works in the filter box. When you click a request, what was sent is in the &lt;strong&gt;Request&lt;/strong&gt; tab rather than "Payload". The docs describe the &lt;strong&gt;Transferred&lt;/strong&gt; and &lt;strong&gt;Size&lt;/strong&gt; columns in terms of the resource being loaded, so I'd expect the same trap there. I didn't run the upload test in Firefox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2: cut the network after the page has loaded
&lt;/h2&gt;

&lt;p&gt;A tool that really runs locally doesn't need the network once its code has loaded.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the page and wait until it's fully ready.&lt;/li&gt;
&lt;li&gt;Go offline. In Chrome DevTools, open the throttling drop-down next to "Disable cache" (it says &lt;strong&gt;No throttling&lt;/strong&gt;) and choose &lt;strong&gt;Offline&lt;/strong&gt;. In Firefox it's the &lt;strong&gt;Throttling&lt;/strong&gt; drop-down, which also has &lt;strong&gt;Offline&lt;/strong&gt;. Or just turn off Wi-Fi or unplug the cable, which takes the whole machine offline rather than one tab.&lt;/li&gt;
&lt;li&gt;Pick the file and run the tool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it finishes and you get a working file, the work happened on your machine. If it hangs or shows a network error, it needs a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these checks can't prove
&lt;/h2&gt;

&lt;p&gt;Both checks show what happened while you were watching, on that one visit. Neither one can show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Later sending.&lt;/strong&gt; A page can keep the file in the browser's storage and send it when you're back online or on your next visit. The offline test only proves the tool doesn't &lt;em&gt;need&lt;/em&gt; the network to work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small pieces.&lt;/strong&gt; A file can be split into many small requests, or the page can send something derived from it (extracted text, a thumbnail), which would be much smaller than the file. That's why you open each POST instead of looking for one big one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding.&lt;/strong&gt; Base64 makes data about a third bigger and it can be wrapped in JSON, so the body won't look like a PDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSockets.&lt;/strong&gt; A WebSocket connection shows up as one entry; what's sent over it is under that entry's &lt;strong&gt;Messages&lt;/strong&gt; tab (Chrome lists up to the last 100 messages, with a Length column). Click the &lt;strong&gt;Socket&lt;/strong&gt; button (older versions say &lt;strong&gt;WS&lt;/strong&gt;) to find these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything outside the tab.&lt;/strong&gt; DevTools shows that tab's traffic. A browser extension, another tab, or a desktop app isn't in that list. Chrome's &lt;code&gt;chrome://net-export&lt;/code&gt; page records the whole browser's network activity to a file while that page stays open; reading the file needs Chromium's netlog viewer, so it's more work, but it doesn't depend on which tab's DevTools you had open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And they can't tell you what the site's code will do next week. A tool that's local today can change. If a file is sensitive enough that this matters, an offline command-line tool avoids the question. For merging, qpdf does it in one line (I used qpdf 12.4.2):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--empty&lt;/span&gt; &lt;span class="nt"&gt;--pages&lt;/span&gt; first.pdf second.pdf &lt;span class="nt"&gt;--&lt;/span&gt; merged.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I saw on my own merge tool
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, so I ran both checks on its merge page as well, with two dummy PDFs I generated (509,728 and 151,787 bytes; 2 pages and 1 page). I used headless Chrome driven by Playwright, logging every request with its method, host and request body size, and searching each body for chunks of either file, raw and base64 encoded.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Loading the page made about 110 requests, almost all GETs to the site itself for its own code and fonts, plus a Google Tag Manager script and three small analytics POSTs (272 to 289 bytes) to gateway.umami.is.&lt;/li&gt;
&lt;li&gt;From picking the files through to the download, and for 20 seconds after it, there was one request: a 357-byte POST to gateway.umami.is. Its body is JSON recording a "tool-run" event for "pdf-merge", with the page URL, screen size and language. No file names and no file content.&lt;/li&gt;
&lt;li&gt;No request body contained any part of either PDF. The merged file was 3 pages, 659,579 bytes, with both files' text in order.&lt;/li&gt;
&lt;li&gt;With the browser set offline after the page had loaded, the merge still finished and saved a 3-page file of the same size. The only failure was that analytics ping, which errored with &lt;code&gt;ERR_INTERNET_DISCONNECTED&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool itself is at &lt;a href="https://toolocean.co/merge-pdf-without-uploading" rel="noopener noreferrer"&gt;toolocean.co/merge-pdf-without-uploading&lt;/a&gt;. The checks above are the same ones I'd run on anyone else's.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>devtools</category>
      <category>security</category>
    </item>
    <item>
      <title>The portal wants a JPG of my PDF certificate</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Tue, 03 Nov 2026 04:25:58 +0000</pubDate>
      <link>https://dev.to/glinck/the-portal-wants-a-jpg-of-my-pdf-certificate-4h9o</link>
      <guid>https://dev.to/glinck/the-portal-wants-a-jpg-of-my-pdf-certificate-4h9o</guid>
      <description>&lt;p&gt;Plenty of upload forms take a certificate only as a JPG, usually with a size limit like "under 200 KB" and sometimes a maximum width in pixels. The PDF you have doesn't have pixels at all: its text is drawn from outlines, so when you turn it into a JPG you choose how many pixels each inch of paper gets. That one number decides whether the small print survives, and the KB limit is a separate step after it. I tested everything below on a dummy A4 certificate I generated with fake details: a big heading, a name, a 9 pt certificate number, 6 and 7 pt small print, a seal, and a pale wavy background pattern like the security print many certificates have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pixels come from the resolution you render at
&lt;/h2&gt;

&lt;p&gt;A PDF page has a size in inches (A4 is 8.27 x 11.69 in), so the pixel size of the image is just inches x DPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DPI    A4 in pixels    6 pt type in pixels
72     596 x 842       6
96     794 x 1123      8
150    1241 x 1754     12.5
200    1654 x 2339     17
300    2481 x 3508     25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pixel sizes are what &lt;code&gt;pdftoppm&lt;/code&gt; actually wrote for my A4 page (it rounds up, so 1241 rather than 1240). The last column is points / 72 x DPI, the font size in pixels; capital letters are only about two thirds of that. A screenshot of a whole A4 page on a 1080p screen can't be more than about 1080 px tall, which is under 100 DPI. At 72 DPI, which is what a viewer's "1x" export gives, the "8" and the "B" in my 6 pt serial number were grey smudges that looked alike. At 96 they were readable if I squinted, and at 150 they were clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The command line: pdftoppm
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pdftoppm&lt;/code&gt; comes with poppler (&lt;code&gt;poppler-utils&lt;/code&gt; on Linux, &lt;code&gt;brew install poppler&lt;/code&gt; on a Mac). I used version 25.03.0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftoppm &lt;span class="nt"&gt;-r&lt;/span&gt; 200 &lt;span class="nt"&gt;-jpeg&lt;/span&gt; &lt;span class="nt"&gt;-jpegopt&lt;/span&gt; &lt;span class="nv"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;85 &lt;span class="nt"&gt;-singlefile&lt;/span&gt; certificate.pdf certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-r&lt;/code&gt; is the DPI, &lt;code&gt;-jpegopt quality=&lt;/code&gt; sets the JPEG quality (without it, my files came out at quality 75), and &lt;code&gt;-singlefile&lt;/code&gt; writes &lt;code&gt;certificate.jpg&lt;/code&gt; instead of &lt;code&gt;certificate-1.jpg&lt;/code&gt;. For one page out of a longer file, add &lt;code&gt;-f 2 -l 2&lt;/code&gt; for page 2. It also writes the DPI into the JPEG header, so the 200 DPI file reports 200 x 200 if a form checks that field.&lt;/p&gt;

&lt;p&gt;If the form gives a pixel size instead of a DPI, let pdftoppm work out the scale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftoppm &lt;span class="nt"&gt;-scale-to-x&lt;/span&gt; 1000 &lt;span class="nt"&gt;-scale-to-y&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;-jpeg&lt;/span&gt; &lt;span class="nt"&gt;-singlefile&lt;/span&gt; certificate.pdf certificate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave me 1000 x 1415; &lt;code&gt;-1&lt;/code&gt; keeps the proportions.&lt;/p&gt;

&lt;h2&gt;
  
  
  macOS Preview
&lt;/h2&gt;

&lt;p&gt;Open the PDF, select the page, then File &amp;gt; Export (not Export as PDF), set Format to JPEG, type a Resolution, and use the Quality slider. Export writes only the selected page. I ran my tests on Linux, so I haven't clicked through Preview for this post; check the pixel size of what it saves before uploading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the KB limit
&lt;/h2&gt;

&lt;p&gt;Here's what my dummy certificate came to at the default quality 75:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         150 DPI    200 DPI    300 DPI
with background pattern  386,593    537,605    965,995 bytes
plain white background   127,690    192,847    351,011 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The text barely costs anything. The fine wavy background is what fills the file, and converting to grayscale didn't fix that: &lt;code&gt;-gray&lt;/code&gt; at 150 DPI only went from 386,593 to 383,104 bytes.&lt;/p&gt;

&lt;p&gt;So to get under 200 KB there are two dials, pixels and quality, and the order matters. I pushed my 300 DPI render to 200 KB without reducing pixels, and ImageMagick had to go down to quality 8: the text still read fine, but the cream background broke into patchy white blocks. Reducing to 1240 px wide first, the same 200 KB came out at quality 47 and looked like the original at normal zoom. For 100 KB, 1000 px wide landed at quality 30 while 1240 px needed 17, with visible smudges around the letters.&lt;/p&gt;

&lt;p&gt;With ImageMagick (version 7, the command is &lt;code&gt;magick&lt;/code&gt;; on version 6 it's &lt;code&gt;convert&lt;/code&gt;), render a lossless PNG once, then resize and let ImageMagick find the quality:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftoppm &lt;span class="nt"&gt;-r&lt;/span&gt; 300 &lt;span class="nt"&gt;-png&lt;/span&gt; &lt;span class="nt"&gt;-singlefile&lt;/span&gt; certificate.pdf certificate-300
magick certificate-300.png &lt;span class="nt"&gt;-resize&lt;/span&gt; 1240x &lt;span class="nt"&gt;-define&lt;/span&gt; jpeg:extent&lt;span class="o"&gt;=&lt;/span&gt;200kb upload.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-resize 1240x&lt;/code&gt; sets the width and keeps the proportions, and &lt;code&gt;jpeg:extent&lt;/code&gt; searches for the highest quality that fits. My result was 194,735 bytes at 1240 x 1753. Starting from the PNG instead of a JPEG avoids compressing twice.&lt;/p&gt;

&lt;p&gt;With poppler only, a small loop does the same search by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200000
&lt;span class="k"&gt;for &lt;/span&gt;q &lt;span class="k"&gt;in &lt;/span&gt;90 85 80 75 70 65 60 55 50 45 40&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;pdftoppm &lt;span class="nt"&gt;-r&lt;/span&gt; 120 &lt;span class="nt"&gt;-jpeg&lt;/span&gt; &lt;span class="nt"&gt;-jpegopt&lt;/span&gt; &lt;span class="nv"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="nt"&gt;-singlefile&lt;/span&gt; certificate.pdf upload
  &lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %s upload.jpg&lt;span class="si"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# macOS: stat -f %z upload.jpg&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"quality &lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt; bytes"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 150 DPI my patterned certificate was still 221,594 bytes at quality 40, so I dropped to 120 DPI, which stopped at quality 60 with 195,945 bytes. If even that isn't enough, lower &lt;code&gt;-r&lt;/code&gt; again rather than the quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: don't start from screen resolution
&lt;/h2&gt;

&lt;p&gt;The usual advice is "render higher and scale down", and the part of it that matters is the first half. When I compared a page rendered directly at 1000 px wide with one rendered at 300 DPI and then resized to 1000 px, I couldn't see a difference in the small print; pdftoppm smooths edges as it renders. What does ruin small print is starting from too few pixels: a screenshot, or a viewer export at 72 DPI, can't be fixed by enlarging it later, because the detail was never captured. So render at 200 or 300 DPI, keep that file, and come down only as far as the form's pixel and KB limits make you. Before uploading, open the JPG at 100% and read the smallest line, usually the certificate number or the verification code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and its PDF to images page renders pages in the browser without uploading the file. It offers PNG or JPEG and three scales, 1x, 2x and 4x, which the page describes as 72, 144 and 288 DPI. PNG is selected by default, so switch it to JPEG for an upload form. On my dummy certificate the JPEGs came out at 595 x 841 (105,487 bytes), 1190 x 1683 (439,103 bytes) and 2381 x 3367 (1,247,072 bytes), downloaded as a ZIP; each page also has its own Download button. Two things to know: it has no quality or KB setting, so you'll still need one of the steps above to get under a limit, and its JPEGs say 72 DPI in the header at every scale even though the pixels match the scale you picked. The page says it can't open password-protected PDFs. After I picked the file, the only network requests were the page's own renderer script and fonts and a 340-byte analytics ping: &lt;a href="https://toolocean.co/pdf-to-images" rel="noopener noreferrer"&gt;toolocean.co/pdf-to-images&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>images</category>
      <category>cli</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Send only the pages they asked for: splitting a PDF before you share it</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Mon, 02 Nov 2026 04:27:23 +0000</pubDate>
      <link>https://dev.to/glinck/send-only-the-pages-they-asked-for-splitting-a-pdf-before-you-share-it-4aim</link>
      <guid>https://dev.to/glinck/send-only-the-pages-they-asked-for-splitting-a-pdf-before-you-share-it-4aim</guid>
      <description>&lt;p&gt;When someone asks for "page 3 of your statement" or "the discharge summary", the easy thing is to send the whole PDF. That hands over every other page too: other transactions, other diagnoses, an address, an account number. Cutting the file down to the pages they asked for takes a minute, and there's one detail that catches people out: some ways of doing it keep data from the pages you removed. I tested everything below on a dummy 8-page bank statement I generated with fake numbers (title, author and an account number in its metadata, one bookmark per page).&lt;/p&gt;

&lt;h2&gt;
  
  
  The built-in routes
&lt;/h2&gt;

&lt;p&gt;You probably already have something that can do this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS Preview:&lt;/strong&gt; View &amp;gt; Thumbnails, select the pages you want (Cmd-click for several), and drag them onto the desktop. Preview makes a new PDF from just those pages. Another way is File &amp;gt; Print, set the page range, then PDF &amp;gt; Save as PDF at the bottom left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome or Edge, any OS:&lt;/strong&gt; open the PDF in the browser, Print, choose "Save as PDF" (Chrome) or "Microsoft Print to PDF" (Edge on Windows) as the printer, set Pages to Custom and type something like &lt;code&gt;3-4&lt;/code&gt; or &lt;code&gt;2,5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acrobat Reader:&lt;/strong&gt; the free Reader can't extract pages itself, but its Print dialog with "Microsoft Print to PDF" or the Mac "Save as PDF" does the same job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran the tests on Linux, so I haven't clicked through these apps for this post. The print routes write the pages out again through the printing system rather than copying them, so check the result: zoom in on small print, and see if the text can still be selected if that matters to whoever's receiving it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The command line: qpdf
&lt;/h2&gt;

&lt;p&gt;qpdf copies pages without re-rendering them, so text, fonts and images stay exactly as they were. I used qpdf 12.4.2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--empty&lt;/span&gt; &lt;span class="nt"&gt;--pages&lt;/span&gt; statement.pdf 3-4 &lt;span class="nt"&gt;--&lt;/span&gt; pages-3-4.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--empty&lt;/code&gt; starts from a blank document, &lt;code&gt;--pages&lt;/code&gt; names the input and the range, and &lt;code&gt;--&lt;/code&gt; ends the page list. The range syntax is worth knowing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3-4        pages 3 and 4
2,5        page 2 then page 5, in the order you type them
7-z        page 7 to the last page (z is the last page)
r2-r1      the last two pages, counted from the end
1-z,x5     every page except 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked each one by pulling the text back out of the result with &lt;code&gt;pdftotext&lt;/code&gt; and looking for a marker I'd printed on every page. &lt;code&gt;1-z,x5&lt;/code&gt; and &lt;code&gt;1-4,6-8&lt;/code&gt; both gave the same 7 pages, and &lt;code&gt;5,2&lt;/code&gt; really did put page 5 first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: what comes along from the original
&lt;/h2&gt;

&lt;p&gt;qpdf's own help (&lt;code&gt;qpdf --help=page-selection&lt;/code&gt;) starts with a form that uses an input file as the base instead of &lt;code&gt;--empty&lt;/code&gt;, and says "Document-level information, such as outlines, tags, etc., is taken from in.pdf and is preserved in out.pdf." Here's what that means in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf statement.pdf &lt;span class="nt"&gt;--pages&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; 3-4 &lt;span class="nt"&gt;--&lt;/span&gt; pages-3-4.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.&lt;/code&gt; means "the main input file". This gives the same two pages, but it keeps what belongs to the document rather than to a page. Here's what each output carried, from &lt;code&gt;pdfinfo&lt;/code&gt; and a count of bookmark entries with pypdf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         pages  Title / Author / Subject    bookmarks
original                   8    yes (incl. account number)      8
qpdf ... --pages . 3-4     2    yes, all kept                   8
qpdf --empty --pages 3-4   2    none                            0
pdfseparate + pdfunite     2    none                            0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the two-page file from the &lt;code&gt;.&lt;/code&gt; form still had the account number in its Subject field and all eight bookmarks, which name pages that aren't in it anymore. In a real statement, bookmark titles and metadata can say a lot. Use &lt;code&gt;--empty&lt;/code&gt; when the point is sharing less, and check with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdfinfo pages-3-4.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and look at the bookmarks pane in your viewer. &lt;code&gt;pdfinfo&lt;/code&gt; only shows the info dictionary; there can also be XMP metadata, which &lt;code&gt;exiftool -a -G1 file.pdf&lt;/code&gt; lists.&lt;/p&gt;

&lt;p&gt;One more thing to check before sending: a split never changes what's printed on the pages you keep. If page 3 has your full account number in its header, so does the extract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Poppler, if that's what you have
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pdfseparate&lt;/code&gt; writes one file per page, and &lt;code&gt;pdfunite&lt;/code&gt; joins them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdfseparate &lt;span class="nt"&gt;-f&lt;/span&gt; 3 &lt;span class="nt"&gt;-l&lt;/span&gt; 4 statement.pdf page-%d.pdf
pdfunite page-3.pdf page-4.pdf pages-3-4.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%d&lt;/code&gt; becomes the page number. Neither tool brought over the metadata or bookmarks in my test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Password-protected files
&lt;/h2&gt;

&lt;p&gt;Most bank statements open with a password, and splitting needs that password because the page tree is encrypted. qpdf without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;qpdf &lt;span class="nt"&gt;--empty&lt;/span&gt; &lt;span class="nt"&gt;--pages&lt;/span&gt; locked.pdf 3-4 &lt;span class="nt"&gt;--&lt;/span&gt; out.pdf
qpdf: locked.pdf: invalid password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it, you put &lt;code&gt;--password=&lt;/code&gt; after the file name inside the page list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--empty&lt;/span&gt; &lt;span class="nt"&gt;--pages&lt;/span&gt; locked.pdf &lt;span class="nt"&gt;--password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOURPASSWORD 3-4 &lt;span class="nt"&gt;--&lt;/span&gt; out.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result came out unencrypted (&lt;code&gt;pdfinfo&lt;/code&gt; says &lt;code&gt;Encrypted: no&lt;/code&gt;), which is usually what the person asking expects, but it means the extract no longer needs a password, so send it the way you'd send any unprotected document. &lt;code&gt;pdfseparate&lt;/code&gt; failed on the locked file with "Incorrect password", and the version I have (poppler 25.03.0) has no password option at all, so remove the password first if you're using poppler. Typing a password on the command line also leaves it in your shell history. qpdf has &lt;code&gt;--password-file=&lt;/code&gt;, but it isn't accepted inside the &lt;code&gt;--pages&lt;/code&gt; list (I got "unrecognized argument"), so to keep the password out of history, decrypt first and split the decrypted copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--decrypt&lt;/span&gt; &lt;span class="nt"&gt;--password-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pw.txt locked.pdf unlocked.pdf
qpdf &lt;span class="nt"&gt;--empty&lt;/span&gt; &lt;span class="nt"&gt;--pages&lt;/span&gt; unlocked.pdf 3-4 &lt;span class="nt"&gt;--&lt;/span&gt; pages-3-4.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then delete &lt;code&gt;pw.txt&lt;/code&gt; and &lt;code&gt;unlocked.pdf&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and its split page does the &lt;code&gt;--empty&lt;/code&gt; kind of extraction in the browser without uploading the file. I ran the dummy statement through the live page in headless Chrome with the range &lt;code&gt;3-4&lt;/code&gt;: it saved &lt;code&gt;split_pages_3-4.pdf&lt;/code&gt; with 2 pages, no Title, Author or Subject, and no bookmarks. The only network request after I picked the file was a 326-byte analytics ping, nowhere near the size of the PDF. Two differences from qpdf: it sorts pages into ascending order, so &lt;code&gt;5,2&lt;/code&gt; gives page 2 first, and it can't open a password-protected file (it showed "Could not read the PDF file" for my locked copy), so remove the password first: &lt;a href="https://toolocean.co/pdf-split" rel="noopener noreferrer"&gt;toolocean.co/pdf-split&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>privacy</category>
      <category>cli</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Shooting a signature on a phone so it doesn't come out grey</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:29:58 +0000</pubDate>
      <link>https://dev.to/glinck/shooting-a-signature-on-a-phone-so-it-doesnt-come-out-grey-3pe2</link>
      <guid>https://dev.to/glinck/shooting-a-signature-on-a-phone-so-it-doesnt-come-out-grey-3pe2</guid>
      <description>&lt;p&gt;Upload forms that want a scanned signature usually mean "dark ink on a white background", within a pixel size and a KB range. A phone photo of a signature on white paper rarely looks like that. The paper comes out grey, often darker in one corner, and the file is bigger than it should be. Here's what I worked out about why, and how to fix it at the shooting step and afterwards, tested on dummy signature photos I generated (no real signatures).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why white paper comes out grey
&lt;/h2&gt;

&lt;p&gt;A phone camera sets its exposure so the scene averages out to a middle brightness. A sheet of white paper with a thin line of ink on it is almost all bright, so the camera turns the brightness down until the paper sits somewhere in the middle. Dim indoor light makes it worse: the paper gets darker, the camera raises its gain, and the grey picks up grain.&lt;/p&gt;

&lt;p&gt;To see what that does to the file, I made two fake 4032 x 3024 phone photos of the same scribbled signature with Pillow and NumPy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;indoor.jpg    dim light: paper 48% to 63% grey, noisy, darker bottom right   2,261,328 bytes
daylight.jpg  window light: paper 87% to 92%, little noise                    1,273,147 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The percentages are the average brightness of 50 x 50 pixel patches of paper, measured like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick indoor.jpg &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-crop&lt;/span&gt; 50x50+3950+2950 &lt;span class="nt"&gt;-format&lt;/span&gt; &lt;span class="s1"&gt;'%[fx:round(100*mean)]%%'&lt;/span&gt; info:
48%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same signature, same pixel count, and the indoor photo is 78% bigger. The ink didn't change. The noise did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shooting it so there's less to fix
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Light:&lt;/strong&gt; daylight near a window, not direct sun on the page. Turn the flash off; it leaves a bright spot in the middle and darker edges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exposure:&lt;/strong&gt; after tapping to focus on the signature, drag the exposure control up a little (the sun icon next to the focus box on iPhone, a slider on most Android cameras) until the paper looks white on screen, but not so far that thin strokes start to fade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Angle:&lt;/strong&gt; hold the phone flat above the page, pointing straight down. A tilted shot turns a rectangle into a trapezoid and puts one side of the paper in more shadow than the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own shadow:&lt;/strong&gt; with the light in front of you, the phone and your hand shade the page. Turn so the window is to one side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pen and paper:&lt;/strong&gt; plain white paper with nothing showing through from the back, and a pen that gives a solid dark line. Ruled or textured paper adds lines and grain the file has to store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framing:&lt;/strong&gt; fill a good part of the frame with the signature and leave some margin. You'll crop it anyway, and a signature that's 500 pixels wide in the photo holds up better than one that's 150.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Crop tight first
&lt;/h2&gt;

&lt;p&gt;The rest of the photo is paper, and nobody asked for it. I cropped both photos to the same box around the signature (1569 x 559 pixels, starting at 1271, 1245) with a small margin. ImageMagick can find the ink for you if you threshold a copy first; &lt;code&gt;%@&lt;/code&gt; prints the bounding box of everything that isn't background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick indoor.jpg &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-threshold&lt;/span&gt; 30% &lt;span class="nt"&gt;-format&lt;/span&gt; &lt;span class="s1"&gt;'%@'&lt;/span&gt; info:
1489x479+1311+1285
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's width x height + left + top. I added 40 pixels on each side for the crop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick indoor.jpg &lt;span class="nt"&gt;-crop&lt;/span&gt; 1569x559+1271+1245 +repage &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-quality&lt;/span&gt; 85 crop.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+repage&lt;/code&gt; drops the old canvas offset, so later steps treat the crop as a fresh image. &lt;code&gt;-colorspace Gray&lt;/code&gt; keeps one channel instead of three, which suits a black or blue signature; skip it if the form insists the ink colour must show.&lt;/p&gt;

&lt;p&gt;One thing that surprised me: at the same output width, cropping made the file bigger, not smaller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600 px wide, quality 85
uncropped photo        600 x 450    10,851 bytes
cropped                600 x 214    16,456 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the uncropped version the signature is only about 220 pixels wide and most of the image is blurred, smoothed-out paper. Cropping is about making the signature fill the pixels the form gives you. It's the next step that brings the size down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the paper white: -level, not just brightness
&lt;/h2&gt;

&lt;p&gt;Raising brightness lifts the ink along with the paper, so you get pale grey on pale grey. What you want is to set a black point and a white point: everything darker than one value becomes black, everything lighter than another becomes white, and the strokes' soft edges are stretched in between.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick indoor.jpg &lt;span class="nt"&gt;-crop&lt;/span&gt; 1569x559+1271+1245 +repage &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-level&lt;/span&gt; 25%,45% &lt;span class="nt"&gt;-quality&lt;/span&gt; 85 clean.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My ink measured between 12% and 17% brightness and 99% of the pixels in the photo were above 46%, so 25% and 45% sit between the two. Check your own numbers with the &lt;code&gt;fx:mean&lt;/code&gt; command above on a patch of paper near the darkest corner, and pick a white point a bit below it.&lt;/p&gt;

&lt;p&gt;Here is what that does to the size. Same crop, same quality 85, the only difference is whether the paper is grey or white:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          grey paper    white paper (-level)
full crop, 1569 x 559      109,093         36,100
resized to 600 px wide      15,040          9,940
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At full size the white version is 67% smaller. JPEG stores each 8 x 8 block of pixels as a sum of patterns, and a block of flat white needs almost nothing. A block of grey paper with grain needs many patterns to describe the grain, so JPEG spends bytes on noise nobody wants. After &lt;code&gt;-level&lt;/code&gt;, 93% of the pixels in the 600 px version were within 5 of pure white (250 or more); before it, none were.&lt;/p&gt;

&lt;p&gt;The daylight photo shows that shooting well does most of the work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;600 px wide, quality 85, cropped, grey
indoor              15,040 bytes   (paper median 139 of 255)
daylight            10,054 bytes   (paper median 228)
daylight + -level    9,584 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Threshold works, but it's rougher
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-threshold&lt;/code&gt; makes every pixel pure black or pure white. It's tempting for a signature, and it's small, but the order matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-threshold 35% then -resize 600x    10,360 bytes   edges softened by the resize
-resize 600x then -threshold 35%    11,448 bytes   hard stair-step edges on curves
-level 25%,45% then -resize 600x     9,940 bytes   smooth edges
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thresholding after resizing gave the jaggiest result and was the biggest of the three, because hard edges are expensive for JPEG too. &lt;code&gt;-level&lt;/code&gt; kept the smooth edges and came out smallest, so that's what I'd use.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line, with automatic cropping
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-trim&lt;/code&gt; removes borders that match the corner colour, and &lt;code&gt;-fuzz&lt;/code&gt; sets how close counts as a match. On a grey, uneven photo it's useless on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick indoor.jpg &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-fuzz&lt;/span&gt; 10% &lt;span class="nt"&gt;-trim&lt;/span&gt; +repage &lt;span class="nt"&gt;-quality&lt;/span&gt; 85 out.jpg
1894 x 2943, 643,324 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It kept most of the photo, because the paper gets darker towards one corner and that gradient doesn't match the corner colour. After &lt;code&gt;-level&lt;/code&gt; the paper is flat white and &lt;code&gt;-trim&lt;/code&gt; works, as long as the white point is low enough to clear the darkest paper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick indoor.jpg &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-level&lt;/span&gt; 25%,40% &lt;span class="nt"&gt;-fuzz&lt;/span&gt; 10% &lt;span class="nt"&gt;-trim&lt;/span&gt; +repage &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-bordercolor&lt;/span&gt; white &lt;span class="nt"&gt;-border&lt;/span&gt; 40 &lt;span class="nt"&gt;-resize&lt;/span&gt; 600x &lt;span class="nt"&gt;-quality&lt;/span&gt; 85 signature.jpg
600 x 213, 10,002 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;-level 25%,45%&lt;/code&gt; instead, a few noisy pixels in the dim corner stayed just below white, and &lt;code&gt;-trim&lt;/code&gt; kept a 2723 x 1669 box to include them. At 40% it found the 1491 x 479 signature. If &lt;code&gt;-trim&lt;/code&gt; returns something much bigger than the ink, lower the white point a little.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-border 40&lt;/code&gt; puts a white margin back so the strokes don't touch the edge. If the form sets an exact size like 140 x 60 pixels, use &lt;code&gt;-resize 140x60 -background white -gravity center -extent 140x60&lt;/code&gt; so it fits inside and pads the rest with white rather than stretching. Some forms set a minimum KB as well as a maximum, and a clean signature can come out under it: that 140 x 60 version of my test signature was 1,551 bytes. Raising &lt;code&gt;-quality&lt;/code&gt;, or the pixel size if the form allows a range, is the way back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without the command line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows Photos:&lt;/strong&gt; Edit image, then the crop tool to trim to the signature. The Adjustment panel has brightness, exposure, contrast, highlights and shadows; pushing exposure or highlights up until the paper turns white does roughly what &lt;code&gt;-level&lt;/code&gt; does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS Preview:&lt;/strong&gt; draw a rectangle round the signature and use Tools &amp;gt; Crop. Tools &amp;gt; Adjust Color shows a histogram with sliders under it; dragging the right one left sets the white point and the left one right sets the black point, which is the same idea as &lt;code&gt;-level&lt;/code&gt;. Saturation at zero makes it grey. Then File &amp;gt; Export as JPEG and watch the size as you move the quality slider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone editors:&lt;/strong&gt; most gallery apps can crop, and many have exposure, contrast and a black point slider in their adjust menu. Some also have a white point or highlights slider. A mono filter does the grey step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested everything above on a Linux box with ImageMagick 7.1.2, so I haven't run these apps myself. The order is what carries over: shoot in good light, crop to the signature with a margin, set the paper to white, then resize and set the quality, and zoom in to check the thin strokes before uploading.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and its image-crop page does the crop step in the browser without uploading the photo. You drag a box on the image or type X, Y, width and height. I ran my indoor photo through the live page in headless Chrome with the same box as above: it saved &lt;code&gt;cropped.png&lt;/code&gt;, 1569 x 559, pixel for pixel the same as ImageMagick's crop. As a PNG of grey, noisy paper that came to 1,278,084 bytes; the same crop of a copy I'd already put through &lt;code&gt;-level&lt;/code&gt; was 155,733 bytes. So set the paper to white first (ToolOcean's image-filters page has brightness and contrast if you'd rather stay in the browser), and convert the result to JPEG at the end if the form wants one: &lt;a href="https://toolocean.co/image-crop" rel="noopener noreferrer"&gt;toolocean.co/image-crop&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>images</category>
      <category>imagemagick</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>100 KB for a photo of a document: keeping the small print readable</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Sat, 31 Oct 2026 04:31:52 +0000</pubDate>
      <link>https://dev.to/glinck/100-kb-for-a-photo-of-a-document-keeping-the-small-print-readable-5hi3</link>
      <guid>https://dev.to/glinck/100-kb-for-a-photo-of-a-document-keeping-the-small-print-readable-5hi3</guid>
      <description>&lt;p&gt;Upload forms often want a scan or photo of a document "under 100 KB". A phone photo of a page is often a few megabytes, and the obvious fix, dragging a quality slider down until the number fits, tends to turn the small print into mush. The order you do things in matters more than the slider. Here's what I worked out, tested on dummy document photos I generated (no real documents).&lt;/p&gt;

&lt;h2&gt;
  
  
  100 KB is two different numbers
&lt;/h2&gt;

&lt;p&gt;Some software counts 1 KB as 1,000 bytes and some as 1,024, so "100 KB" is either 100,000 or 102,400 bytes. A form rarely says which. If you aim for 100,000 bytes or less, the file passes under either reading, so that's the target I use below.&lt;/p&gt;

&lt;h2&gt;
  
  
  My test photos
&lt;/h2&gt;

&lt;p&gt;I drew an A4 page with Pillow at 300 pixels per inch (2480 x 3508): a title, 30 lines of body text at about 11 pt, and 14 lines of "small print" at about 8 pt full of 12-digit reference numbers. That last block is the part that has to survive. Then I made it look like a phone photo: tilted 3 degrees, on a brown desk, 3024 x 4032 pixels, a little sensor noise and blur, saved at JPEG quality 92. Four versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shadowed.jpg      uneven light + paper grain   2,090,034 bytes
shadow-only.jpg   uneven light, smooth paper   1,610,917 bytes
texture-only.jpg  even light + paper grain     2,293,309 bytes
even.jpg          even light, smooth paper     1,745,211 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "shadow" is a smooth darkening towards the bottom right, down to about half brightness in the corner. The "grain" is low-level blurred noise across the paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality alone doesn't get there
&lt;/h2&gt;

&lt;p&gt;At full size, even Pillow's lowest quality settings were too big:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3024 x 4032, quality 1 -&amp;gt; 118,201 bytes
3024 x 4032, quality 0 -&amp;gt; 118,201 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve million pixels cost bytes even when each one is stored badly. So the job is to remove pixels you don't need first, and only then spend what's left on quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: crop to the paper and straighten
&lt;/h2&gt;

&lt;p&gt;The desk is pure waste, and a tilted page means the crop has to include desk corners. I straightened first, then cropped. ImageMagick can measure the tilt for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick shadowed.jpg &lt;span class="nt"&gt;-deskew&lt;/span&gt; 40% &lt;span class="nt"&gt;-format&lt;/span&gt; &lt;span class="s1"&gt;'%[deskew:angle]'&lt;/span&gt; info:
3.0047113682709132
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It found the 3 degrees I'd put in. Then rotate the other way and crop to the page. In Pillow, &lt;code&gt;rotate()&lt;/code&gt; with a positive angle turns anticlockwise, so my page needed &lt;code&gt;-3&lt;/code&gt;, and the crop box came from looking at pixel positions in an image viewer: &lt;code&gt;282,272,2742,3760&lt;/code&gt;, which gives a 2460 x 3488 page.&lt;/p&gt;

&lt;p&gt;Cropped and straightened but still full size, it fits, at quality 2 in colour (97,435 bytes) or 3 in grey (89,455 bytes). That fits the number and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: pick the width, then search for quality
&lt;/h2&gt;

&lt;p&gt;This is the step that decides whether the small print survives. I wrote a script that does all the steps and then binary-searches the JPEG quality for the largest file under the target. Quality and file size go up together, so halving the range each time finds the answer in about 7 encodes instead of trying all 95:&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="c1"&gt;# fit.py: python fit.py in.jpg out.jpg [--angle A] [--box l,t,r,b] [--width W] [--gray] [--flatten] [--target BYTES]
# Straighten, crop, resize, optionally grey and flatten the lighting,
# then binary-search the JPEG quality for the largest file that fits.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&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;ImageFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ImageMath&lt;/span&gt;

&lt;span class="n"&gt;ap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--angle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# degrees, positive = anticlockwise
&lt;/span&gt;&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--box&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="c1"&gt;# crop after rotating
&lt;/span&gt;&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--gray&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store_true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--flatten&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store_true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 100,000 fits both readings of "100 KB"
&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&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="n"&gt;a&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="k"&gt;if&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;angle&lt;/span&gt;&lt;span class="p"&gt;:&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;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rotate&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;angle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resample&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="n"&gt;BICUBIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expand&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fillcolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;white&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;:&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;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&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;box&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="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;if&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;width&lt;/span&gt; &lt;span class="ow"&gt;and&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;width&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&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;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&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;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&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;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&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="n"&gt;LANCZOS&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gray&lt;/span&gt;&lt;span class="p"&gt;:&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;im&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;L&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# estimate the paper brightness around each pixel (local max, then blur),
&lt;/span&gt;    &lt;span class="c1"&gt;# divide it out, then push near-white paper to pure white
&lt;/span&gt;    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&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;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;L&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ImageFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MaxFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ImageFilter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GaussianBlur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageMath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lambda_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;g&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="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;g&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="n"&gt;bg&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;L&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;215&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;215&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&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="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPEG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;q&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# about 7 encodes instead of 95
&lt;/span&gt;    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  q=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: even quality 1 is over &lt;/span&gt;&lt;span class="si"&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;target&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; use fewer pixels&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="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;a&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&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;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;im&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;im&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, quality &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;best&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="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same page, grey, different widths, each searched down to 100,000 bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;width  quality  bytes
2460      3     89,455
1600      8     94,578
1240     13     95,756
1000     21     99,899
 800     34     97,692
 600     66     98,873
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fewer pixels means each one gets more bytes. The question is where the text stops being readable, and it depends on how tall the letters end up in pixels. My 8 pt line is 33 pixels per em at 300 DPI, so it's about 13 pixels at 1000 wide, 11 at 800 and 8 at 600. I zoomed in on the reference numbers in each file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1240, 1000 and 800 px:&lt;/strong&gt; every digit I checked read correctly. The low quality shows as block noise on the paper and smudges around the strokes, worst at 1240 with quality 13.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;600 px, quality 66:&lt;/strong&gt; far less block noise, but the digits are too small. 3, 5 and 8 start to look alike.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So for this page, 800 to 1000 pixels wide was where the digits stayed clear with the most quality to spare. A page with larger print can go narrower; a page with tiny print in a table may need 1240 and a cleaner photo. Check the smallest digits, not the headings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grey vs colour: less difference than I expected
&lt;/h2&gt;

&lt;p&gt;To compare fairly I fixed the width (1240) and quality (30) and only changed the input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;shadowed, colour       194,462
shadowed, grey         187,898
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Going grey saved only 3.4% here (2.2% at quality 60). That surprised me until I checked how the colour file was stored: Pillow uses 4:2:0 chroma subsampling by default, so the two colour channels are kept at a quarter of the resolution, and on a nearly grey page they're almost flat and cost very little. Grey still helps, and it helps more if the paper is tinted or has coloured stamps, but it's not the big lever. Pixels are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually eats the bytes
&lt;/h2&gt;

&lt;p&gt;Same width and quality again, the four photos side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      quality 30   quality 60
even, grey              160,301      227,318
texture-only, grey      225,555      352,794
shadow-only, grey       135,349      195,370
shadowed, grey          187,898      304,753
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The paper grain added 41% at quality 30 and 55% at quality 60. JPEG stores each 8 x 8 block as a sum of patterns; a flat block needs almost nothing, and a grainy block needs many patterns to describe the grain, even though nobody wants the grain.&lt;/p&gt;

&lt;p&gt;My smooth shadow didn't cost bytes on its own. It actually saved some, because darkening a region lowers the contrast of the text in it, and lower contrast is cheaper to store. That's not a good trade: it's the text getting fainter. In a real photo a shadow usually comes with more sensor noise as well, since the camera has less light to work with there, and noise costs bytes the same way grain does. My synthetic shadow had no extra noise, so this test doesn't show that part.&lt;/p&gt;

&lt;p&gt;So the advice is the same either way: shoot in even light, near a window or under a ceiling light, and hold the phone so it doesn't throw its own shadow on the page.&lt;/p&gt;

&lt;p&gt;If the photo is already shadowed, the &lt;code&gt;--flatten&lt;/code&gt; option in the script divides out the lighting and pushes near-white paper to pure white. At 1240 px and quality 30 the shadowed page went from 187,898 to 169,992 bytes (9.5% smaller), and in the size-searched 1240 px file the background behind the small print came out white instead of grey and grainy.&lt;/p&gt;

&lt;h2&gt;
  
  
  ImageMagick in one line
&lt;/h2&gt;

&lt;p&gt;The same steps with ImageMagick 7, using its own tilt detection and a size limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick shadowed.jpg &lt;span class="nt"&gt;-deskew&lt;/span&gt; 40% +repage &lt;span class="nt"&gt;-gravity&lt;/span&gt; center &lt;span class="nt"&gt;-crop&lt;/span&gt; 2460x3488+0+0 +repage &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-colorspace&lt;/span&gt; Gray &lt;span class="nt"&gt;-resize&lt;/span&gt; 1000x &lt;span class="nt"&gt;-define&lt;/span&gt; jpeg:extent&lt;span class="o"&gt;=&lt;/span&gt;100000B out.jpg
&lt;span class="nv"&gt;$ &lt;/span&gt;magick identify &lt;span class="nt"&gt;-format&lt;/span&gt; &lt;span class="s2"&gt;"%wx%h %[colorspace] q=%Q %B bytes&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; out.jpg
1000x1418 Gray &lt;span class="nv"&gt;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 93808 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I ran into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Without the &lt;code&gt;+repage&lt;/code&gt; after &lt;code&gt;-deskew&lt;/code&gt;, the crop used the old canvas offsets and cut off the left edge of the page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jpeg:extent&lt;/code&gt; doesn't fail when it can't hit the target. Pointed at the full 3024 x 4032 photo it wrote a 118,310 byte file and exited with status 0. Always check the size afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also stopped a step early: with the 100,000 byte limit above it settled on quality 20 (93,808 bytes), though &lt;code&gt;-quality 21&lt;/code&gt; on the same command gives 98,018 bytes and would have fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without the command line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows Photos:&lt;/strong&gt; the crop tool trims the desk, and the three-dot menu has Resize image, where you set a width in pixels, move the Quality slider, and see the new file size before saving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS Preview:&lt;/strong&gt; crop with the selection tool, Tools &amp;gt; Adjust Size for the width, Tools &amp;gt; Adjust Color with Saturation at zero for grey, then File &amp;gt; Export and use the JPEG quality slider, which shows the file size as you move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone editors:&lt;/strong&gt; the crop tool in most gallery apps has a straighten dial, and a mono or black-and-white filter does the grey step. Crop and straighten there, then resize and set the quality on a computer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested everything above on a Linux box, so I haven't run these apps myself. The order is the part that carries over: crop, straighten, choose a width where the smallest digits are still clear, then lower quality until it fits, and zoom in before uploading.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and its compress-image-to-100kb page does the last step: it picks the JPEG quality, and the scale if needed, so the file lands at or under the ceiling, in the browser without uploading the photo. Its 100 KB is 102,400 bytes. I ran two of my test files through the live page in headless Chrome. The raw shadowed photo came back at 1701 x 2268, 100,078 bytes; my cropped 1000 px grey page came back at 1000 x 1418, 101,075 bytes, saved as a colour JPEG. Both are under 102,400 and both are over 100,000, so a form that counts 1 KB as 1,000 bytes would reject them. The page doesn't crop, straighten or convert to grey, so do those first: &lt;a href="https://toolocean.co/compress-image-to-100kb" rel="noopener noreferrer"&gt;toolocean.co/compress-image-to-100kb&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>images</category>
      <category>python</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Turning phone photos of a marksheet into one small PDF</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:28:19 +0000</pubDate>
      <link>https://dev.to/glinck/turning-phone-photos-of-a-marksheet-into-one-small-pdf-4c2h</link>
      <guid>https://dev.to/glinck/turning-phone-photos-of-a-marksheet-into-one-small-pdf-4c2h</guid>
      <description>&lt;p&gt;A lot of upload forms want "one PDF, under 100 KB" for something you only have as phone photos: a two-sided marksheet, a certificate, a few pages of a transcript. Putting photos into a PDF is easy. Getting a small one with even pages takes knowing what the PDF actually does with your JPEGs. Here's what I worked out, tested on dummy marksheet-style images I generated (no real documents).&lt;/p&gt;

&lt;h2&gt;
  
  
  What a PDF does with a JPEG
&lt;/h2&gt;

&lt;p&gt;A PDF can store a JPEG as-is. The image object gets the filter &lt;code&gt;/DCTDecode&lt;/code&gt; (DCT is the maths inside JPEG), and the compressed bytes from your file are copied straight in. Nothing is decoded or recompressed, so quality doesn't drop. It also means the PDF can't be smaller than the photos inside it.&lt;/p&gt;

&lt;p&gt;Other tools decode the JPEG to pixels and encode a new JPEG. That can make the file smaller or bigger depending on their quality setting, and it's a second round of lossy compression either way.&lt;/p&gt;

&lt;p&gt;So the order matters: crop and compress the photos first, then put them in a PDF with something that copies JPEGs unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  My test images
&lt;/h2&gt;

&lt;p&gt;I made three "phone photos" with Pillow: a fake marksheet table on paper-coloured background, a brown desk border around it, some noise so it compresses like a photo, saved at quality 92. Two are 3024 x 4032, and a third is 2448 x 3264 as if taken on a different phone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page1.jpg  3024x4032  1,441,946 bytes
page2.jpg  3024x4032  1,440,963 bytes
page3.jpg  2448x3264    974,380 bytes   (3,857,289 total)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  img2pdf: JPEG passthrough
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;img2pdf&lt;/code&gt; (&lt;code&gt;pip install img2pdf&lt;/code&gt;) embeds JPEGs without touching them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;img2pdf page1.jpg page2.jpg page3.jpg &lt;span class="nt"&gt;-o&lt;/span&gt; straight.pdf
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; straight.pdf
3859731 straight.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 2,442 bytes more than the three photos together, which is the PDF structure. &lt;code&gt;pdfimages -list&lt;/code&gt; (from poppler-utils) shows what's inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pdfimages -list straight.pdf
page   num  type   width height color comp bpc  enc interp  object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
   1     0 image    3024  4032  rgb     3   8  jpeg   no        13  0    96    96 1408K 3.9%
   2     1 image    3024  4032  rgb     3   8  jpeg   no         2  0    96    96 1407K 3.9%
   3     2 image    2448  3264  rgb     3   8  jpeg   no         5  0    96    96  952K 4.1%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To prove it's a straight copy, I pulled the images back out and compared bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pdfimages &lt;span class="nt"&gt;-j&lt;/span&gt; straight.pdf x/s
&lt;span class="nv"&gt;$ &lt;/span&gt;cmp x/s-000.jpg page1.jpg &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;identical
identical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ImageMagick and Pillow re-encode
&lt;/h2&gt;

&lt;p&gt;The same three photos through ImageMagick 7:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;magick page1.jpg page2.jpg page3.jpg magick.pdf
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; magick.pdf
3493823 magick.pdf
&lt;span class="nv"&gt;$ &lt;/span&gt;pdfimages &lt;span class="nt"&gt;-j&lt;/span&gt; magick.pdf x/m
&lt;span class="nv"&gt;$ &lt;/span&gt;cmp x/m-000.jpg page1.jpg
x/m-000.jpg page1.jpg differ: byte 181, line 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image is still &lt;code&gt;/DCTDecode&lt;/code&gt;, but it's a new JPEG: 1,301,295 bytes where the original was 1,441,946. ImageMagick decoded the photo and encoded it again.&lt;/p&gt;

&lt;p&gt;Pillow does the same. This script:&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="c1"&gt;# pillow_pdf.py: python pillow_pdf.py out.pdf a.jpg b.jpg ...
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&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;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
&lt;span class="n"&gt;ims&lt;/span&gt; &lt;span class="o"&gt;=&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;f&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;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ims&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;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;save_all&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;append_images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="n"&gt;resolution&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;gave a 1,584,553 byte PDF, with page 1's image re-encoded down to 588,020 bytes. Smaller, but that's quality loss you didn't choose, and still nowhere near 100 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: crop and shrink the photos
&lt;/h2&gt;

&lt;p&gt;For a document photo, most of the bytes go on things nobody needs: the desk, 12 megapixels, and colour on a page that's black text. This script crops to the paper, resizes, optionally converts to greyscale, and saves a JPEG at a chosen quality:&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="c1"&gt;# shrink.py: python shrink.py in.jpg out.jpg left top right bottom [width] [quality] [gray]
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&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;src&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="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;1240&lt;/span&gt;
&lt;span class="n"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&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;gray&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# cut the desk away
&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&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="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&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;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&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="n"&gt;LANCZOS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# 1240 px is A4 width at 150 DPI
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gray&lt;/span&gt;&lt;span class="p"&gt;:&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;im&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;L&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# one channel instead of three
&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="n"&gt;out&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="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;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&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;out&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="n"&gt;size&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="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crop box comes from looking at the photo (any image viewer shows pixel positions). What each setting gave me, for all three pages through img2pdf:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1240 px wide, quality 60, colour  -&amp;gt; 150,709 bytes
1240 px wide, quality 60, grey    -&amp;gt; 131,641 bytes
1000 px wide, quality 40, grey    -&amp;gt;  87,696 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the last one is under 100 KB. At 1000 pixels across an A4 width the marks and table lines were still clear when I zoomed in. My images are synthetic, so a real photo will land on different numbers; the method is to step width and quality down until it fits, then zoom in and check every digit is readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: pages take the image's size, not A4
&lt;/h2&gt;

&lt;p&gt;Look at the page sizes in &lt;code&gt;straight.pdf&lt;/code&gt;. I checked them with pypdf:&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="c1"&gt;# pages.py: print each page's size in pt and mm
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mediabox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mediabox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; page &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; x &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; pt = &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mf"&gt;25.4&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; x &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mf"&gt;25.4&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; mm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;straight.pdf page 1: 2268.0 x 3024.0 pt = 800 x 1067 mm
straight.pdf page 2: 2268.0 x 3024.0 pt = 800 x 1067 mm
straight.pdf page 3: 1836.0 x 2448.0 pt = 648 x 864 mm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each page is the image's pixel size divided by a DPI. My test photos had no DPI in the header (the JFIF density was only an aspect ratio), so img2pdf assumed 96 and made pages nearly a metre tall. ImageMagick assumed 72 and went bigger still (1067 x 1422 mm). And because the third photo came from a "different phone", its page is smaller than the others. Print that and it comes out uneven; on screen the pages jump in size as you scroll. Even after cropping, my pages were 279, 279 and 277 mm tall, because no two crops are exactly the same shape.&lt;/p&gt;

&lt;p&gt;The fix is to give the PDF a fixed paper size and let the image fit inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;img2pdf &lt;span class="nt"&gt;--pagesize&lt;/span&gt; A4 &lt;span class="nt"&gt;--border&lt;/span&gt; 10mm final1.jpg final2.jpg final3.jpg &lt;span class="nt"&gt;-o&lt;/span&gt; marksheet.pdf
&lt;span class="nv"&gt;$ &lt;/span&gt;pdfinfo marksheet.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Pages|Page size|File size"&lt;/span&gt;
Pages:           3
Page size:       595.276 x 841.89 pts &lt;span class="o"&gt;(&lt;/span&gt;A4&lt;span class="o"&gt;)&lt;/span&gt;
File size:       87696 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three pages are A4, and &lt;code&gt;cmp&lt;/code&gt; on the extracted images still says identical: setting the page size only changes where the image is drawn, not the image data.&lt;/p&gt;

&lt;h2&gt;
  
  
  No OCR
&lt;/h2&gt;

&lt;p&gt;The result is pictures of text. &lt;code&gt;pdffonts&lt;/code&gt; lists no fonts, and &lt;code&gt;pdftotext&lt;/code&gt; gave me back three page-break characters and no text. If a portal needs searchable text, that's a separate OCR step; none of the routes above does it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without the command line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows:&lt;/strong&gt; select the photos in File Explorer, right-click, Print, and pick "Microsoft Print to PDF" as the printer. The paper size you choose there becomes the page size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS Preview:&lt;/strong&gt; open all the photos in one window, drag the thumbnails into order, select them all, then File &amp;gt; Print and choose Save as PDF from the PDF menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone scanner apps:&lt;/strong&gt; the scan feature in many notes and file apps finds the page edges, crops and straightens. Check the size of what they export, since some save large colour pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn't run these on my Linux test machine, so I can't say whether they keep the JPEG or re-encode it. Either way, compress first and check the result with &lt;code&gt;pdfimages -list&lt;/code&gt; and &lt;code&gt;pdfinfo&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and its images-to-PDF page works the img2pdf way. I fed the live page two of my test photos in headless Chrome: both JPEGs came out byte-identical inside the PDF, and the pages were sized from the pixels (1067 x 1422 mm and 864 x 1151 mm, so the mixed-size gotcha applies). It doesn't crop, compress or OCR, so do those first: &lt;a href="https://toolocean.co/images-to-pdf" rel="noopener noreferrer"&gt;toolocean.co/images-to-pdf&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>python</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>3.5 x 4.5 cm is not a pixel size: converting cm and DPI for form photos</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Thu, 29 Oct 2026 04:29:06 +0000</pubDate>
      <link>https://dev.to/glinck/35-x-45-cm-is-not-a-pixel-size-converting-cm-and-dpi-for-form-photos-5l7</link>
      <guid>https://dev.to/glinck/35-x-45-cm-is-not-a-pixel-size-converting-cm-and-dpi-for-form-photos-5l7</guid>
      <description>&lt;p&gt;A lot of application forms ask for a passport-style photo of "3.5 x 4.5 cm". An image file has no centimetres in it. It has a width and height in pixels, plus an optional number in the header that says how many of those pixels should go into one inch when it's printed. Here's how I turn a size in cm into pixels, how to do it with free tools, and the one setting that looks like it changes the size but doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula
&lt;/h2&gt;

&lt;p&gt;There are 2.54 cm in an inch, and DPI (dots per inch, often meaning pixels per inch for images) says how many pixels make one inch. So:&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;pixels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cm&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.54&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;DPI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same 3.5 x 4.5 cm gives very different pixel sizes depending on the DPI. I ran it in Python and rounded to whole pixels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python px.py
 96 DPI: 132.28 x 170.08 -&amp;gt; 132 x 170 px
200 DPI: 275.59 x 354.33 -&amp;gt; 276 x 354 px
300 DPI: 413.39 x 531.50 -&amp;gt; 413 x 531 px
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three are "3.5 x 4.5 cm". The 300 DPI version has about ten times as many pixels as the 96 DPI one (219,303 against 22,440), which matters for both sharpness and file size.&lt;/p&gt;

&lt;p&gt;So a size in cm on its own is incomplete. If the form also gives a pixel size, I use the pixel size. If it gives cm and a DPI, I use the formula. If it gives only cm, I look in the notice and the portal's help for a pixel or DPI figure before guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: crop to the right shape first
&lt;/h2&gt;

&lt;p&gt;3.5 : 4.5 is the same shape as 7 : 9, a ratio of about 0.778. A phone photo is usually 3 : 4 (0.75). If you resize a 3 : 4 photo straight to 413 x 531, the width gets stretched by about 3.7 percent, enough to make a face look wider. So crop to 7 : 9 first, then resize.&lt;/p&gt;

&lt;p&gt;For my 3024 x 4032 test image, that meant keeping the full width and cutting the height to 3024 x 9 / 7 = 3888 pixels, taken from the middle. In a real photo you'd place the crop so the face sits where the form's guidelines want it, rather than cutting blindly from the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: resize and set the DPI with Pillow
&lt;/h2&gt;

&lt;p&gt;This is the script I tested. It crops to 7 : 9 from the centre, resizes to the pixel size for the DPI you give it, and writes that DPI into the JPEG header:&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="c1"&gt;# resize.py: python resize.py input.jpg 300 photo.jpg
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&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;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&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="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;w_px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.5&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.54&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;h_px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.5&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.54&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;src&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;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# too wide: trim the sides
&lt;/span&gt;    &lt;span class="n"&gt;new_w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;
    &lt;span class="n"&gt;left&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;new_w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;im&lt;/span&gt; &lt;span class="o"&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;crop&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;left&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="n"&gt;left&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;new_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# too tall: trim top and bottom
&lt;/span&gt;    &lt;span class="n"&gt;new_h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;new_h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;im&lt;/span&gt; &lt;span class="o"&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;crop&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="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;new_h&lt;/span&gt;&lt;span class="p"&gt;))&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;im&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;w_px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h_px&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="n"&gt;LANCZOS&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="n"&gt;out&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;85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&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;out&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="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python resize.py phone.jpg 300 photo-300.jpg
&lt;span class="go"&gt;photo-300.jpg (413, 531)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check what I got, I read the pixel size and the JFIF density (the DPI field in the JPEG header) and worked out the printed size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;photo-96.jpg: 132x170 px, 1,414 bytes, JFIF density 96x96 dots/inch -&amp;gt; prints at 3.49 x 4.50 cm
photo-200.jpg: 276x354 px, 3,236 bytes, JFIF density 200x200 dots/inch -&amp;gt; prints at 3.51 x 4.50 cm
photo-300.jpg: 413x531 px, 5,713 bytes, JFIF density 300x300 dots/inch -&amp;gt; prints at 3.50 x 4.50 cm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 3.49 and 3.51 come from rounding to whole pixels. My test image is a smooth synthetic picture, so the byte counts are much smaller than a real photo would be, but the direction is the same: more pixels, more bytes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;exiftool&lt;/code&gt; shows the same thing if you have it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ exiftool -ImageSize -XResolution -YResolution -ResolutionUnit photo-300.jpg
Image Size                      : 413x531
X Resolution                    : 300
Y Resolution                    : 300
Resolution Unit                 : inches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Doing it without code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preview on a Mac:&lt;/strong&gt; crop with the selection tool, then Tools &amp;gt; Adjust Size. The width and height fields have a unit menu, and there's a Resolution field. Apple's guide says that deselecting "Resample image" makes the dimensions smaller "without losing any detail". In practice that keeps the same pixels and changes only the print size and resolution. For a form photo that needs fewer pixels, keep "Resample image" ticked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIMP:&lt;/strong&gt; Image &amp;gt; Scale Image changes the pixels (you can type the size in cm and set the resolution there). Image &amp;gt; Print Size only changes the resolution and print size. GIMP's own docs say Print Size "does not change the number of pixels in the image and it does not resample the image".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paint on Windows:&lt;/strong&gt; its Resize dialog works in percent or pixels, so do the cm to pixel maths yourself with the formula above, untick "Maintain aspect ratio" only after you've cropped to the right shape, and type the pixel size.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gotcha: changing only the DPI does nothing to the pixels or the KB
&lt;/h2&gt;

&lt;p&gt;It's tempting to open the file, change 96 to 300 in a DPI box, and call it done. I tried exactly that on the 96 DPI file by rewriting only the density bytes in the header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;photo-96.jpg: 132x170 px, 1,414 bytes, JFIF density 96x96 dots/inch -&amp;gt; prints at 3.49 x 4.50 cm
photo-96-relabelled-300.jpg: 132x170 px, 1,414 bytes, JFIF density 300x300 dots/inch -&amp;gt; prints at 1.12 x 1.44 cm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cmp -l&lt;/code&gt; found 4 bytes different between the two files, all in the header. Same pixels, same file size, and the printed size dropped to 1.12 x 1.44 cm because the same 132 pixels are now spread at 300 per inch. Doing the same relabel with &lt;code&gt;exiftool -XResolution=300 -YResolution=300 -ResolutionUnit=inches&lt;/code&gt; gave the same 132 x 170 pixels; the file grew by 88 bytes only because exiftool also added an EXIF block holding the resolution.&lt;/p&gt;

&lt;p&gt;So if a portal checks pixel dimensions or a KB range, editing the DPI field changes neither. What actually changes them is resampling to a new pixel size, and then compressing if the KB is still too high.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and its resizer page takes the size in px, cm, mm or inches with a DPI (96 by default, with 200 and 300 presets), does the conversion in the browser without uploading the photo, and stores that DPI in the JFIF header of a JPEG download. A PNG download keeps pixels only. It doesn't crop, so crop to the right shape first: &lt;a href="https://toolocean.co/image-resizer" rel="noopener noreferrer"&gt;toolocean.co/image-resizer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>images</category>
      <category>tutorial</category>
      <category>python</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Three months of statements as one PDF for a loan application</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Wed, 28 Oct 2026 04:28:07 +0000</pubDate>
      <link>https://dev.to/glinck/three-months-of-statements-as-one-pdf-for-a-loan-application-3b8b</link>
      <guid>https://dev.to/glinck/three-months-of-statements-as-one-pdf-for-a-loan-application-3b8b</guid>
      <description>&lt;p&gt;Loan and rental forms often ask for the last three months of bank statements as a single upload. Banks usually hand them out one month at a time, and often with a password on each file. Here's how I join them into one PDF with free tools, and the three things that trip the job up: passwords, order, and size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: find out which files are locked
&lt;/h2&gt;

&lt;p&gt;A statement PDF can be protected in two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An open password (user password).&lt;/strong&gt; The file won't show its pages until you type it. Bank statements often come like this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner restrictions only.&lt;/strong&gt; The file opens without a prompt, but printing or copying is switched off. It's still encrypted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;pdfinfo&lt;/code&gt; from poppler-utils shows both. For my test files, the first has an open password and the second has owner restrictions only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pdfinfo jul-locked.pdf
Command Line Error: Incorrect password

&lt;span class="nv"&gt;$ &lt;/span&gt;pdfinfo aug-restricted.pdf | &lt;span class="nb"&gt;grep &lt;/span&gt;Encrypted
Encrypted:       &lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;print:no copy:no change:no addNotes:no algorithm:AES-256&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;qpdf --show-encryption file.pdf&lt;/code&gt; gives more detail if you want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: why the merge fails on locked files
&lt;/h2&gt;

&lt;p&gt;I tried merging all three files as they were. &lt;code&gt;pdfunite&lt;/code&gt;, the merge tool that ships with poppler, refused both encrypted files, including the one that opens without a prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfunite jul-locked.pdf aug-restricted.pdf sep.pdf out.pdf
&lt;span class="go"&gt;Command Line Error: Incorrect password
Syntax Error: Could not merge damaged documents ('jul-locked.pdf')

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pdfunite aug-restricted.pdf sep.pdf out.pdf
&lt;span class="go"&gt;Unimplemented Feature: Could not merge encrypted files ('aug-restricted.pdf')
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second one is the gotcha. A file that opens fine on screen can still be rejected by a merge tool, because it's encrypted even though it has no open password.&lt;/p&gt;

&lt;p&gt;Not every tool behaves the same. qpdf and pypdf both merged the owner-restricted file without complaint, and both stopped at the file with the open password (&lt;code&gt;qpdf: jul-locked.pdf: invalid password&lt;/code&gt;, and pypdf raised &lt;code&gt;FileNotDecryptedError&lt;/code&gt;). One note on pypdf: it needs the &lt;code&gt;cryptography&lt;/code&gt; package installed to read AES-encrypted files, otherwise it fails with "cryptography&amp;gt;=3.1 is required for AES algorithm".&lt;/p&gt;

&lt;p&gt;So the simplest rule is: remove the protection from each file first, then merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: remove each password (you need to know it)
&lt;/h2&gt;

&lt;p&gt;With qpdf, for a file with an open password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--decrypt&lt;/span&gt; &lt;span class="nt"&gt;--password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PASSWORD jul-locked.pdf statement-2026-07.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an owner-restricted file, no password is needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--decrypt&lt;/span&gt; aug-restricted.pdf statement-2026-08.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A wrong password gives &lt;code&gt;invalid password&lt;/code&gt; and exit code 2, and no output file. After both commands, &lt;code&gt;pdfinfo&lt;/code&gt; showed &lt;code&gt;Encrypted: no&lt;/code&gt; on the new copies.&lt;/p&gt;

&lt;p&gt;Typing the password on the command line leaves it in your shell history. qpdf can read it from a file instead with &lt;code&gt;--password-file=pw.txt&lt;/code&gt;, which worked the same way in my test. Delete that file afterwards.&lt;/p&gt;

&lt;p&gt;This only works for passwords you already have. It's a decrypt step, not a way to get into a file you can't open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: put them in order, oldest to newest
&lt;/h2&gt;

&lt;p&gt;Whoever reviews the application reads the file top to bottom, so I put the oldest month first. The easy way to get that order for free is to name the files by year and month, &lt;code&gt;statement-2026-07.pdf&lt;/code&gt;, &lt;code&gt;statement-2026-08.pdf&lt;/code&gt;, &lt;code&gt;statement-2026-09.pdf&lt;/code&gt;, because the shell sorts the wildcard alphabetically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdfunite statement-2026-&lt;span class="k"&gt;*&lt;/span&gt;.pdf statements-jul-sep-2026.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last name on the line is the output. Note that &lt;code&gt;pdfunite&lt;/code&gt; overwrites it if it already exists.&lt;/p&gt;

&lt;p&gt;To check the order, I printed the first line of each page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5 6&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;pdftotext &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt; statements-jul-sep-2026.pdf - | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
&lt;/span&gt;SAMPLE BANK - Account statement - July 2026 - page 1
SAMPLE BANK - Account statement - July 2026 - page 2
SAMPLE BANK - Account statement - August 2026 - page 1
SAMPLE BANK - Account statement - August 2026 - page 2
SAMPLE BANK - Account statement - September 2026 - page 1
SAMPLE BANK - Account statement - September 2026 - page 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On scanned statements there's no text to extract, so open the merged file and flip through it instead.&lt;/p&gt;

&lt;p&gt;qpdf can also do the decrypt and the merge in one command. A &lt;code&gt;--password=&lt;/code&gt; written after a file name applies to that file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--empty&lt;/span&gt; &lt;span class="nt"&gt;--pages&lt;/span&gt; jul-locked.pdf &lt;span class="nt"&gt;--password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PASSWORD aug-restricted.pdf sep.pdf &lt;span class="nt"&gt;--&lt;/span&gt; statements.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave me a 6-page file with &lt;code&gt;Encrypted: no&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a Mac without the terminal:&lt;/strong&gt; Apple's Preview guide describes combining PDFs by opening them, choosing View &amp;gt; Thumbnails, and dragging thumbnails (or a PDF's icon from the Finder) into another document's sidebar, then dragging thumbnails to reorder. Preview saves changes automatically, so Apple suggests File &amp;gt; Duplicate first if you want to keep the originals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: check the size against the portal's limit
&lt;/h2&gt;

&lt;p&gt;Merging copies pages; it doesn't compress them. The output is about the sum of the inputs. My text-only test statements were tiny, so I also made three scan-like statements with two image pages each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Size (bytes)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;scan-2026-07.pdf&lt;/td&gt;
&lt;td&gt;2,412,623&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scan-2026-08.pdf&lt;/td&gt;
&lt;td&gt;2,412,521&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scan-2026-09.pdf&lt;/td&gt;
&lt;td&gt;2,412,750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sum of inputs&lt;/td&gt;
&lt;td&gt;7,237,894&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merged with pdfunite&lt;/td&gt;
&lt;td&gt;7,236,678&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merged with qpdf&lt;/td&gt;
&lt;td&gt;7,235,689&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three files of about 2.4 MB each became one file of about 7.2 MB. Upload limits differ from portal to portal, so check the portal's limit before you merge. If the merged file is over it, compress it as a separate step, and check that the figures are still readable afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few more things I check before uploading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page count.&lt;/strong&gt; &lt;code&gt;pdfinfo merged.pdf | grep Pages&lt;/code&gt; should equal the page counts of the inputs added together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The merged file has no password.&lt;/strong&gt; That's usually what a single-file upload needs. If you're emailing it instead, you can add a password to the merged copy with &lt;code&gt;qpdf --encrypt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the originals.&lt;/strong&gt; I work on copies and keep the bank's files untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and it has a merge page that combines PDFs in the order you drag them into, in the browser without uploading the files. It doesn't take encrypted files, so a locked statement goes through its unlock page first, using a password you already know: &lt;a href="https://toolocean.co/pdf-merge" rel="noopener noreferrer"&gt;toolocean.co/pdf-merge&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>cli</category>
    </item>
    <item>
      <title>Filling a PDF form on a laptop, and why I flatten the copy I send</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Mon, 26 Oct 2026 04:19:39 +0000</pubDate>
      <link>https://dev.to/glinck/filling-a-pdf-form-on-a-laptop-and-why-i-flatten-the-copy-i-send-381e</link>
      <guid>https://dev.to/glinck/filling-a-pdf-form-on-a-laptop-and-why-i-flatten-the-copy-i-send-381e</guid>
      <description>&lt;p&gt;A lot of forms still arrive as PDFs with a note that says "print, fill, scan, and send back". Most of the time you don't need the printer. Here's how to tell what kind of form you have, fill it with free tools, and decide whether the copy you send should still be editable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that look like a form
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An AcroForm.&lt;/strong&gt; The PDF stores a list of named fields (text boxes, checkboxes, radio groups, dropdowns), and each one has a widget on the page that draws its value. This is the kind almost every viewer can fill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An XFA form.&lt;/strong&gt; An older Adobe format where the form is described in XML inside the PDF. Most viewers outside Adobe's own apps can't render it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A flat scan or export.&lt;/strong&gt; Boxes and lines drawn on the page, with no fields at all. It only looks like a form.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;pdfinfo&lt;/code&gt; (from poppler-utils) tells you which one you have on its &lt;code&gt;Form:&lt;/code&gt; line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pdfinfo form.pdf | &lt;span class="nb"&gt;grep &lt;/span&gt;Form
Form:            AcroForm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints &lt;code&gt;AcroForm&lt;/code&gt;, &lt;code&gt;XFA&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The XFA gotcha
&lt;/h2&gt;

&lt;p&gt;If you open a PDF and see only a page that starts with "Please wait... If this message is not eventually replaced by the proper contents of the document, your PDF viewer may not be able to display this type of document", that's an XFA form. Waiting won't help. Browsers and most free viewers show that placeholder page instead of the form. The realistic options are Adobe Acrobat Reader, which still fills XFA, or asking the sender for an AcroForm or Word version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling an AcroForm without extra software
&lt;/h2&gt;

&lt;p&gt;Chrome, Edge, Firefox and macOS Preview all fill AcroForm fields: open the file, click into a field, type, tick, and save. The saved file is still a form. Your answers sit in the fields, and anyone who opens it can change them.&lt;/p&gt;

&lt;p&gt;If you get &lt;code&gt;none&lt;/code&gt;, there's nothing to fill. You can still type on top using a viewer's text or annotation tool, but that's adding text to the page, not filling fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling from a script
&lt;/h2&gt;

&lt;p&gt;For a form you fill often, a short pypdf script is handy. First list the field names, because they're whatever the form's author called them, not the labels you see:&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;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;get_fields&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;items&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/FT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;field&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/_States_&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my test form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;full_name /Tx
agree /Btn ['/Off', '/Yes']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/Tx&lt;/code&gt; is a text field and &lt;code&gt;/Btn&lt;/code&gt; is a checkbox or radio. The states in brackets are the exact values a checkbox accepts. Here it's &lt;code&gt;/Yes&lt;/code&gt;, but some forms use &lt;code&gt;/On&lt;/code&gt; or &lt;code&gt;/1&lt;/code&gt;, so read them instead of guessing.&lt;/p&gt;

&lt;p&gt;Then fill:&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;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PdfWriter&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;flatten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--flatten&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;

&lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfReader&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;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfWriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;writer&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;reader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;values&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;full_name&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;Test Person&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;agree&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;/Yes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_page_form_field_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auto_regenerate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flatten&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;flatten&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;flatten&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove_annotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subtypes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Widget&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root_object&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/AcroForm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this with pypdf 6.21. Without &lt;code&gt;--flatten&lt;/code&gt;, the output is still a form with the values in the fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I flatten the copy I send
&lt;/h2&gt;

&lt;p&gt;Flattening draws each field's appearance into the page content and removes the fields. The answers still show, but they're page content now, not a form someone can edit by accident (or on purpose) and send on.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;--flatten&lt;/code&gt;, my test file went from &lt;code&gt;Form: AcroForm&lt;/code&gt; to &lt;code&gt;Form: none&lt;/code&gt;, the field list came back empty, and &lt;code&gt;pdftotext&lt;/code&gt; still printed "Test Person". Rendering the page with &lt;code&gt;pdftoppm&lt;/code&gt; showed the name and the ticked box.&lt;/p&gt;

&lt;p&gt;qpdf can flatten a file you already filled in a viewer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--generate-appearances&lt;/span&gt; &lt;span class="nt"&gt;--flatten-annotations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all filled.pdf flat.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--generate-appearances&lt;/code&gt; makes qpdf draw fields whose viewer didn't save an appearance, and &lt;code&gt;--flatten-annotations=all&lt;/code&gt; burns them into the page. On my filled test file this gave the same result: &lt;code&gt;Form: none&lt;/code&gt; and the values visible on the page. Note that &lt;code&gt;all&lt;/code&gt; also flattens comments and other annotations, which is usually what you want for a final copy.&lt;/p&gt;

&lt;p&gt;Two cautions. Keep an unflattened copy if you might need to change an answer, because flattening is one-way. And don't flatten a form the receiver asked for as a fillable file, since some offices read the field data straight out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signature fields
&lt;/h2&gt;

&lt;p&gt;A signature field is a placeholder for a digital signature, not a text box. None of the methods above signs anything. If the form wants a drawn signature, add an image on the page; if it wants a real digital signature, that's a separate step with a certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and it has a page that lists a PDF's AcroForm fields, lets you fill them, and downloads the result with flattening off by default. It runs in the browser without uploading the file, skips signature fields, and doesn't fill XFA forms: &lt;a href="https://toolocean.co/pdf-form-fill" rel="noopener noreferrer"&gt;toolocean.co/pdf-form-fill&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>python</category>
      <category>tutorial</category>
      <category>cli</category>
    </item>
    <item>
      <title>My PDF was telling people who made it: checking and cleaning PDF metadata</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Sun, 25 Oct 2026 04:28:05 +0000</pubDate>
      <link>https://dev.to/glinck/my-pdf-was-telling-people-who-made-it-checking-and-cleaning-pdf-metadata-55pl</link>
      <guid>https://dev.to/glinck/my-pdf-was-telling-people-who-made-it-checking-and-cleaning-pdf-metadata-55pl</guid>
      <description>&lt;p&gt;Before you send a PDF outside your team, it's worth knowing that the file carries a few labels you never typed on the page: an author name, a title, the app that made it, and when. Those labels often hold a draft title or your computer's account name. Here's how to see them and remove them with free tools, and why the most common one-liner doesn't fully remove anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the labels live
&lt;/h2&gt;

&lt;p&gt;A PDF can describe itself in two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Info dictionary.&lt;/strong&gt; A small key/value table: Title, Author, Subject, Keywords, Creator (the app that made the document), Producer (the library that wrote the PDF), and the creation and modification dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The XMP packet.&lt;/strong&gt; An XML block stored as a metadata stream. It usually repeats the same ideas (for example &lt;code&gt;dc:creator&lt;/code&gt; for the author and &lt;code&gt;xmp:CreatorTool&lt;/code&gt; for the app) and can hold more.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cleaning only one of them leaves the other behind, so check both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading them
&lt;/h2&gt;

&lt;p&gt;I made a one-page test PDF with an author, a title and an XMP packet, then read it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pdfinfo&lt;/code&gt; (from poppler-utils, on most Linux distros and in Homebrew) shows the Info dictionary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pdfinfo sample.pdf
Title:           Draft offer letter v3
Subject:         Internal
Keywords:
Author:          Test Author
Creator:         Writer on my laptop
Producer:        ReportLab PDF Library - (opensource)
...
Metadata Stream: yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line tells you an XMP packet exists. &lt;code&gt;pdfinfo -meta sample.pdf&lt;/code&gt; prints it as raw XML.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;exiftool&lt;/code&gt; reads both places and labels which one each value came from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ exiftool -a -G1 -s sample.pdf
[PDF]      Author        : Test Author
[PDF]      Creator       : Writer on my laptop
[PDF]      Title         : Draft offer letter v3
[XMP-dc]   Creator       : Test Author
[XMP-xmp]  CreatorTool   : Writer on my laptop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[PDF]&lt;/code&gt; is the Info dictionary and &lt;code&gt;[XMP-...]&lt;/code&gt; is the XMP packet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-liner that only hides things
&lt;/h2&gt;

&lt;p&gt;The usual advice is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;:all&lt;span class="o"&gt;=&lt;/span&gt; file.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards, &lt;code&gt;exiftool&lt;/code&gt; and &lt;code&gt;pdfinfo&lt;/code&gt; both report no author. But exiftool prints a warning while it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: [minor] ExifTool PDF edits are reversible. Deleted tags may be recovered!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It means what it says. For PDFs, exiftool appends an update to the end of the file instead of rewriting it, so the old Info dictionary is still in the bytes. On my test file, &lt;code&gt;grep -c "Test Author"&lt;/code&gt; still found the name twice after the "strip", and this command brought every value back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-pdf-update&lt;/span&gt;:all&lt;span class="o"&gt;=&lt;/span&gt; file.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So after exiftool, rewrite the file so the unreferenced old objects are dropped. qpdf does that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;:all&lt;span class="o"&gt;=&lt;/span&gt; file.pdf
qpdf &lt;span class="nt"&gt;--linearize&lt;/span&gt; file.pdf clean.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;clean.pdf&lt;/code&gt;, grep found no copy of the name, and exiftool showed no author.&lt;/p&gt;

&lt;h2&gt;
  
  
  Another route: copy only the pages
&lt;/h2&gt;

&lt;p&gt;If you have Python, pypdf can build a new PDF from the pages alone, which leaves out the old Info dictionary and the XMP packet:&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;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PdfWriter&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfWriter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&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;add_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;python strip.py in.pdf out.pdf&lt;/code&gt; gave a file where &lt;code&gt;pdfinfo&lt;/code&gt; showed only &lt;code&gt;Producer: pypdf&lt;/code&gt; and &lt;code&gt;Metadata Stream: no&lt;/code&gt;. Bookmarks, form fields and other document-level parts don't come across this way, so check the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of this touches
&lt;/h2&gt;

&lt;p&gt;Metadata is not page content. My test page said "Prepared by Test Author", and after every method above, &lt;code&gt;pdftotext clean.pdf -&lt;/code&gt; still printed that line. A name in the text, in a header or footer, in a comment, or inside an attached file has to be removed in the source document or with a real redaction step. Also, don't use grep to check page text: page streams are usually compressed, so grep can miss text that is plainly visible on the page.&lt;/p&gt;

&lt;p&gt;The cheapest fix is often upstream: clear the author and title in the document properties of the app you write in, then export again.&lt;/p&gt;

&lt;h2&gt;
  
  
  A browser option
&lt;/h2&gt;

&lt;p&gt;I built ToolOcean, and it has a page that reads both the Info dictionary and the XMP packet in your browser, lets you edit or strip them, and saves a new copy without uploading the file. Its strip doesn't touch page content either, and it refuses encrypted files: &lt;a href="https://toolocean.co/pdf-metadata" rel="noopener noreferrer"&gt;toolocean.co/pdf-metadata&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>privacy</category>
      <category>tutorial</category>
      <category>cli</category>
    </item>
    <item>
      <title>Password-protecting a PDF before emailing it, and why the "no printing" box isn't a lock</title>
      <dc:creator>Anuj Kabra</dc:creator>
      <pubDate>Sat, 24 Oct 2026 04:23:42 +0000</pubDate>
      <link>https://dev.to/glinck/password-protecting-a-pdf-before-emailing-it-and-why-the-no-printing-box-isnt-a-lock-16dd</link>
      <guid>https://dev.to/glinck/password-protecting-a-pdf-before-emailing-it-and-why-the-no-printing-box-isnt-a-lock-16dd</guid>
      <description>&lt;p&gt;Bank statements, salary slips and ID scans get emailed around all the time. Putting a password on the PDF first is cheap insurance: if the mail lands in the wrong inbox or sits in a shared folder, the file is useless without the password. It only works if you know what the password actually protects, and PDF encryption has two passwords that do very different jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two passwords, two jobs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The open password&lt;/strong&gt; (the spec calls it the user password). A reader won't show any page until you type it. This is the part that keeps a stranger out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The owner password.&lt;/strong&gt; It controls the permission flags: printing, copying text, editing, filling forms, and so on. Someone who opens the file with the open password gets the restricted version. Someone who opens it with the owner password gets everything.&lt;/p&gt;

&lt;p&gt;The file is encrypted either way. The flags are different: they're a request written into the file, and it's up to the reader software to honour it. I'll show below what that means in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built-in ways to do it
&lt;/h2&gt;

&lt;p&gt;You may not need anything new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Word (Windows):&lt;/strong&gt; File &amp;gt; Save As, choose PDF, click Options, and tick "Encrypt the document with a password".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LibreOffice:&lt;/strong&gt; File &amp;gt; Export as PDF, open the Security tab, and use Set Passwords. It asks for the open password and the permissions password separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS Preview:&lt;/strong&gt; recent versions have File &amp;gt; Edit Permissions, where you can require a password to open the document and set an owner password for the permissions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you use, open the result afterwards and confirm it asks for the password before you delete the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  From the command line with qpdf
&lt;/h2&gt;

&lt;p&gt;qpdf is free and open source, and it's my go-to when I want to know exactly what's in the file. This sets a different open and owner password, uses 256-bit AES, and turns off printing and copying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--encrypt&lt;/span&gt; &lt;span class="nt"&gt;--user-password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;river-cobalt-42 &lt;span class="nt"&gt;--owner-password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Owner-only-9x &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;256 &lt;span class="nt"&gt;--print&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none &lt;span class="nt"&gt;--extract&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;n &lt;span class="nt"&gt;--&lt;/span&gt; statement.pdf statement-protected.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check what you made:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;river-cobalt-42 &lt;span class="nt"&gt;--show-encryption&lt;/span&gt; statement-protected.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my test file that printed &lt;code&gt;R = 6&lt;/code&gt;, &lt;code&gt;Supplied password is user password&lt;/code&gt;, &lt;code&gt;print high resolution: not allowed&lt;/code&gt;, &lt;code&gt;extract for any purpose: not allowed&lt;/code&gt;, and &lt;code&gt;AESv3&lt;/code&gt; for streams, strings and files. R = 6 with AESv3 is the AES-256 handler from PDF 2.0, the strongest standard option a PDF has.&lt;/p&gt;

&lt;p&gt;poppler's &lt;code&gt;pdfinfo&lt;/code&gt; makes a good second check, because it won't touch the file without the password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdfinfo statement-protected.pdf
&lt;span class="c"&gt;# Command Line Error: Incorrect password&lt;/span&gt;
pdfinfo &lt;span class="nt"&gt;-upw&lt;/span&gt; river-cobalt-42 statement-protected.pdf | &lt;span class="nb"&gt;grep &lt;/span&gt;Encrypted
&lt;span class="c"&gt;# Encrypted: yes (print:no copy:no change:yes addNotes:yes algorithm:AES-256)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why the "no printing" box isn't a lock
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprised me. With that same file and only the open password, two ordinary commands ignored the restrictions completely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pdftotext&lt;/code&gt; pulled the text out, even though the file says copying isn't allowed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pdftotext &lt;span class="nt"&gt;-upw&lt;/span&gt; river-cobalt-42 statement-protected.pdf - | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
&lt;span class="c"&gt;# Account statement, test file&lt;/span&gt;
&lt;span class="c"&gt;# Balance: 12,345.67&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And qpdf wrote a fully decrypted copy with no restrictions at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qpdf &lt;span class="nt"&gt;--password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;river-cobalt-42 &lt;span class="nt"&gt;--decrypt&lt;/span&gt; statement-protected.pdf plain.pdf
qpdf &lt;span class="nt"&gt;--show-encryption&lt;/span&gt; plain.pdf
&lt;span class="c"&gt;# File is not encrypted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That isn't a bug in either tool. Once you have the open password, you can decrypt the content, and the flags are only a note asking the reader to behave. Acrobat and most mainstream viewers respect them. A script doesn't have to. So treat the permission flags as a guard against accidental editing or printing by a cooperative reader, not as a way to stop a determined recipient from copying something you've already let them read.&lt;/p&gt;

&lt;p&gt;If you want the flags to mean anything, the owner password has to be different from the open password. When I encrypted the same file with both passwords set to the same string, &lt;code&gt;--show-encryption&lt;/code&gt; reported &lt;code&gt;Supplied password is owner password&lt;/code&gt; for the open password, which means anyone who can open the file holds the full set of rights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending the password
&lt;/h2&gt;

&lt;p&gt;The point of the password is lost if it travels in the same email as the file. Send it by another channel: a text message, a call, or a password you agreed earlier. For recurring documents, some banks and employers use a pattern built from your name and date of birth, which is convenient and also guessable by anyone who knows you. For files you protect yourself, use a long passphrase. AES-256 isn't the weak point; a short password is, because an attacker with the file can try guesses offline as fast as their hardware allows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha: a blank owner password can mean no password at all
&lt;/h2&gt;

&lt;p&gt;Leaving the owner password empty feels harmless, as if it just means "no extra restrictions". With 256-bit encryption it's worse than that. qpdf refuses outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qpdf: A PDF with a non-empty user password and an empty owner password
encrypted with a 256-bit key is insecure as it can be opened without a password.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I forced it with &lt;code&gt;--allow-insecure&lt;/code&gt; to see, and the warning is accurate. &lt;code&gt;pdfinfo&lt;/code&gt; opened the result with no password at all and printed &lt;code&gt;Encrypted: yes (print:yes copy:yes ...)&lt;/code&gt;, and &lt;code&gt;pdftotext&lt;/code&gt; read the text straight out. A reader tries the empty string as the owner password, it matches, and the open password never gets asked for. If a tool lets you leave the owner password blank, check what it writes in its place, then test the output with &lt;code&gt;pdfinfo&lt;/code&gt; before you send it.&lt;/p&gt;

&lt;p&gt;I built ToolOcean, a set of browser-based file tools. Its password page encrypts with AES-256 (revision 6) in the browser tab, and when the owner password is left blank it stores a copy of the open password instead of an empty one, so the file still needs a password to open: &lt;a href="https://toolocean.co/pdf-encrypt" rel="noopener noreferrer"&gt;https://toolocean.co/pdf-encrypt&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drafted with AI help, tested and checked by me.&lt;/p&gt;

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