<?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: Marcus Hale</title>
    <description>The latest articles on DEV Community by Marcus Hale (@marcushale).</description>
    <link>https://dev.to/marcushale</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4182652%2Fa4a73021-4451-46ed-a62a-ede516aa7670.webp</url>
      <title>DEV Community: Marcus Hale</title>
      <link>https://dev.to/marcushale</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcushale"/>
    <language>en</language>
    <item>
      <title>Your Generated PDFs Open Slowly in the Browser? They Are Probably Not Linearized</title>
      <dc:creator>Marcus Hale</dc:creator>
      <pubDate>Wed, 14 Oct 2026 16:13:45 +0000</pubDate>
      <link>https://dev.to/marcushale/your-generated-pdfs-open-slowly-in-the-browser-they-are-probably-not-linearized-5gl4</link>
      <guid>https://dev.to/marcushale/your-generated-pdfs-open-slowly-in-the-browser-they-are-probably-not-linearized-5gl4</guid>
      <description>&lt;h1&gt;
  
  
  Your Generated PDFs Open Slowly in the Browser? They Are Probably Not Linearized
&lt;/h1&gt;

&lt;p&gt;Last month a user emailed me about a report my little side project generates: "The PDF takes forever to show the first page. I thought it was broken."&lt;/p&gt;

&lt;p&gt;The file was 4 MB. Not huge. But the in-browser preview sat on a spinner for several seconds before page one appeared, and on hotel Wi-Fi it was worse. The PDF itself was fine — it opened instantly in a desktop reader once fully downloaded. That was the clue: &lt;strong&gt;the browser could not render page one until it had downloaded essentially the whole file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is the diagnosis and the fix, because if your app generates PDFs (invoices, reports, statements, tickets), your users are probably hitting the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem: byte order, not file size
&lt;/h2&gt;

&lt;p&gt;A regular PDF is laid out for a writer, not a reader. The cross-reference table and page resources can sit anywhere in the file, objects for page one may be scattered from byte 0 to byte 4,000,000, and a viewer that wants to draw the first page has to go hunting — which, over HTTP, means range requests all over the file or just downloading everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linearization&lt;/strong&gt; (Adobe calls it "Fast Web View") restructures the file specifically for this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page one's objects are moved to the front of the file, in order.&lt;/li&gt;
&lt;li&gt;A hint table up front tells the viewer exactly which byte ranges it needs for each page.&lt;/li&gt;
&lt;li&gt;The viewer can then fetch &lt;em&gt;just the first page's bytes&lt;/em&gt; with an HTTP range request and render immediately, pulling later pages on demand as the user scrolls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same content, same visual result, completely different first-paint behavior. The spec detail that matters: the file's first line carries a linearization marker in the header comment, and you can check for 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="c"&gt;# quick check: is this PDF linearized?&lt;/span&gt;
&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 1024 report.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Linearized"&lt;/span&gt;
&lt;span class="c"&gt;# or with qpdf, if you have it installed:&lt;/span&gt;
qpdf &lt;span class="nt"&gt;--check&lt;/span&gt; report.pdf | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; linearized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those come back empty, your generated PDFs are not linearized — no matter what library produced them. Most generators (including the very common ones) do &lt;strong&gt;not&lt;/strong&gt; linearize by default, because it is a second full pass over the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured on my own file
&lt;/h2&gt;

&lt;p&gt;Personal test, one file, throttled connection (Chrome DevTools "Slow 4G" profile), before and after linearizing the exact same generated report:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After linearization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;4.1 MB&lt;/td&gt;
&lt;td&gt;4.2 MB (slightly larger — hint tables cost bytes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytes needed to render page 1&lt;/td&gt;
&lt;td&gt;~4.1 MB (effectively all of it)&lt;/td&gt;
&lt;td&gt;~180 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first page on screen&lt;/td&gt;
&lt;td&gt;~3.5 s&lt;/td&gt;
&lt;td&gt;under 0.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth noting from that table. First, the linearized file got marginally &lt;em&gt;bigger&lt;/em&gt;, and that is normal — you are trading a few percent of size for page-at-a-time delivery. Second, the win is not "the file loads faster"; it is "the first page renders after ~4% of the bytes." On a fast office connection nobody noticed the problem. On the connections your users actually complain from, it is the whole experience.&lt;/p&gt;

&lt;p&gt;Your numbers will differ — this was my file, my hosting, my throttle profile — but the mechanism is deterministic: if page one needs the whole file, slow networks will feel it linearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, at three levels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Level 1 — your generator.&lt;/strong&gt; Some toolchains can emit linearized output directly or via a post-processing flag. If yours can, turn it on in the generation pipeline and be done. The post-processing step in pseudo-code is trivial; the cost is one extra pass per file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;generate report.pdf
linearize report.pdf -&amp;gt; report-web.pdf
serve report-web.pdf (with a server that honors Range requests)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Level 2 — your server.&lt;/strong&gt; Linearization only pays off if your hosting actually answers HTTP &lt;code&gt;Range&lt;/code&gt; requests with &lt;code&gt;206 Partial Content&lt;/code&gt;. Most static hosts and CDNs do. Check yours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Range: bytes=0-1023"&lt;/span&gt; https://your-host/report.pdf &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"HTTP/|accept-ranges|content-range"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;200&lt;/code&gt; instead of &lt;code&gt;206&lt;/code&gt;, the browser will fall back to downloading everything and the linearization buys you almost nothing. Fix the serving layer first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Level 3 — one-off files.&lt;/strong&gt; For PDFs that already exist (a backlog of generated reports, files clients send you, anything not flowing through your pipeline), you just need a linearizer. I used the one in PDFLean — the dedicated tool page is pdflean.com/tools/pdf-linearize — dropped the original report in, downloaded the processed copy, and verified it with the &lt;code&gt;head&lt;/code&gt;/&lt;code&gt;grep&lt;/code&gt; check above before swapping it into my test. For a one-off fix or a sanity check before you automate anything, that is the fastest path: process, verify the marker is there, re-measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I now run for generated PDFs
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Generate a sample with production-like data (empty test files hide this — you need real length).&lt;/li&gt;
&lt;li&gt;Check the linearization marker (command above).&lt;/li&gt;
&lt;li&gt;Confirm the host returns &lt;code&gt;206&lt;/code&gt; for a range request.&lt;/li&gt;
&lt;li&gt;Throttle the connection and time first-page render, not full download.&lt;/li&gt;
&lt;li&gt;Only then argue about compression — size and structure are separate problems, and structure is the one that blocks first paint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That user who emailed me never wrote back, which in support terms is the best possible outcome. The report did not get smaller or prettier. It just stopped making people wait for page one.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>webdev</category>
      <category>performance</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The PDF Review Loop That Finally Stopped Eating My Afternoons</title>
      <dc:creator>Marcus Hale</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:00:40 +0000</pubDate>
      <link>https://dev.to/marcushale/the-pdf-review-loop-that-finally-stopped-eating-my-afternoons-2817</link>
      <guid>https://dev.to/marcushale/the-pdf-review-loop-that-finally-stopped-eating-my-afternoons-2817</guid>
      <description>&lt;p&gt;Most of the contract work I do for clients ends the same way: a PDF goes out, and a PDF comes back covered in comments. Highlights with no note attached. A sticky note that just says “fix this?” on page 9. Three people commenting on three different versions, because the file in the email thread is never the file in the shared drive.&lt;/p&gt;

&lt;p&gt;The tools were never the problem. The problem was that “review” had no rules, so every round produced a new pile of comments I had to archaeology my way through. Here’s the loop I run now. It’s half conventions, half a small script, and it cut my review rounds from four-ish to usually two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: One marked copy, named like it means it
&lt;/h2&gt;

&lt;p&gt;There is exactly one file that accepts comments: &lt;code&gt;contract_CLIENT_review.pdf&lt;/code&gt;. Not &lt;code&gt;v7&lt;/code&gt;, not &lt;code&gt;final-FINAL&lt;/code&gt;, not the one attached to the third reply in the thread. When a new round starts, I archive the previous marked copy as &lt;code&gt;contract_CLIENT_review_r1.pdf&lt;/code&gt; and the working name stays the same.&lt;/p&gt;

&lt;p&gt;This sounds trivial and it eliminated the single biggest time sink: merging comments from parallel versions. If someone sends me comments on a different file, those comments don’t exist until they’re redone on the one marked copy. People adapt to this rule in about one round, because redoing comments is more annoying than following it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: Three annotation types, three meanings
&lt;/h2&gt;

&lt;p&gt;I ask reviewers to use only three things, each with a fixed meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Highlight + note&lt;/strong&gt; = a question. (“Is this the rate we agreed in March?”) Questions get answered in a reply note, not by silently editing the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strikethrough with replacement text&lt;/strong&gt; = a proposed edit. If you want a clause changed, strike it and type the replacement. No “maybe soften this paragraph somehow” — that’s a question, use a highlight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sticky note starting with DECISION:&lt;/strong&gt; = something already settled that I should just apply. (“DECISION: payment terms are net 30, confirmed with their finance lead.”)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every other annotation type — the arrows, the drawing tools, the lone carets with nothing attached — gets ignored, and reviewers know it gets ignored. The constraint feels strict for about ten minutes. Then people realize their comments actually get read, in order, and addressed.&lt;/p&gt;

&lt;p&gt;Most of my clients annotate in whatever’s open on their machine. The ones without Acrobat use the annotate tool in PDFLean in a browser tab, which covers exactly these three types and nothing else — honestly the limited palette is part of why it works. A reviewer with fourteen annotation tools will use all fourteen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: The script turns comments into a checklist
&lt;/h2&gt;

&lt;p&gt;Before I touch the document, I extract every annotation into a plain list with page numbers:&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;fitz&lt;/span&gt;  &lt;span class="c1"&gt;# PyMuPDF
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dump_annots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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;doc&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;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;annots&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;info&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;info&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&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;number&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&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="nb"&gt;type&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="c1"&gt;# Highlight, StrikeOut, FreeText, ...
&lt;/span&gt;                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;info&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;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&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;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;dump_annots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contract_CLIENT_review.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;9&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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&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;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That printout becomes the round’s checklist. Every line gets one of three marks as I work: applied, answered, or pushed back with a reason. At the end of the round, the marked-up checklist goes back to the client with the new draft. Half the value is the checklist itself — reviewers can see their DECISION notes were applied and their questions got answers instead of vanishing into a fresh PDF. The other half is selfish: two rounds later, when someone asks “why does clause 6 say net 30?”, the answer is in a text file, not buried in a dead PDF’s comment layer.&lt;/p&gt;

&lt;p&gt;One gotcha: some tools store the visible note text in the annotation’s popup rather than its content field, so occasionally the script prints an empty note for an annotation that visibly has text. When the checklist shows a blank, I open that page and read the popup directly. It happens rarely enough that I haven’t bothered automating around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: What ships is flat and clean
&lt;/h2&gt;

&lt;p&gt;When a round closes, two files leave my machine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The new working draft (still commentable, still the same filename pattern).&lt;/li&gt;
&lt;li&gt;When the document is final: a &lt;strong&gt;flattened&lt;/strong&gt; copy. Flattening bakes the annotations into the page content — or, for the truly final version, I produce a clean copy with no annotations at all and archive the last marked copy with its checklist.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The distinction matters. A flattened file is for records and signatures: nobody’s comment layer should be editable in the archived version of a signed agreement. The marked copy plus checklist is the audit trail; the flat file is the artifact. Mixing those two jobs into one file is how you end up, months later, unable to prove which comments were ever addressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn’t fix
&lt;/h2&gt;

&lt;p&gt;It doesn’t make reviewers respond faster, and it doesn’t rescue a document that was badly structured before review started — comments cluster on the confusing parts either way, which is its own kind of useful signal. What it does is make each round &lt;em&gt;terminal&lt;/em&gt;: every comment enters as a question, an edit, or a decision, gets extracted into a list, and leaves a written trace. Review stops being a recurring meeting with a PDF and becomes a queue that empties.&lt;/p&gt;

&lt;p&gt;If your current process is “send PDF, receive vibes,” steal the parts in order: one marked copy first, the three-type rule second, the extraction script third. The first one alone is worth more than the other two combined.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>productivity</category>
      <category>tutorial</category>
      <category>python</category>
    </item>
    <item>
      <title>Batch-Converting 60 Receipt Photos into One PDF (and Back Again)</title>
      <dc:creator>Marcus Hale</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:47:22 +0000</pubDate>
      <link>https://dev.to/marcushale/batch-converting-60-receipt-photos-into-one-pdf-and-back-again-447d</link>
      <guid>https://dev.to/marcushale/batch-converting-60-receipt-photos-into-one-pdf-and-back-again-447d</guid>
      <description>&lt;p&gt;A client of mine documents site work with a phone. Every Friday I get a folder -- sometimes a literal zip over email -- with 40 to 80 photos: receipts, signed delivery notes, whiteboard shots, the occasional crooked picture of a screen. My job is to turn that into one PDF the bookkeeper can file, in capture order, without spending my afternoon in a file dialog.&lt;/p&gt;

&lt;p&gt;This is the batch image workflow I landed on. It has two directions, because the reverse job shows up just as often: a PDF arrives and somebody needs the images &lt;em&gt;out&lt;/em&gt; of it, one file per image, right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direction 1: Many images, one PDF
&lt;/h2&gt;

&lt;p&gt;The scripting route takes about fifteen lines with Pillow:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;photos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;site-photos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;photos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;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;p&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;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1654&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2339&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;          &lt;span class="c1"&gt;# ~A4 at 200 DPI, plenty for receipts
&lt;/span&gt;    &lt;span class="n"&gt;pages&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;img&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;site-week-41.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&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;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;resolution&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;200.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter more than the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sort order is the document.&lt;/strong&gt; Phone filenames (&lt;code&gt;IMG_2041.jpg&lt;/code&gt;) sort fine until someone AirDrops from a second phone and the names interleave wrong. If order matters -- and for receipts it does -- rename by timestamp first (&lt;code&gt;exiftool "-FileName&amp;lt;DateTimeOriginal"&lt;/code&gt; does it in one pass) or sort by modification time and eyeball the first and last page before sending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downscale before you bind.&lt;/strong&gt; A 48MP phone photo at full size makes a 200MB PDF nobody can email. 200 DPI at A4-ish dimensions is more than legible for a receipt and cuts the file by an order of magnitude.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.convert("RGB")&lt;/code&gt; is not optional.&lt;/strong&gt; One PNG with an alpha channel or one HEIC-converted CMYK JPEG will kill the whole save at page 37. Ask me how I know.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Direction 2: One PDF, many images
&lt;/h2&gt;

&lt;p&gt;Extracting with PyMuPDF gets you the &lt;em&gt;embedded&lt;/em&gt; images at their stored resolution, which is what you want for re-use:&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;fitz&lt;/span&gt;  &lt;span class="c1"&gt;# PyMuPDF
&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;catalog.pdf&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_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&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="n"&gt;doc&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;img&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="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_images&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full&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;xref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;pix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xref&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;pix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# CMYK etc.
&lt;/span&gt;            &lt;span class="n"&gt;pix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pixmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;csRGB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;pix&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;page_no&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&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;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.png&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;Know what you're getting: this pulls the raw image objects. If the "image" on a page is actually a rendered chart or a scanned page assembled from tiles, you'll get tiles and charts, not the screenshot you were imagining. For a full-page render instead, &lt;code&gt;page.get_pixmap(dpi=200)&lt;/code&gt; is the honest tool -- different job, different output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I stop scripting
&lt;/h2&gt;

&lt;p&gt;I keep the scripts for the Friday folder, because it arrives in the same shape every week. For the one-off jobs -- a client sends three PNGs and wants one PDF back &lt;em&gt;now&lt;/em&gt;, or I need a quick batch conversion on a machine where I haven't set anything up -- I use the &lt;a href="https://pdflean.com/tools/image-batch" rel="noopener noreferrer"&gt;image batch tool on PDFLean&lt;/a&gt; in the browser instead of touching a terminal. Same operations, drag, drop, done. Files I process there without an account are deleted immediately after processing, which is the property I check first whenever a client's documents are involved; a free account raises the per-file limit to 50MB for the weeks the photos arrive huge.&lt;/p&gt;

&lt;p&gt;The rule of thumb after a year of this: if the job repeats weekly in the same shape, script it once and never think again. If it happens twice a quarter, a batch tool in the browser is faster than finding the script, remembering its arguments, and updating it because Pillow changed something. Both directions live in my runbook now -- the expensive version of this task was always doing it one file at a time.&lt;/p&gt;

</description>
      <category>python</category>
      <category>pdf</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Batch-Stamp and Rename 40 Invoices in One Go</title>
      <dc:creator>Marcus Hale</dc:creator>
      <pubDate>Tue, 13 Oct 2026 23:02:22 +0000</pubDate>
      <link>https://dev.to/marcushale/how-i-batch-stamp-and-rename-40-invoices-in-one-go-pcb</link>
      <guid>https://dev.to/marcushale/how-i-batch-stamp-and-rename-40-invoices-in-one-go-pcb</guid>
      <description>&lt;p&gt;Every month, my clients' suppliers send me a folder's worth of invoices as PDFs. The filenames are chaos: &lt;code&gt;INV_final2.pdf&lt;/code&gt;, &lt;code&gt;scan0003.pdf&lt;/code&gt;, &lt;code&gt;document (1).pdf&lt;/code&gt;. Before any of them can go to the bookkeeper, each one needs two things: a "RECEIVED" stamp with the date, and a filename a human can search later — my pattern is &lt;code&gt;YYYY-MM-DD_vendor_total.pdf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Doing this by hand for ~40 files took me about an hour of opening, stamping, saving, renaming. Now the mechanical part is a script, and my job is a 10-minute review pass. Here's the setup, including the parts that broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Three folders: &lt;code&gt;inbox/&lt;/code&gt; (originals, never touched), &lt;code&gt;work/&lt;/code&gt; (script output), &lt;code&gt;review/&lt;/code&gt; (files the script couldn't handle, which I fix manually). The rule that saves me monthly: &lt;strong&gt;the script only reads from &lt;code&gt;inbox/&lt;/code&gt; and only writes to &lt;code&gt;work/&lt;/code&gt;&lt;/strong&gt;. No in-place edits, ever. If a run goes wrong, delete &lt;code&gt;work/&lt;/code&gt; and re-run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Extract the data from the text layer
&lt;/h3&gt;

&lt;p&gt;Most invoices are born-digital PDFs, so the vendor name, date, and total are sitting in the text layer. I pull them with &lt;code&gt;pdfplumber&lt;/code&gt; and a couple of regexes:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pdfplumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Total due[:\s]*\$?\s*([\d,]+\.\d{2})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&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;date&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d{4})-(\d{2})-(\d{2})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vendor names come from a small lookup dict I maintain (&lt;code&gt;{"ACME LOGISTICS": "acme-logistics", ...}&lt;/code&gt;) matched against the first lines of text. New supplier? The script doesn't guess — it flags the file for &lt;code&gt;review/&lt;/code&gt; and I add one line to the dict. That dict is boring and it's the most valuable part of the whole system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Build the stamp as its own tiny PDF
&lt;/h3&gt;

&lt;p&gt;Don't draw the stamp into the invoice with a graphics library page by page — generate one transparent stamp PDF (&lt;code&gt;RECEIVED 2026-10-05&lt;/code&gt;, red, slightly rotated) and reuse it as an overlay:&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;# stamp.py — run once per month with the received date
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reportlab.pdfgen&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reportlab.lib.colors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HexColor&lt;/span&gt;

&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stamp.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pagesize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStrokeColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HexColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#C0392B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFillColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HexColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#C0392B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLineWidth&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;212&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Helvetica-Bold&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCentredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RECEIVED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Helvetica&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawCentredString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-10-05&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;c&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Overlay and rename with pikepdf
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pikepdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Pdf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rectangle&lt;/span&gt;

&lt;span class="n"&gt;stamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Pdf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stamp.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&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;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;Pdf&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf&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;0&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="nf"&gt;add_overlay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Rectangle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;380&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;730&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;pdf&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;dst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# dst = work/2026-10-02_acme-logistics_1240.00.pdf
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Placement is a &lt;code&gt;Rectangle&lt;/code&gt; in the top-right of page 1 only — that's where every supplier leaves white space, and stamping all pages was overkill for an internal processing mark. (This is a bookkeeping stamp, not a legal one; anything that needs an audit trail gets handled in the accounting system, not burned into the PDF.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks — the review folder is the point
&lt;/h2&gt;

&lt;p&gt;Roughly a quarter of my invoices are scans: flat images, no text layer, the extractor returns nothing. Early on I tried to make the script "smart" about these. That was a mistake — silent misreads are worse than no reads. Now anything without a usable text layer, a missing total, or an unknown vendor goes straight to &lt;code&gt;review/&lt;/code&gt; untouched.&lt;/p&gt;

&lt;p&gt;For the scans I run one OCR pass — I use PDFLean's OCR tool for this, since the files are already leaving my machine nowhere else that month — then drop the OCR'd copies back into &lt;code&gt;inbox/&lt;/code&gt; and re-run the script. On a typical month that's 8–10 files, and the second pass usually clears all but one or two genuinely mangled scans (skewed, stamped over the total by the supplier's own stamp, that kind of thing). Those last ones I name by hand. It's fine. A workflow that handles 38 of 40 and clearly shows you the other 2 beats a workflow that "handles" all 40 and hides its guesses.&lt;/p&gt;

&lt;p&gt;Other failure modes worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locale formats.&lt;/strong&gt; A European supplier writes &lt;code&gt;1.240,00&lt;/code&gt;. My regex reads that as &lt;code&gt;1.240&lt;/code&gt;. The review pass catches it because the renamed total looks absurd against the invoice count — but only because I eyeball the list before sending. Keep the review pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credit notes&lt;/strong&gt; have negative totals and the word "Total" next to a positive number. I filename them with a &lt;code&gt;-credit&lt;/code&gt; suffix by hand; automating that distinction wasn't worth it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate submissions.&lt;/strong&gt; Same invoice sent twice by two people at the client. Identical file size + identical total is my cheap dedupe signal in the summary printout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I didn't automate
&lt;/h2&gt;

&lt;p&gt;The script ends by printing a summary: filename, vendor, total, one line per invoice. I read that list against the bank feed before anything goes to the bookkeeper. That's the 10 minutes. Everything before it — the opening, stamping, saving, renaming — was never judgment work, it was just slow. That's the line I try to draw in all my document workflows: automate the mechanical pass completely, and make the exceptions &lt;em&gt;louder&lt;/em&gt;, not quieter.&lt;/p&gt;

&lt;p&gt;Full script (minus my vendor dict) is about 120 lines. The monthly cost of running it is zero and one coffee, most of which I spend reading the summary anyway.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>python</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
