Parsing PDFs & Office Documents

10 min read Module 2 of 9 Topic 4 of 25

What you'll learn

  • Explain why naive PDF text extraction breaks tables, columns, and reading order
  • Run a layout-aware parsing pipeline with Docling and export structured markdown
  • Design a normalized document model (blocks with types, hierarchy, and provenance) that downstream chunking can rely on
  • Handle DOCX, PPTX, and HTML sources through the same normalized pipeline as PDFs
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Every RAG failure post-mortem eventually reaches the same sentence: “we looked at what was actually in the index.” Wrong prices from shredded tables, policies attributed to the wrong section, footers stitched into the middle of paragraphs, entire tables reduced to a soup of numbers with no row or column meaning left. Retrieval and generation were fine, tuned carefully according to every later module in this course. The data was ruined at minute one, by parsing, and nothing downstream could recover what parsing threw away.

This lesson is about not ruining it, and it’s deliberately the first hands-on lesson in the course, because everything else assumes clean, structured input exists to work with.

Why PDFs fight you

A PDF is not a document in the semantic sense, whatever its file extension suggests. It’s a set of drawing instructions: place glyph, move cursor, draw line, fill rectangle. Reading order, paragraphs, tables, headers, footnotes: none of that exists in the format as data. It exists only in the visual arrangement on the page, and a parser has to reconstruct meaning from pixels and coordinates, which makes this fundamentally a computer vision and layout-analysis problem as much as a text-extraction one.

Naive extractors, the get_text() school of PDF handling that most quick-and-dirty scripts still use, read glyphs in stream order or raw coordinate order. On a clean single-column report with no tables, they mostly work, which is exactly why teams underestimate the problem until they hit a real corpus. On the documents enterprises actually have, full of tables, sidebars, and multi-column layouts, they fail in ways that are invisible until an answer built from their output is confidently wrong:

  • Multi-column layouts interleave: line one of column A, line one of column B, line two of column A, producing a document that reads as gibberish to both humans and embedding models.
  • Tables collapse into a run of cell values with no row or column boundaries, so a three-column pricing table becomes an unstructured sequence of numbers that could mean almost anything.
  • Headers and footers repeat on every page and get spliced into sentences mid-paragraph, since the extractor has no concept of “this text repeats on every page and is furniture, not content.”
  • Reading order breaks around figures, sidebars, pull quotes, and text boxes, often interleaving a caption with the paragraph that follows it on the page.
  • Footnotes and endnotes get pulled out of their anchor context entirely, floating as isolated fragments with no link back to the claim they were qualifying.

Layout-aware parsing with Docling

By 2026 the open-source default for this job is Docling (started at IBM Research, now a Linux Foundation AI project with wide community adoption). It runs layout detection and table-structure recognition models over each page, reconstructs reading order using the detected layout regions rather than raw coordinate order, classifies blocks by type, and exports clean structure. It handles PDF, DOCX, PPTX, XLSX, HTML, and images through one consistent interface, and it runs entirely locally, which matters enormously when documents contractually or legally cannot leave your infrastructure to reach a hosted API.

# pip install docling
from docling.document_converter import DocumentConverter

converter = DocumentConverter()

# Docling runs layout analysis + table structure recognition
# (its TableFormer model) on each page, then rebuilds the
# document as a tree of typed blocks in reading order.
result = converter.convert("q3-pricing-policy.pdf")
doc = result.document

# Export 1: markdown for humans, debugging, and LLM prompts.
# Tables come out as real markdown tables, not glyph soup.
markdown = doc.export_to_markdown()

# Export 2: the structured tree for your pipeline. Walk it to
# build your own document model with types and hierarchy intact.
for item, level in doc.iterate_items():
    # item.label is the block type: section_header, text,
    # table, list_item, picture, code, footnote...
    print(level * "  ", item.label, getattr(item, "text", "")[:60])

The exported markdown is what you’ll feed to chunking in Module 3, and it’s genuinely readable if you print it out mid-pipeline for debugging, which is worth doing on your first ten documents to build intuition for what “good parsing” looks like on your actual corpus. The structured tree, walked with iterate_items(), is what you’ll use to build the metadata every later lesson depends on: section paths for context, page numbers for citations, table boundaries for atomic chunking.

Normalize everything into one document model

Docling covers files well. But your corpus also has Confluence pages arriving as HTML, Slack threads arriving as JSON with a completely different shape, Notion exports, and ticket bodies from your helpdesk system. Handling each source format with its own bespoke downstream logic is how ingestion pipelines become unmaintainable within a year. The winning move, and the one every subsequent module in this course assumes you made, is to normalize every source into one internal schema before chunking, so every downstream stage handles exactly one shape regardless of where the document came from:

# The document model every connector must produce.
# One schema in, regardless of source format.

from dataclasses import dataclass, field

@dataclass
class Block:
    type: str          # "heading" | "paragraph" | "table" | "list" | "code"
    text: str          # markdown-rendered content of the block
    level: int = 0     # heading depth, 0 for non-headings
    page: int | None = None   # provenance for citations

@dataclass
class ParsedDoc:
    doc_id: str            # stable ID from the source system
    source: str            # "confluence" | "drive" | "slack" | ...
    title: str
    url: str               # deep link users can open from a citation
    updated_at: str        # ISO timestamp, drives freshness logic
    acl_tags: list[str]    # who may see this (Module 7 depends on this!)
    blocks: list[Block] = field(default_factory=list)

Two fields here quietly carry half the course’s later material, and skipping them at parse time means re-crawling every single source later to backfill them, which on a large corpus is a multi-day operation you want to avoid ever repeating. updated_at powers incremental sync in Lesson 6, telling the system when a document last changed so it knows whether to re-process it. acl_tags powers permission-aware retrieval in Module 7, and it is the single field that, if missing or wrong, turns your knowledge assistant into a data leak.

Applying this to a non-PDF source is mostly a matter of writing a thin adapter. A Confluence connector, for instance, walks the page’s HTML (or uses the Confluence API’s structured content model directly, which is cleaner where available), maps <h2> to a heading block at level 2, maps <table> to a table block, and maps the page’s own last-modified timestamp and space-level permissions into updated_at and acl_tags. The adapter is source-specific; the output it produces is not, and that’s the entire point.

Practical rules that save reindexing later

Strip repeated furniture. Detect headers and footers by cross-page repetition (the same text appearing in the same position across most pages of a document) and drop them from the block stream entirely. They add noise to embeddings and waste tokens in every prompt they sneak into, and worse, a footer containing a copyright year can confuse a chunk about which year the surrounding content actually refers to.

Keep tables intact and typed. A table exported as markdown, with its caption and nearest preceding heading attached as context, is one of the most retrieval-valuable objects in an enterprise corpus, because tables are exactly where the specific numbers users search for tend to live. Never let a chunker split a table across chunk boundaries; Module 3 treats tables as atomic units for precisely this reason.

Preserve provenance per block. Page number and section path per block make span-level citations possible later, in Module 6. Users trust “page 12, Section 4.2, updated March 2026” far more than a bare document link with no indication of where in a 40-page document the relevant text actually sits, and that trust difference shows up directly in whether people click citations to verify an answer.

Route by confidence. Docling reports when a page has no extractable text layer at all, or when layout confidence is low (a common signal on documents with unusual layouts, heavy scanning artifacts, or handwritten annotations mixed with print). Send those pages to the VLM path covered in the next lesson, rather than accepting a low-confidence parse into your index silently. A parsing pipeline that fails loudly on hard pages is more valuable than one that fails quietly and lets bad chunks through.

Knowledge Check

4 questions to test your understanding

1 A pricing table parsed with a naive extractor turned into a single line of jumbled numbers, and the assistant now quotes the wrong tier price. What is the root cause?

2 Why should parsed output preserve heading hierarchy (H1/H2/H3) instead of flattening everything to plain text?

3 Your corpus has 60,000 born-digital PDFs and 4,000 scanned ones. What is the pragmatic parsing strategy?

4 A DOCX file with nested bullet lists, a footnote, and an embedded Excel table needs to enter the same pipeline as your PDFs. What is the correct design?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan