We needed a reliable way to pull structure out of financial PDFs: text, tables, page numbers, and bounding boxes. Not “roughly understand the document” — a checkable artifact we could index and query later.
This is not a tutorial on Docling. It’s a short write-up of four problems we actually hit, the options we considered, and why we chose the path we did.
The goal
Input: a PDF
Output: a structured object with text chunks, tables, multi-page table groups, and provenance (page_no, bbox) for every element. Plus a strict schema so nothing silently drifts downstream.
Stack for this stage: Docling for layout-aware parsing, Pydantic for the schema, tests from day one.
Problem 1. One form type “lost” its tables
We fed in a dense regulatory form. The parser found one table. The other three were flattened into text items — stock name, date, transaction code, quantity — each as a separate TextItem.
Options we considered:
Custom coordinate clustering
Rebuild rows and columns from X/Y positions ourselves.
Pros: we might recover all four tables.
Cons: several days of work that mostly reimplements what a layout parser already tries to do — for one awkward form type.
Ask an LLM to reconstruct the table
Pros: sometimes it understands context.
Cons: non-deterministic, slow, expensive. Bad fit for a base layer that must be repeatable.
Change the test document
Treat this form as a known edge case, switch to a report where tables have clear borders, and move on.
We took the third option. Not because those forms don’t matter, but because polishing one edge case for a week would block proving that the rest of the pipeline works on normal documents.
Takeaway: prove the pipeline on standard data first. Special forms can wait.
Problem 2. Bounding-box validation was a silent pass
In the schema, if left > right or top > bottom, the code just did pass. Classic “I’ll fix it later.” In production that means garbage coordinates flow downstream without anyone noticing.
Options:
- Leave it — fast, unsafe
- Raise
ValueErrorand fail the whole document — too aggressive; one bad block kills the file - Normalize: swap the bounds when they’re inverted
We normalized. If a table is truly upside-down in a weird way, we might miss it. But we don’t drop the document, and we keep a clear invariant: left < right, top < bottom.
Takeaway: in a strict schema, never leave a silent pass. Either fix the data with an explicit rule or fail loudly.
Problem 3. Money normalization was wrong
Values like ($1,250) were supposed to become negative numbers. We got things like -$1250 — the dollar sign stayed. Tests that expected a clean number failed.
Root cause: we stripped parentheses first, then commas, and never touched the currency symbol. Order of operations mattered more than “what looks logical.”
Working approach:
- Strip everything that isn’t a digit or a dot
- Look at the original string to decide if parentheses mean “negative”
- If cleaning leaves an empty string, don’t invent a number — keep the original or mark it
Takeaway: normalization is a contract. Define what counts as a number, what happens to junk, and in which order rules apply.
Problem 4. Table-grouping heuristic was too soft
We wanted to group multi-page tables: nearby pages, similar headers, same section. The first version used a soft check — “if a piece of one header appears inside the other.” In practice "Net" happily merged with "Net Income".
Options:
- Soft substring match — many merges, including wrong ones
- Strict header equality — fewer groups, each one explainable
- Similarity score (e.g. Jaccard) with a threshold — flexible, but harder to tune and debug
For the first version we chose strict equality. Tables marked “continued” might not merge. That’s fine. We preferred a controlled miss over silent false merges.
Takeaway: for an MVP, a strict heuristic with known limits beats a clever one that fails quietly.
Bonus debate: “another model found 61 tables, we found 184”
Someone ran the same files through a different stack and got a smaller table count. The temptation was to add filters until our number looked “nicer.”
We didn’t. Different tools use different definitions of “table.” Our parser often treats every visual grid as a table. Some of that is noise; some is useful context. Trimming everything for a pretty metric means losing data and weakening auditability.
We documented the behavior instead: this is what the parser returns; these are the limits; the system has to live with that volume.
What we locked in
By the end of this stage we had:
- Deterministic parse output with provenance (page, bbox, source ref)
- A strict schema with no silent holes
- A conservative table-grouping rule for the MVP
- Tests for schema, money normalization, and grouping
- An explicit list of known limits (collapsed multi-level headers, messy complex tables, occasional leftover currency symbols)
Principles we kept:
- Don’t polish the parser forever for one form type
- No silent
passin data contracts - In normalization, clean first, then apply meaning
- Prefer a strict heuristic over a “smart” one that lies
- Don’t chase someone else’s table count — define what you mean by a table and make it checkable
Next step is indexing and comparing chunking strategies (text-only vs hybrid vs table-aware). None of that matters if the PDF parse underneath is sand.
If you’ve parsed similar report-style PDFs: where did switching test data save you time, and where did you still have to write custom logic on top of a layout parser?
Top comments (0)