<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged pdf</title>
<link>https://coderlegion.com/tag/pdf</link>
<description>Powered by Question2Answer</description>
<item>
<title>Fine in Chrome, broken in Preview: two PDFKit gotchas for anyone who writes PDFs</title>
<link>https://coderlegion.com/32435/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs</link>
<description>&lt;p&gt;&lt;em&gt;This article was originally published on Hashnode: &lt;a rel=&quot;nofollow&quot; href=&quot;https://wangyan.hashnode.dev/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs&quot;&gt;Fine in Chrome, broken in Preview&lt;/a&gt;. It is republished here with light edits.&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; we make &lt;a rel=&quot;nofollow&quot; href=&quot;https://lensup.ai/pdf-editor/&quot;&gt;LensUp's PDF editor&lt;/a&gt;, the browser tool where we ran into both bugs, so we have a direct connection to the product mentioned here. This write-up was drafted and edited with AI assistance; the bugs, test results and reproduction scripts come from our own testing, and the scripts are public so you can check every claim yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We build a PDF editor that runs in the browser. It opens a PDF, lets you add text, a signature or a highlight, fill in form fields, and writes the result back as an incremental update. We checked every save with three renderers: pdf.js (Firefox and many web viewers), pdfium (Chrome) and poppler (most Linux tools). All three were happy.&lt;/p&gt;
&lt;p&gt;Then we rendered the same files with Apple's PDFKit, the engine behind Preview on the Mac, the Files app on iOS and PDFs in Safari. It showed two problems none of the others did: a translucent highlighter that turned into an opaque bar or a sentence that vanished, and form answers printed twice on top of each other.&lt;/p&gt;
&lt;p&gt;Neither problem shows up if you only test on Linux or in Chrome. Here are both, with small scripts that reproduce them on any Mac in a few seconds: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;github.com/ppop123/pdfkit-pitfalls&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;gotcha-1-an-incremental-update-must-use-the-same-cross-reference-form-as-the-file-it-updates&quot;&gt;Gotcha 1: an incremental update must use the same cross-reference form as the file it updates&lt;/h3&gt;
&lt;h4 id=&quot;some-background&quot;&gt;Some background&lt;/h4&gt;
&lt;p&gt;An incremental update appends to a PDF instead of rewriting it. The new and changed objects go at the end, followed by a new cross-reference section that points back to the previous one. That's how signed PDFs stay signed, and it's why saving an edit doesn't mean rewriting the whole file: only the edit is written.&lt;/p&gt;
&lt;p&gt;The cross-reference section comes in two forms. The classic one is an &lt;code&gt;xref&lt;/code&gt; table followed by a &lt;code&gt;trailer&lt;/code&gt;. PDF 1.5 added cross-reference streams (&lt;code&gt;/Type /XRef&lt;/code&gt;), which usually come with object streams (&lt;code&gt;/Type /ObjStm&lt;/code&gt;): many small non-stream objects compressed together into one stream.&lt;/p&gt;
&lt;p&gt;We opened files for incremental update with pdf-lib (the @cantoo/pdf-lib fork, which supports incremental updates) and saved with the defaults, which turn object streams on. So whatever the original file used, our update ended in an XRef stream, with our new dictionaries packed into an object stream.&lt;/p&gt;
&lt;h4 id=&quot;what-pdfkit-does-with-that&quot;&gt;What PDFKit does with that&lt;/h4&gt;
&lt;p&gt;Take a one-page PDF that ends in a classic &lt;code&gt;xref&lt;/code&gt; table and append a highlighter stroke: a yellow rectangle drawn through an ExtGState with 60% opacity and the Multiply blend mode. Written as a table-based update, it renders the same everywhere. Written as an XRef stream with the ExtGState inside an object stream, poppler still draws it correctly, but PDFKit shows an opaque yellow bar where the sentence was.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ppop123/pdfkit-pitfalls/main/xref-mismatch.png&quot; alt=&quot;The same highlight, appended four ways, rendered by Apple PDFKit and by poppler&quot;&gt;&lt;/p&gt;
&lt;p&gt;Render it with &lt;code&gt;CG_PDF_VERBOSE=1&lt;/code&gt; set, and CoreGraphics says why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;encountered unexpected object type: 7.
missing or invalid object number.
missing or invalid cross-reference stream.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It gives up on the update's XRef stream. Objects the update wrote out on their own are still found: the rectangle itself is drawn. Objects inside the update's object stream are not, and the ExtGState is one of them. Without it the rectangle is painted fully opaque, on top of the text.&lt;/p&gt;
&lt;p&gt;The reverse breaks too. Over an original that ends in an XRef stream, an update written with a classic table gets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;failed to find start of cross-reference section.
font `Helvetica-7098480789' not found in document.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This time it's the original's object stream that gets lost, and the font was in it. The highlight is there; the sentence is gone.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Original's last section &lt;/th&gt;&lt;th&gt;Update written as &lt;/th&gt;&lt;th&gt;PDFKit&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;XRef stream with object streams &lt;/td&gt;&lt;td&gt;loses the objects in the update's object stream&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;loses the objects in the original's object streams&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In our editor's checks, pdf.js, pdfium and poppler accepted all four combinations. That's exactly why a Linux-only test suite never caught it.&lt;/p&gt;
&lt;h4 id=&quot;the-fix&quot;&gt;The fix&lt;/h4&gt;
&lt;p&gt;Look at where the original's last &lt;code&gt;startxref&lt;/code&gt; points. If it lands on the keyword &lt;code&gt;xref&lt;/code&gt;, the last section is a table; otherwise it's a stream. Then write the update in the same form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// The form of the original's last cross-reference section: 'table' or 'stream'.
function xrefForm(bytes) {
  const tail = new TextDecoder('latin1').decode(bytes.subarray(Math.max(0, bytes.length - 2048)));
  const found = [...tail.matchAll(/startxref\s+(\d+)/g)];
  const offset = found.length ? Number(found[found.length - 1][1]) : NaN;
  if (!(offset &amp;gt;= 0 &amp;amp;&amp;amp; offset &amp;lt; bytes.length)) return 'table';
  let i = offset;
  while (i &amp;lt; bytes.length &amp;amp;&amp;amp; /\s/.test(String.fromCharCode(bytes[i]))) i += 1;
  return new TextDecoder('latin1').decode(bytes.subarray(i, i + 4)) === 'xref' ? 'table' : 'stream';
}

const doc = await PDFDocument.load(bytes, { forIncrementalUpdate: true });
// … edit …
const saved = await doc.save({ useObjectStreams: xrefForm(bytes) === 'stream' });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hybrid-reference files, whose trailer is a table that also points to an XRef stream, end in a table, so they count as &lt;code&gt;'table'&lt;/code&gt; here.&lt;/p&gt;
&lt;h3 id=&quot;gotcha-2-pdfkit-draws-filled-text-fields-itself&quot;&gt;Gotcha 2: PDFKit draws filled text fields itself&lt;/h3&gt;
&lt;h4 id=&quot;what-pdfkit-does&quot;&gt;What PDFKit does&lt;/h4&gt;
&lt;p&gt;A form field's look is supposed to come from its appearance stream (&lt;code&gt;/AP&lt;/code&gt;): a small content stream in which the writer draws the value. For a text field that has a value, PDFKit does something else. It draws the value (&lt;code&gt;/V&lt;/code&gt;) itself with CoreText, in the font named by the field's default appearance string (&lt;code&gt;/DA&lt;/code&gt;), and it drops the text-showing operators (&lt;code&gt;BT … Tj … ET&lt;/code&gt;) from the appearance stream. Everything else in the appearance stream it still paints, such as paths and Form XObjects.&lt;/p&gt;
&lt;p&gt;You can see the first half even with pdf-lib's own appearance. In the first row below, poppler draws pdf-lib's auto-sized Helvetica, while PDFKit ignores it and draws its own rendering of the value at a different size.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ppop123/pdfkit-pitfalls/main/field-double-draw.png&quot; alt=&quot;One filled text field with three kinds of appearance, rendered by Apple PDFKit and by poppler&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-that-bit-us&quot;&gt;Why that bit us&lt;/h4&gt;
&lt;p&gt;Our text engine shapes text itself (joining Persian and Arabic, ordering mixed-direction text, falling back between fonts) and draws the result as glyph outlines, so every reader shows exactly the same glyphs. In a field's appearance, those outlines are paths. PDFKit painted the paths and then drew &lt;code&gt;/V&lt;/code&gt; again with CoreText on top: two copies of every answer, as in the second row. On a real IRS W-9 the two copies didn't even line up, because PDFKit left-aligned its copy while our appearance was right-aligned.&lt;/p&gt;
&lt;p&gt;None of these stopped the second drawing: making the field read-only, setting &lt;code&gt;/NeedAppearances&lt;/code&gt; to false, removing the &lt;code&gt;/Tx BMC … EMC&lt;/code&gt; marked content, or changing the font or color in &lt;code&gt;/DA&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-1&quot;&gt;The fix&lt;/h4&gt;
&lt;p&gt;Write the value as text after all, just not in a real font: use a Type 3 font whose glyph procedures are the same outlines. PDFKit drops text-showing operators, Type 3 included, and draws the value once in its own rendering. pdf.js, pdfium and poppler draw the Type 3 glyphs, which are our outlines. That's the third row.&lt;/p&gt;
&lt;p&gt;The trade-off is that in Preview you get PDFKit's rendering of the value (its font from &lt;code&gt;/DA&lt;/code&gt;, its alignment), not yours. For us that was acceptable, since CoreText shapes Persian correctly.&lt;/p&gt;
&lt;p&gt;Two related notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dropdowns and list boxes are different.&lt;/strong&gt; PDFKit doesn't redraw them, so for choice fields we kept the outlines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Acrobat had its own problem with outlines.&lt;/strong&gt; Acrobat Reader drew our outline appearances in some forms but left the box blank in others (fields created by pdf-lib, with a &lt;code&gt;/MK&lt;/code&gt; background color). It drew the Type 3 version in both.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;bonus-gotcha-from-acrobat-reader-extensions-ur3-&quot;&gt;Bonus gotcha from Acrobat: Reader extensions (&lt;code&gt;/UR3&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Many official forms, the W-9 among them, carry usage rights (&lt;code&gt;/Perms /UR3&lt;/code&gt;, &quot;Reader extensions&quot;): a signature over the file as issued. Change the file and Acrobat Reader opens it with:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;This document enabled extended features in Adobe Acrobat Reader. The document has been changed since it was created and use of extended features is no longer available.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every save changes the file, so on save we now remove &lt;code&gt;/UR3&lt;/code&gt; and the older &lt;code&gt;/UR&lt;/code&gt; from &lt;code&gt;/Perms&lt;/code&gt; (and &lt;code&gt;/Perms&lt;/code&gt; itself if nothing else is left). A certification signature (&lt;code&gt;/DocMDP&lt;/code&gt;) stays. iText does the same thing with &lt;code&gt;removeUsageRights&lt;/code&gt;. To be clear about what we've checked: we saw the warning in Acrobat Reader, and we haven't re-tested the fixed files there yet.&lt;/p&gt;
&lt;h3 id=&quot;how-to-test-with-pdfkit-without-clicking-through-preview&quot;&gt;How to test with PDFKit without clicking through Preview&lt;/h3&gt;
&lt;p&gt;PDFKit is scriptable from Swift in about twenty lines: open the file with &lt;code&gt;PDFDocument&lt;/code&gt;, draw page 1 into a bitmap with &lt;code&gt;PDFPage.draw(with:to:)&lt;/code&gt; (form widgets included), and write a PNG. Run it with &lt;code&gt;CG_PDF_VERBOSE=1&lt;/code&gt; and CoreGraphics prints what it didn't like. The repo's &lt;code&gt;render-pdfkit.swift&lt;/code&gt; does exactly that. &lt;code&gt;qlmanage -t&lt;/code&gt;, the Quick Look thumbnailer, goes through PDFKit too.&lt;/p&gt;
&lt;p&gt;If you compare against pdfium from Python: pypdfium2 doesn't draw form fields unless you call &lt;code&gt;pdf.init_forms()&lt;/code&gt; first. Without it, every field looks empty, which is easy to mistake for a bug in your writer.&lt;/p&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Three renderers agreeing on a file doesn't mean the viewer on every Mac and iPhone will. If your PDFs reach Apple devices, render them with PDFKit, in CI on a macOS runner or at least by hand.&lt;/li&gt;
&lt;li&gt;In an incremental update, match the cross-reference form of the file you're updating.&lt;/li&gt;
&lt;li&gt;For a filled text field, PDFKit draws the value itself. Avoid drawing the value into the appearance as paths; text in a Type 3 font works everywhere we tested.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reproductions, in Node and Swift, are MIT-licensed: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;github.com/ppop123/pdfkit-pitfalls&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;About the author: the team behind this post builds &lt;a rel=&quot;nofollow&quot; href=&quot;https://lensup.ai/&quot;&gt;LensUp Scanner&lt;/a&gt;, a free set of browser tools for scanning documents and working with PDFs. If you have seen other PDFKit quirks, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;pdfkit-pitfalls repo&lt;/a&gt; is open to issues and pull requests.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32435/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs</guid>
<pubDate>Mon, 26 Oct 2026 00:16:01 +0000</pubDate>
</item>
<item>
<title>Designing a Clear Upload-to-Export Flow for Four Document Jobs</title>
<link>https://coderlegion.com/28621/designing-a-clear-upload-to-export-flow-for-four-document-jobs</link>
<description>&lt;p&gt;An upload control can look simple while hiding a product decision: what does the person want to accomplish with the file? A scanned page, a photo of a document, searchable text, and a spreadsheet-ready receipt are related, but they are not the same job. A useful interface makes those paths legible before the user starts.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://scanpdf.ai/&quot;&gt;ScanPDF AI&lt;/a&gt; presents four entry points—Scan PDF, Photo to PDF, OCR PDF, and Receipt to Excel. The interface gives us a concrete example of organizing around outcomes rather than asking everyone to start with one generic “upload” action.&lt;/p&gt;
&lt;h3 id=&quot;a-task-selector-is-part-of-the-product-model&quot;&gt;A task selector is part of the product model&lt;/h3&gt;
&lt;p&gt;Designing workflow labels means deciding which distinctions matter to users. For document work, the source file may be a PDF, a phone image, or a photo of a receipt. Yet the source alone does not tell us the desired result. Someone may want a portable document, extracted text, or structured transaction details.&lt;/p&gt;
&lt;p&gt;Putting those options near the start lets people choose by intent. It also helps a team keep each workflow’s promise narrow. A photo-to-PDF path can focus on document conversion. An OCR path can explain recognized-text exports. A receipt path can set expectations about fields and spreadsheet output.&lt;/p&gt;
&lt;h3 id=&quot;show-an-output-contract-before-upload&quot;&gt;Show an output contract before upload&lt;/h3&gt;
&lt;p&gt;The OCR page lists PDF, JPG, PNG, and WebP inputs and describes PDF, TXT, and DOCX exports. The receipt page describes PDF, TXT, and CSV output, and names merchant, date, total, tax, currency, and line items. This is a useful pattern: tell users what kind of artifact a workflow is intended to produce before they invest time in it.&lt;/p&gt;
&lt;p&gt;For product designers, the next improvement to look for is whether that contract remains visible after selection. Are supported formats near the file picker? Is the output type clear before a potentially lengthy upload? Does the result screen distinguish editable text from a formatted document? These are practical review questions, not assumptions about any one implementation.&lt;/p&gt;
&lt;h3 id=&quot;keep-recognized-separate-from-verified-&quot;&gt;Keep “recognized” separate from “verified”&lt;/h3&gt;
&lt;p&gt;OCR and extraction outputs can look authoritative because they are neatly formatted. That presentation should not imply perfect recognition. A receipt total might be confused with a subtotal; a date can be ambiguous; a low-quality image can omit a line. Products should make it easy to inspect extracted values and correct them before export.&lt;/p&gt;
&lt;p&gt;An example on a marketing page can explain the shape of the result, but it is not evidence of performance on arbitrary files. Teams evaluating a tool should test varied samples, including skewed photos, faded print, multiple currencies, and receipts with several tax or line-item formats. Review actual outputs before incorporating them into an operational workflow.&lt;/p&gt;
&lt;h3 id=&quot;design-for-the-whole-handoff&quot;&gt;Design for the whole handoff&lt;/h3&gt;
&lt;p&gt;The interaction does not end when a file is accepted. People need to know how the document will be processed, where the result appears, which export fits their next tool, and what they should verify. Good labels and clear output options can remove uncertainty without adding more controls.&lt;/p&gt;
&lt;p&gt;That is the broader lesson from these four paths: map the interface to the user’s next decision. When scanning, conversion, OCR, and structured extraction are presented as distinct jobs, the product can explain each one honestly—and users can pick the outcome they actually need.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28621/designing-a-clear-upload-to-export-flow-for-four-document-jobs</guid>
<pubDate>Fri, 25 Sep 2026 14:56:23 +0000</pubDate>
</item>
<item>
<title>Shipping Internal Tools: Why Build Automation Matters More Than Code Quality</title>
<link>https://coderlegion.com/27533/shipping-internal-tools-why-build-automation-matters-more-than-code-quality</link>
<description>&lt;p&gt;A practitioner's guide to shipping PDF tools and automation to non-technical users. Lessons from three years of production tooling at a legal process serving company.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-setup-why-this-matters&quot;&gt;The Setup: Why This Matters&lt;/h3&gt;
&lt;p&gt;I've been building PDF tools for a legal process serving company for three years. The most valuable lesson didn't come from Python—it came from learning that &lt;strong&gt;packaged tools are infrastructure, not applications&lt;/strong&gt;. The difference is profound.&lt;/p&gt;
&lt;p&gt;Most tutorials teach you to write a Python script and hand it to PyInstaller. This post teaches you something different: how internal tools actually ship in production, what trade-offs matter, and why the boring build script is where the real engineering lives.&lt;/p&gt;
&lt;p&gt;Last year, we shipped a batch PDF page extractor. Nothing fancy—extract the last page of a PDF, name it consistently, process an entire folder in one click. Our filing staff use it dozens of times daily.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Here's what surprised me:&lt;/strong&gt; the code quality barely matters. The build automation, UX design, and reproducibility matter infinitely more. A tool with mediocre code that ships, iterates, and doesn't break is better than perfect code that doesn't exist.&lt;/p&gt;
&lt;p&gt;This post walks through the real decisions: library choices with tradeoffs, packaging alternatives with honest pros/cons, and build infrastructure that scales from one tool to fifty.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-1-choosing-your-pdf-library-the-tradeoff-matrix&quot;&gt;Part 1: Choosing Your PDF Library — The Tradeoff Matrix&lt;/h3&gt;
&lt;p&gt;Most posts gloss over this. They'll say &quot;use PyMuPDF&quot; and move on. But for production tools, library choice cascades into build size, speed, and compatibility. Let's actually compare.&lt;/p&gt;
&lt;h4 id=&quot;pymupdf-fitz-&quot;&gt;PyMuPDF (fitz)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import fitz
doc = fitz.open(&quot;file.pdf&quot;)
new_doc = fitz.open()
new_doc.insert_pdf(doc, from_page=5, to_page=5)
new_doc.save(&quot;output.pdf&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;✓ Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fastest page extraction (C backend via SWIG)&lt;/li&gt;
&lt;li&gt;No re-encoding—copies raw page bytes, preserves quality&lt;/li&gt;
&lt;li&gt;Handles complex PDFs (annotations, forms, transparencies) better than pure Python&lt;/li&gt;
&lt;li&gt;Lowest memory footprint for large files&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;✗ Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;~25 MB added to .exe (largest of all options)&lt;/li&gt;
&lt;li&gt;Commercial licensing required for some use cases (read the terms for your scenario)&lt;/li&gt;
&lt;li&gt;Smaller community than PyPDF2&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Batch processing, large files, production tools where speed/reliability &amp;gt; size&lt;/p&gt;
&lt;h4 id=&quot;pypdf2&quot;&gt;PyPDF2&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from PyPDF2 import PdfReader, PdfWriter
reader = PdfReader(&quot;file.pdf&quot;)
writer = PdfWriter()
writer.add_page(reader.pages[0])
writer.write(&quot;output.pdf&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;✓ Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pure Python, no native dependencies&lt;/li&gt;
&lt;li&gt;Adds only ~3 MB to .exe&lt;/li&gt;
&lt;li&gt;Permissive BSD license&lt;/li&gt;
&lt;li&gt;Large, well-documented community&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;✗ Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Slower on large files (pages are re-encoded in Python)&lt;/li&gt;
&lt;li&gt;Poor handling of complex PDFs&lt;/li&gt;
&lt;li&gt;Higher memory use&lt;/li&gt;
&lt;li&gt;Recent versions have performance regression issues&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Simple PDFs, license-conscious projects, minimal file size&lt;/p&gt;
&lt;h4 id=&quot;pdfplumber&quot;&gt;pdfplumber&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pdfplumber
with pdfplumber.open(&quot;file.pdf&quot;) as pdf:
    page = pdf.pages[0]
    # Good for extraction/analysis, not for rewriting
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;✓ Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Designed for text/table extraction (excels here)&lt;/li&gt;
&lt;li&gt;Built on pdfminer.six + PyPDF2&lt;/li&gt;
&lt;li&gt;Cleaner API for reading&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;✗ Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Not meant for page manipulation&lt;/li&gt;
&lt;li&gt;Slower than PyMuPDF for batch operations&lt;/li&gt;
&lt;li&gt;Adds ~10 MB to .exe&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Data extraction, not page manipulation&lt;/p&gt;
&lt;h4 id=&quot;nuitka-compilation-not-a-library-&quot;&gt;Nuitka (Compilation, Not a Library)&lt;/h4&gt;
&lt;p&gt;Some will suggest compiling the entire thing with Nuitka instead of PyInstaller. Don't. For internal tools:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Nuitka compilation takes 20–60 seconds per build (vs. 5 seconds for PyInstaller)&lt;/li&gt;
&lt;li&gt;Final binary is only 5–10% smaller (marginal)&lt;/li&gt;
&lt;li&gt;Debugging is harder&lt;/li&gt;
&lt;li&gt;Not worth it unless you're shipping to thousands of users&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Use &lt;strong&gt;PyMuPDF&lt;/strong&gt; for production tools. The 25 MB is negligible on modern systems, and the speed/reliability gain is real.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-2-the-actual-tool-with-lessons-baked-in-&quot;&gt;Part 2: The Actual Tool (With Lessons Baked In)&lt;/h3&gt;
&lt;p&gt;Here's a production-ready extractor:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
last_page_extractor.py — Extract last pages from PDFs in bulk.

Modes:
  • Double-click in folder with PDFs → processes all .pdf files
  • Drag-drop files → processes only those files
  • Command line → same as drag-drop
&quot;&quot;&quot;

import sys
from pathlib import Path

try:
    import fitz
except ImportError:
    print(&quot;ERROR: pymupdf not installed. Install with: pip install pymupdf&quot;)
    sys.exit(1)


def extract_last_page(input_pdf: str) -&amp;gt; bool:
    &quot;&quot;&quot;Extract last page. Return True if successful.&quot;&quot;&quot;
    input_path = Path(input_pdf)
    
    if not input_path.exists():
        print(f&quot;  ✗ Not found: {input_pdf}&quot;)
        return False
    
    if not input_path.suffix.lower() == &quot;.pdf&quot;:
        print(f&quot;  ✗ Not PDF: {input_pdf}&quot;)
        return False
    
    try:
        doc = fitz.open(input_pdf)
        page_count = len(doc)
        
        if page_count == 0:
            print(f&quot;  ✗ Empty: {input_pdf}&quot;)
            doc.close()
            return False
        
        # Extract last page
        new_doc = fitz.open()
        new_doc.insert_pdf(doc, from_page=page_count - 1, to_page=page_count - 1)
        
        # Output: consistent naming
        output_path = input_path.parent / f&quot;{input_path.stem}_last_page.pdf&quot;
        new_doc.save(output_path)
        new_doc.close()
        doc.close()
        
        print(f&quot;  ✓ {input_path.name} ({page_count} pages)&quot;)
        return True
        
    except Exception as e:
        print(f&quot;  ✗ Error: {input_pdf}: {e}&quot;)
        return False


def process_folder() -&amp;gt; None:
    &quot;&quot;&quot;Batch mode: scan folder for all PDFs.&quot;&quot;&quot;
    cwd = Path.cwd()
    pdf_files = sorted(cwd.glob(&quot;*.pdf&quot;))
    
    if not pdf_files:
        print(f&quot;No PDFs found in: {cwd}&quot;)
        input(&quot;Press Enter to exit...&quot;)
        return
    
    print(f&quot;\nFound {len(pdf_files)} PDF(s). Processing...\n&quot;)
    
    success = sum(1 for f in pdf_files if extract_last_page(str(f)))
    
    print(f&quot;\n✓ {success}/{len(pdf_files)} successful&quot;)
    input(&quot;Press Enter to exit...&quot;)


def process_files(files: list) -&amp;gt; None:
    &quot;&quot;&quot;Drag-drop mode: process specific files.&quot;&quot;&quot;
    print(f&quot;\nProcessing {len(files)} file(s)...\n&quot;)
    success = sum(1 for f in files if extract_last_page(f))
    print(f&quot;\n✓ {success}/{len(files)} successful&quot;)


if __name__ == &quot;__main__&quot;:
    if len(sys.argv) == 1:
        process_folder()
    else:
        process_files(sys.argv[1:])
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;design-decisions-that-matter-&quot;&gt;Design decisions that matter:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Return boolean, not exit on error&lt;/strong&gt; — Allows batch processing to continue when one file fails&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consistent naming&lt;/strong&gt; — &lt;code&gt;_last_page&lt;/code&gt; suffix is predictable (staff can glob these later)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two modes baked in&lt;/strong&gt; — Same binary, context-aware behavior&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Output format&lt;/strong&gt; — Checkmarks + filename + page count. Users see what happened.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pause at end&lt;/strong&gt; — &lt;code&gt;input(&quot;Press Enter...&quot;)&lt;/code&gt; is intentional. Windows closes console windows instantly; this gives users a moment to see results.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-3-packaging-alternatives-beyond-pyinstaller-&quot;&gt;Part 3: Packaging Alternatives (Beyond PyInstaller)&lt;/h3&gt;
&lt;p&gt;Everyone reaches for PyInstaller. Here's why it's right for this use case—and when it isn't.&lt;/p&gt;
&lt;h4 id=&quot;pyinstaller-our-choice-&quot;&gt;PyInstaller (Our Choice)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pyinstaller --onefile --console last_page_extractor.py
# Creates: dist/last_page_extractor.exe (~35 MB)
# Build time: ~5 seconds
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Size:&lt;/strong&gt; 35 MB (PyMuPDF + Python + runtime)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speed:&lt;/strong&gt; Instant startup for small tools, 2–3 sec for complex ones&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debuggability:&lt;/strong&gt; Can inspect .exe with &lt;code&gt;--debug all&lt;/code&gt; if needed&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compatibility:&lt;/strong&gt; Works on Windows 7+; no C++ redistributables needed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;When to use:&lt;/strong&gt; Internal tools, small teams, iterative development. Build automation is frictionless.&lt;/p&gt;
&lt;h4 id=&quot;cx-freeze&quot;&gt;cx_Freeze&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cxfreeze --target-dir dist last_page_extractor.py
# Creates: dist/last_page_extractor.exe + libraries/ folder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Size:&lt;/strong&gt; Smaller .exe (~20 MB) but requires library folder&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Distribution:&lt;/strong&gt; Users get a folder, not one file (friction)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build time:&lt;/strong&gt; 8–12 seconds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debuggability:&lt;/strong&gt; Less mature than PyInstaller&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Never, honestly. The folder distribution kills the benefit of smaller size.&lt;/p&gt;
&lt;h4 id=&quot;auto-py-to-exe&quot;&gt;Auto-py-to-exe&lt;/h4&gt;
&lt;p&gt;This is a GUI wrapper around PyInstaller. Don't use for automation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; You can't script it, you can't version it, you can't integrate it into CI/CD. Build infrastructure needs to be reproducible code.&lt;/p&gt;
&lt;h4 id=&quot;nuitka-honest-assessment-&quot;&gt;Nuitka (Honest Assessment)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python -m nuitka --onefile last_page_extractor.py
# Creates: last_page_extractor.exe (~28 MB)
# Build time: 45–120 seconds (!!)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Size:&lt;/strong&gt; 5–10% smaller (~28 MB vs. 35 MB)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speed:&lt;/strong&gt; Compiled Python is faster, but doesn't matter for this workload&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build time:&lt;/strong&gt; 10–20x slower than PyInstaller&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debugging:&lt;/strong&gt; Harder to debug; less community resources&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Not worth it. The 7 MB savings costs you 100+ extra seconds per build. On your 50th build, you've wasted 1.5 hours.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Recommendation:&lt;/strong&gt; Use PyInstaller for anything under 100 MB final size or under 10 seconds build time. If you hit that limit, reconsider your approach—probably your tool is doing too much.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-4-build-automation-the-real-infrastructure-&quot;&gt;Part 4: Build Automation (The Real Infrastructure)&lt;/h3&gt;
&lt;p&gt;Here's the honest truth: &lt;strong&gt;the Python code is 20% of shipping. The build script is 80%.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most tutorials ignore this. They'll show you &lt;code&gt;pyinstaller --onefile&lt;/code&gt; and say &quot;now you have an exe.&quot; They don't mention:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What Python version the user has installed&lt;/li&gt;
&lt;li&gt;Whether PyInstaller is in their PATH&lt;/li&gt;
&lt;li&gt;Whether they're on a network drive (UNC paths break PyInstaller)&lt;/li&gt;
&lt;li&gt;What happens when their venv is stale&lt;/li&gt;
&lt;li&gt;How to version builds&lt;/li&gt;
&lt;li&gt;How to handle rebuilds without pollution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here's a production build script:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;&amp;lt;#
.SYNOPSIS
  Build last_page_extractor.py → last_page_extractor.exe
.DESCRIPTION
  Handles venv creation, dependency installation, PyInstaller build.
  Detects Python variant (py, python, python3).
  Handles UNC paths by staging to %LOCALAPPDATA%.
  Creates dist/last_page_extractor.exe.
#&amp;gt;

$ErrorActionPreference = &quot;Stop&quot;
$scriptDir = $PSScriptRoot

# ── UNC path handling (network drives cause PyInstaller to fail) ──────────
$isUNC = $scriptDir -match '^\\\\' -or $scriptDir -match '^//'
if ($isUNC) {
    $stageDir = Join-Path $env:LOCALAPPDATA &quot;lpe_build_berman&quot;
    Write-Host &quot;[UNC Path] Staging to local: $stageDir&quot;
    if (Test-Path $stageDir) { Remove-Item $stageDir -Recurse -Force }
    Copy-Item $scriptDir $stageDir -Recurse
    Set-Location $stageDir
} else {
    Set-Location $scriptDir
}

# ── Find Python (works with py, python, python3) ───────────────────────
$py = $null
foreach ($candidate in @(&quot;py -3&quot;, &quot;python&quot;, &quot;python3&quot;)) {
    try {
        $ver = &amp;amp; ([scriptblock]::Create(&quot;$candidate --version&quot;)) 2&amp;gt;&amp;amp;1
        if ($ver -match &quot;Python 3\.([8-9]|1[0-9])&quot;) { 
            $py = $candidate
            break 
        }
    } catch {}
}
if (-not $py) {
    Write-Host &quot;ERROR: Python 3.8+ not found&quot; -ForegroundColor Red
    exit 1
}
Write-Host &quot;Python: $py ($ver)&quot;

# ── Clean old build artifacts ─────────────────────────────────────────
if (Test-Path &quot;build&quot;) { Remove-Item &quot;build&quot; -Recurse -Force }
if (Test-Path &quot;dist&quot;) { Remove-Item &quot;dist&quot; -Recurse -Force }
if (Test-Path &quot;.venv&quot;) { Remove-Item &quot;.venv&quot; -Recurse -Force }

# ── Create venv ───────────────────────────────────────────────────────
Write-Host &quot;Creating venv...&quot;
&amp;amp; ([scriptblock]::Create(&quot;$py -m venv .venv&quot;))

$pip = Join-Path &quot;.venv&quot; &quot;Scripts\pip.exe&quot;
$pyVenv = Join-Path &quot;.venv&quot; &quot;Scripts\python.exe&quot;

# ── Install dependencies ─────────────────────────────────────────────
Write-Host &quot;Installing dependencies...&quot;
&amp;amp; $pip install --upgrade pip --quiet
&amp;amp; $pip install pymupdf pyinstaller --quiet
if ($LASTEXITCODE -ne 0) {
    Write-Host &quot;Dependency installation failed&quot; -ForegroundColor Red
    exit 1
}

# ── Build with PyInstaller ────────────────────────────────────────────
Write-Host &quot;Building exe...&quot;
&amp;amp; $pyVenv -m PyInstaller `
    --onefile `
    --console `
    --name last_page_extractor `
    --clean `
    last_page_extractor.py

if ($LASTEXITCODE -ne 0) {
    Write-Host &quot;PyInstaller build failed&quot; -ForegroundColor Red
    exit 1
}

# ── Verify and copy back ──────────────────────────────────────────────
$exe = Join-Path &quot;dist&quot; &quot;last_page_extractor.exe&quot;
if (-not (Test-Path $exe)) {
    Write-Host &quot;ERROR: .exe not found at $exe&quot; -ForegroundColor Red
    exit 1
}

Write-Host &quot;SUCCESS: Built $exe&quot; -ForegroundColor Green

if ($isUNC) {
    Copy-Item $exe (Join-Path $scriptDir &quot;last_page_extractor.exe&quot;) -Force
    Write-Host &quot;Copied to: $scriptDir\last_page_extractor.exe&quot;
}

Write-Host &quot;`nBuild complete. Ready to ship.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wrap in a &lt;code&gt;.cmd&lt;/code&gt; for non-technical users:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-batch&quot;&gt;@echo off
REM BuildExe.cmd — double-click to build
powershell -ExecutionPolicy Bypass -File &quot;%~dp0build_exe.ps1&quot;
pause
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-this-script-matters-&quot;&gt;Why this script matters:&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python detection&lt;/strong&gt; — Works whether user has py, python, or python3&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UNC path handling&lt;/strong&gt; — Network drives fail with PyInstaller; script detects and stages locally&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean builds&lt;/strong&gt; — Removes old artifacts each time (prevents stale builds)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version check&lt;/strong&gt; — Requires Python 3.8+ (PyMuPDF requirement)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error handling&lt;/strong&gt; — Fails loudly if any step breaks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One-click for users&lt;/strong&gt; — &lt;code&gt;BuildExe.cmd&lt;/code&gt; is trivial to run&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The script is 50 lines. The gain is automatic rebuilds without friction. That's the real shipping.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-5-learnings-from-three-years-of-shipping&quot;&gt;Part 5: Learnings from Three Years of Shipping&lt;/h3&gt;
&lt;h4 id=&quot;1-batch-ux-gt-code-quality&quot;&gt;1. Batch UX &amp;gt; Code Quality&lt;/h4&gt;
&lt;p&gt;A mediocre tool that processes a whole folder beats a beautiful tool that only works on one file. Users don't care about your code. They care about not doing it manually.&lt;/p&gt;
&lt;h4 id=&quot;2-the-build-script-is-your-insurance-policy&quot;&gt;2. The Build Script is Your Insurance Policy&lt;/h4&gt;
&lt;p&gt;When you find a bug, you fix the Python file. Your build script proves it works reproducibly. In three years, we've shipped bug fixes in &amp;lt;10 minutes because the build was automated. Without it? 30+ minutes of manual setup each time.&lt;/p&gt;
&lt;h4 id=&quot;3-console-output-is-richer-than-you-think&quot;&gt;3. Console Output is Richer Than You Think&lt;/h4&gt;
&lt;p&gt;We use &lt;code&gt;--console&lt;/code&gt; (not &lt;code&gt;--windowed&lt;/code&gt;) specifically so staff see output. Checkmarks (✓) vs crosses (✗) are scannable. File counts are proof the tool worked. &lt;code&gt;input(&quot;Press Enter...&quot;)&lt;/code&gt; at the end is the pause button—Windows closes console windows instantly; you need to force it to stay.&lt;/p&gt;
&lt;h4 id=&quot;4-venv-per-build-not-shared&quot;&gt;4. Venv Per Build, Not Shared&lt;/h4&gt;
&lt;p&gt;Some shops keep a single venv and rebuild from it. Don't. Create fresh each time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;5 extra seconds&lt;/li&gt;
&lt;li&gt;Guarantees clean isolation&lt;/li&gt;
&lt;li&gt;Catches hidden dependency issues&lt;/li&gt;
&lt;li&gt;Avoids &quot;works on mine&quot; errors&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;5-pyinstaller-fresh-venv-beats-all-alternatives&quot;&gt;5. PyInstaller + Fresh Venv Beats All Alternatives&lt;/h4&gt;
&lt;p&gt;We considered Nuitka, cx_Freeze, even .NET (C#). PyInstaller wins because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simplest to automate&lt;/li&gt;
&lt;li&gt;Fastest builds (under 10 seconds)&lt;/li&gt;
&lt;li&gt;Easiest to debug&lt;/li&gt;
&lt;li&gt;Smallest mental overhead&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Optimize elsewhere. Not here.&lt;/p&gt;
&lt;h4 id=&quot;6-output-naming-is-part-of-ux&quot;&gt;6. Output Naming is Part of UX&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;name&amp;gt;_last_page.pdf&lt;/code&gt; is predictable. Staff know what happened. If you shipped &lt;code&gt;output_1.pdf&lt;/code&gt;, they'd hate you. Boring naming is invisible—that's good.&lt;/p&gt;
&lt;h4 id=&quot;7-two-mode-ux-folder-files-is-invisible&quot;&gt;7. Two-Mode UX (Folder + Files) is Invisible&lt;/h4&gt;
&lt;p&gt;Same binary, no config, no menus. Double-click → scan folder. Drag files → process those. The code detects context (&lt;code&gt;sys.argv&lt;/code&gt; length) and does the right thing. Users see one tool with two behaviors. This is underrated engineering.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;part-6-scaling-to-a-tool-suite&quot;&gt;Part 6: Scaling to a Tool Suite&lt;/h3&gt;
&lt;p&gt;We've shipped 12+ tools on this foundation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Batch PDF splitters (first page, last page, even pages, ranges)&lt;/li&gt;
&lt;li&gt;Missouri county filing fee lookups (CSV + web scraping)&lt;/li&gt;
&lt;li&gt;Case.net metadata extractors (Selenium + PDF parsing)&lt;/li&gt;
&lt;li&gt;Court PACER integrations&lt;/li&gt;
&lt;li&gt;File renaming utilities&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;First tool:&lt;/strong&gt; invest in build script (one-time cost)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Second tool:&lt;/strong&gt; copy script, change filename&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tenth tool:&lt;/strong&gt; you have a template; new tools are 1–2 hours of work&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We've paid off the build script investment 12x over in dev time saved.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-complete-package&quot;&gt;The Complete Package&lt;/h3&gt;
&lt;p&gt;You need three files:&lt;/p&gt;
&lt;h4 id=&quot;last-page-extractor-py-100-lines-&quot;&gt;last_page_extractor.py (100 lines)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# (code from Part 2 above)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;build-exe-ps1-60-lines-&quot;&gt;build_exe.ps1 (60 lines)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-powershell&quot;&gt;# (script from Part 4 above)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;buildexe-cmd-4-lines-&quot;&gt;BuildExe.cmd (4 lines)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-batch&quot;&gt;@echo off
powershell -ExecutionPolicy Bypass -File &quot;%~dp0build_exe.ps1&quot;
pause
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;workflow&quot;&gt;Workflow&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Double-click &lt;code&gt;BuildExe.cmd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Wait 10 seconds&lt;/li&gt;
&lt;li&gt;Get &lt;code&gt;dist/last_page_extractor.exe&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Give to staff&lt;/li&gt;
&lt;li&gt;They double-click it in a folder of PDFs&lt;/li&gt;
&lt;li&gt;All last pages extract automatically&lt;/li&gt;
&lt;li&gt;They see results; they know it worked&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No terminal knowledge required anywhere.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-boring-infrastructure-wins&quot;&gt;Conclusion: Boring Infrastructure Wins&lt;/h3&gt;
&lt;p&gt;The software development world obsesses over code elegance, design patterns, and frameworks. But shipping tools to non-technical users teaches you something different: &lt;strong&gt;boring infrastructure beats clever code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A well-built tool with mediocre Python code ships faster, iterates faster, and scales better than a beautifully architected tool with no build automation.&lt;/p&gt;
&lt;p&gt;This is what separates teams that ship one thing and declare victory from teams that ship a suite of tools.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The formula:&lt;/strong&gt; Write the code. Automate the build. Iterate without friction. That's how you build infrastructure instead of toys.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What's your experience shipping tools to non-technical users? Where does your build automation fail?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27533/shipping-internal-tools-why-build-automation-matters-more-than-code-quality</guid>
<pubDate>Thu, 17 Sep 2026 13:51:38 +0000</pubDate>
</item>
<item>
<title>One report, rendered to HTML, PDF and Excel without a headless browser</title>
<link>https://coderlegion.com/27362/one-report-rendered-to-html-pdf-and-excel-without-a-headless-browser</link>
<description>&lt;p&gt;The ticket says &quot;let customers export their statement.&quot; You read it twice looking for the catch, because there doesn't seem to be one. Then the follow-ups arrive. It should look right on screen. It should print to a PDF with the column headers repeated on page two. And finance wants the same thing as a spreadsheet they can pivot, please, by Friday.&lt;/p&gt;
&lt;p&gt;Nobody volunteers for this ticket twice.&lt;/p&gt;
&lt;h3 id=&quot;the-three-code-paths-you-end-up-with&quot;&gt;The three code paths you end up with&lt;/h3&gt;
&lt;p&gt;The first instinct is to render HTML and print it. You already have the markup, Puppeteer will turn a page into a PDF, done by lunch. Then you containerise it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The image.&lt;/strong&gt; I ran &lt;code&gt;npm install puppeteer&lt;/code&gt; on this machine today: 29 MB of packages, plus a 372 MB Chrome download into the cache before anything renders. That is not a rounding error in a deployment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The race.&lt;/strong&gt; Your PDF is a screenshot of a page load, so it inherits every timing problem a page load has. Fonts that weren't ready. An image still in flight. A layout that settled one tick after the capture.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The second stylesheet.&lt;/strong&gt; Print CSS disagrees with screen CSS, and you now maintain both. Page numbers and running headers mean &lt;code&gt;@page&lt;/code&gt; margin boxes and a hopeful attitude.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So you skip the browser and reach for a PDF library instead. Now the bytes are yours, which sounds like a win until you notice what came with them: pagination, keeping a group heading with its first row, repeating a table header across a page break, embedding and subsetting a font. Typesetting, in other words. By hand, per report.&lt;/p&gt;
&lt;p&gt;And the spreadsheet is still a separate code path. Same columns, same totals, written twice. The two copies start drifting the week after they ship, usually in the direction of whichever one the customer looks at more.&lt;/p&gt;
&lt;p&gt;There's a fourth trap for anyone doing this in the browser. Plenty of template engines build their expressions with &lt;code&gt;new Function&lt;/code&gt; under the hood, which works fine right up to the security review that asks why &lt;code&gt;script-src&lt;/code&gt; needs &lt;code&gt;unsafe-eval&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;a-report-is-one-problem-not-four&quot;&gt;A report is one problem, not four&lt;/h3&gt;
&lt;p&gt;Those four headaches have one thing in common: the report itself never changed. The bands, the grouping, the totals, the columns are identical in all of them. Only the output differs.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://getquario.com&quot;&gt;quario&lt;/a&gt; is my attempt to put the shared part in one place. You write the report once as a JSON document, compile it once, and then pick an output at the call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { quario } from &quot;quario&quot;;
import { html } from &quot;@quario/html&quot;;

const schema = {
  data: &quot;$.orders[*]&quot;,
  detail: {
    columns: [
      { header: &quot;Product&quot;, value: &quot;{{ @.product }}&quot; },
      { header: &quot;Price&quot;, value: &quot;{{ @.price }}&quot; },
    ],
  },
};

const data = {
  orders: [
    { product: &quot;Desk&quot;, price: 250 },
    { product: &quot;Chair&quot;, price: 120 },
    { product: &quot;Lamp&quot;, price: 40 },
  ],
};

const report = quario().report(schema);
const page = await report.render(html(), data);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;page&lt;/code&gt; is a fragment you drop into your own shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&quot;q-report&quot;&amp;gt;
  &amp;lt;table class=&quot;q-table&quot;&amp;gt;
    &amp;lt;colgroup&amp;gt;&amp;lt;col&amp;gt;&amp;lt;col&amp;gt;&amp;lt;/colgroup&amp;gt;
    &amp;lt;thead&amp;gt;&amp;lt;tr&amp;gt;&amp;lt;th&amp;gt;Product&amp;lt;/th&amp;gt;&amp;lt;th&amp;gt;Price&amp;lt;/th&amp;gt;&amp;lt;/tr&amp;gt;&amp;lt;/thead&amp;gt;
    &amp;lt;tbody&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;Desk&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;250&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;Chair&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;120&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
      &amp;lt;tr&amp;gt;&amp;lt;td&amp;gt;Lamp&amp;lt;/td&amp;gt;&amp;lt;td&amp;gt;40&amp;lt;/td&amp;gt;&amp;lt;/tr&amp;gt;
    &amp;lt;/tbody&amp;gt;
  &amp;lt;/table&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing up front, because finding it out in paragraph nine is annoying: quario is commercial, source-available software. The evaluation build is free forever and does everything, it just stamps a watermark line on what it renders. More on the licence at the end.&lt;/p&gt;
&lt;h3 id=&quot;change-the-import-not-the-report&quot;&gt;Change the import, not the report&lt;/h3&gt;
&lt;p&gt;Keep that schema exactly as it is. Swap which target you hand to &lt;code&gt;render&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { pdf } from &quot;@quario/pdf&quot;;
import { xlsx } from &quot;@quario/xlsx&quot;;
import { csv } from &quot;@quario/csv&quot;;

await report.render(pdf(), data);   // 1,411 bytes, a paginated document
await report.render(xlsx(), data);  // 6,711 bytes, a real workbook
await report.render(csv(), data);   // &quot;Product,Price\nDesk,250\n...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those byte counts are from running it, not from a spec sheet. Render the PDF twice and you get the same bytes both times, which means your golden-file tests can compare the file instead of parsing it.&lt;/p&gt;
&lt;p&gt;quario does the typesetting itself. Pagination, keep-together, repeating table headers, font embedding and subsetting, a document outline built from your group tree. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/cantoo-scribe/pdf-lib&quot;&gt;&lt;code&gt;@cantoo/pdf-lib&lt;/code&gt;&lt;/a&gt; writes the final bytes. &lt;code&gt;npm install quario @quario/pdf&lt;/code&gt; puts 32 MB on disk and there is no browser in it.&lt;/p&gt;
&lt;p&gt;The same layout pass also drives the on-screen preview in the &lt;code&gt;&amp;lt;quario-viewer&amp;gt;&lt;/code&gt; element, so the preview breaks its pages exactly where the PDF does, instead of looking fine in the modal and wrong in the download.&lt;/p&gt;
&lt;h3 id=&quot;why-the-spreadsheet-gets-a-real-number&quot;&gt;Why the spreadsheet gets a real number&lt;/h3&gt;
&lt;p&gt;A cell's value isn't a string on its way through the engine. It's a token stream: literal runs, plus interpolations carrying the value &lt;em&gt;before&lt;/em&gt; it gets stringified or escaped. So &lt;code&gt;{{ @.price }}&lt;/code&gt; reaches the XLSX target as the number 250, and reaches the HTML target as escaped text.&lt;/p&gt;
&lt;p&gt;Unzip that workbook and look at the sheet:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;c r=&quot;A3&quot; s=&quot;2&quot; t=&quot;s&quot;&amp;gt;&amp;lt;v&amp;gt;3&amp;lt;/v&amp;gt;&amp;lt;/c&amp;gt;
&amp;lt;c r=&quot;B3&quot; s=&quot;2&quot;&amp;gt;&amp;lt;v&amp;gt;250&amp;lt;/v&amp;gt;&amp;lt;/c&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;A3&lt;/code&gt; is the product name, carrying &lt;code&gt;t=&quot;s&quot;&lt;/code&gt; for a shared string. &lt;code&gt;B3&lt;/code&gt; has no type attribute at all, which in OOXML means a number. Excel will sum that column. You wrote one column definition and both targets did the correct, different thing with it.&lt;/p&gt;
&lt;p&gt;So if you want a typed cell, interpolate the bare value and let the target format it. &lt;code&gt;{{ money(@.price) }}&lt;/code&gt; is a string everywhere, by your own request.&lt;/p&gt;
&lt;p&gt;Two asymmetries you'll notice eventually, both deliberate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CSV prefixes formula-shaped text with an apostrophe.&lt;/strong&gt; A product literally named &lt;code&gt;=SUM(A1)&lt;/code&gt; comes out as &lt;code&gt;'=SUM(A1)&lt;/code&gt;. A CSV field has no way to say &quot;this is inert text,&quot; so the guard goes in the data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XLSX doesn't.&lt;/strong&gt; It writes that same value as a shared string, which &lt;em&gt;is&lt;/em&gt; inert, so no guard is needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Different behaviour, same reason: use the format's own mechanism when it has one.&lt;/p&gt;
&lt;h3 id=&quot;reports-are-data-not-code&quot;&gt;Reports are data, not code&lt;/h3&gt;
&lt;p&gt;Because the report is plain JSON, it's an ordinary value in your system. You can store it in a row, diff it in a pull request, send it between services, and hand it to a visual designer that's just another consumer of the same schema. &lt;code&gt;&amp;lt;quario-editor&amp;gt;&lt;/code&gt; is exactly that: it edits the band structure and gives you back the document.&lt;/p&gt;
&lt;p&gt;Reading any report definition takes three syntaxes:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Syntax &lt;/th&gt;&lt;th&gt;Means&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;&quot;{{ @.product }}&quot;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;A &lt;strong&gt;template&lt;/strong&gt;, a text cell's value. Interpolations are always escaped.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;&quot;=@.price * @.qty&quot;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;An &lt;strong&gt;expression&lt;/strong&gt;. A leading &lt;code&gt;=&lt;/code&gt; on any other property makes it dynamic.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;&quot;sum:=@.price&quot;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;A &lt;strong&gt;reducer spec&lt;/strong&gt;, an aggregate: a reducer name plus an expression.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And four anchors those expressions can reach: &lt;code&gt;@&lt;/code&gt; is the current row, &lt;code&gt;$&lt;/code&gt; is the report root, a named group handle like &lt;code&gt;region&lt;/code&gt; carries &lt;code&gt;.key&lt;/code&gt; plus its own aggregates, and &lt;code&gt;run.&amp;lt;name&amp;gt;&lt;/code&gt; holds running totals on a detail row.&lt;/p&gt;
&lt;p&gt;That's the whole vocabulary. A grouped sales report with subtotals, a grand total and your own &lt;code&gt;money()&lt;/code&gt; formatter is those same four ideas, just more of them.&lt;/p&gt;
&lt;h3 id=&quot;every-target-uses-the-same-seam&quot;&gt;Every target uses the same seam&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;report.stream(data)&lt;/code&gt; gives you the raw event stream, and it is ordered the way you'd read the report out loud: &lt;code&gt;report-start&lt;/code&gt;, header items, then groups and detail rows (&lt;code&gt;group-start&lt;/code&gt;, &lt;code&gt;table-start&lt;/code&gt;, &lt;code&gt;row&lt;/code&gt;, &lt;code&gt;total-row&lt;/code&gt;, &lt;code&gt;table-end&lt;/code&gt;, &lt;code&gt;group-end&lt;/code&gt;), then footers, then &lt;code&gt;report-end&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A target is &lt;code&gt;{ name, compile }&lt;/code&gt; and nothing else. The shipped ones get no privileged access to the engine, which is what makes an official target and one you wrote yourself indistinguishable to it.&lt;/p&gt;
&lt;p&gt;The repo carries the proof as a tested example: a complete Markdown target in 55 lines of code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export function markdown() {
  return {
    name: &quot;markdown&quot;,
    compile: (stream) =&amp;gt; async (data) =&amp;gt; {
      let out = &quot;&quot;;
      await walk(stream(data), {
        item: (e) =&amp;gt; { out += inline(e) + &quot;\n&quot;; },
        &quot;table-start&quot;: (e) =&amp;gt; {
          out += cells(e.header.cells) + &quot;\n&quot;;
          out += &quot;| &quot; + e.columns.map(() =&amp;gt; &quot;---&quot;).join(&quot; | &quot;) + &quot; |\n&quot;;
        },
        row: (e) =&amp;gt; { out += cells(e.cells) + &quot;\n&quot;; },
      });
      return out;
    },
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run the grouped sales example through it and you get GFM pipe tables, subtotals and all. If your company's output is a fixed-width bank file or an ancient EDI format, that's the shape of the work.&lt;/p&gt;
&lt;h3 id=&quot;nothing-here-compiles-to-javascript&quot;&gt;Nothing here compiles to JavaScript&lt;/h3&gt;
&lt;p&gt;Expressions and templates parse to closures. Every operator and every interpolation is a small function that already exists in the shipped source, and the parser wires them together. There's no &lt;code&gt;eval&lt;/code&gt; and no &lt;code&gt;new Function&lt;/code&gt; anywhere in the stack, so &lt;code&gt;script-src&lt;/code&gt; never needs &lt;code&gt;unsafe-eval&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That's enforced three ways: the unit suites run under Node's &lt;code&gt;--disallow-code-generation-from-strings&lt;/code&gt;, a per-package source scan fails the build if the strings even appear, and Playwright harnesses load the published files under a strict CSP in a real browser.&lt;/p&gt;
&lt;p&gt;The three primitives underneath are separately published, and all three are open source: &lt;a rel=&quot;nofollow&quot; href=&quot;/post/let-users-write-rules-without-letting-them-write-javascript&quot;&gt;xprsn&lt;/a&gt; for expressions, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/robinvdvleuten/sjabloon&quot;&gt;sjabloon&lt;/a&gt; for templates, and &lt;a rel=&quot;nofollow&quot; href=&quot;/post/a-jsonpath-engine-that-follows-the-spec-and-cant-run-code&quot;&gt;padvinder&lt;/a&gt; for RFC 9535 JSONPath. I wrote those up separately, because each one turned out to be a post of its own.&lt;/p&gt;
&lt;p&gt;One honest caveat. &quot;CSP-safe&quot; here means no string ever becomes code. It does not mean every directive is happy: conditional formatting emits inline &lt;code&gt;style&lt;/code&gt; attributes, so a restrictive &lt;code&gt;style-src&lt;/code&gt; has to allow them.&lt;/p&gt;
&lt;p&gt;Escaping has one home, too. The stream is never markup-escaped, and the HTML target escapes every interpolated value plus every class, style and attribute value it emits. There's no &lt;code&gt;{{{ raw }}}&lt;/code&gt; syntax to reach for, because it doesn't exist and asking for it is a definition error. The line quario draws: it trusts report definitions and registered functions, and it does not trust their data.&lt;/p&gt;
&lt;h3 id=&quot;the-total-that-wasn-t-a-floating-point-bug&quot;&gt;The total that wasn't a floating point bug&lt;/h3&gt;
&lt;p&gt;A receipt total came out of the workbook as &lt;code&gt;83.75999999999999&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I knew that number on sight, and I was wrong about it. The obvious suspect was drift accumulating inside &lt;code&gt;sum:&lt;/code&gt;, so I went looking at the aggregate. Every aggregate was exact. Kahan summation would have fixed nothing at all.&lt;/p&gt;
&lt;p&gt;The drift was in the cell, in the plain addition the author had written: &lt;code&gt;72 + 2.52 + 9.24&lt;/code&gt;. Try it in a REPL and you'll get the same thing. The actual defect wasn't arithmetic, it was that the person writing the report had no declarative way to say &quot;two decimals, please.&quot;&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;round&lt;/code&gt;, &lt;code&gt;floor&lt;/code&gt;, &lt;code&gt;ceil&lt;/code&gt; and &lt;code&gt;abs&lt;/code&gt; went in as built-in scalar functions. Both of the one-liners everyone reaches for get a different case wrong:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;Math.round(1.005 * 100) / 100;  // =&amp;gt; 1     (want 1.01)
Number((2.675).toFixed(2));     // =&amp;gt; 2.67  (want 2.68)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These functions round the decimal the author actually wrote, through &lt;code&gt;Intl.NumberFormat&lt;/code&gt;'s &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode&quot;&gt;&lt;code&gt;roundingMode&lt;/code&gt;&lt;/a&gt;, which is the one place in the platform that reasons about decimals instead of doubles.&lt;/p&gt;
&lt;h3 id=&quot;what-it-deliberately-isn-t&quot;&gt;What it deliberately isn't&lt;/h3&gt;
&lt;p&gt;The non-goals are in the spec on purpose, and they're the part I'd read first if I were you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Not a query tool.&lt;/strong&gt; &lt;code&gt;data&lt;/code&gt; is a JSONPath into the object you pass in. There's no database connector and no SQL, so it renders whatever you hand it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No charts, no barcodes.&lt;/strong&gt; Deferred rather than declined. Both would compile down to the existing image item. Today you rasterize with your own tool and pass the bytes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No hyperlinks.&lt;/strong&gt; Declined, not deferred. Only one of five targets could honour it, and it is the only declaration whose failure mode is a security question, namely a &lt;code&gt;javascript:&lt;/code&gt; URL in a slot an author fills.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row groups only.&lt;/strong&gt; No crosstabs, no matrix, no column groups. A second grouping axis isn't there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No subreports, table of contents or drill-down.&lt;/strong&gt; Those want runtime state or forward references that a pagination-agnostic core doesn't carry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not a WYSIWYG page designer.&lt;/strong&gt; The editor edits band structure, not pixels. Bands stack, items stack, tables have columns, and there is no free positioning.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's also 0.9.0 today. The plan is to freeze the authoring surface at 1.0, the document plus the semantics you can observe without knowing which target you're rendering to. That freeze isn't in effect yet, so a breaking authoring change right now is an ordinary, named, breaking change.&lt;/p&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install quario @quario/html
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swap &lt;code&gt;@quario/html&lt;/code&gt; for &lt;code&gt;@quario/pdf&lt;/code&gt;, &lt;code&gt;@quario/xlsx&lt;/code&gt;, &lt;code&gt;@quario/csv&lt;/code&gt; or &lt;code&gt;@quario/docx&lt;/code&gt;, or install several. Each target declares the engine as a peer dependency, so your app carries one copy of it. Everything is ESM-only and wants Node 22 or newer, and there's no build step in any package: they publish plain JavaScript with hand-written type declarations.&lt;/p&gt;
&lt;p&gt;The evaluation licence is free, needs no key and has no time limit, and it watermarks whatever it renders. A paid seat takes the watermark off. Whichever you end up on, the check runs offline through WebCrypto: it never phones home, never throws and never gates a feature. &lt;a rel=&quot;nofollow&quot; href=&quot;https://getquario.com/pricing&quot;&gt;The pricing page&lt;/a&gt; has the terms.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://getquario.com/docs/getting-started&quot;&gt;getting started guide&lt;/a&gt; builds an invoice from scratch, subtotals and all, and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://getquario.com/docs/diving-deeper/scopes&quot;&gt;scopes page&lt;/a&gt; is the one to read next.&lt;/p&gt;
&lt;h3 id=&quot;closing-thoughts&quot;&gt;Closing thoughts&lt;/h3&gt;
&lt;p&gt;Most of &quot;make it a PDF&quot; turned out to have nothing to do with PDFs. It was about writing the report down somewhere a second renderer could read it.&lt;/p&gt;
&lt;p&gt;Once the definition was data instead of code, the spreadsheet stopped being a rewrite and became another reader of the same document. The Markdown target took an afternoon. What actually cost me weeks was deciding what a &lt;code&gt;width&lt;/code&gt; or a &lt;code&gt;size&lt;/code&gt; should mean when one target has a stylesheet, one has a fixed page, and one has no styling model at all. A target is allowed to approximate what you declared. It is not allowed to resolve it against something you never declared.&lt;/p&gt;
&lt;p&gt;If you're maintaining two copies of the same report right now, one for the screen and one for the export, I'd like to hear how far apart they've drifted.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27362/one-report-rendered-to-html-pdf-and-excel-without-a-headless-browser</guid>
<pubDate>Wed, 16 Sep 2026 07:04:07 +0000</pubDate>
</item>
<item>
<title>Magics PDF Converter</title>
<link>https://coderlegion.com/26169/magics-pdf-converter</link>
<description>&lt;p&gt;Experience the next generation of document management with Magics PDF PRO — the powerful, all-in-one desktop PDF Editor, 15-Format Universal Converter, AI OCR Scanner, and Military-Grade AES-256 Cyber Vault Security Suite.&lt;/p&gt;
&lt;p&gt;Designed with a strict 100% Local-First architecture, Magics PDF PRO processes all your sensitive documents, financial reports, and confidential files directly on your computer. Your files never leave your PC and are never uploaded to third-party cloud servers.&lt;/p&gt;
&lt;p&gt;═══════════════════════════════════════════&lt;br&gt;
FREE TRIAL LIMITATIONS &amp;amp; PRO PRICING DISCLOSURE&lt;br&gt;
═══════════════════════════════════════════&lt;br&gt;
• Free Evaluation Tier: Free download includes unlimited PDF viewing and reading, plus 1 free trial attempt for each sensitive tool (1 free 15-format document conversion, 1 free Cyber Vault encryption, 1 free Cyber Vault decryption, 1 free AI OCR text extraction, 1 free smart redaction, and 1 free page manipulation).&lt;/p&gt;
&lt;p&gt;═══════════════════════════════════════════&lt;br&gt;
KEY CAPABILITIES &amp;amp; CORE FEATURES&lt;br&gt;
═══════════════════════════════════════════&lt;/p&gt;
&lt;p&gt;🛡️ CYBER VAULT — MILITARY-GRADE AES-256 ENCRYPTION &amp;amp; DECRYPTION&lt;br&gt;
• Lock and secure confidential PDF documents with unbreakable AES-256-CBC encryption and PBKDF2 key derivation.&lt;br&gt;
• Instant one-click decryption with authorized password access.&lt;br&gt;
• Protect intellectual property, legal contracts, and personal data from unauthorized access, leaks, and ransomware.&lt;/p&gt;
&lt;p&gt;🔄 UNIVERSAL 15-FORMAT DOCUMENT CONVERTER&lt;br&gt;
• PDF to Word (DOCX / DOC) &amp;amp; Word to PDF&lt;br&gt;
• PDF to Excel (XLSX / CSV) &amp;amp; Excel to PDF&lt;br&gt;
• PDF to PowerPoint (PPTX) &amp;amp; PPTX to PDF&lt;br&gt;
• PDF to High-Resolution Images (PNG, JPEG, BMP, TIFF, SVG, WebP)&lt;br&gt;
• PDF to Structured Data (HTML5, XML, RTF, Plain Text)&lt;/p&gt;
&lt;p&gt;🤖 LOCAL AI OPTICAL CHARACTER RECOGNITION (OCR)&lt;br&gt;
• Extract editable text from scanned PDFs, receipts, book pages, and images.&lt;br&gt;
• High-accuracy local neural OCR processing without internet connection.&lt;br&gt;
• Export recognized text directly into editable formats.&lt;/p&gt;
&lt;p&gt;✏️ FULL PDF EDITING &amp;amp; PAGE MANIPULATION&lt;br&gt;
• Merge multiple PDF documents into a single organized file.&lt;br&gt;
• Split multi-page PDFs into separate chapters or individual pages.&lt;br&gt;
• Rotate, reorder, delete, and extract specific pages.&lt;br&gt;
• Compress and optimize large PDF file sizes without losing visual quality.&lt;/p&gt;
&lt;p&gt;🔒 SMART REDACTION &amp;amp; SECURITY PERMISSIONS&lt;br&gt;
• Permanently redact and black out sensitive numbers, names, and credit card data.&lt;/p&gt;
&lt;p&gt;⚡ ULTRA-FAST NATIVE 64-BIT PERFORMANCE&lt;br&gt;
• Built with native 64-bit C++ architecture and Google PDFium rendering core.&lt;br&gt;
• Smooth multi-tab viewing, continuous vertical scroll, and instant search.&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/26169/magics-pdf-converter</guid>
<pubDate>Thu, 03 Sep 2026 20:13:29 +0000</pubDate>
</item>
<item>
<title>Making a screenshot PDF searchable — no OCR, because we rendered the page</title>
<link>https://coderlegion.com/24991/making-a-screenshot-pdf-searchable-no-ocr-because-we-rendered-the-page</link>
<description>&lt;p&gt;If you generate PDFs from web pages by screenshotting them, you've probably hit this: the PDF looks perfect and is completely useless the moment you want to &lt;em&gt;use&lt;/em&gt; the text. &lt;code&gt;Ctrl+F&lt;/code&gt; finds nothing. You can't copy a sentence. A screen reader opens the document and sees one big image on an otherwise empty page.&lt;/p&gt;
&lt;p&gt;We archive whole pages this way — a full-height screenshot dropped onto a PDF page — so we had exactly this problem. Here's how we gave those PDFs a real, invisible, &lt;strong&gt;searchable&lt;/strong&gt; text layer with &lt;a rel=&quot;nofollow&quot; href=&quot;https://pdf-lib.js.org/&quot;&gt;&lt;code&gt;pdf-lib&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;@pdf-lib/fontkit&lt;/code&gt;, and the one part that turned out to be genuinely hard.&lt;/p&gt;
&lt;p&gt;The trick is the same one a &quot;searchable scan&quot; uses: draw the real text &lt;strong&gt;invisibly&lt;/strong&gt; over the image, at the exact coordinates where each word appears. The difference is that a scanner needs OCR to guess the text — we &lt;em&gt;rendered the page ourselves&lt;/em&gt;, so we already have the ground truth. No OCR, no guessing.&lt;/p&gt;
&lt;h3 id=&quot;the-shape-of-it&quot;&gt;The shape of it&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;While the page is still open in the headless browser, ask the DOM where every word is.&lt;/li&gt;
&lt;li&gt;Assemble the PDF: embed the screenshot as the page background.&lt;/li&gt;
&lt;li&gt;For each word, &lt;code&gt;drawText&lt;/code&gt; it at its coordinates with &lt;code&gt;opacity: 0&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps 1 and 3 are easy. The trap is in &lt;em&gt;which&lt;/em&gt; words you're allowed to draw.&lt;/p&gt;
&lt;h3 id=&quot;step-1-ask-the-browser-where-the-words-are&quot;&gt;Step 1 — ask the browser where the words are&lt;/h3&gt;
&lt;p&gt;Running inside the page (Puppeteer's &lt;code&gt;page.evaluate&lt;/code&gt;), walk every text node and measure each word with a &lt;code&gt;Range&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
// ...for each word in each text node:
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const rects = range.getClientRects();
if (!rects.length) continue;           // display:none or empty line box
const b = rects[0];                    // first rect = where the word starts
out.push({
  t: word,
  x: b.left + window.scrollX,          // document coordinates, not viewport
  y: b.top  + window.scrollY,
  w: b.width, h: b.height,
  fs: parseFloat(getComputedStyle(el).fontSize) || 12,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;getClientRects()&lt;/code&gt; returns viewport coordinates, so we add &lt;code&gt;scrollX/scrollY&lt;/code&gt; to get &lt;strong&gt;document&lt;/strong&gt; coordinates — the ones that line up with a full-page screenshot. A word wrapped across two lines returns several rects; the first is where a search should land, so that's the one we keep.&lt;/p&gt;
&lt;h3 id=&quot;step-2-the-hidden-text-trap-the-hard-part-&quot;&gt;Step 2 — the hidden-text trap (the hard part)&lt;/h3&gt;
&lt;p&gt;The naive version above shipped a bug immediately. On a real page, search started highlighting words that &lt;strong&gt;weren't in the picture&lt;/strong&gt; — items inside a collapsed dropdown, off-canvas nav, &lt;code&gt;text-indent: -9999px&lt;/code&gt; screen-reader text. The image showed them collapsed; the text layer had them anyway.&lt;/p&gt;
&lt;p&gt;The tempting assumption is &quot;hidden text has no client rects.&quot; It's false. Only &lt;code&gt;display: none&lt;/code&gt; produces no rects. Every &lt;em&gt;other&lt;/em&gt; way of hiding something still reports perfectly good coordinates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;visibility: hidden&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;opacity: 0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max-height: 0&lt;/code&gt; + &lt;code&gt;overflow: hidden&lt;/code&gt; (the collapsed accordion / submenu)&lt;/li&gt;
&lt;li&gt;parked offscreen with &lt;code&gt;text-indent: -9999px&lt;/code&gt; or a negative position&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So each word has to be checked against its &lt;strong&gt;whole ancestor chain&lt;/strong&gt;, not just its own box. Memoise one walk up the tree per element (a deep DOM would otherwise re-read computed styles thousands of times):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function chainInfo(el) {
  if (cache.has(el)) return cache.get(el);
  const above = el.parentElement ? chainInfo(el.parentElement)
                                 : { visible: true, opacity: 1, clips: [] };
  let info;
  if (!above.visible) {
    info = { visible: false, opacity: 0, clips: above.clips };
  } else {
    const cs      = getComputedStyle(el);
    const opacity = above.opacity * (parseFloat(cs.opacity) || 0);   // multiplied down the chain
    const visible = cs.visibility === 'visible' &amp;amp;&amp;amp; opacity &amp;gt; 0.05;
    const clips   = /\b(hidden|clip)\b/.test(cs.overflow)
      ? above.clips.concat([el.getBoundingClientRect()])
      : above.clips;
    info = { visible, opacity, clips };
  }
  cache.set(el, info);
  return info;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A word survives only if its chain is visible, its multiplied opacity is above a floor, it &lt;strong&gt;intersects&lt;/strong&gt; every clipping ancestor (a zero-height collapsed container clips its contents to nothing), and it isn't parked entirely outside the document box. Clipping is judged on &lt;em&gt;zero&lt;/em&gt; intersection, not partial — a word half-cut by a container is still half in the picture, and dropping it would lose real text.&lt;/p&gt;
&lt;p&gt;That single ancestor-walk is the difference between &quot;searchable PDF&quot; and &quot;PDF that lies about what's in it.&quot;&lt;/p&gt;
&lt;h3 id=&quot;step-3-flip-the-y-axis&quot;&gt;Step 3 — flip the Y axis&lt;/h3&gt;
&lt;p&gt;The DOM measures from the top-left; PDF space is bottom-left. So when we draw, Y inverts, plus a small nudge so the selection highlight sits on the glyphs rather than below them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const y = imageH - b.y - b.h + b.h * 0.2;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-4-the-font-problem&quot;&gt;Step 4 — the font problem&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;pdf-lib&lt;/code&gt;'s built-in fonts are &lt;strong&gt;WinAnsi&lt;/strong&gt;. They can't encode Cyrillic, Greek or CJK — and they even choke on curly typographic quotes, of which there are four on our own English blog. Draw an unencodable glyph and the call throws.&lt;/p&gt;
&lt;p&gt;So embed a real TrueType font, &lt;strong&gt;subset to only the glyphs actually used&lt;/strong&gt;, via fontkit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fontkit = require('@pdf-lib/fontkit');
doc.registerFontkit(fontkit);
const font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;subset: true&lt;/code&gt; is what keeps the cost near zero — only the handful of glyphs on the page get embedded, not the whole 300 KB face. We probe a small candidate list and take the first that exists on the box (DejaVu Sans on our Linux servers covers Latin + Cyrillic + Greek; Arial/Segoe locally). If none is found, we ship the image &lt;strong&gt;without&lt;/strong&gt; a text layer rather than failing the capture — losing search is not losing the archive.&lt;/p&gt;
&lt;h3 id=&quot;step-5-draw-it-invisibly-and-don-t-let-one-glyph-kill-the-archive&quot;&gt;Step 5 — draw it invisibly, and don't let one glyph kill the archive&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;for (const b of boxes) {
  const y = imageH - b.y - b.h + b.h * 0.2;
  try {
    pdfPage.drawText(b.t, { x: b.x, y, size: b.fs, font, opacity: 0 });
    drawn++;
  } catch (e) {
    skipped++;   // a glyph even this font can't encode — skip the word, keep the PDF
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;opacity: 0&lt;/code&gt; is the whole point: the image is what you see, the text is what you search. And the per-word &lt;code&gt;try/catch&lt;/code&gt; matters — a single exotic glyph should cost you one unsearchable word, not the entire multi-page document.&lt;/p&gt;
&lt;h3 id=&quot;did-it-actually-work-&quot;&gt;Did it actually work?&lt;/h3&gt;
&lt;p&gt;Measured on a Cyrillic page, before/after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;626 of 626&lt;/strong&gt; visible words got coordinates and a text entry&lt;/li&gt;
&lt;li&gt;file grew &lt;strong&gt;7086 KB → 7147 KB — +0.9%&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/ToUnicode&lt;/code&gt; is present in the output, so the text is genuinely searchable &lt;em&gt;and&lt;/em&gt; copyable, not just present&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That +0.9% buys you &lt;code&gt;Ctrl+F&lt;/code&gt;, copy-paste, and a document a screen reader can actually read — on what is still, pixel for pixel, an exact image of the page.&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24991/making-a-screenshot-pdf-searchable-no-ocr-because-we-rendered-the-page</guid>
<pubDate>Thu, 20 Aug 2026 13:13:18 +0000</pubDate>
</item>
<item>
<title>How to Convert Word to PDF in the Browser with Vue 3, mammoth, and html2pdf.js</title>
<link>https://coderlegion.com/20522/how-to-convert-word-to-pdf-in-the-browser-with-vue-3-mammoth-and-html2pdf-js</link>
<description>&lt;p&gt;Converting Word documents to PDFs on the server is the classic approach: upload the file, run LibreOffice or a cloud API, send the result back. But that means your users’ resumes, contracts, and reports touch your infrastructure.&lt;/p&gt;
&lt;p&gt;I wanted something simpler for &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.sotool.top&quot;&gt;en.sotool.top&lt;/a&gt;: pick a .docx file in the browser, preview the parsed content, and download a PDF. No server involved.&lt;/p&gt;
&lt;p&gt;Here is how I built it with Vue 3, &lt;code&gt;mammoth&lt;/code&gt;, and &lt;code&gt;html2pdf.js&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-client-side-&quot;&gt;Why Client-Side?&lt;/h3&gt;
&lt;p&gt;The main reason is privacy. Resumes, contracts, tax documents — users do not want them on a stranger’s server. Client-side conversion also means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No upload bandwidth limits&lt;/li&gt;
&lt;li&gt;No file size caps from your server&lt;/li&gt;
&lt;li&gt;No storage to clean up&lt;/li&gt;
&lt;li&gt;Works offline after the page loads&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-off is that very complex documents are limited by the browser’s rendering capabilities. For typical office documents, that is fine.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-stack&quot;&gt;The Stack&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vue 3&lt;/strong&gt; — UI, file handling, and reactive state&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mammoth&lt;/strong&gt; — Parse .docx files into clean HTML&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;html2pdf.js&lt;/strong&gt; — Render the HTML into a PDF using html2canvas + jsPDF&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native File API&lt;/strong&gt; — File selection&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install mammoth html2pdf.js
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;loading-the-word-document&quot;&gt;Loading the Word Document&lt;/h3&gt;
&lt;p&gt;The first step is reading the uploaded .docx file into an &lt;code&gt;ArrayBuffer&lt;/code&gt;, then converting it to HTML with &lt;code&gt;mammoth&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import mammoth from 'mammoth';

async function convertDocxToHtml(file) {
  const arrayBuffer = await file.arrayBuffer();
  const result = await mammoth.convertToHtml({ arrayBuffer });
  return result.value;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mammoth&lt;/code&gt; intentionally produces simple, clean HTML. It ignores complex formatting like text boxes and embedded fonts, which makes the output predictable.&lt;/p&gt;
&lt;p&gt;I keep the HTML in a reactive ref and render it in a preview panel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-vue&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div ref=&quot;previewRef&quot; class=&quot;word-preview&quot; v-html=&quot;htmlContent&quot;&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;

&amp;lt;script setup&amp;gt;
import { ref } from 'vue';

const htmlContent = ref('');
const previewRef = ref(null);
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;generating-the-pdf&quot;&gt;Generating the PDF&lt;/h3&gt;
&lt;p&gt;Once the user is happy with the preview, &lt;code&gt;html2pdf.js&lt;/code&gt; turns the preview element into a PDF. It uses &lt;code&gt;html2canvas&lt;/code&gt; to rasterize the HTML and &lt;code&gt;jsPDF&lt;/code&gt; to create the PDF file.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import html2pdf from 'html2pdf.js';

async function convertToPdf() {
  const element = previewRef.value;
  if (!element) return;

  await html2pdf()
    .from(element)
    .set({
      margin: [10, 10, 10, 10],
      filename: 'document.pdf',
      image: { type: 'jpeg', quality: 0.98 },
      html2canvas: { scale: 2, useCORS: true },
      jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
    })
    .save();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;scale: 2&lt;/code&gt; setting gives crisp text and images on high-DPI screens. &lt;code&gt;useCORS: true&lt;/code&gt; helps if the preview contains external images.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;styling-the-preview&quot;&gt;Styling the Preview&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;html2pdf.js&lt;/code&gt; renders whatever is in the DOM, so the preview styles directly affect the output. I use scoped CSS with &lt;code&gt;:deep()&lt;/code&gt; to style the converted HTML:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.word-preview :deep(h1) {
  font-size: 1.5rem;
  font-weight: 700;
  margin-bottom: 1rem;
}

.word-preview :deep(p) {
  margin-bottom: 0.75rem;
  line-height: 1.7;
}

.word-preview :deep(table) {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 1rem;
}

.word-preview :deep(td),
.word-preview :deep(th) {
  border: 1px solid #d1d5db;
  padding: 0.5rem;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep the preview background white. Dark mode UI is nice for the app, but a white page produces a clean PDF.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;handling-errors-gracefully&quot;&gt;Handling Errors Gracefully&lt;/h3&gt;
&lt;p&gt;Not every file is a valid .docx. I wrap the conversion in a try/catch and reset the state on failure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function handleFile(file) {
  try {
    const arrayBuffer = await file.arrayBuffer();
    const result = await mammoth.convertToHtml({ arrayBuffer });
    htmlContent.value = result.value;
  } catch (e) {
    console.error(e);
    alert('Failed to parse the document. Please check the file format.');
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also surface &lt;code&gt;mammoth&lt;/code&gt; warnings if you want to show the user which styles or elements were ignored.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. Mammoth is opinionated by design&lt;/strong&gt;&lt;br&gt;
It strips complex Word features to produce clean HTML. If your users need pixel-perfect fidelity, a server-side converter is still a better fit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Page size matters&lt;/strong&gt;&lt;br&gt;
If the document contains wide tables or landscape sections, A4 portrait may clip them. Consider letting users choose the output format.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. html2canvas is heavy on large documents&lt;/strong&gt;&lt;br&gt;
Very long documents with many images can take several seconds and block the main thread. For heavy usage, consider chunking or using a Web Worker.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Keep the original file around&lt;/strong&gt;&lt;br&gt;
If users want to adjust settings and re-convert, you will need the original &lt;code&gt;File&lt;/code&gt; object or &lt;code&gt;ArrayBuffer&lt;/code&gt; because &lt;code&gt;mammoth&lt;/code&gt; does not expose an editable model.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try It&lt;/h3&gt;
&lt;p&gt;The Word to PDF tool is live at &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.sotool.top/word-to-pdf&quot;&gt;en.sotool.top/word-to-pdf&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Free, no signup, and nothing uploads to a server.&lt;/p&gt;
&lt;p&gt;Full source code is on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sunshey/pdf-tool&quot;&gt;GitHub&lt;/a&gt;. The conversion logic lives in &lt;code&gt;src/views/WordToPdf.vue&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;want-more-advanced-pdf-features-&quot;&gt;Want More Advanced PDF Features?&lt;/h3&gt;
&lt;p&gt;If you need OCR, form editing, digital signatures, or batch processing beyond what a browser tool can do, &lt;a href=&quot;https://www.anrdoezrs.net/click-101775418-14071692?url=https%3A%2F%2Fpdf.wondershare.com%2F%3Futm_source%3Dcommission-junction%26utm_medium%3Daffiliate%26utm_campaign%3D3months_epc_high_low%26utm_content%3Dlink_25136016_2025-06-11&quot; target=&quot;_blank&quot; rel=&quot;noopener sponsored nofollow&quot;&gt;Wondershare PDFelement&lt;/a&gt; is a solid desktop option. It keeps everything local and adds professional tools on top.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This post contains affiliate links.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Have you built client-side document converters? What did you use — mammoth, pdf-lib, or a server-side tool?&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20522/how-to-convert-word-to-pdf-in-the-browser-with-vue-3-mammoth-and-html2pdf-js</guid>
<pubDate>Sat, 13 Jun 2026 13:24:06 +0000</pubDate>
</item>
<item>
<title>How to Split PDF Files in the Browser (No Server Required)</title>
<link>https://coderlegion.com/20410/how-to-split-pdf-files-in-the-browser-no-server-required</link>
<description>&lt;p&gt;In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/sunshey/how-to-merge-pdf-files-in-the-browser-no-server-required-3kjp&quot;&gt;last post&lt;/a&gt;, I covered merging PDFs in the browser using &lt;code&gt;pdf-lib&lt;/code&gt;. Today we're doing the opposite: splitting them.&lt;/p&gt;
&lt;p&gt;Splitting is surprisingly common:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A 200-page report where you only need chapters 3 and 7&lt;/li&gt;
&lt;li&gt;A scanned contract where the signature page needs to go to legal&lt;/li&gt;
&lt;li&gt;A PDF that's too large to email, so you split it into two attachments&lt;/li&gt;
&lt;li&gt;Extracting specific pages from a bulk download&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Just like merging, we'll do this entirely in the browser — no server uploads, no privacy trade-offs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-browser-side-splitting-matters&quot;&gt;Why Browser-Side Splitting Matters&lt;/h3&gt;
&lt;p&gt;The same privacy argument applies. When you upload a PDF to an online splitter, your file leaves your device. For documents containing personal data, financial records, or client contracts, that's a risk you don't need to take.&lt;/p&gt;
&lt;p&gt;Browser-side splitting keeps everything local. The PDF loads into your browser tab, &lt;code&gt;pdf-lib&lt;/code&gt; manipulates it in memory, and you download the result. The server never sees the content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; Browser memory. A 300MB scanned document will strain a tab. For typical office documents (under 50-100MB), splitting is instant.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;basic-split-extract-a-single-page-range&quot;&gt;Basic Split: Extract a Single Page Range&lt;/h3&gt;
&lt;p&gt;The simplest use case: &quot;Give me pages 5 through 10.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { PDFDocument } from 'pdf-lib';

async function extractPages(file, startPage, endPage) {
  // Load the source PDF
  const arrayBuffer = await file.arrayBuffer();
  const sourcePdf = await PDFDocument.load(arrayBuffer);

  // Create a new empty PDF
  const newPdf = await PDFDocument.create();

  // Convert to 0-based indices
  const pageIndices = [];
  for (let i = startPage - 1; i &amp;lt; endPage; i++) {
    pageIndices.push(i);
  }

  // Copy pages from source to new document
  const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
  copiedPages.forEach(page =&amp;gt; newPdf.addPage(page));

  // Save and return
  const pdfBytes = await newPdf.save();
  return new Blob([pdfBytes], { type: 'application/pdf' });
}

// Usage: extract pages 5-10
const blob = await extractPages(file, 5, 10);
downloadBlob(blob, 'pages-5-to-10.pdf');
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Load the full source PDF into memory&lt;/li&gt;
&lt;li&gt;Create a blank destination PDF&lt;/li&gt;
&lt;li&gt;Copy only the requested pages using &lt;code&gt;copyPages()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Save the new, smaller document&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The source PDF isn't modified — we're creating a new document containing only the pages we want.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;split-by-page-ranges-one-input-multiple-outputs&quot;&gt;Split by Page Ranges: One Input, Multiple Outputs&lt;/h3&gt;
&lt;p&gt;Real-world scenario: a user uploads a 50-page document and wants three separate files — pages 1-10, 11-30, and 31-50.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function splitByRanges(file, ranges) {
  // ranges = [{ name: 'intro', start: 1, end: 10 }, ...]
  const arrayBuffer = await file.arrayBuffer();
  const sourcePdf = await PDFDocument.load(arrayBuffer);

  const results = [];

  for (const range of ranges) {
    const newPdf = await PDFDocument.create();

    const pageIndices = [];
    for (let i = range.start - 1; i &amp;lt; range.end; i++) {
      pageIndices.push(i);
    }

    const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
    copiedPages.forEach(page =&amp;gt; newPdf.addPage(page));

    const pdfBytes = await newPdf.save();
    results.push({
      name: range.name,
      blob: new Blob([pdfBytes], { type: 'application/pdf' })
    });
  }

  return results;
}

// Usage
const ranges = [
  { name: 'chapter-1', start: 1, end: 10 },
  { name: 'chapter-2', start: 11, end: 30 },
  { name: 'chapter-3', start: 31, end: 50 }
];

const files = await splitByRanges(pdfFile, ranges);
files.forEach(f =&amp;gt; downloadBlob(f.blob, `${f.name}.pdf`));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Memory note:&lt;/strong&gt; We load the source PDF once, then create multiple new documents from it. Each new document only holds the pages it needs, so memory usage stays proportional to the output size, not the input size.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;advanced-split-by-file-size-email-friendly-chunks-&quot;&gt;Advanced: Split by File Size (Email-Friendly Chunks)&lt;/h3&gt;
&lt;p&gt;Here's a trickier problem: &quot;Split this PDF into chunks under 20MB each so I can email it.&quot;&lt;/p&gt;
&lt;p&gt;Unlike page-range splitting, we don't know the page count in advance. We need to accumulate pages until adding one more would exceed the limit, then start a new chunk.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function splitBySize(file, maxSizeBytes = 20 * 1024 * 1024) {
  const arrayBuffer = await file.arrayBuffer();
  const sourcePdf = await PDFDocument.load(arrayBuffer);
  const totalPages = sourcePdf.getPageCount();

  const chunks = [];
  let currentChunk = await PDFDocument.create();
  let currentSize = 0;
  let currentStartPage = 1;

  for (let i = 0; i &amp;lt; totalPages; i++) {
    // Try adding this page to the current chunk
    const testChunk = await PDFDocument.create();

    // Copy all pages currently in the chunk plus the new one
    const pagesToCopy = [];
    for (let j = currentStartPage - 1; j &amp;lt;= i; j++) {
      pagesToCopy.push(j);
    }

    const copiedPages = await testChunk.copyPages(sourcePdf, pagesToCopy);
    copiedPages.forEach(page =&amp;gt; testChunk.addPage(page));

    const testBytes = await testChunk.save();

    if (testBytes.length &amp;gt; maxSizeBytes &amp;amp;&amp;amp; currentStartPage &amp;lt;= i) {
      // This page would push us over the limit. Finalize current chunk.
      const finalChunk = await PDFDocument.create();
      const finalPages = [];
      for (let j = currentStartPage - 1; j &amp;lt; i; j++) {
        finalPages.push(j);
      }
      const finalCopied = await finalChunk.copyPages(sourcePdf, finalPages);
      finalCopied.forEach(page =&amp;gt; finalChunk.addPage(page));

      const finalBytes = await finalChunk.save();
      chunks.push(new Blob([finalBytes], { type: 'application/pdf' }));

      // Start new chunk with current page
      currentStartPage = i + 1;
    }
  }

  // Don't forget the last chunk
  if (currentStartPage &amp;lt;= totalPages) {
    const finalChunk = await PDFDocument.create();
    const finalPages = [];
    for (let j = currentStartPage - 1; j &amp;lt; totalPages; j++) {
      finalPages.push(j);
    }
    const finalCopied = await finalChunk.copyPages(sourcePdf, finalPages);
    finalCopied.forEach(page =&amp;gt; finalChunk.addPage(page));

    const finalBytes = await finalChunk.save();
    chunks.push(new Blob([finalBytes], { type: 'application/pdf' }));
  }

  return chunks;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why this is expensive:&lt;/strong&gt; We save a test document on every page to check the size. For a 100-page document, that's 100 save operations. In practice, I optimize this by:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Estimating page size from the first few pages&lt;/li&gt;
&lt;li&gt;Using binary search instead of linear checking&lt;/li&gt;
&lt;li&gt;Adding a small buffer (aim for 18MB instead of exactly 20MB)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For production, a simpler heuristic works well: if the original is 45MB and 50 pages, each page is roughly 0.9MB. Split every 22 pages to stay safely under 20MB.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;extracting-every-nth-page&quot;&gt;Extracting Every Nth Page&lt;/h3&gt;
&lt;p&gt;Another common pattern: &quot;I only need the odd pages&quot; or &quot;Extract every 5th page for a summary.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function extractEveryNthPage(file, n, offset = 0) {
  const arrayBuffer = await file.arrayBuffer();
  const sourcePdf = await PDFDocument.load(arrayBuffer);
  const totalPages = sourcePdf.getPageCount();

  const newPdf = await PDFDocument.create();
  const pageIndices = [];

  for (let i = offset; i &amp;lt; totalPages; i += n) {
    pageIndices.push(i);
  }

  const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
  copiedPages.forEach(page =&amp;gt; newPdf.addPage(page));

  const pdfBytes = await newPdf.save();
  return new Blob([pdfBytes], { type: 'application/pdf' });
}

// Extract odd pages only
const oddPages = await extractEveryNthPage(file, 2, 0);

// Extract even pages only
const evenPages = await extractEveryNthPage(file, 2, 1);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;handling-page-rotation-and-metadata&quot;&gt;Handling Page Rotation and Metadata&lt;/h3&gt;
&lt;p&gt;When you split a PDF, you might want to preserve or modify metadata:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function splitWithMetadata(file, startPage, endPage) {
  const arrayBuffer = await file.arrayBuffer();
  const sourcePdf = await PDFDocument.load(arrayBuffer);
  const newPdf = await PDFDocument.create();

  // Copy pages
  const pageIndices = [];
  for (let i = startPage - 1; i &amp;lt; endPage; i++) {
    pageIndices.push(i);
  }
  const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
  copiedPages.forEach(page =&amp;gt; newPdf.addPage(page));

  // Preserve or update metadata
  const author = sourcePdf.getAuthor();
  const creator = sourcePdf.getCreator();

  newPdf.setTitle(`Extracted Pages ${startPage}-${endPage}`);
  newPdf.setAuthor(author || 'Unknown');
  newPdf.setCreator('sotool PDF Splitter');
  newPdf.setProducer('pdf-lib');
  newPdf.setCreationDate(new Date());

  const pdfBytes = await newPdf.save();
  return new Blob([pdfBytes], { type: 'application/pdf' });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is useful when the recipient needs to know where the extract came from.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;performance-the-200-page-test&quot;&gt;Performance: The 200-Page Test&lt;/h3&gt;
&lt;p&gt;I tested the page-range splitter on a 200-page, 85MB scanned document:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Operation               &lt;/th&gt;&lt;th&gt;Time &lt;/th&gt;&lt;th&gt;Memory Peak&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Load PDF                &lt;/td&gt;&lt;td&gt;2.1s &lt;/td&gt;&lt;td&gt;180MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Extract pages 50-100    &lt;/td&gt;&lt;td&gt;1.8s &lt;/td&gt;&lt;td&gt;95MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Split into 4 chunks     &lt;/td&gt;&lt;td&gt;4.2s &lt;/td&gt;&lt;td&gt;210MB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Extract every 10th page &lt;/td&gt;&lt;td&gt;0.9s &lt;/td&gt;&lt;td&gt;45MB&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The key insight: &lt;code&gt;copyPages()&lt;/code&gt; is fast because it copies page references and shared resources efficiently. You're not deep-cloning the entire document for each output.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Optimization tip:&lt;/strong&gt; If you're splitting a PDF into many small chunks, load the source once and reuse the &lt;code&gt;PDFDocument&lt;/code&gt; instance. Don't reload it for each chunk.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;limitations-honest-assessment-&quot;&gt;Limitations (Honest Assessment)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Limitation               &lt;/th&gt;&lt;th&gt;Why                                     &lt;/th&gt;&lt;th&gt;Workaround&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Bookmarks lost&lt;/strong&gt;       &lt;/td&gt;&lt;td&gt;Splitting creates new document trees    &lt;/td&gt;&lt;td&gt;Acceptable for most extracts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Internal links break&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Page references change                  &lt;/td&gt;&lt;td&gt;Rarely an issue for extracts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Large scans choke&lt;/strong&gt;    &lt;/td&gt;&lt;td&gt;Browser memory limits                   &lt;/td&gt;&lt;td&gt;Split server-side for 500MB+ files&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Form data complexity&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Some forms have cross-page dependencies &lt;/td&gt;&lt;td&gt;Test thoroughly with fillable PDFs&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For 95% of use cases — extracting a chapter, splitting a contract, creating email-friendly chunks — these limitations don't matter.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-the-live-tool&quot;&gt;Try the Live Tool&lt;/h3&gt;
&lt;p&gt;If you want to test browser-based PDF splitting without writing code:&lt;/p&gt;
&lt;p&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://en.sotool.top/split&quot;&gt;en.sotool.top/split&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Extract specific page ranges&lt;/li&gt;
&lt;li&gt;Split by fixed page count (every N pages)&lt;/li&gt;
&lt;li&gt;Split by file size (email-friendly chunks)&lt;/li&gt;
&lt;li&gt;Visual page thumbnails for selection&lt;/li&gt;
&lt;li&gt;Pure browser-side processing&lt;/li&gt;
&lt;li&gt;Free, no signup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sunshey/pdf-tool&quot;&gt;source code&lt;/a&gt; is open source if you want to see how the Vue 3 + pdf-lib integration works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-next-&quot;&gt;What's Next?&lt;/h3&gt;
&lt;p&gt;If you enjoyed this post, check out the companion piece on &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/sunshey/how-to-merge-pdf-files-in-the-browser-no-server-required-3kjp&quot;&gt;merging PDFs in the browser&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Next up in the series:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Compressing PDFs&lt;/strong&gt; by downsampling embedded images&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adding watermarks&lt;/strong&gt; with text and image overlays&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Encrypting and password-protecting&lt;/strong&gt; PDFs client-side&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Have you built browser-based document tools? What's your approach to handling large files or complex page operations? Let me know in the comments.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20410/how-to-split-pdf-files-in-the-browser-no-server-required</guid>
<pubDate>Fri, 12 Jun 2026 13:20:17 +0000</pubDate>
</item>
<item>
<title>Binary PDF Modification Detection: How It Works and Where It Fails</title>
<link>https://coderlegion.com/19042/binary-pdf-modification-detection-how-it-works-and-where-it-fails</link>
<description>&lt;p&gt;The question sounds simple: has this PDF been edited after it was originally created? The answer, it turns out, requires understanding how PDF files store their history at the byte level.&lt;/p&gt;
&lt;p&gt;This article explains the technical approach behind binary PDF modification detection — what signals we look for, why they are reliable, and critically, where the method breaks down. If you want a quick answer without the technical detail, &lt;a rel=&quot;nofollow&quot; href=&quot;https://htpbe.tech&quot;&gt;HTPBE.tech&lt;/a&gt; is an online tool that tells you whether a PDF has been edited (yes/no) in seconds. If you want to understand how that answer is produced, read on.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;how-a-pdf-file-is-structured&quot;&gt;How a PDF File Is Structured&lt;/h3&gt;
&lt;p&gt;Before discussing detection, you need to understand the PDF file format at a structural level.&lt;/p&gt;
&lt;p&gt;A PDF file consists of four components:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Header&lt;/strong&gt; — declares the PDF version (&lt;code&gt;%PDF-1.7&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Body&lt;/strong&gt; — a collection of numbered objects (pages, fonts, images, metadata)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-reference table (xref)&lt;/strong&gt; — an index mapping object numbers to byte offsets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trailer&lt;/strong&gt; — points to the xref table and the document root object&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A minimal trailer looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;trailer
&amp;lt;&amp;lt;
  /Size 42
  /Root 1 0 R
  /Info 2 0 R
&amp;gt;&amp;gt;
startxref
9876
%%EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;/Info&lt;/code&gt; entry points to the document information dictionary — this is where metadata lives.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-metadata-dictionary&quot;&gt;The Metadata Dictionary&lt;/h3&gt;
&lt;p&gt;The PDF Information Dictionary (&lt;code&gt;/Info&lt;/code&gt;) contains fields that describe the document&amp;rsquo;s origin and history:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Field &lt;/th&gt;&lt;th&gt;Meaning&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/CreationDate&lt;/code&gt; &lt;/td&gt;&lt;td&gt;When the document was originally created&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/ModDate&lt;/code&gt; &lt;/td&gt;&lt;td&gt;When the document was last modified&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/Creator&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Application that created the source document (e.g., Microsoft Word)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/Producer&lt;/code&gt; &lt;/td&gt;&lt;td&gt;PDF library or printer that generated the PDF (e.g., macOS Quartz PDFContext)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/Author&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Document author&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/Title&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Document title&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Dates are encoded in a specific format:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;D:20231015143022+03'00'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means: 2023-10-15 at 14:30:22, UTC+3. The timezone offset is significant — we will come back to it.&lt;/p&gt;
&lt;p&gt;The Creator/Producer distinction matters enormously. When someone writes a document in Microsoft Word and exports it to PDF:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Creator = Microsoft Word&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Producer = Microsoft Word&lt;/code&gt; (or a Word-internal PDF engine)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When someone then opens that PDF in Adobe Acrobat and edits it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Creator&lt;/code&gt; stays as &lt;code&gt;Microsoft Word&lt;/code&gt; (preserved from original)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Producer&lt;/code&gt; changes to &lt;code&gt;Adobe Acrobat 23.0&lt;/code&gt; (the tool that wrote the new version)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This mismatch is a strong signal of modification.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-incremental-save-mechanism&quot;&gt;The Incremental Save Mechanism&lt;/h3&gt;
&lt;p&gt;This is the core of PDF modification detection, and it is what makes PDFs fundamentally different from most other file formats.&lt;/p&gt;
&lt;p&gt;When you edit a PDF and save it, most PDF editors do &lt;strong&gt;not&lt;/strong&gt; rewrite the entire file. Instead, they &lt;strong&gt;append&lt;/strong&gt; changes to the end:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The original file body is left intact&lt;/li&gt;
&lt;li&gt;New or modified objects are appended after &lt;code&gt;%%EOF&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;A new cross-reference table is appended, containing entries for the changed objects&lt;/li&gt;
&lt;li&gt;A new trailer is appended with a &lt;code&gt;/Prev&lt;/code&gt; field pointing to the byte offset of the previous xref table&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;[original body]
%%EOF
[new objects]
xref
[new xref entries]
trailer
&amp;lt;&amp;lt;
  /Size 55
  /Root 1 0 R
  /Prev 9876          ← points to original xref
&amp;gt;&amp;gt;
startxref
14523
%%EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This incremental update mechanism exists for good reasons: it enables fast partial saves, supports undo history, and is required for certain digital signature workflows. But it also leaves a clear paper trail.&lt;/p&gt;
&lt;p&gt;A PDF with multiple &lt;code&gt;%%EOF&lt;/code&gt; markers, multiple xref sections, or a trailer with &lt;code&gt;/Prev&lt;/code&gt; has been saved more than once. This does not automatically mean fraud — legitimate workflows involve multiple saves. But it is a definitive indicator that the file was modified after initial creation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-automated-detection-actually-examines&quot;&gt;What Automated Detection Actually Examines&lt;/h3&gt;
&lt;p&gt;A detection tool like HTPBE examines several independent signals and combines them into a confidence score.&lt;/p&gt;
&lt;h4 id=&quot;1-timestamp-consistency&quot;&gt;1. Timestamp Consistency&lt;/h4&gt;
&lt;p&gt;The simplest check: is &lt;code&gt;ModDate&lt;/code&gt; later than &lt;code&gt;CreationDate&lt;/code&gt;? If yes, the file was modified. But there are subtleties:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ModDate == CreationDate&lt;/code&gt; does not mean the file was never edited. Some editors reset &lt;code&gt;ModDate&lt;/code&gt; to match &lt;code&gt;CreationDate&lt;/code&gt; to hide modifications.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ModDate&lt;/code&gt; earlier than &lt;code&gt;CreationDate&lt;/code&gt; is impossible under normal conditions and indicates tampering with metadata.&lt;/li&gt;
&lt;li&gt;Timezone changes between &lt;code&gt;CreationDate&lt;/code&gt; and &lt;code&gt;ModDate&lt;/code&gt; (e.g., creation in &lt;code&gt;+03'00'&lt;/code&gt; but modification in &lt;code&gt;Z&lt;/code&gt;) suggest the file was modified on a different machine or with a different tool.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-cross-reference-table-analysis&quot;&gt;2. Cross-Reference Table Analysis&lt;/h4&gt;
&lt;p&gt;Counting xref sections reveals the number of save operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1 xref section&lt;/strong&gt;: the file was saved exactly once — typical for freshly exported PDFs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2+ xref sections&lt;/strong&gt;: the file was modified at least once after initial creation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3+ xref sections&lt;/strong&gt;: multiple rounds of editing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The content of the xref updates also matters. If the updated objects include the &lt;code&gt;/Info&lt;/code&gt; dictionary (metadata), it suggests metadata was modified after the fact.&lt;/p&gt;
&lt;h4 id=&quot;3-creator-producer-mismatch-analysis&quot;&gt;3. Creator/Producer Mismatch Analysis&lt;/h4&gt;
&lt;p&gt;We maintain a knowledge base of known PDF creation tool combinations. Some mismatches are routine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Creator: Word&lt;/code&gt; + &lt;code&gt;Producer: Adobe PDF Printer&lt;/code&gt; — normal, Word printed to PDF&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Creator: Google Docs&lt;/code&gt; + &lt;code&gt;Producer: Skia/PDF&lt;/code&gt; — normal Google Docs export&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some mismatches are suspicious:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Creator: Microsoft Excel&lt;/code&gt; + &lt;code&gt;Producer: iLovePDF&lt;/code&gt; — the file was processed by an online editor&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Creator: LibreOffice&lt;/code&gt; + &lt;code&gt;Producer: Adobe Acrobat&lt;/code&gt; — the file was opened and re-saved in Acrobat&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Creator: &lt;/code&gt; (empty) + &lt;code&gt;Producer: FPDF&lt;/code&gt; — programmatically generated, may be a reconstructed document&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The tool also looks for editors known to be used for document fraud: certain online PDF editors, specific versions of commercial tools with known history-stripping features.&lt;/p&gt;
&lt;h4 id=&quot;4-digital-signature-integrity&quot;&gt;4. Digital Signature Integrity&lt;/h4&gt;
&lt;p&gt;PDF supports cryptographic digital signatures (&lt;code&gt;/Sig&lt;/code&gt; objects). When a signed PDF is modified:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The signature covers specific byte ranges of the file&lt;/li&gt;
&lt;li&gt;If content outside those byte ranges is modified, the signature becomes invalid&lt;/li&gt;
&lt;li&gt;If the signature object itself is removed and the document re-saved, the absence of a previously-present signature is detectable via the revision history&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;5-object-stream-anomalies&quot;&gt;5. Object Stream Anomalies&lt;/h4&gt;
&lt;p&gt;PDF 1.5+ supports compressed object streams. Legitimate PDF generators produce consistent, well-formed object streams. Anomalies we check for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Objects with inconsistent generation numbers&lt;/li&gt;
&lt;li&gt;Orphaned objects not reachable from the document root&lt;/li&gt;
&lt;li&gt;Objects that exist in the xref table but whose byte offsets point to different object types (indicates partial overwrite attempts)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;putting-it-together-the-confidence-score&quot;&gt;Putting It Together: The Confidence Score&lt;/h3&gt;
&lt;p&gt;No single signal is conclusive. A document with &lt;code&gt;ModDate &amp;gt; CreationDate&lt;/code&gt; might be completely legitimate. A document with matching timestamps might have been forged with a tool that resets them.&lt;/p&gt;
&lt;p&gt;Detection tools combine multiple signals into a weighted confidence score:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Signal &lt;/th&gt;&lt;th&gt;Weight &lt;/th&gt;&lt;th&gt;Rationale&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Multiple xref sections &lt;/td&gt;&lt;td&gt;High &lt;/td&gt;&lt;td&gt;Hard to fake without specialized knowledge&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Creator/Producer mismatch with known editor &lt;/td&gt;&lt;td&gt;Medium-High &lt;/td&gt;&lt;td&gt;Common in legitimate workflows too&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Timestamp anomaly (impossible date) &lt;/td&gt;&lt;td&gt;High &lt;/td&gt;&lt;td&gt;Never legitimate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Timestamp anomaly (reset to match) &lt;/td&gt;&lt;td&gt;Medium &lt;/td&gt;&lt;td&gt;Detectable but requires suspicion&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Known fraud-associated producer &lt;/td&gt;&lt;td&gt;Medium &lt;/td&gt;&lt;td&gt;Context-dependent&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Digital signature removed &lt;/td&gt;&lt;td&gt;High &lt;/td&gt;&lt;td&gt;Strong indicator of post-signing modification&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A score above a threshold produces a &amp;ldquo;Modified&amp;rdquo; verdict. Below the threshold: &amp;ldquo;Original&amp;rdquo;. The threshold is calibrated to minimize false positives on legitimate documents.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;false-positives-when-unmodified-pdfs-look-modified&quot;&gt;False Positives: When Unmodified PDFs Look Modified&lt;/h3&gt;
&lt;p&gt;This is where the method has real limitations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Legitimate workflows that trigger modification signals:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PDF/A conversion&lt;/strong&gt; — archival format conversion modifies the file structure. &lt;code&gt;ModDate&lt;/code&gt; will differ from &lt;code&gt;CreationDate&lt;/code&gt; even if content is unchanged.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Digital signing&lt;/strong&gt; — adding a digital signature is a modification. A signed-after-creation document will show multiple xref sections even if the content was never altered.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Optimization&lt;/strong&gt; — tools like Ghostscript, qpdf, or Adobe Acrobat&amp;rsquo;s &amp;ldquo;Reduce File Size&amp;rdquo; rewrite the entire file, resetting the xref structure but potentially preserving old timestamps.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Printer workflows&lt;/strong&gt; — some enterprise print systems re-process PDFs (adding watermarks, headers, or compliance stamps) as a routine step.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Email clients&lt;/strong&gt; — some email clients modify PDF attachments (adding metadata, stripping features) during send or receive.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Merging or splitting&lt;/strong&gt; — combining PDFs with tools like PDFtk or iLovePDF creates a new document with a new Producer and potentially inconsistent metadata from the source files.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In all these cases, the document content may be perfectly authentic, but the structural signals indicate modification. This is why results should always be interpreted in context.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;false-negatives-when-modified-pdfs-look-original&quot;&gt;False Negatives: When Modified PDFs Look Original&lt;/h3&gt;
&lt;p&gt;More dangerous is the opposite case: a forged document that passes detection.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Techniques that evade detection:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Full reconstruction&lt;/strong&gt; — printing to PDF (or using &lt;code&gt;print → save as PDF&lt;/code&gt;) creates a completely new file with a fresh xref, consistent timestamps, and a single clean structure. All modification traces are lost. The resulting PDF looks freshly created, even if it was forged.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Metadata scrubbing&lt;/strong&gt; — tools like &lt;code&gt;exiftool&lt;/code&gt; or custom scripts can overwrite all metadata fields, including timestamps and creator information, before the file is distributed. A sophisticated forger resets &lt;code&gt;CreationDate = ModDate&lt;/code&gt; and sets &lt;code&gt;Producer&lt;/code&gt; to match the expected original tool.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-export from source format&lt;/strong&gt; — if the forger has access to the source document (a Word file, for example), they can modify the source and re-export to PDF. The resulting PDF has no modification traces because it was never &amp;ldquo;modified&amp;rdquo; — it was created fresh from a modified source.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PDF reconstruction tools&lt;/strong&gt; — specialized tools can parse a modified PDF and reconstruct it as a clean single-revision file, stripping all incremental save history.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The fundamental limitation: &lt;strong&gt;binary detection works on file structure, not content&lt;/strong&gt;. We can detect that a file&amp;rsquo;s structure shows signs of modification, but we cannot verify whether the visible text or numbers are correct. A forger skilled enough to reconstruct the PDF properly can evade structural detection entirely.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-this-means-in-practice&quot;&gt;What This Means in Practice&lt;/h3&gt;
&lt;p&gt;Binary PDF modification detection is a fast, automated first-line check. It catches:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Naive modifications made with standard PDF editors (the vast majority of document fraud cases)&lt;/li&gt;
&lt;li&gt;Metadata inconsistencies that reveal post-creation editing&lt;/li&gt;
&lt;li&gt;Structural anomalies that indicate multiple save operations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It does not catch:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Sophisticated forgeries where the PDF was fully reconstructed&lt;/li&gt;
&lt;li&gt;Source-level modifications (edit the Word doc, re-export)&lt;/li&gt;
&lt;li&gt;Cases where the file was &amp;ldquo;legitimately&amp;rdquo; processed by a PDF workflow tool&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For high-stakes verification (legal documents, financial instruments, regulated industries), automated detection should be combined with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Human review of the document content for semantic consistency&lt;/li&gt;
&lt;li&gt;Verification of the document&amp;rsquo;s origin through the issuing party&lt;/li&gt;
&lt;li&gt;Cryptographic verification if the original was digitally signed&lt;/li&gt;
&lt;li&gt;Chain-of-custody controls that prevent the question from arising&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;technical-implementation-notes&quot;&gt;Technical Implementation Notes&lt;/h3&gt;
&lt;p&gt;For developers building similar tools, the key libraries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pdf-lib&lt;/strong&gt; (JavaScript/Node.js) — parse PDF structure, read xref tables, access object streams&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PyMuPDF / fitz&lt;/strong&gt; (Python) — comprehensive PDF analysis, supports most PDF versions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pdfminer.six&lt;/strong&gt; (Python) — lower-level access to PDF internals&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iTextSharp&lt;/strong&gt; (.NET) — commercial-grade PDF analysis&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The core algorithm:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Parse the xref table(s) — count revisions&lt;/li&gt;
&lt;li&gt;Extract &lt;code&gt;/Info&lt;/code&gt; dictionary — compare timestamps, analyze Creator/Producer&lt;/li&gt;
&lt;li&gt;Walk the xref chain via &lt;code&gt;/Prev&lt;/code&gt; pointers — identify which objects changed between revisions&lt;/li&gt;
&lt;li&gt;Check for &lt;code&gt;/Sig&lt;/code&gt; objects — verify digital signature presence and coverage&lt;/li&gt;
&lt;li&gt;Score each signal — weight by reliability and combine&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The implementation challenge is handling malformed PDFs. Real-world documents frequently deviate from the PDF specification. A robust tool must handle:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Missing or corrupted xref tables (PDF readers use recovery mode)&lt;/li&gt;
&lt;li&gt;Linearized PDFs (a different structure used for web-optimized files)&lt;/li&gt;
&lt;li&gt;Encrypted PDFs (metadata may be accessible even if content is not)&lt;/li&gt;
&lt;li&gt;PDF 2.0 documents with new structural features&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Binary PDF modification detection is a reliable heuristic, not a cryptographic proof. It answers the question &amp;ldquo;does this file&amp;rsquo;s structure suggest modification?&amp;rdquo; with high accuracy for everyday document fraud. Against a skilled adversary who understands PDF internals, structural detection alone is insufficient.&lt;/p&gt;
&lt;p&gt;The honest answer to &amp;ldquo;has this PDF been edited?&amp;rdquo; is: we can tell you what the file&amp;rsquo;s structure reveals. For most fraud cases — edited invoices, tampered bank statements, modified contracts — that is enough.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://htpbe.tech&quot;&gt;HTPBE.tech&lt;/a&gt; implements the detection approach described in this article. The web tool is free, requires no registration, and processes PDFs in seconds. The API is available for integration into document verification workflows.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19042/binary-pdf-modification-detection-how-it-works-and-where-it-fails</guid>
<pubDate>Wed, 27 May 2026 08:26:25 +0000</pubDate>
</item>
<item>
<title>[ROADMAP] Beyond the CSV: Statements as a Product Surface</title>
<link>https://coderlegion.com/13909/roadmap-beyond-the-csv-statements-as-a-product-surface</link>
<description>&lt;hr&gt;
&lt;h2 id=&quot;beyond-the-csv-statements-as-a-product-surface&quot;&gt;Beyond the CSV: Statements as a Product Surface&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Part 10&lt;/strong&gt; looks past today’s CSV importers at &lt;strong&gt;broker statements&lt;/strong&gt; (often PDF). The architecture below is &lt;strong&gt;directional&lt;/strong&gt;: we are spelling out constraints and a credible pipeline, not announcing a finished PDF-to-portfolio feature in production.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-can-use-today&quot;&gt;What you can use today&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Broker adapters and universal CSV&lt;/strong&gt; — importer package and in-app CSV flow. Heavy parsing stays &lt;strong&gt;in the browser&lt;/strong&gt;; where cloud-assisted mapping exists, only &lt;strong&gt;bounded&lt;/strong&gt; previews go upstream (for example headers plus a small sample of rows).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ask AI context&lt;/strong&gt; — always the &lt;code&gt;buildPortfolioContext&lt;/code&gt; string (Parts 2–3), no matter how trades entered the app.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-pdf-is-harder-than-csv&quot;&gt;Why PDF is harder than CSV&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Layout&lt;/strong&gt; — multi-column tables, repeated headers, page breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Noise&lt;/strong&gt; — footnotes, disclaimers, mixed fonts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scans&lt;/strong&gt; — OCR errors break deterministic parsers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trust&lt;/strong&gt; — people should &lt;strong&gt;confirm&lt;/strong&gt; extracted rows before anything merges into a ledger—the same bar we set on universal CSV mapping.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;target-pipeline-when-we-ship-it-&quot;&gt;Target pipeline (when we ship it)&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Extract&lt;/strong&gt; — client-side or trusted worker → row-like text or tables.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Map&lt;/strong&gt; — the same schema discipline as CSV (still &lt;strong&gt;bounded&lt;/strong&gt; if the cloud helps).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parse deterministically&lt;/strong&gt; — &lt;code&gt;Trade[]&lt;/code&gt; in the same spirit as today’s &lt;code&gt;genericParse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Review&lt;/strong&gt; — explicit user confirmation before merge.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Scope.&lt;/strong&gt; This installment is engineering intent and product reasoning. &lt;strong&gt;End-to-end PDF statement import&lt;/strong&gt; is not described here as something you can do in the live app today; when we ship it, we will say so in release notes and product copy, not only in a serial essay.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Part 10 of &lt;strong&gt;Sovereign Engineering&lt;/strong&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read the full &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pocketportfolio.app/book/sovereign-intelligence&quot;&gt;Sovereign Intelligence&lt;/a&gt; book or &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pocketportfolio.app&quot;&gt;try the app&lt;/a&gt;.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13909/roadmap-beyond-the-csv-statements-as-a-product-surface</guid>
<pubDate>Wed, 06 May 2026 11:33:00 +0000</pubDate>
</item>
<item>
<title>The document could not be saved. A number is out of range- Fixed</title>
<link>https://coderlegion.com/13365/the-document-could-not-be-saved-a-number-is-out-of-range-fixed</link>
<description>&lt;p&gt;The &lt;strong&gt;'The document could not be saved. A number is out of range`&lt;/strong&gt; error happens when Acrobat detects an issue with the PDF file's underlying structure. The solution for this problem is&lt;code&gt;&lt;strong&gt; exporting a PDF&lt;/strong&gt; &lt;strong&gt;to PostScrip&lt;/strong&gt;t&lt;/code&gt;, and then convert the PostScript file &lt;code&gt;&lt;strong&gt;back to PDF&lt;/strong&gt;&lt;/code&gt;. Other solutions are also discussed in this article.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://kodlogs.net/?qa=blob&amp;amp;qa_blobid=1477928991728469158&quot; alt=&quot;&quot; width=&quot;539&quot; height=&quot;359&quot;&gt;&lt;/p&gt;
&lt;h5 class=&quot;pk-title pk-toc-title pk-font-block&quot;&gt;Photo by &lt;a href=&quot;https://www.pexels.com/photo/people-standing-on-top-of-mountain-2114206/&quot; rel=&quot;nofollow&quot;&gt;Rachel Vine&lt;/a&gt;&lt;/h5&gt;
&lt;h5 class=&quot;pk-title pk-toc-title pk-font-block&quot;&gt;TABLE OF CONTENTS&lt;/h5&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#what-is-attributeerror-module-object-has-no-attribute-strptime&quot;&gt;What is &quot;The document could not be saved. A number is out of range&quot;?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#Why-does-blocked-a-frame-with-origin-null-from-accessing-a-cross-origin-frame-happens&quot;&gt;Why &quot;&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#what-is-attributeerror-module-object-has-no-attribute-strptime&quot;&gt;The document could not be saved. A number is out of range&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#Why-does-blocked-a-frame-with-origin-null-from-accessing-a-cross-origin-frame-happens&quot;&gt;&quot; happens?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#how-to-resolve&quot;&gt;How to resolve &quot;&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#what-is-attributeerror-module-object-has-no-attribute-strptime&quot;&gt;The document could not be saved. A number is out of range&lt;/a&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#how-to-resolve&quot;&gt;&lt;strong&gt;&quot;&lt;/strong&gt;:&lt;/a&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#solution-1-use-local-web-server&quot;&gt;Solution 1: Export the PDF as a PostScript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#solution-2-import-the-datetime-module-directly-and-access-the-method-through-its-class-name&quot;&gt;Solution 2: Rename and open PDF&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;#conclusion&quot;&gt;Conclusion&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;what-is-attributeerror-module-object-has-no-attribute-strptime&quot;&gt;&lt;strong&gt;What is  “The document could not be saved. A number is out of range&lt;/strong&gt;&lt;strong&gt;”?&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;We may wonder why Adobe allows this &lt;strong&gt;BUG &lt;/strong&gt;to exist when everyone does not have Acrobat. The problem here arises due to a &lt;strong&gt;faulty PDF&lt;/strong&gt;. The structure of a PDF file is complicated so such issues can arise.&lt;/p&gt;
&lt;p&gt; &lt;/p&gt;
&lt;h2 id=&quot;Why-does-blocked-a-frame-with-origin-null-from-accessing-a-cross-origin-frame-happens&quot;&gt;&lt;strong&gt;Why does “The document could not be saved. A number is out of range&lt;/strong&gt;&lt;strong&gt;“ happens?&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;When the user tries to view a PDF or delete a page from a PDF, then you see an error message;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Following is the error message you get:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;`A number &lt;span class=&quot;hljs-keyword&quot;&gt;is&lt;/span&gt; out &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; range`&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;A user can also get the same error when you try to perform other actions on the PDF, such as combining it with another PDF or inserting a new page.&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;How to resolve “The document could not be saved. A number is out of range&lt;/strong&gt;&lt;strong&gt;”?&lt;/strong&gt;&lt;/h2&gt;
&lt;p style=&quot;line-height: 1;&quot;&gt;Please take the following actions outlined below to remedy the issue.&lt;/p&gt;
&lt;h2 id=&quot;solution-1-use-local-web-server&quot; style=&quot;line-height: 1;&quot;&gt;&lt;strong&gt;Solution 1:&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Export the PDF as a PostScript file and then convert it into a PDF using Distiller. The “A number is out of range” error occurs when Acrobat finds some problem with the underlying structure of the PDF file. The best solution is to get a new PDF from the owner. If you can’t get one, then follow the below steps to resolve the problem.&lt;/p&gt;
&lt;h2&gt;Steps to follow:&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Open the PDF in Acrobat.&lt;/li&gt;
&lt;li&gt;Select File &amp;gt; Export To &amp;gt; PostScript from the File menu.&lt;/li&gt;
&lt;li&gt;Enter a new name for the file in the Save As PDF dialogue box, then click Save.&lt;/li&gt;
&lt;li&gt;Go to the folder where the PostScript file was saved. Right-click the file and select Adobe Acrobat Distiller from the context menu.&lt;/li&gt;
&lt;li&gt;The PostScript file is converted to a PDF file by Acrobat Distiller. In the same folder, the PDF file is saved.&lt;/li&gt;
&lt;li&gt;Acrobat should be used to open the PDF file. Now try seeing the pages in the PDF or deleting, inserting, or adding a page.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt; &lt;/p&gt;
&lt;p&gt;⚠️ This method works effectively for PDFs that do not contain interactive features like multimedia or form fields.&lt;/p&gt;
&lt;p&gt; &lt;/p&gt;
&lt;h2 id=&quot;solution-2-import-the-datetime-module-directly-and-access-the-method-through-its-class-name&quot; style=&quot;line-height: 1;&quot;&gt;&lt;strong&gt;Solution 2: Rename and open PDF&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;1. Rename the original file's name&lt;br&gt;2. Go to Chrome Browser and open this file&lt;br&gt;3. Print&amp;gt; Save as .pdf&lt;br&gt;4. Open in Adobe &lt;/p&gt;
&lt;p style=&quot;line-height: 1;&quot;&gt; &lt;/p&gt;
&lt;p&gt;ℹ   If you're a Mac user, you can avoid the problem by using Preview or avoiding Acrobat.&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot;&gt; &lt;/h3&gt;
&lt;h3 style=&quot;text-align: left;&quot;&gt;&lt;strong&gt;Conclusion:&lt;/strong&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: left;&quot;&gt;&lt;strong&gt;The document could not be saved. A number is out of range&lt;/strong&gt; is the issue we might face due to corrupted PDF files, in this article we discussed a number of resolutions such as renaming pdf files and exporting pdf files et. I hope this article will be useful, happy exploring !!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13365/the-document-could-not-be-saved-a-number-is-out-of-range-fixed</guid>
<pubDate>Mon, 23 Mar 2026 09:43:47 +0000</pubDate>
</item>
<item>
<title>Auto-Generate PDF Receipts After Stripe Payments</title>
<link>https://coderlegion.com/13179/auto-generate-pdf-receipts-after-stripe-payments</link>
<description>&lt;p&gt;Every app that takes payments needs to send receipts. Stripe has built-in receipts, but they're Stripe-branded and limited. If you need custom receipts — with your company info, tax breakdowns, payment details — you have to generate them yourself.&lt;/p&gt;
&lt;p&gt;The typical approach is setting up Puppeteer, writing an HTML template, managing CSS, and rendering PDFs on your server. It works, but it's heavy and annoying to maintain.&lt;/p&gt;
&lt;p&gt;Here's a simpler approach: listen for Stripe's &lt;code&gt;payment_intent.succeeded&lt;/code&gt; webhook, call a receipt API, and email the PDF to your customer. No HTML templates. No Puppeteer. Under 50 lines of code.&lt;/p&gt;
&lt;h3 id=&quot;the-setup&quot;&gt;The Setup&lt;/h3&gt;
&lt;p&gt;We need three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A Stripe webhook listener&lt;/li&gt;
&lt;li&gt;A receipt PDF generator (we'll use &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com&quot;&gt;Kagyz&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;An email sender (SendGrid, Resend, or any SMTP)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;step-1-listen-for-stripe-payments&quot;&gt;Step 1: Listen for Stripe Payments&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import Stripe from &quot;stripe&quot;;
import express from &quot;express&quot;;

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

app.post(
  &quot;/webhook/stripe&quot;,
  express.raw({ type: &quot;application/json&quot; }),
  async (req, res) =&amp;gt; {
    const sig = req.headers[&quot;stripe-signature&quot;];

    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    if (event.type === &quot;payment_intent.succeeded&quot;) {
      await handlePayment(event.data.object);
    }

    res.json({ received: true });
  }
);

app.listen(3000);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Standard Stripe webhook setup. Nothing special here — we verify the signature and handle &lt;code&gt;payment_intent.succeeded&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;step-2-generate-the-receipt-pdf&quot;&gt;Step 2: Generate the Receipt PDF&lt;/h3&gt;
&lt;p&gt;This is where it gets simple. Instead of rendering HTML with Puppeteer, we send JSON to Kagyz and get a PDF back:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function generateReceipt(paymentData) {
  const response = await fetch(&quot;https://api.kagyz.com/v1/receipt&quot;, {
    method: &quot;POST&quot;,
    headers: {
      &quot;Authorization&quot;: `Bearer ${process.env.KAGYZ_API_KEY}`,
      &quot;Content-Type&quot;: &quot;application/json&quot;,
    },
    body: JSON.stringify({
      receipt_number: `REC-${paymentData.id.slice(-8)}`,
      date: new Date().toISOString().split(&quot;T&quot;)[0],
      currency: paymentData.currency.toUpperCase(),
      payment_method: paymentData.payment_method_types[0],
      transaction_id: paymentData.id,
      from: {
        name: &quot;Your Company Name&quot;,
        email: &quot;billing@yourcompany.com&quot;,
        address: &quot;123 Business St\nNew York, NY 10001&quot;,
      },
      to: {
        name: paymentData.metadata.customer_name || &quot;Customer&quot;,
        email: paymentData.receipt_email,
      },
      items: [
        {
          description: paymentData.description || &quot;Payment&quot;,
          quantity: 1,
          unit_price: paymentData.amount / 100,
        },
      ],
      notes: &quot;Thank you for your purchase!&quot;,
    }),
  });

  return await response.arrayBuffer();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. No HTML template. No CSS. No Puppeteer running on your server. The API returns a professionally formatted PDF with a PAID badge, payment method, transaction ID, and all the details your customer needs.&lt;/p&gt;
&lt;h3 id=&quot;step-3-email-it-to-the-customer&quot;&gt;Step 3: Email it to the Customer&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { Resend } from &quot;resend&quot;;

const resend = new Resend(process.env.RESEND_API_KEY);

async function emailReceipt(email, pdfBuffer, receiptNumber) {
  await resend.emails.send({
    from: &quot;billing@yourcompany.com&quot;,
    to: email,
    subject: `Receipt ${receiptNumber} — Thank you for your payment`,
    html: `&amp;lt;p&amp;gt;Hi,&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;Thanks for your payment. Your receipt is attached.&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;— Your Company&amp;lt;/p&amp;gt;`,
    attachments: [
      {
        filename: `receipt-${receiptNumber}.pdf`,
        content: Buffer.from(pdfBuffer).toString(&quot;base64&quot;),
      },
    ],
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;putting-it-all-together&quot;&gt;Putting It All Together&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function handlePayment(paymentIntent) {
  // Skip if no email
  if (!paymentIntent.receipt_email) return;

  const receiptNumber = `REC-${paymentIntent.id.slice(-8)}`;

  // Generate PDF
  const pdfBuffer = await generateReceipt(paymentIntent);

  // Email to customer
  await emailReceipt(
    paymentIntent.receipt_email,
    pdfBuffer,
    receiptNumber
  );

  console.log(`Receipt ${receiptNumber} sent to ${paymentIntent.receipt_email}`);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a Stripe payment succeeds:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Webhook fires&lt;/li&gt;
&lt;li&gt;We build receipt data from the payment intent&lt;/li&gt;
&lt;li&gt;Kagyz generates the PDF&lt;/li&gt;
&lt;li&gt;We email it to the customer&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The whole flow runs in under a second.&lt;/p&gt;
&lt;h3 id=&quot;adding-tax-support&quot;&gt;Adding Tax Support&lt;/h3&gt;
&lt;p&gt;If you need tax on receipts (VAT, GST, sales tax), add a &lt;code&gt;tax&lt;/code&gt; field:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// Invoice-level tax — applies to all items
tax: { rate: 20 }  // 20% VAT

// Or item-level tax — different rates per item
items: [
  {
    description: &quot;Software License&quot;,
    quantity: 1,
    unit_price: 99,
    tax: { rate: 20 }  // 20% on this item
  },
  {
    description: &quot;Support Plan&quot;,
    quantity: 1,
    unit_price: 29,
    tax: { rate: 0 }   // Tax-exempt
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Item-level tax overrides invoice-level tax for that item — useful for EU VAT where digital services and physical goods have different rates.&lt;/p&gt;
&lt;h3 id=&quot;why-not-just-use-stripe-s-built-in-receipts-&quot;&gt;Why Not Just Use Stripe's Built-in Receipts?&lt;/h3&gt;
&lt;p&gt;Stripe's receipts work, but they have limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stripe branding&lt;/strong&gt; — the receipt says Stripe, not your company&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limited customization&lt;/strong&gt; — you can't add custom fields, detailed tax breakdowns, or your own notes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No PDF file&lt;/strong&gt; — Stripe sends an email with a link, not a downloadable PDF attachment&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inconsistent with your other documents&lt;/strong&gt; — if you also generate invoices and quotes, they'll look different from Stripe's receipts&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With Kagyz, your receipts match your invoices. Same design, same layout, same professional look — just different content.&lt;/p&gt;
&lt;h3 id=&quot;testing-locally&quot;&gt;Testing Locally&lt;/h3&gt;
&lt;p&gt;Use the Stripe CLI to forward webhooks to your local server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install Stripe CLI, then:
stripe listen --forward-to localhost:3000/webhook/stripe

# In another terminal, trigger a test payment:
stripe trigger payment_intent.succeeded
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-full-picture&quot;&gt;The Full Picture&lt;/h3&gt;
&lt;p&gt;Once this is running, you have automated receipts with zero manual work. But the same pattern works for other documents too:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;payment_intent.succeeded&lt;/code&gt; → generate &lt;strong&gt;receipt&lt;/strong&gt; via &lt;code&gt;POST /v1/receipt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Subscription created → generate &lt;strong&gt;invoice&lt;/strong&gt; via &lt;code&gt;POST /v1/invoice&lt;/code&gt;  &lt;/li&gt;
&lt;li&gt;Deal closed in your CRM → generate &lt;strong&gt;quote&lt;/strong&gt; via &lt;code&gt;POST /v1/quote&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Refund issued → generate &lt;strong&gt;credit note&lt;/strong&gt; via &lt;code&gt;POST /v1/credit-note&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Same API, same design language, four document types. &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com/docs&quot;&gt;Full docs here&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Kagyz&lt;/strong&gt; is a JSON-to-PDF API for business documents. Free tier: 100 PDFs/month, no credit card needed.&lt;/p&gt;
&lt;p&gt;→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com&quot;&gt;kagyz.com&lt;/a&gt;&lt;br&gt;
→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com/docs&quot;&gt;API docs&lt;/a&gt;&lt;br&gt;
→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com/docs/receipt&quot;&gt;Receipt endpoint docs&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13179/auto-generate-pdf-receipts-after-stripe-payments</guid>
<pubDate>Thu, 19 Mar 2026 08:27:15 +0000</pubDate>
</item>
<item>
<title>We Added a Dashboard and Custom Branding to Our JSON-to-PDF API</title>
<link>https://coderlegion.com/13178/we-added-a-dashboard-and-custom-branding-to-our-json-to-pdf-api</link>
<description>&lt;p&gt;Two weeks ago Kagyz was a simple API: send JSON, get a PDF. That's still the core, but we just shipped two features that make it feel like a real product.&lt;/p&gt;
&lt;h3 id=&quot;dashboard&quot;&gt;Dashboard&lt;/h3&gt;
&lt;p&gt;Every API product needs a dashboard eventually. Ours lives at app.kagyz.com and covers the basics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;API key management&lt;/strong&gt; — create, delete, copy keys. Keys are shown once on creation and never again.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Usage stats&lt;/strong&gt; — see how many PDFs you've generated this month, broken down by day.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Billing&lt;/strong&gt; — see your current plan, upgrade, or manage your subscription.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Login is passwordless — enter your email, click the magic link, you're in. No passwords to remember or reset.&lt;/p&gt;
&lt;h3 id=&quot;custom-branding&quot;&gt;Custom Branding&lt;/h3&gt;
&lt;p&gt;This one was requested from day one. You can now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Upload your company logo&lt;/strong&gt; — it appears in the top corner of every document.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set an accent color&lt;/strong&gt; — applied to document titles, table headers, and dividers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Upload once in the dashboard. Every PDF you generate after that carries your branding. You can also override the accent color per-request if you need different colors for different clients:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;invoice_number&quot;: &quot;INV-001&quot;,
  &quot;branding&quot;: {
    &quot;accent_color&quot;: &quot;#ff6600&quot;
  },
  &quot;from&quot;: { &quot;name&quot;: &quot;Acme Corp&quot; },
  &quot;to&quot;: { &quot;name&quot;: &quot;Client Inc.&quot; },
  &quot;items&quot;: [
    { &quot;description&quot;: &quot;Consulting&quot;, &quot;quantity&quot;: 10, &quot;unit_price&quot;: 150 }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-kagyz-does&quot;&gt;What Kagyz Does&lt;/h3&gt;
&lt;p&gt;If you haven't seen it before — Kagyz is a JSON-to-PDF API for business documents. Send structured JSON, get a clean PDF back. No HTML templates, no CSS debugging, no Puppeteer.&lt;/p&gt;
&lt;p&gt;Currently supports 5 document types:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Invoice&lt;/li&gt;
&lt;li&gt;Receipt&lt;/li&gt;
&lt;li&gt;Quote / Estimate&lt;/li&gt;
&lt;li&gt;Credit Note&lt;/li&gt;
&lt;li&gt;Packing Slip&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Plus Arabic/RTL support, multi-currency, and item-level tax rates.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's Next&lt;/h3&gt;
&lt;p&gt;We're working on email delivery (generate + send in one API call) and PDF storage (get a hosted URL instead of binary). After that, a proper comparison article against the alternatives.&lt;/p&gt;
&lt;p&gt;Free tier is 100 PDFs/month with all features included. No feature gating between plans — you just pay for volume.&lt;/p&gt;
&lt;p&gt;Try it: &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com&quot;&gt;kagyz.com&lt;/a&gt;&lt;br&gt;
Dashboard: &lt;a rel=&quot;nofollow&quot; href=&quot;https://app.kagyz.com&quot;&gt;app.kagyz.com&lt;/a&gt;&lt;br&gt;
Docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com/docs&quot;&gt;kagyz.com/docs&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kagyz&quot;&gt;github.com/kagyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13178/we-added-a-dashboard-and-custom-branding-to-our-json-to-pdf-api</guid>
<pubDate>Thu, 19 Mar 2026 08:27:01 +0000</pubDate>
</item>
<item>
<title>Kagyz vs PDFShift vs CraftMyPDF — Which PDF API Should You Use?</title>
<link>https://coderlegion.com/13177/kagyz-vs-pdfshift-vs-craftmypdf-which-pdf-api-should-you-use</link>
<description>&lt;p&gt;I spent a lot of time evaluating PDF generation APIs before building my own. Here's an honest comparison of three tools — what they're good at, where they fall short, and who each one is for.&lt;/p&gt;
&lt;h3 id=&quot;the-short-version&quot;&gt;The Short Version&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Kagyz &lt;/th&gt;&lt;th&gt;PDFShift &lt;/th&gt;&lt;th&gt;CraftMyPDF&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Approach&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Send JSON, get a PDF &lt;/td&gt;&lt;td&gt;Send HTML, get a PDF &lt;/td&gt;&lt;td&gt;Design template in editor, fill with JSON&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Templates&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Built-in (no design needed) &lt;/td&gt;&lt;td&gt;You build HTML/CSS yourself &lt;/td&gt;&lt;td&gt;Drag-and-drop visual editor&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Invoices, receipts, quotes, business docs &lt;/td&gt;&lt;td&gt;Any HTML-to-PDF conversion &lt;/td&gt;&lt;td&gt;Custom-designed documents, reports&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Free tier&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;100 PDFs/month &lt;/td&gt;&lt;td&gt;50 credits/month &lt;/td&gt;&lt;td&gt;50 PDFs/month, 3 templates&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Paid from&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;$19/month &lt;/td&gt;&lt;td&gt;$9/month &lt;/td&gt;&lt;td&gt;$29/month&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Arabic/RTL&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Built-in &lt;/td&gt;&lt;td&gt;Not supported &lt;/td&gt;&lt;td&gt;Supported&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Custom branding&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Logo + accent color (all plans) &lt;/td&gt;&lt;td&gt;N/A (you control the HTML) &lt;/td&gt;&lt;td&gt;Full template control&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Dashboard&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Yes (API keys, usage, billing) &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Setup time&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;5 minutes &lt;/td&gt;&lt;td&gt;15-30 minutes &lt;/td&gt;&lt;td&gt;30-60 minutes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;kagyz-json-in-pdf-out&quot;&gt;Kagyz — JSON In, PDF Out&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com&quot;&gt;kagyz.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Kagyz takes a different approach. Instead of converting HTML to PDF, you send structured JSON and get back a formatted business document. No templates to build, no CSS to debug.&lt;/p&gt;
&lt;p&gt;It currently supports five document types: invoices, receipts, quotes/estimates, credit notes, and packing slips. All five share a clean, consistent design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I like:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Zero setup. Send JSON, get a PDF. No HTML, no CSS, no template design.&lt;/li&gt;
&lt;li&gt;Arabic/RTL support is built in — add &lt;code&gt;direction: &quot;rtl&quot;&lt;/code&gt; to any request and it works. 12+ Arabic currencies supported.&lt;/li&gt;
&lt;li&gt;Custom branding — upload your logo and pick an accent color. Every document comes out branded.&lt;/li&gt;
&lt;li&gt;All features available on every plan, including free. You only pay for volume.&lt;/li&gt;
&lt;li&gt;Dashboard for managing API keys, tracking usage, and handling billing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;What it doesn't do:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Only business documents. You can't generate arbitrary PDFs from HTML.&lt;/li&gt;
&lt;li&gt;Five document types currently — no reports, certificates, or contracts.&lt;/li&gt;
&lt;li&gt;Template design is fixed. You can add a logo and change the accent color, but you can't rearrange the layout.&lt;/li&gt;
&lt;li&gt;Newer product with a smaller community.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developers who need invoice/receipt/quote PDFs and don't want to deal with HTML templates. Especially strong for Arabic/MENA markets.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example request:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;POST https://api.kagyz.com/v1/invoice
{
  &quot;invoice_number&quot;: &quot;INV-001&quot;,
  &quot;currency&quot;: &quot;USD&quot;,
  &quot;branding&quot;: { &quot;accent_color&quot;: &quot;#22c55e&quot; },
  &quot;from&quot;: { &quot;name&quot;: &quot;Acme Corp&quot; },
  &quot;to&quot;: { &quot;name&quot;: &quot;Client Inc.&quot; },
  &quot;items&quot;: [
    { &quot;description&quot;: &quot;Consulting&quot;, &quot;quantity&quot;: 10, &quot;unit_price&quot;: 150 }
  ],
  &quot;tax&quot;: { &quot;rate&quot;: 8.25 }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. No HTML. No CSS. You get a branded invoice PDF back.&lt;/p&gt;
&lt;h3 id=&quot;pdfshift-html-to-pdf-conversion&quot;&gt;PDFShift — HTML to PDF Conversion&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://pdfshift.io&quot;&gt;pdfshift.io&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;PDFShift is the most straightforward HTML-to-PDF converter. Give it a URL or raw HTML, and it returns a PDF. It uses headless Chromium under the hood, so the output matches what you'd see in a browser.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I like:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dead simple API. One endpoint, one job.&lt;/li&gt;
&lt;li&gt;Supports modern CSS — flexbox, grid, web fonts, Tailwind, Bootstrap.&lt;/li&gt;
&lt;li&gt;Parallel conversions for batch processing.&lt;/li&gt;
&lt;li&gt;Webhook support for async generation.&lt;/li&gt;
&lt;li&gt;HIPAA compliant — doesn't store documents by default.&lt;/li&gt;
&lt;li&gt;Good documentation with examples in multiple languages.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;What it doesn't do:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You have to build and maintain your own HTML templates. That's the whole point — but it's also the main work.&lt;/li&gt;
&lt;li&gt;No visual template editor. Everything is code.&lt;/li&gt;
&lt;li&gt;No built-in document types. You're starting from scratch.&lt;/li&gt;
&lt;li&gt;No Arabic/RTL support out of the box.&lt;/li&gt;
&lt;li&gt;Pricing is credit-based (1 credit per 5MB of output), which can be unpredictable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that already have HTML templates and just need a reliable rendering engine. Great for converting existing web pages to PDF.&lt;/p&gt;
&lt;h3 id=&quot;craftmypdf-visual-template-editor-api&quot;&gt;CraftMyPDF — Visual Template Editor + API&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Website:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://craftmypdf.com&quot;&gt;craftmypdf.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;CraftMyPDF is the most feature-rich option. It offers a drag-and-drop template editor where you design your PDF layout visually, then fill it with data via API or no-code tools like Zapier and Make.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I like:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Visual drag-and-drop editor is genuinely good. Non-developers can design templates.&lt;/li&gt;
&lt;li&gt;Huge component library: text, images, QR codes, barcodes, charts, tables.&lt;/li&gt;
&lt;li&gt;Import existing PDFs as templates — overlay dynamic data on top.&lt;/li&gt;
&lt;li&gt;Fillable PDF forms (text fields, checkboxes, dropdowns).&lt;/li&gt;
&lt;li&gt;Regional API endpoints (US, EU, Singapore, Australia).&lt;/li&gt;
&lt;li&gt;Integrations with Zapier, Make, Bubble, Coda.&lt;/li&gt;
&lt;li&gt;Multi-language support including Arabic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;What it doesn't do:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;More complex to set up. You need to design a template before making your first API call.&lt;/li&gt;
&lt;li&gt;Steeper learning curve for the editor.&lt;/li&gt;
&lt;li&gt;Paid plans start at $29/month — more expensive entry point.&lt;/li&gt;
&lt;li&gt;Template limit on free tier (3 templates).&lt;/li&gt;
&lt;li&gt;Overkill if you just need standard invoices and receipts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Businesses that need custom-designed documents (reports, certificates, contracts) and want visual control over every pixel. Great for no-code teams using Zapier/Make.&lt;/p&gt;
&lt;h3 id=&quot;when-to-use-which&quot;&gt;When to Use Which&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Choose Kagyz if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need invoices, receipts, quotes, credit notes, or packing slips&lt;/li&gt;
&lt;li&gt;You want zero template work — just send JSON&lt;/li&gt;
&lt;li&gt;You serve Arabic/MENA markets and need RTL support&lt;/li&gt;
&lt;li&gt;You want all features on every plan (no feature gating)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose PDFShift if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You already have HTML templates and need a rendering engine&lt;/li&gt;
&lt;li&gt;You need to convert existing web pages to PDF&lt;/li&gt;
&lt;li&gt;You want the simplest possible API (URL in, PDF out)&lt;/li&gt;
&lt;li&gt;You need HIPAA compliance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose CraftMyPDF if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need full visual control over document design&lt;/li&gt;
&lt;li&gt;You generate documents beyond invoices (reports, certificates, contracts)&lt;/li&gt;
&lt;li&gt;Your team includes non-developers who need to edit templates&lt;/li&gt;
&lt;li&gt;You use no-code tools like Zapier or Make&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;pricing-comparison&quot;&gt;Pricing Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Plan &lt;/th&gt;&lt;th&gt;Kagyz &lt;/th&gt;&lt;th&gt;PDFShift &lt;/th&gt;&lt;th&gt;CraftMyPDF&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Free &lt;/td&gt;&lt;td&gt;100 PDFs/month, all features &lt;/td&gt;&lt;td&gt;50 credits/month &lt;/td&gt;&lt;td&gt;50 PDFs/month, 3 templates&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Entry paid &lt;/td&gt;&lt;td&gt;$19/month — 2,000 PDFs &lt;/td&gt;&lt;td&gt;$9/month — 500 credits &lt;/td&gt;&lt;td&gt;$29/month — 1,200 PDFs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mid &lt;/td&gt;&lt;td&gt;$49/month — 10,000 PDFs &lt;/td&gt;&lt;td&gt;$24/month — 2,500 credits &lt;/td&gt;&lt;td&gt;$59/month — 5,000 PDFs&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Note: PDFShift's credit system means 1 credit = 1 PDF up to 5MB. Larger documents cost more credits.&lt;/p&gt;
&lt;h3 id=&quot;my-take&quot;&gt;My Take&lt;/h3&gt;
&lt;p&gt;These tools solve different problems. PDFShift is a rendering engine — bring your own HTML. CraftMyPDF is a design platform — build templates visually. Kagyz is a document API — send data, get a finished document.&lt;/p&gt;
&lt;p&gt;If you're a developer building a SaaS and you just need invoice PDFs without the headache, Kagyz is the fastest path. If you need pixel-perfect custom layouts, CraftMyPDF gives you the most control. If you already have HTML and just need it converted, PDFShift is the simplest.&lt;/p&gt;
&lt;p&gt;I built Kagyz because I kept setting up Puppeteer for invoice PDFs in every project. Now I don't have to.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Kagyz: &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com&quot;&gt;kagyz.com&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://kagyz.com/docs&quot;&gt;Docs&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/kagyz&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;PDFShift: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pdfshift.io&quot;&gt;pdfshift.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;CraftMyPDF: &lt;a rel=&quot;nofollow&quot; href=&quot;https://craftmypdf.com&quot;&gt;craftmypdf.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13177/kagyz-vs-pdfshift-vs-craftmypdf-which-pdf-api-should-you-use</guid>
<pubDate>Thu, 19 Mar 2026 08:26:35 +0000</pubDate>
</item>
<item>
<title>Easily Convert PDF Files to Word Documents in R</title>
<link>https://coderlegion.com/1589/easily-convert-pdf-files-to-word-documents-in-r</link>
<description>&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=14673266400460095220&quot; alt=&quot;Convert to Docx R package&quot;&gt;&lt;/p&gt;
&lt;p&gt;In this post, I will show you how you can very easily convert your PDF files into Word documents in the R programming language using the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Ifeanyi55/Convert2Docx&quot;&gt;Convert2Docx&lt;/a&gt; R package.&lt;/p&gt;
&lt;p&gt;Convert2Docx is a lightweight R package that R developers can readily use in their workflow to convert PDF files to Word documents easily.&lt;/p&gt;
&lt;p&gt;I wrote the package as an R wrapper for the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pdf2docx.readthedocs.io/en/latest/&quot;&gt;pdf2docx&lt;/a&gt; Python module, which converts PDF files to Word documents in Python.&lt;/p&gt;
&lt;p&gt;The package contains just three functions for converting your PDF file to Word or DOCX.&lt;/p&gt;
&lt;p&gt;Now, let us see this package in action.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Install Package&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;At the moment, the Convert2Docx R package is only available on GitHub. Therefore, to install it, you will need to first of all install &lt;a rel=&quot;nofollow&quot; href=&quot;https://devtools.r-lib.org/&quot;&gt;devtools&lt;/a&gt;. Therefore, in your R console, run the code below to install devtools:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# install devtools 
install. Packages(&quot;devtools&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Having installed devtools, we can now install Convert2Docx package from GitHub. Run the code below to install it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# install Convert2Docx from Github
devtools::install_github(&quot;Ifeanyi55/Convert2Docx&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Awesome! We are now ready to do some conversion. The next thing to do now is to install the conversion engine. Run the below code to install it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# install engine
install_engine()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Please note that you only need to run this code once, and should you encounter any problem installing the engine, go to your terminal and run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pip install pdf2docx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This should take care of any dependency issues. Make sure you have the latest version of Python installed on your machine before running the code, though.&lt;/p&gt;
&lt;p&gt;After doing that, try installing the conversion engine again, and if all goes well, you should be able to access the full functionality of the package.&lt;/p&gt;
&lt;p&gt;Let us now start converting files!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Convert Entire PDF File&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It is good to mention at this point that you do not have to read the PDF file into your R environment before you can convert it. All you need to do is just specify the relative path to the file from your current working directory like so and run the converter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# convert entire pdf file
pdf_file &amp;lt;- &quot;myFile.pdf&quot;

Converter(pdf_file = pdf_file,
          docx_filename = &quot;myFile.docx&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, if you check your current working directory, you should see the converted file there. Let us explore this package further by converting from one page to another.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Convert From One Page to the Other&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here, we will use another function in the package to convert pages 3 to 5 of the PDF file to Word&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# convert from one page to the other
pdf_file &amp;lt;- &quot;myFile.pdf&quot;

startANDend(pdf_file = pdf_file,
            docx_filename = &quot;threetofive.docx&quot;,
            start = 2,
            end = 5)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is good to mention here that the pages of some PDF files might not be correctly numbered.&lt;/p&gt;
&lt;p&gt;Therefore, when the conversion to Word is done, especially when converting from one page to another, you could find that the page numbering is slightly different from what you were expecting.&lt;/p&gt;
&lt;p&gt;In the example above, page 3 of the PDF file starts at page 2 by page count, which is why you see &lt;code&gt;start = 2&lt;/code&gt; in the code instead of 3.&lt;/p&gt;
&lt;p&gt;However, this “problem” does not occur when you convert the entire document to Word as demonstrated earlier.&lt;/p&gt;
&lt;p&gt;Now, let us convert selected pages in the PDF file.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Convert Selected Pages&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;To do this, you will need to parse a numeric vector representing the pages from the PDF file you want to convert&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# convert selected pages
pdf_file &amp;lt;- &quot;myFile.pdf&quot;

selectPages(pdf_file = pdf_file,
            docx_filename = &quot;mySelectedPages.docx&quot;,
            pages = c(2,4,6))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the above code, I selected pages 2, 4, and 6 from the PDF file to be converted to Word, and it did a good job.&lt;/p&gt;
&lt;p&gt;As you convert, don’t forget to check your current working directory for the converted files.&lt;/p&gt;
&lt;p&gt;So, there you have it! Now you can easily convert your PDF files to Word documents in the R programming language thanks to the Convert2Docx R package. &lt;/p&gt;
&lt;p&gt;Please, do not forget to give the Convert2Docx R package’s &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Ifeanyi55/Convert2Docx&quot;&gt;GitHub repository&lt;/a&gt; a ⭐️ if you find it helpful.&lt;/p&gt;
&lt;p&gt;I hope you enjoyed reading this post.&lt;/p&gt;
&lt;p&gt;You can follow me on GitHub: Ifeanyi55 and on X: @Ifeanyidiaye.&lt;/p&gt;
</description>
<category>Getting Started</category>
<guid isPermaLink="true">https://coderlegion.com/1589/easily-convert-pdf-files-to-word-documents-in-r</guid>
<pubDate>Wed, 19 Mar 2025 17:31:00 +0000</pubDate>
</item>
</channel>
</rss>