<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zane Kwok</title>
    <description>The latest articles on DEV Community by Zane Kwok (@zane_kwok_d4c4bb8b83e6959).</description>
    <link>https://dev.to/zane_kwok_d4c4bb8b83e6959</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4026261%2F8e891fe1-16c3-4d4f-8e83-4916fe03ef0b.png</url>
      <title>DEV Community: Zane Kwok</title>
      <link>https://dev.to/zane_kwok_d4c4bb8b83e6959</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zane_kwok_d4c4bb8b83e6959"/>
    <language>en</language>
    <item>
      <title>I thought my PDF parser was done — then I ran it on 80 real resumes</title>
      <dc:creator>Zane Kwok</dc:creator>
      <pubDate>Sun, 12 Jul 2026 17:29:53 +0000</pubDate>
      <link>https://dev.to/zane_kwok_d4c4bb8b83e6959/i-thought-my-pdf-parser-was-done-then-i-ran-it-on-80-real-resumes-12pe</link>
      <guid>https://dev.to/zane_kwok_d4c4bb8b83e6959/i-thought-my-pdf-parser-was-done-then-i-ran-it-on-80-real-resumes-12pe</guid>
      <description>&lt;p&gt;Building RAG pipelines, the step that kept letting me down was the very first one: turning a PDF into text. Two runs of the same file could give slightly different output, tables collapsed into garbled lines, and Chinese PDFs were mostly a coin flip. When your chunks change between runs, so do your embeddings and your eval numbers — and you stop trusting the index.&lt;/p&gt;

&lt;p&gt;So I wrote &lt;a href="https://github.com/casperkwok/pdfmuse" rel="noopener noreferrer"&gt;&lt;strong&gt;pdfmuse&lt;/strong&gt;&lt;/a&gt;: a PDF/DOCX parser with one goal — be a precise, &lt;em&gt;boring&lt;/em&gt; pre-layer for RAG. Same file in, same output out, every time. No probabilistic models in the core path.&lt;/p&gt;

&lt;p&gt;This post is partly about how it works, and partly about the humbling bug I only found by dogfooding it on real data. That second part is the one worth your time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One Rust core, three bindings (Python / Node / WASM) that are byte-identical.&lt;/strong&gt; They all serialize through one path; a CI gate fails if their output diverges by a single byte. Prototype in Python, ship in Node/WASM, get the exact same chunks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast.&lt;/strong&gt; On a public, reproducible benchmark (65 arXiv papers — the fetch script ships in the repo, so you can rerun it) it's &lt;strong&gt;~7.7× faster than PyMuPDF and wins every file&lt;/strong&gt;, and ~150× faster than pdfplumber, at 100% character coverage. That came from an O(1)-per-object parser and a zero-allocation content-stream tokenizer instead of leaning on the general-purpose library underneath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic + zero ML in the core.&lt;/strong&gt; Scanned/image-only pages don't get guessed at — they return a &lt;code&gt;NeedsOcr&lt;/code&gt; warning, and OCR/layout inference are a pluggable backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CJK is first-class.&lt;/strong&gt; CID/Type0 fonts and CMap/ToUnicode are in the main path, not bolted on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can try it in your browser (WASM, nothing gets uploaded — your file never leaves the tab): &lt;strong&gt;&lt;a href="https://casperkwok.github.io/pdfmuse/" rel="noopener noreferrer"&gt;https://casperkwok.github.io/pdfmuse/&lt;/a&gt;&lt;/strong&gt; — drag a PDF and watch it come apart into text-with-coordinates, tables, and Markdown.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pdfmuse        &lt;span class="c"&gt;# or: npm i @pdfmuse/node  /  cargo add pdfmuse-core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pdfmuse&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfmuse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# plain reading-order text
&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfmuse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_markdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# headings + tables
&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdfmuse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# full IR: chars/blocks with exact bboxes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part where I was wrong
&lt;/h2&gt;

&lt;p&gt;I had a test suite. Snapshot tests, a fuzzer, cross-binding parity, a CJK suite — all green. I ran it on a handful of sample resumes and the output looked great. I was ready to call the extraction layer done.&lt;/p&gt;

&lt;p&gt;Then I ran it against &lt;strong&gt;80 real resumes&lt;/strong&gt; pulled from a product database (I build a resume tool on the side). The plan was a boring "shadow comparison" vs PyMuPDF to confirm parity before switching anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8 of the 80 came back with zero characters.&lt;/strong&gt; Not garbled — &lt;em&gt;empty&lt;/em&gt;. And it failed &lt;strong&gt;silently&lt;/strong&gt;: no warning, no error, just an empty document. PyMuPDF read all 8 fine.&lt;/p&gt;

&lt;p&gt;That's a 10% total-failure rate, invisible to my entire test suite, because my test files happened to be clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chasing it down
&lt;/h3&gt;

&lt;p&gt;The 8 failing files all shared traits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All used &lt;strong&gt;Type0 / Identity-H subset fonts&lt;/strong&gt; — and they &lt;em&gt;had&lt;/em&gt; valid &lt;code&gt;ToUnicode&lt;/code&gt; maps. So it wasn't a missing-CMap problem.&lt;/li&gt;
&lt;li&gt;7 of 8 were generated by &lt;strong&gt;Canva&lt;/strong&gt;, the 8th by &lt;strong&gt;PDFium&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The tell: the failing files had &lt;strong&gt;2–15 form XObjects&lt;/strong&gt; each; every file that worked had &lt;strong&gt;zero&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There it was. Canva (and Chrome's PDFium, and plenty of design tools) draw the page's text &lt;em&gt;inside a form XObject&lt;/em&gt;, invoked by a &lt;code&gt;Do&lt;/code&gt; operator. My content-stream interpreter didn't handle &lt;code&gt;Do&lt;/code&gt; at all — and, worse, a lazy-loading optimization I'd added skipped &lt;code&gt;/XObject&lt;/code&gt; bodies for speed. So the actual page text was never even parsed. On a clean PDF where text lives in the page content stream directly, everything worked. On a Canva export, I silently got nothing.&lt;/p&gt;

&lt;p&gt;The fix was to make the interpreter recurse into form XObjects (decode the stream, apply its &lt;code&gt;/Matrix&lt;/code&gt;, build its own font map, recurse with a depth guard) and stop skipping them in the loader. After that:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;zero-char failures: &lt;strong&gt;8/80 → 0/80&lt;/strong&gt;. The 10 form-XObject files now match PyMuPDF ~100%.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The lesson isn't "I wrote a bug."&lt;/strong&gt; It's that a green test suite on synthetic fixtures told me nothing about the real distribution. Canva is one of the most popular resume builders on earth; "10% of resumes" is not an edge case. I only found it because I ran it on real data before trusting it. If you're swapping any parser into a pipeline, do the shadow-comparison on &lt;em&gt;your&lt;/em&gt; corpus first — the demo files lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  A performance truth, too
&lt;/h2&gt;

&lt;p&gt;While I was in there: I'd been claiming "4× faster than PyMuPDF." For a Python user, that turned out to be… not quite true. The Rust core parses in ~1.3 ms, but if you &lt;code&gt;json.loads&lt;/code&gt; the full intermediate representation (every char with its bbox) back into Python, that deserialization costs ~3.5 ms and eats the win — you end up roughly on par with PyMuPDF.&lt;/p&gt;

&lt;p&gt;The fix was obvious once measured: if you only want text, don't materialize the whole IR. &lt;code&gt;to_text()&lt;/code&gt; / &lt;code&gt;to_markdown()&lt;/code&gt; now return a string straight from Rust, and the Python text path is back to full core speed. Measure the path your users actually take, not the one that flatters your benchmark. (Profiling that same corpus later turned up two more hotspots — a table detector that mistook a dense figure for a 1000-cell table, and a form XObject re-parsed 18,000 times on a single page — and fixing those is what got it to winning every file.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it in RAG
&lt;/h2&gt;

&lt;p&gt;There are drop-in loaders for the three big frameworks, all emitting chunks with &lt;code&gt;heading_path&lt;/code&gt; + &lt;code&gt;bbox&lt;/code&gt; metadata (which section, where on the page):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# LangChain
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_pdfmuse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfmuseLoader&lt;/span&gt;
&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfmuseLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# LlamaIndex
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;llama_index.readers.pdfmuse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfmuseReader&lt;/span&gt;
&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfmuseReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;load_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Haystack
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pdfmuse_haystack&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfmuseConverter&lt;/span&gt;
&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PdfmuseConverter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;markdown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No OCR&lt;/strong&gt; (by design). Scanned pages return &lt;code&gt;NeedsOcr&lt;/code&gt;; OCR is a backend, kept out of the deterministic core.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading order on dense two-column academic layouts.&lt;/strong&gt; Single-column, tables, and clean two-column read correctly, but papers with very tight gutters (think IEEE/arXiv, where equations bleed into the column gap) can still interleave the two columns. It's a hard geometric-layout problem I'm deliberately leaving to a future vision backend rather than faking with brittle heuristics.&lt;/li&gt;
&lt;li&gt;Young project (v0.1.x). The core tests are strong, but see the story above about sample size.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it / break it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playground:&lt;/strong&gt; &lt;a href="https://casperkwok.github.io/pdfmuse/" rel="noopener noreferrer"&gt;https://casperkwok.github.io/pdfmuse/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/casperkwok/pdfmuse" rel="noopener noreferrer"&gt;https://github.com/casperkwok/pdfmuse&lt;/a&gt; (MIT OR Apache-2.0)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it, I'd genuinely love the files it gets wrong — that feedback is worth more than stars. Thanks for reading.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>python</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
