# Table Stitching — Multi-Page Table Extraction When a table spans multiple pages, ADE emits separate chunks per page and may represent some pages as plain text instead of `` HTML. This inconsistency can occur on **any** page — not just the last one. This reference covers three approaches to stitch those chunks into a single output, generalized for any document type. --- ## Decision Guide | Approach | ADE Calls | Handles non-table chunks | Fragility | Best when | |----------|-----------|--------------------------|-----------|-----------| | **A — Parse + Extract** | 2 | ✓ LLM reads full markdown | Low | Accuracy is paramount; cost is secondary | | **B — HTML table parsing** | 1 | ✓ with fallback regex | **High** — requires uniform row structure | Rows are highly uniform; cost savings justify fragility | | **C — pandas read_html** | 1 | ✗ misses non-table chunks | Medium | Quick prototyping; missing some rows is acceptable | --- ## Approach A — Parse + Extract (LLM-based) The simplest and most robust approach. Parse the document, then call `client.extract()` with a schema that describes the full table as a `List[RowModel]`. The LLM reads the entire markdown — including any pages where the table was emitted as plain text — and returns structured JSON. ### Schema design for multi-page tables ```python from typing import List, Optional from pydantic import BaseModel, Field class TableRow(BaseModel): """Customize fields for your specific table.""" key_column: str = Field( description=( "Primary identifier (e.g., date, ID, row number). " "Use empty string for continuation rows." ) ) description: str = Field( description="Description or label column." ) amount_a: Optional[str] = Field( default=None, description=( "First amount column (digits and commas only, " "no currency symbol). " "Null if not applicable for this row." ), ) amount_b: Optional[str] = Field( default=None, description=( "Second amount column. " "Null if not applicable." ), ) running_total: str = Field( description="Running total or balance after this row." ) class DocumentWithTable(BaseModel): rows: List[TableRow] = Field( description=( "All data rows in order across ALL pages of the " "document. Include rows from every page of the " "table, even if some pages render as plain text " "rather than tables. " "Skip column-header rows and section-header rows." ) ) ``` ### Key schema tips - **Say "across ALL pages"** in the `List` field description — this tells the LLM to look beyond the first table chunk. - **Mention "even if some pages render as plain text"** — the LLM will then scan text chunks for table-like content. - **Say "Skip column-header rows"** — continued tables often repeat headers on each page. - **Use `Optional[str]` for mutually exclusive columns** — e.g., when only one of "debit" or "credit" applies per row. - **Use `str` (not `float`) for amounts** to preserve original formatting (commas, decimals). Convert downstream if needed. ### Implementation pattern ```python import json from pathlib import Path from landingai_ade import LandingAIADE from landingai_ade.lib import pydantic_to_json_schema client = LandingAIADE() # Step 1: Parse (cache the result to avoid re-parsing) parse_json = Path("output/parsed.json") if parse_json.exists(): data = json.loads(parse_json.read_text()) markdown = data["markdown"] else: pr = client.parse(document=Path("document.pdf")) parse_json.parent.mkdir(parents=True, exist_ok=True) parse_json.write_text( json.dumps(pr.model_dump(), indent=2, default=str) ) markdown = pr.markdown # Step 2: Extract with the multi-page table schema er = client.extract( schema=pydantic_to_json_schema(DocumentWithTable), markdown=markdown, ) rows = er.extraction["rows"] ``` ### Pros / Cons - ✅ Handles all pages uniformly, including plain-text edge cases - ✅ No custom parsing code - ❌ Two ADE API calls → ~2× credit cost - ❌ Slower: two network round-trips --- ## Approach B — HTML Table Parsing (parse-only, fragile) Reuse the cached parse result (zero extra credits). Parse `
` elements from the ADE markdown, detect which tables belong to the target table, merge their rows, then fall back to regex for any plain-text rows. > **Warning:** This approach requires **strong similarity between rows** > to write reliable detection and extraction regex. It is brittle when > table format varies across documents or even across pages of the same > document. Always validate against Approach A on a sample before > relying on this in production. ### Generic HTML table parser ```python from html.parser import HTMLParser class TableParser(HTMLParser): """Extract all
elements as list-of-rows-of-cells.""" def __init__(self) -> None: super().__init__() self._tables: list[list[list[str]]] = [] self._tbl: list[list[str]] | None = None self._row: list[str] | None = None self._cell: list[str] | None = None def handle_starttag(self, tag: str, attrs: list) -> None: if tag == "table": self._tbl = [] elif tag == "tr" and self._tbl is not None: self._row = [] elif tag == "td" and self._row is not None: self._cell = [] def handle_endtag(self, tag: str) -> None: if tag == "table" and self._tbl is not None: self._tables.append(self._tbl) self._tbl = None elif tag == "tr" and self._tbl is not None: if self._row is not None: self._tbl.append(self._row) self._row = None elif tag == "td" and self._row is not None: if self._cell is not None: self._row.append( "".join(self._cell).strip() ) self._cell = None def handle_data(self, data: str) -> None: if self._cell is not None: self._cell.append(data) @property def tables(self) -> list[list[list[str]]]: return self._tables def extract_html_tables( markdown: str, ) -> list[list[list[str]]]: p = TableParser() p.feed(markdown) return p.tables ``` ### Table detection strategies Column count alone is often insufficient — multiple table types may share the same number of columns. Use content-based signals: ``` Is there a header row with known column names? ├─ YES → Match on header content (most reliable) │ e.g., cells[0]=="Date" and "Description" in cells[1] └─ NO → Does the first data cell match a known pattern? ├─ YES → Match on first-column pattern │ e.g., regex for "Mon DD" date format └─ NO → Use column count + content heuristics (least reliable — last resort) ``` ### Row filtering After detecting the target table, filter out non-data rows: - **Column-header rows** — repeated on each page (e.g., `cells[0] == "Date"`) - **Section sub-headers** — account names, category labels - **Summary/totals rows** — may need special handling ### Plain-text fallback When ADE emits table rows as a text chunk, use regex to extract them. This is the most fragile part — it requires the text to have a predictable structure: ```python import re # Example: lines starting with a date pattern DATE_RE = re.compile( r"^((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" r"\s+\d{1,2})\s+(.*)" ) AMOUNT_RE = re.compile(r"[\d,]+\.\d{2}") def parse_text_rows( text: str, ) -> list[dict[str, str]]: """Extract rows from plain-text table content. Customize the date/amount patterns for your document type. """ rows: list[dict[str, str]] = [] for line in text.splitlines(): m = DATE_RE.match(line.strip()) if not m: continue date_str, rest = m.group(1), m.group(2) amounts = AMOUNT_RE.findall(rest) desc = AMOUNT_RE.sub("", rest).strip() rows.append({ "date": date_str, "description": desc, "amounts": amounts, }) return rows ``` ### Domain-specific semantic checks After stitching, add validation that leverages domain knowledge to both **confirm correctness** and **resolve ambiguity**: | Domain | Check | Example | |--------|-------|---------| | Financial | Running balance | `prev ± amount ≈ new_balance` | | Financial | Column totals | Sum of rows = reported total | | Inventory | Quantity conservation | `in - out = remaining` | | Time-series | Chronological order | Dates are monotonically increasing | | Scientific | Consistent units | All values in same column share units | These checks are especially valuable when plain-text fallback produces amounts that could belong to multiple columns. ### Pros / Cons - ✅ Single ADE API call → half the credit cost - ✅ Fast (parse result is cached; parsing runs locally) - ❌ Requires strong row similarity for reliable regex - ❌ Brittle — format changes across documents break detection - ❌ Plain-text fallback needs domain-specific validation --- ## Approach C — pandas `read_html` (parse-only, quick) Let pandas do the heavy lifting. Extract `
` HTML strings with a regex, feed each to `pd.read_html()`, use pandas signals to detect target tables, then `pd.concat` to merge. > **Limitation:** This approach cannot recover rows from non-table > chunks. If ADE emits some pages as plain text, those rows are lost. ### Implementation pattern ```python import re from io import StringIO import pandas as pd TABLE_RE = re.compile(r"]*>.*?
", re.DOTALL) def stitch_tables_pandas( markdown: str, expected_cols: int, date_pattern: str = ( r"^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)" r"\s+\d+" ), ) -> pd.DataFrame: """Extract and merge multi-page tables from ADE markdown. Args: markdown: Full ADE markdown output. expected_cols: Expected number of columns in the target table. date_pattern: Regex to match date-like values in column 0. """ table_htmls = TABLE_RE.findall(markdown) target_dfs: list[pd.DataFrame] = [] for html in table_htmls: df = pd.read_html(StringIO(html), thousands=",")[0] if df.shape[1] != expected_cols: continue col0 = df.iloc[:, 0] has_date = bool( col0.astype(str).str.match(date_pattern).any() ) has_numeric = ( df.select_dtypes(include="number").shape[1] >= 1 ) if has_date or has_numeric: target_dfs.append(df) if not target_dfs: raise ValueError("No target tables found") return pd.concat(target_dfs, ignore_index=True) ``` ### Detection signals from pandas | Signal | How | Use | |--------|-----|-----| | `df.shape` | Column count | Filter by expected column count | | `select_dtypes("number")` | Numeric column count | Tables without headers have auto-inferred numeric cols | | `col0.str.match(pattern)` | First-column pattern | Date, ID, or other key column pattern | | `col0.str.strip() == "Header"` | Header presence | Detect repeated column headers to drop | ### Pitfall: pandas 3.x NaN trap After `pd.read_html`, empty cells are `float('nan')`. In pandas 3.x, after `.astype(str)`, StringDtype keeps them as the `float('nan')` **object** rather than the string `"nan"`. This means: ```python # BROKEN in pandas 3.x: col_str = df.iloc[:, 0].astype(str) mask = col_str == "nan" # Always False! # CORRECT — check on the raw column before string conversion: mask = pd.isna(df.iloc[:, 0]) ``` ### Amount formatting `pd.read_html(thousands=",")` strips commas from numbers during dtype inference. If you need comma-formatted output in CSV: ```python def fmt_amount(val: object) -> str: if pd.isna(val): return "" if isinstance(val, float): return f"{val:,.2f}" s = str(val).strip() try: return f"{float(s):,.2f}" except ValueError: return s ``` ### Pros / Cons - ✅ Least code — `pd.read_html` + `pd.concat` do most of the work - ✅ No custom HTML parser or regex - ✅ pandas dtype signals give a clear detection path - ❌ Cannot recover rows from non-table chunks - ❌ `pd.NA` / `float('nan')` trap in pandas 3.x - ❌ `thousands=","` strips commas — must re-format for output --- ## Common Pitfalls 1. **ADE may emit any page as plain text** — not just the last page. Always check chunk types across all pages during pre-flight. 2. **Column count alone is insufficient** — when multiple table types share the same column count, use content-based detection (header matching, first-column patterns, dtype signals). 3. **Approach B regex is brittle** — test on multiple documents before relying on it. Format variations across documents (or even across pages of the same document) will break detection. 4. **Always validate with domain-specific semantic checks** — these catch errors that structural parsing misses and resolve ambiguities in column assignment. 5. **Cache parse results** — save `pr.model_dump()` to JSON after the first parse. Load it for development instead of calling `client.parse()` again. Only re-parse when the document changes. --- ## Pre-Flight Checklist for Table Stitching Before choosing an approach, run the diagnostic parse and check: | What to check | How | Why | |---------------|-----|-----| | Chunk types per page | Count `type == "table"` vs `"text"` per page | Any page may have inconsistent types | | Column count consistency | Compare column counts across table chunks | Inconsistent counts may indicate different tables | | Header row presence | Check first row of each table chunk | Needed for detection and row filtering | | Non-target tables | Look for summary/metadata tables with same column count | Must distinguish target from others | | Row uniformity | Compare row structure across pages | Low uniformity makes Approach B fragile | | Plain-text table content | Inspect text chunks for table-like patterns | Determines if fallback is needed |