PDF/DOCX Lease Ingestion Workflows

The first-mile ingestion stage that turns raw lease PDFs and DOCX files into structured, hash-verified text for ASC 842 / IFRS 16 — deterministic format routing, a normalized page-map schema, decimal-safe extraction, and the encoding and scan-quality gotchas that corrupt downstream clause parsing.

Ingestion is the single point where a lease first crosses from an opaque binary file into the financial-reporting control environment, and it is the stage most often built carelessly because it looks like plumbing rather than accounting. It is not: a mis-decoded ligature in a payment table, a two-column PDF read in the wrong order, or a scanned amendment silently skipped because it carried no text layer will each corrupt every number the standards later test against. A missed renewal page understates the lease term, a garbled escalation figure understates the lease liability, and a dropped page becomes an audit qualification that no amount of downstream sophistication can recover. This page treats ingestion as a deterministic, hash-verified transform — raw bytes in, a normalized page-map with per-element provenance out — so that corporate accountants get a document they can reconcile against on demand and FinTech engineers get an idempotent stage they can test byte-for-byte. It is the foundational ingestion layer inside the lease document extraction and clause parsing pipelines architecture, and it hands its structured output to NLP clause extraction and tagging downstream.

PDF/DOCX lease ingestion data-flow overview A raw lease file crosses an ingestion boundary where it is first SHA-256 hashed to produce an idempotency key, then reaches a highlighted routing node that sniffs the magic bytes and scan quality. The node splits into three lanes: a native DOCX file walks its XML tree, a text-layer PDF is read with coordinate-aware extraction, and a scanned or image-only PDF falls through to OCR gated by a mean-confidence threshold tau. All three lanes converge on a single highlighted normalized page-map object carrying document_id, a contiguous page_map, and metadata, which then flows right into NLP clause extraction downstream. Raw file bytes in SHA-256 hash content_hash idempotency key sniff bytes route lane DOCX XML tree walk paragraphs · tables Text-layer PDF coordinate-aware reading order kept Scanned PDF rasterize · OCR gate mean c ≥ τ Normalized page-map document_id page_map 1..N metadata → clause extraction
Ingestion as a deterministic transform: raw bytes are hashed for idempotency, the highlighted routing node sniffs magic bytes to pick one of three lanes, and all lanes converge on a single normalized page-map that flows into clause extraction.

Standard References: Why Ingestion Is a Measurement Control Link to this section

Ingestion does not classify or measure anything, but it is the stage that decides which facts the standards will ever get to see. Both frameworks define a lease by contractual substance, so if a fact never survives ingestion, the downstream engine cannot test for it. Under ASC 842 (FASB Accounting Standards Codification Topic 842, Leases) and IFRS 16 (Leases, IASB), a contract is or contains a lease if it conveys the right to control the use of an identified asset for a period of time in exchange for consideration (ASC 842-10-15-3; IFRS 16.9). That control test can only be applied against the asset description, the term language, and the consideration structure — all of which live in the document that ingestion must render faithfully.

Two document-integrity obligations bind this stage in particular. First, the completeness of the extracted contract underpins the measurement inputs: variable-payment triggers (ASC 842-10-30-5; IFRS 16.27), renewal and termination options feeding the term assessment, and lease incentives feeding the right-of-use asset construction all originate as clauses that must first be extracted intact. Second, both standards operate inside a financial-reporting control framework (SOX §404 for SEC filers) that requires a lease number to trace back to its source contract; a page-map without per-element provenance breaks that audit trail before any accountant ever sees the figure. Ingestion is therefore standard-neutral — it emits jurisdiction-agnostic structured text — but it is the control that determines whether the core measurement architecture is working from the true contract or a lossy copy of it.

Input / Output Specification Link to this section

The ingestion contract is deliberately narrow: any supported binary in, one normalized page-map out. Locking the schema at this boundary is what lets every later stage be tested independently of file format.

Field Type Direction Validation rule
raw_bytes bytes in Non-empty; MIME sniffed, not trusted from extension
declared_format enum{pdf, docx} in Must match magic-byte signature (%PDF, PK\x03\x04)
document_id str (UUIDv4) out Stable per logical document across retries
content_hash str (SHA-256 hex) out Computed over raw_bytes; drives idempotency
page_map dict[int, PageBlock] out Keys are contiguous 1..N; no gaps
page_map[n].paragraphs list[str] out Reading-order corrected; no header/footer bleed
page_map[n].tables list[list[list[str]]] out Row/column aligned; empty cells preserved as ""
metadata dict out execution_date, jurisdiction, lessor, lessee where present
ocr_confidence Decimal or None out None for native text; Decimal 0–1 for OCR pages
source_ref[element] (page, bbox) out Every element carries page + bounding box for provenance

The invariant that matters most: page_map keys must be contiguous. A gap means a page was silently dropped, which is the single most dangerous ingestion failure because it is invisible downstream — the pipeline happily parses the pages it did receive and reconciles cleanly against nothing.

Formula Block: Idempotency Key and OCR Acceptance Gate Link to this section

Two small pieces of math govern this stage. The idempotency key is the content hash, so that re-ingesting the same bytes is a no-op rather than a duplicate liability recognition:

where logical_key ties a re-scanned or re-uploaded copy of the same contract to its existing record. The second is the OCR acceptance gate. For a scanned page, ingestion computes a mean recognition confidence and compares it to a calibrated threshold below which the page is routed to review rather than trusted:

where is the per-token confidence returned by the OCR engine over recognized tokens, and is tuned per document class (dense legal boilerplate tolerates a lower than a payment table, where a single mis-read digit is material). Confidence is carried as a Decimal so the gate comparison is exact and reproducible.

Step-by-Step Python Implementation Link to this section

The following builds the ingestion transform in five steps. Each converges on the normalized page-map defined in the specification table above.

Step 1 — Hash first, then route on sniffed format Link to this section

Compute the content hash before anything else so retries are idempotent, and route on the magic bytes rather than the filename — a .pdf extension on a DOCX payload is a common vendor-portal error.

import hashlib
from decimal import Decimal
from dataclasses import dataclass, field

@dataclass(frozen=True)
class IngestResult:
    document_id: str
    content_hash: str
    page_map: dict            # {page_no: {"paragraphs": [...], "tables": [...]}}
    metadata: dict
    ocr_confidence: Decimal | None = None

def sniff_format(raw: bytes) -> str:
    if raw[:4] == b"%PDF":
        return "pdf"
    if raw[:4] == b"PK\x03\x04":   # DOCX is a ZIP container
        return "docx"
    raise ValueError("Unsupported or corrupt lease document")

def content_hash(raw: bytes) -> str:
    return hashlib.sha256(raw).hexdigest()

Step 2 — Parse native DOCX as a structured XML tree Link to this section

Word files expose a document object model, so paragraphs and tables come out already segmented. Preserve empty cells as "" rather than dropping them — an empty rent cell is a fact.

from docx import Document
import io

def ingest_docx(raw: bytes) -> tuple[dict, dict]:
    doc = Document(io.BytesIO(raw))
    paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
    tables = [
        [[cell.text for cell in row.cells] for row in tbl.rows]
        for tbl in doc.tables
    ]
    core = doc.core_properties
    metadata = {"lessor": None, "lessee": None,
                "execution_date": core.created}
    # DOCX is single-stream; treat as one logical page block.
    page_map = {1: {"paragraphs": paragraphs, "tables": tables}}
    return page_map, metadata

Step 3 — Extract text-layer PDFs with coordinate awareness Link to this section

For PDFs carrying an embedded text layer, use coordinate-aware extraction so multi-column and table layouts keep reading order. This is where naive extractors silently interleave columns.

import pdfplumber
import io

def ingest_text_pdf(raw: bytes) -> dict:
    page_map = {}
    with pdfplumber.open(io.BytesIO(raw)) as pdf:
        for i, page in enumerate(pdf.pages, start=1):
            text = page.extract_text(x_tolerance=1.5, y_tolerance=3) or ""
            tables = page.extract_tables() or []
            page_map[i] = {
                "paragraphs": [ln for ln in text.split("\n") if ln.strip()],
                "tables": tables,
            }
    return page_map

Step 4 — Fall through to OCR and apply the acceptance gate (, ) Link to this section

A PDF with no recoverable text layer is a scan. Rasterize it, run OCR, and apply the acceptance gate from the formula block — pages below are flagged, not trusted. The heavy detail of tuning this stage lives in the Python OCR pipeline for legacy lease PDFs.

import pytesseract
from pdf2image import convert_from_bytes

TAU = Decimal("0.80")   # calibrated per document class

def ingest_scanned_pdf(raw: bytes) -> tuple[dict, Decimal]:
    page_map, confidences = {}, []
    for i, image in enumerate(convert_from_bytes(raw, dpi=300), start=1):
        data = pytesseract.image_to_data(
            image, output_type=pytesseract.Output.DICT
        )
        tokens = [(t, int(c)) for t, c in
                  zip(data["text"], data["conf"]) if t.strip() and int(c) >= 0]
        if tokens:
            page_conf = Decimal(sum(c for _, c in tokens)) / Decimal(len(tokens)) / Decimal(100)
        else:
            page_conf = Decimal("0")
        confidences.append(page_conf)
        page_map[i] = {
            "paragraphs": [t for t, _ in tokens],
            "tables": [],
            "needs_review": page_conf < TAU,   # acceptance gate
        }
    mean_conf = (sum(confidences) / Decimal(len(confidences))
                 if confidences else Decimal("0"))
    return page_map, mean_conf

Step 5 — Assemble the normalized result and verify contiguity Link to this section

Converge all three lanes on one IngestResult and assert the page keys are contiguous before the document leaves the stage — this is the guard against the silent dropped-page failure.

import uuid

def ingest(raw: bytes, logical_key: str) -> IngestResult:
    fmt = sniff_format(raw)
    chash = content_hash(raw)
    doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{logical_key}:{chash}"))
    ocr_conf = None

    if fmt == "docx":
        page_map, metadata = ingest_docx(raw)
    else:
        page_map = ingest_text_pdf(raw)
        if not any(p["paragraphs"] for p in page_map.values()):
            page_map, ocr_conf = ingest_scanned_pdf(raw)
        metadata = {"execution_date": None, "lessor": None, "lessee": None}

    pages = sorted(page_map)
    assert pages == list(range(1, len(pages) + 1)), "Non-contiguous page map — a page was dropped"

    return IngestResult(doc_id, chash, page_map, metadata, ocr_conf)
Ingestion routing decision flowchart A top-to-bottom decision flow. Raw bytes are SHA-256 hashed, then a magic-byte sniff branches the document. A DOCX file walks its XML tree. A PDF reaches a decision node asking whether an embedded text layer exists: if yes, coordinate-aware pdfplumber extraction runs; if no, the page is rasterized and OCR'd, then a second decision node tests whether the mean confidence meets the threshold tau — pages at or above tau are accepted while pages below tau are flagged needs_review and held for human review. All lanes converge to assemble the page_map, after which a final assertion checks that the page keys are contiguous 1 to N; a gap raises a dropped-page error, and a contiguous map emits the IngestResult. Raw bytes SHA-256 → content_hash sniff magic bytes DOCX lane XML tree walk text layer? pdfplumber extract coordinate-aware rasterize + OCR per-token conf mean c ≥ τ ? flag needs_review hold for human Assemble page_map converge all lanes keys contiguous 1..N ? raise dropped-page error · block stage emit IngestResult DOCX PDF yes no < τ ≥ τ gap ok
The routing logic unique to this stage: sniff bytes to pick a lane, test whether a PDF carries a text layer, gate OCR output against τ, then assert page-map contiguity so a silently dropped page raises an error instead of reconciling cleanly against nothing.

Enterprise Architecture & Portfolio Scaling Link to this section

Enterprise lease portfolios do not arrive as single tidy uploads; they stream in from procurement portals, mailbox attachments, and vendor APIs, often as multi-hundred-page bundles with stapled amendments. Ingestion therefore cannot run synchronously behind an upload request. Each document is dispatched through async batch processing for lease portfolios, where the content hash from Step 1 doubles as the exactly-once key so a retried or re-delivered file never produces a duplicate record. Malformed payloads — corrupt PDF headers, truncated ZIP containers, unsupported formats — are captured with their exception context and routed to a dead-letter queue for adjudication rather than crashing the batch. Pages that fall below the OCR acceptance threshold are flagged needs_review and held for a human-in-the-loop queue; an unverified payment figure must never silently reach the ledger. Once a document clears ingestion, its normalized page-map is streamed to the extraction stage, decoupling ingestion throughput from downstream general-ledger posting so accounting can run validation in parallel while engineers scale the parser pool horizontally.

Debugging & Precision Gotchas Link to this section

These failure modes account for the majority of reconciliation breaks traced back to the ingestion stage. Each has a concrete correction.

  1. Silent dropped page. A page that fails to parse gets skipped and the pipeline reconciles cleanly against the pages it did receive. Fix: build page_map keys from the source page count and assert contiguity (Step 5) — never let a parser filter a page out silently.

  2. Format trusted from the file extension. A DOCX renamed .pdf (a common vendor-portal habit) hits the wrong parser and yields empty text that then falls through to a pointless OCR pass. Fix: sniff magic bytes (%PDF, PK\x03\x04) in Step 1 and ignore the declared extension.

  3. Multi-column PDF read across columns. A default text extractor concatenates a two-column lease left-to-right across the page, splicing unrelated clauses into one sentence and breaking the downstream tagger. Fix: use coordinate-aware extraction with tuned x_tolerance/y_tolerance (Step 3), or segment columns by x-position first.

  4. OCR confidence stored as float. Carrying the acceptance-gate confidence as a binary float makes the >= τ comparison non-reproducible at the boundary and fails a deterministic re-run. Fix: keep ocr_confidence and as Decimal (Step 4) so the gate decision is exact.

  5. Encoding and ligature corruption. A PDF that encodes fi, , or a soft hyphen as a private-use glyph silently mangles amounts and defined terms. Fix: normalize extracted text with unicodedata.normalize("NFKC", text) before it leaves ingestion, and unit-test against a known-ligature fixture.

Compliance Checkboxes Link to this section

Complete this validation list before an ingested document is passed to clause extraction and the period is closed:

Frequently Asked Questions Link to this section

Why compute a content hash before parsing instead of trusting a filename or upload ID?

Because enterprise portfolios re-deliver the same contract constantly — a vendor re-sends an attachment, a portal re-syncs, a user re-uploads — and each of those events would otherwise create a duplicate lease record and, worse, a duplicate liability recognition. A SHA-256 hash over the raw bytes is a stable fingerprint of the document itself, so pairing it with a logical key (Step 1) makes ingestion idempotent: identical bytes resolve to the same document_id and the second delivery becomes a no-op.

When should a PDF fall through to the OCR lane?

Only when it has no recoverable embedded text layer. Many lease PDFs are digitally generated and carry selectable text, which coordinate-aware extraction reads faithfully and far more accurately than any OCR pass. The trigger (Step 5) is empirical: attempt text-layer extraction first, and only if no page yields any text does the document rasterize and route to OCR. Running OCR on a native-text PDF needlessly degrades accuracy and throughput.

What OCR confidence threshold is safe for a payment table?

Higher than for prose. Dense legal boilerplate tolerates a lower because a mis-read word rarely changes an accounting fact, but a payment or escalation table is unforgiving — a single mis-recognized digit is material and can misstate the liability. Tune per document class (Step 4), gate table regions more strictly than paragraph regions, and route any sub-threshold page to human review rather than trusting it.

Does ingestion differ between ASC 842 and IFRS 16?

No. Ingestion is standard-neutral: it emits jurisdiction-agnostic structured text with provenance, and the same normalized page-map serves both frameworks. The divergence appears only downstream in classification and measurement — ASC 842's dual finance/operating model versus IFRS 16's single on-balance-sheet model — which is applied in the core measurement architecture, not during ingestion.

Continue reading