Skip to content

Add borderless (booktabs-style) table detection to paper-qa-pymupdf - #1329

Open
77652189 wants to merge 3 commits into
Future-House:mainfrom
77652189:feat/borderless-table-detection
Open

Add borderless (booktabs-style) table detection to paper-qa-pymupdf#1329
77652189 wants to merge 3 commits into
Future-House:mainfrom
77652189:feat/borderless-table-detection

Conversation

@77652189

Copy link
Copy Markdown

Detect borderless (three-line / booktabs-style) tables in paper-qa-pymupdf

Summary

Academic papers — especially LaTeX-compiled papers using \toprule / \midrule / \bottomrule
(the booktabs package) and Chinese journal "三线表" tables — contain tables with only
three full-width horizontal rules and no vertical separators
.

PyMuPDF's page.find_tables() (default strategy) misses these because it builds a cell grid
from intersecting horizontal and vertical lines. When there are no vertical lines, there
are no intersections, so find_tables() returns [] and the table falls through as
unstructured plain text in the chunk.

The practical impact is significant: quantitative experimental results, parameter comparisons,
and measurement data that sit in table cells end up as a flat stream of numbers and labels
mixed with body text, with no column-header context. No ParsedMedia(type="table") is
emitted, so neither the markdown-table prompt path nor the multimodal screenshot path is
triggered.

This PR adds a supplementary detection pass that catches these missed tables using
four complementary strategies, evidence scoring, and deduplication against tables already
found by find_tables().


Changes

New: packages/paper-qa-pymupdf/src/paperqa_pymupdf/borderless_tables.py

A self-contained module (~1 500 lines) implementing a full multi-strategy detection pipeline
ported from PaperSort's TableRegionDetector / extract_threeline /
detect_rotated_table with all pdfplumber-specific APIs replaced by PyMuPDF equivalents.

Detection strategies

Four strategies run in order; their candidates are merged and scored before output:

Priority Strategy Trigger condition
1 (primary) Caption anchoring "Table N" / "表N" text found; extends bbox downward to capture table body using rules and table-like text lines
2 (secondary) Wide H-rule clustering Drawing-layer horizontal paths spanning ≥ 20 % of page width; groups within 200 pt are clustered into a table band
3 (fallback) Text-alignment runs ≥ 3 consecutive text lines with ≥ 2 distinct x-alignment bins; detects tables with no border lines at all
4 (special) Rotated-text detection Lines whose PyMuPDF dir vector has non-zero sin component; finds 90°-rotated tables by locating a rotated caption and expanding to nearby character groups; structured text is extracted by _extract_rotated_cells_from_chars (ported from PaperSort's detect_rotated_table)

All thresholds are module-level named constants (no magic numbers). New constants
for the rotated-extraction path: _ROTATED_HEADER_KEYWORDS, _ROTATED_EXTRACT_GAP_PT,
_ROTATED_EXTRACT_CLUSTER_TOL, _ROTATED_HEADER_GAP_PT.

Evidence-based scoring

Each candidate receives a confidence score in [0, 1] from:

Evidence Score
Caption nearby +0.35
≥ 3 wide horizontal rules +0.30
≥ 2 wide horizontal rules +0.20
≥ 3 aligned text lines +0.30
≥ 2 aligned text lines +0.15
High numeric token density (≥ 20 %) +0.15
Short cell-like tokens (≥ 70 %) +0.10
≥ 3 long prose lines (≥ 18 words) −0.30
Section-heading word at line start −0.20

Candidates below the minimum keep score (0.25) are discarded.

Structure extraction

Per detected table:

  1. Rotated tables (strategy 4) bypass steps 2–3 and use _extract_rotated_cells_from_chars
    instead: characters are re-grouped by 0.5 pt x-bins, intra-group gaps identify cell
    boundaries, and a gap-clustering pass infers row/column layout (full port of PaperSort's
    detect_rotated_table).
  2. For all other tables, clip rect is expanded by 3 pt on all sides for robust word capture.
  3. Numeric-error table pattern (key | mean ± sd | mean ± sd) is tried first.
  4. Falls back to standard column inference (gap-merge of word x-spans).
  5. merge_multiline_cells handles dual-column layouts and wrap-around continuation rows.
  6. Result is rendered as GitHub-flavoured markdown and stored in ParsedMedia.text.
  7. A pixmap screenshot is stored in ParsedMedia.data for multimodal LLMs.

Modified: packages/paper-qa-pymupdf/src/paperqa_pymupdf/reader.py

After the existing find_tables() loop, a single call to detect_borderless_tables()
is inserted (~15 lines). It receives the bboxes of already-detected tables so that
fully-bordered tables detected by find_tables() are not re-emitted as duplicates
(IoU threshold: 0.30).

# ← existing find_tables() loop (unchanged) ...

# NEW: supplementary pass for borderless tables
already_bboxes = [tuple(m.info["bbox"]) for m in media if m.info.get("type") == "table"]
media.extend(
    detect_borderless_tables(
        page,
        page_num=i,
        page_width=float(page.rect.width),
        dpi=dpi,
        pymupdf_pixmap_attrs=PYMUPDF_PIXMAP_ATTRS,
        already_detected_bboxes=already_bboxes,
    )
)

New: packages/paper-qa-pymupdf/tests/test_borderless_tables.py

50 test cases across two levels:

Unit tests (no real PDF):

  • TestTableCaptionRE — English/Chinese caption regex matching
  • TestFindColRanges — span merging, gap threshold, empty input
  • TestAssignCol — centre-in-range, nearest-column fallback
  • TestWordsToGrid — row splitting by y-gap, multi-row layout
  • TestClusterRules — proximity grouping, minimum-size filtering
  • TestToMarkdown — pipe escaping, header/separator/data ordering
  • TestMergeMultilineCells — simple continuation, independent rows, single row
  • TestExtractNumericErrorTable — mean±sd detection, false-positive guard
  • TestExtractRotatedCellsFromChars — title detection, 2-column extraction, edge cases
    (too few chars, ≤ 2 groups, single-cell header)

Integration tests (synthetic PDFs created with PyMuPDF):

  • TestGetWideHRules — verifies rule y-coordinates match what was drawn
  • TestCaptionBasedRegions — caption text anchors correct table area; no caption → empty
  • TestAlignmentBasedRegions — alignment strategy finds table with no rules; no-op on prose
  • TestDetectBorderlessTables — full three-rule and two-rule tables; text-only no-op;
    duplicate suppression; JSON-serialisability of info; caption PDF
  • test_parse_pdf_to_pages_detects_borderless_table — end-to-end through parse_pdf_to_pages
  • test_parse_pdf_to_pages_no_tables_on_text_only_page

What is NOT changed

  • paper-qa-pypdf: pdfplumber's find_tables() has a vertical_strategy="text" option
    that handles this case differently; a follow-up PR can address that parser.
  • paper-qa-docling: Docling uses an ML layout model that is already less sensitive to
    absent border lines.
  • paper-qa-nemotron: unchanged.
  • The full_page=True code path: when the entire page is already captured as a single
    screenshot, the LLM sees the table visually regardless, so no change is needed there.

Known limitations

  1. Column inference quality depends on word spacing in the PDF. Tables whose column
    words are very close together (gap < 8 pt) may be merged into fewer columns. The
    _MIN_COL_GAP_PT constant can be tuned down if needed.

  2. Two-rule tables (only top + bottom, no midrule) are detected but the header/data
    split falls back to a 25 % heuristic, which may misclassify the first data row as the
    header when the header zone is unusually tall.

  3. Rotated tables: structured markdown is generated by _extract_rotated_cells_from_chars
    (a full port of PaperSort's detect_rotated_table). The algorithm requires a recognisable
    title group (leftmost 1–2 x-bins) and ≥ 20 rotated characters; tables with extremely dense
    character spacing (gap < 10 pt between cells) may not split cells correctly. The pixmap
    screenshot in ParsedMedia.data always carries full visual fidelity as a fallback for
    multimodal LLMs.

  4. False positives on pages with wide decorative rules (page headers/footers that are
    also full-width horizontal lines) are filtered by the requirement of ≥ 2 rules within
    200 pt of each other. A single decorative line would be a 1-element cluster and is
    discarded. Decorative lines that happen to come in pairs near body text may pass the
    rule filter but will receive a low evidence score and be discarded by the 0.25 threshold.

  5. text field quality: the extracted markdown is best-effort. For tables with merged
    cells, subscript/superscript characters, or symbol-heavy content the text will be
    imperfect. The screenshot stored in ParsedMedia.data carries full visual fidelity.


Test plan

# Run only the new tests (fast, uses synthetic PDFs — no API key required)
pytest packages/paper-qa-pymupdf/tests/test_borderless_tables.py -v

# Run the full pymupdf test suite to confirm no regressions
# (test_paperqa_pymupdf.py requires OPENAI_API_KEY; skip it in CI if unavailable)
pytest packages/paper-qa-pymupdf/tests/ -v --ignore=packages/paper-qa-pymupdf/tests/test_paperqa_pymupdf.py

To manually verify on a real academic PDF:

from paperqa_pymupdf import parse_pdf_to_pages

result = parse_pdf_to_pages("your_booktabs_paper.pdf", parse_media=True)
for page_key, content in result.content.items():
    if isinstance(content, tuple):
        _, media = content
        for m in media:
            if m.info.get("type") == "table":
                print(f"Page {page_key}: {m.info.get('detection_method', 'find_tables')}")
                print(m.text[:300])
                print()

Any table whose info["detection_method"] == "borderless" was found by this new pass.


Generated with Claude Code

PyMuPDF's find_tables() misses three-line / booktabs-style tables because
it requires cell-border intersections to locate column boundaries. Tables
using only \toprule / \midrule / \bottomrule (and Chinese san-xian-biao)
fall through as unstructured text with no ParsedMedia(type="table") emitted.

This commit adds a supplementary detection pass via detect_borderless_tables()
in a new borderless_tables module. Four complementary strategies run per page:

1. Caption anchoring   - "Table N" / Chinese caption text anchors bbox downward
2. H-rule clustering   - wide drawing-layer rules grouped by vertical proximity
3. Text-alignment runs - repeated x-position bins across consecutive lines
4. Rotated-text        - PyMuPDF rawdict dir-vector identifies 90-rotated tables;
                         _extract_rotated_cells_from_chars (ported from PaperSort)
                         provides structured markdown for the rotated case

Each candidate is scored by multi-evidence confidence (caption, rules, alignment,
numeric density) and deduplicated against tables already found by find_tables()
(IoU threshold 0.30). All thresholds are module-level named constants.

50 new tests cover every helper function (unit) and synthetic PDFs (integration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. enhancement New feature or request labels May 22, 2026
)
from paperqa_pymupdf.reader import PYMUPDF_PIXMAP_ATTRS

# ── shared constants ───────────────────────────────────────────────────────────

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go in the paper-qa-pymupdf tests

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — _PIXMAP_ATTRS is now defined locally in the test file as a minimal rozenset rather than imported from
eader internals.


# ── markdown rendering ─────────────────────────────────────────────────────────

def _to_markdown(header: list[str], data_rows: list[list[str]]) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use PyMuPDF's markdown table functionality?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pymupdf.table.Table.to_markdown() only works on pymupdf.table.Table objects (as used in reader.py for fully-bordered tables). Our detected tables are plain list[list[str]] grids built by word-position inference, so there is no PyMuPDF API that accepts that format directly. The _to_markdown function is 8 lines and implements the same GitHub-flavoured pipe format.

table (IoU >= :data:`_OVERLAP_IOU_THRESHOLD`) are skipped to avoid
re-emitting tables that ``find_tables()`` already found.

Parameters

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use Google-style docstrings

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — all public-facing functions now use Google-style Args: / Returns: / Raises: sections.

B. subtil. 15.1 51.0 data row 3
y=270 ─────────────────────── bottom border (bottomrule)
"""
from __future__ import annotations

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remove all from __future__ import annotations from your code you add

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed from both files. Switched to a direct import pymupdf (consistent with reader.py) so runtime annotation evaluation works without the future import.

return cells if len(cells) >= _MIN_COLS and numeric_count >= 1 else None


def _extract_numeric_error_table(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to figure out how to use upstream libraries to downsize this module by at least 50% (<750 lines)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — module reduced from ~1 500 lines to 728 lines. Removed the three most complex sub-systems: rotated-table detection + extraction (~440 lines), numeric-error table pattern matching (~130 lines), dual-column merge_multiline_cells (~110 lines), and the text-alignment detection strategy (~55 lines). The core value — detecting H-rule and caption-anchored three-line tables — is fully preserved. The removed features can be reintroduced as a follow-up PR if needed.

- Remove from __future__ import annotations (Python >=3.11 native syntax)
- Import pymupdf directly (consistent with reader.py) instead of TYPE_CHECKING guard
- Switch to Google-style docstrings throughout
- Define _PIXMAP_ATTRS locally in tests instead of importing reader internals
- Remove alignment-based strategy, rotated-table detection, numeric-error
  table extraction, and dual-column merge_multiline_cells (~760 lines);
  module is now 728 lines (under the 750-line target)
- _to_markdown: add docstring note explaining why pymupdf.table.Table.to_markdown()
  cannot be used for plain list[list[str]] grids
- merge_multiline_cells: keep simple continuation-row path only
- _score_region: inline former helpers; remove unused page_width param
- Tests: 42 tests (down from 50); remove TestExtractNumericErrorTable,
  TestExtractRotatedCellsFromChars, TestAlignmentBasedRegions, alignment_only_pdf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:XXL This PR changes 1000+ lines, ignoring generated files. labels Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants