table-stitcher 0.4.3__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/workflows/ci.yml +4 -2
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/CHANGELOG.md +51 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/PKG-INFO +43 -33
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/README.md +40 -30
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/pyproject.toml +5 -2
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/__init__.py +12 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/adapters/README.md +3 -3
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/adapters/docling.py +62 -7
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/merger.py +23 -1
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/models.py +12 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/test_docling_adapter.py +153 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/test_merger.py +44 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/test_public_api.py +53 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.claude/settings.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/dependabot.yml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/pull_request_template.md +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/workflows/release.yml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.github/workflows/upstream-smoke.yml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.gitignore +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/.pre-commit-config.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/CONTRIBUTING.md +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/LICENSE +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/SECURITY.md +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/examples/basic_pipeline.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/examples/system_controller.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/scripts/regenerate_docling_snapshots.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/scripts/release_gate.sh +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/adapters/__init__.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/adapters/base.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/src/table_stitcher/py.typed +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/README.md +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/__init__.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/fixtures/tablemeta/headerless-width-drift.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/__init__.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/_tools/__init__.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/_tools/regenerate_expected.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/conftest.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/_synth/__init__.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/_synth/generate.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/.gitkeep +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/.gitkeep +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/.gitkeep +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/spillover/note-overflow.synth.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/spillover/note-overflow.synth.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/spillover/note-overflow.synth.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/.gitkeep +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.docling.json +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.pdf +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/integration/test_fixtures.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/test_intervening_content_guard.py +0 -0
- {table_stitcher-0.4.3 → table_stitcher-0.5.0}/tests/test_tablemeta_fixtures.py +0 -0
|
@@ -19,11 +19,13 @@ jobs:
|
|
|
19
19
|
python-version: "3.12"
|
|
20
20
|
cache: pip
|
|
21
21
|
- name: install ruff
|
|
22
|
-
#
|
|
22
|
+
# Keep this pin in sync with the dev extra in pyproject.toml so local
|
|
23
|
+
# + CI run the same version. Exact pin because ruff format output
|
|
24
|
+
# changes between releases and would break lint on untouched files.
|
|
23
25
|
# No need to install the full dev tree (docling, reportlab) just to lint.
|
|
24
26
|
run: |
|
|
25
27
|
python -m pip install --upgrade pip
|
|
26
|
-
pip install "ruff
|
|
28
|
+
pip install "ruff==0.16.2"
|
|
27
29
|
- name: ruff check
|
|
28
30
|
run: ruff check .
|
|
29
31
|
- name: ruff format --check
|
|
@@ -7,6 +7,57 @@ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] — 2026-08-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Cell bounding boxes were dropped on multi-page merge**
|
|
15
|
+
(`adapters/docling.py`). When N per-page fragments merged into one logical
|
|
16
|
+
table, every `TableCell` in the injected result came back with `bbox=None`,
|
|
17
|
+
breaking word-level grounding for multi-page tables. `_reemit_body_row`
|
|
18
|
+
rebuilt each untouched body row's cells without copying the source cell's
|
|
19
|
+
`bbox`; it is now carried across, so untouched body rows — the large
|
|
20
|
+
majority on a clean multi-page table — keep their geometry. Header rows
|
|
21
|
+
reused from the anchor already preserved theirs; rows the merger transformed
|
|
22
|
+
(stitched continuations, folded overflow) genuinely have none and stay
|
|
23
|
+
`bbox=None`. The FIFO alignment between repeated identical-text rows and
|
|
24
|
+
their source occurrences is now a documented invariant: it is what guarantees
|
|
25
|
+
a repeated row gets its own page's boxes, not a sibling's.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Row-level page association for restored geometry** (`models.py`,
|
|
30
|
+
`adapters/docling.py`). A merged table's `prov` lists all N source pages, but
|
|
31
|
+
pages share one coordinate space, so a restored `bbox` alone cannot say which
|
|
32
|
+
page it belongs to. `LogicalTable.row_pages` now maps grid row index (header
|
|
33
|
+
rows included) to the resolved `page_no` the row's cell boxes are valid on —
|
|
34
|
+
resolved page numbers rather than prov indices, so the map survives
|
|
35
|
+
downstream prov manipulation. A missing key means the row has no single
|
|
36
|
+
source page (transformed rows, which carry no geometry). Recorded during
|
|
37
|
+
injection, the only point where each row's source fragment is known; a
|
|
38
|
+
consumer cannot reconstruct the mapping after the fact.
|
|
39
|
+
- **`TableStitcher.last_logical_tables`** exposes the `LogicalTable` results of
|
|
40
|
+
the most recent `stitch()` call — previously discarded after injection,
|
|
41
|
+
which would have left `row_pages` unreachable through the public API.
|
|
42
|
+
Consumers needing the map should instantiate `TableStitcher` directly; the
|
|
43
|
+
`stitch_tables()` convenience function does not expose the stitcher instance.
|
|
44
|
+
|
|
45
|
+
## [0.4.4] — 2026-08-13
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **Duplicate header labels crashed the multipage merge** (`merger.py`).
|
|
50
|
+
Extracted headers that repeat a label — e.g. SEC 13F voting-authority
|
|
51
|
+
triplets where TableFormer emits `COLUMN 8` three times — made
|
|
52
|
+
`_build_generic_merged_table` raise `ValueError: Reindexing only valid
|
|
53
|
+
with uniquely valued Index objects`, because `pd.concat` cannot align
|
|
54
|
+
fragments on a non-unique column Index. Downstream consumers that
|
|
55
|
+
fail-soft on the error silently kept a degraded table set for the whole
|
|
56
|
+
document. Fragments are now merged under positionally deduped labels
|
|
57
|
+
(collision-safe against pre-existing `X.1`-style names) and the original
|
|
58
|
+
duplicated labels are restored on the merged output, matching how
|
|
59
|
+
single-fragment tables pass through untouched.
|
|
60
|
+
|
|
10
61
|
## [0.4.3] — 2026-06-11
|
|
11
62
|
|
|
12
63
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: table-stitcher
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Reassemble tables split across page boundaries in PDF extraction
|
|
5
5
|
Project-URL: Homepage, https://github.com/pebbleroad/table-stitcher
|
|
6
6
|
Project-URL: Repository, https://github.com/pebbleroad/table-stitcher
|
|
@@ -29,7 +29,7 @@ Requires-Dist: pre-commit>=3.0; extra == 'dev'
|
|
|
29
29
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
30
|
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
31
31
|
Requires-Dist: reportlab>=4.0; extra == 'dev'
|
|
32
|
-
Requires-Dist: ruff
|
|
32
|
+
Requires-Dist: ruff==0.16.2; extra == 'dev'
|
|
33
33
|
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
34
34
|
Provides-Extra: docling
|
|
35
35
|
Requires-Dist: docling-core<3,>=2.50; extra == 'docling'
|
|
@@ -103,8 +103,8 @@ from table_stitcher import stitch_tables
|
|
|
103
103
|
|
|
104
104
|
converter = DocumentConverter()
|
|
105
105
|
doc = converter.convert("report.pdf").document
|
|
106
|
-
doc = stitch_tables(doc)
|
|
107
|
-
|
|
106
|
+
doc = stitch_tables(doc) # merged tables; ready for
|
|
107
|
+
# export_to_markdown() / HTML / LLM
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
`stitch_tables()` mutates `doc` in place and returns the same object. If you
|
|
@@ -132,10 +132,10 @@ Runnable end-to-end scripts live in [`examples/`](examples/):
|
|
|
132
132
|
from table_stitcher import stitch_tables, MultiPageConfig
|
|
133
133
|
|
|
134
134
|
config = MultiPageConfig(
|
|
135
|
-
max_page_gap=1,
|
|
136
|
-
max_width_difference=2,
|
|
137
|
-
header_sim_strict=0.6,
|
|
138
|
-
stitch_separator="\n",
|
|
135
|
+
max_page_gap=1, # Only merge tables on consecutive pages
|
|
136
|
+
max_width_difference=2, # Column count tolerance
|
|
137
|
+
header_sim_strict=0.6, # Threshold for repeated header detection
|
|
138
|
+
stitch_separator="\n", # Join character for split content
|
|
139
139
|
)
|
|
140
140
|
|
|
141
141
|
doc = stitch_tables(doc, config=config)
|
|
@@ -148,6 +148,7 @@ from typing import Any, List
|
|
|
148
148
|
from table_stitcher import TableStitcher, MultiPageConfig, TableMeta, LogicalTable
|
|
149
149
|
from table_stitcher.adapters.base import TableStitcherAdapter
|
|
150
150
|
|
|
151
|
+
|
|
151
152
|
class MyParserAdapter:
|
|
152
153
|
def extract(self, doc, cfg: MultiPageConfig) -> List[TableMeta]:
|
|
153
154
|
"""Read tables from your document format into TableMeta objects."""
|
|
@@ -157,6 +158,7 @@ class MyParserAdapter:
|
|
|
157
158
|
"""Write merged results back into your document format."""
|
|
158
159
|
...
|
|
159
160
|
|
|
161
|
+
|
|
160
162
|
stitcher = TableStitcher(adapter=MyParserAdapter())
|
|
161
163
|
doc = stitcher.stitch(doc)
|
|
162
164
|
```
|
|
@@ -258,7 +260,13 @@ from typing import Any, List
|
|
|
258
260
|
import pandas as pd
|
|
259
261
|
from table_stitcher import TableStitcher, MultiPageConfig, TableMeta, LogicalTable
|
|
260
262
|
from table_stitcher.adapters.base import TableStitcherAdapter
|
|
261
|
-
from table_stitcher.merger import
|
|
263
|
+
from table_stitcher.merger import (
|
|
264
|
+
tokenize,
|
|
265
|
+
normalize_col_name,
|
|
266
|
+
is_numeric_like_colnames,
|
|
267
|
+
first_row_has_number,
|
|
268
|
+
)
|
|
269
|
+
|
|
262
270
|
|
|
263
271
|
class MyParserAdapter:
|
|
264
272
|
def extract(self, doc: Any, cfg: MultiPageConfig) -> List[TableMeta]:
|
|
@@ -281,32 +289,32 @@ class MyParserAdapter:
|
|
|
281
289
|
# 4. Tokenize first row (fallback similarity signal)
|
|
282
290
|
first_row_tokens = set()
|
|
283
291
|
if df.shape[0] > 0:
|
|
284
|
-
first_row_tokens = tokenize(
|
|
285
|
-
" ".join(str(x) for x in df.iloc[0].tolist())
|
|
286
|
-
)
|
|
292
|
+
first_row_tokens = tokenize(" ".join(str(x) for x in df.iloc[0].tolist()))
|
|
287
293
|
|
|
288
294
|
# 5. Classify: is_headerless, is_header_orphan, is_data_orphan
|
|
289
295
|
raw_columns = [str(c) for c in df.columns]
|
|
290
|
-
is_headerless = df.attrs.get(
|
|
291
|
-
|
|
292
|
-
tables_meta.append(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
296
|
+
is_headerless = df.attrs.get("is_headerless", False)
|
|
297
|
+
|
|
298
|
+
tables_meta.append(
|
|
299
|
+
TableMeta(
|
|
300
|
+
idx=idx,
|
|
301
|
+
df=df,
|
|
302
|
+
start_page=start_page,
|
|
303
|
+
pages=pages,
|
|
304
|
+
width=df.shape[1],
|
|
305
|
+
header_tokens=header_tokens,
|
|
306
|
+
first_row_tokens=first_row_tokens,
|
|
307
|
+
raw_columns=raw_columns,
|
|
308
|
+
vert_center=None, # Set if bbox available
|
|
309
|
+
vert_top=None, # Normalized 0-1, 0=top of page
|
|
310
|
+
vert_bottom=None, # Normalized 0-1, 1=bottom of page
|
|
311
|
+
is_header_orphan=False, # True if headers-only, no/few data rows
|
|
312
|
+
is_data_orphan=False, # True if data-only, no real headers
|
|
313
|
+
numeric_like_cols=is_numeric_like_colnames(raw_columns),
|
|
314
|
+
row_count=df.shape[0],
|
|
315
|
+
is_headerless=is_headerless,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
310
318
|
return tables_meta
|
|
311
319
|
|
|
312
320
|
def inject(self, doc: Any, logical_tables: List[LogicalTable]) -> Any:
|
|
@@ -325,6 +333,7 @@ class MyParserAdapter:
|
|
|
325
333
|
|
|
326
334
|
return doc
|
|
327
335
|
|
|
336
|
+
|
|
328
337
|
# Use it:
|
|
329
338
|
stitcher = TableStitcher(adapter=MyParserAdapter())
|
|
330
339
|
doc = stitcher.stitch(doc)
|
|
@@ -374,6 +383,7 @@ except StitchingError as e:
|
|
|
374
383
|
|
|
375
384
|
```python
|
|
376
385
|
import logging
|
|
386
|
+
|
|
377
387
|
logging.getLogger("table_stitcher").setLevel(logging.INFO)
|
|
378
388
|
```
|
|
379
389
|
|
|
@@ -65,8 +65,8 @@ from table_stitcher import stitch_tables
|
|
|
65
65
|
|
|
66
66
|
converter = DocumentConverter()
|
|
67
67
|
doc = converter.convert("report.pdf").document
|
|
68
|
-
doc = stitch_tables(doc)
|
|
69
|
-
|
|
68
|
+
doc = stitch_tables(doc) # merged tables; ready for
|
|
69
|
+
# export_to_markdown() / HTML / LLM
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
`stitch_tables()` mutates `doc` in place and returns the same object. If you
|
|
@@ -94,10 +94,10 @@ Runnable end-to-end scripts live in [`examples/`](examples/):
|
|
|
94
94
|
from table_stitcher import stitch_tables, MultiPageConfig
|
|
95
95
|
|
|
96
96
|
config = MultiPageConfig(
|
|
97
|
-
max_page_gap=1,
|
|
98
|
-
max_width_difference=2,
|
|
99
|
-
header_sim_strict=0.6,
|
|
100
|
-
stitch_separator="\n",
|
|
97
|
+
max_page_gap=1, # Only merge tables on consecutive pages
|
|
98
|
+
max_width_difference=2, # Column count tolerance
|
|
99
|
+
header_sim_strict=0.6, # Threshold for repeated header detection
|
|
100
|
+
stitch_separator="\n", # Join character for split content
|
|
101
101
|
)
|
|
102
102
|
|
|
103
103
|
doc = stitch_tables(doc, config=config)
|
|
@@ -110,6 +110,7 @@ from typing import Any, List
|
|
|
110
110
|
from table_stitcher import TableStitcher, MultiPageConfig, TableMeta, LogicalTable
|
|
111
111
|
from table_stitcher.adapters.base import TableStitcherAdapter
|
|
112
112
|
|
|
113
|
+
|
|
113
114
|
class MyParserAdapter:
|
|
114
115
|
def extract(self, doc, cfg: MultiPageConfig) -> List[TableMeta]:
|
|
115
116
|
"""Read tables from your document format into TableMeta objects."""
|
|
@@ -119,6 +120,7 @@ class MyParserAdapter:
|
|
|
119
120
|
"""Write merged results back into your document format."""
|
|
120
121
|
...
|
|
121
122
|
|
|
123
|
+
|
|
122
124
|
stitcher = TableStitcher(adapter=MyParserAdapter())
|
|
123
125
|
doc = stitcher.stitch(doc)
|
|
124
126
|
```
|
|
@@ -220,7 +222,13 @@ from typing import Any, List
|
|
|
220
222
|
import pandas as pd
|
|
221
223
|
from table_stitcher import TableStitcher, MultiPageConfig, TableMeta, LogicalTable
|
|
222
224
|
from table_stitcher.adapters.base import TableStitcherAdapter
|
|
223
|
-
from table_stitcher.merger import
|
|
225
|
+
from table_stitcher.merger import (
|
|
226
|
+
tokenize,
|
|
227
|
+
normalize_col_name,
|
|
228
|
+
is_numeric_like_colnames,
|
|
229
|
+
first_row_has_number,
|
|
230
|
+
)
|
|
231
|
+
|
|
224
232
|
|
|
225
233
|
class MyParserAdapter:
|
|
226
234
|
def extract(self, doc: Any, cfg: MultiPageConfig) -> List[TableMeta]:
|
|
@@ -243,32 +251,32 @@ class MyParserAdapter:
|
|
|
243
251
|
# 4. Tokenize first row (fallback similarity signal)
|
|
244
252
|
first_row_tokens = set()
|
|
245
253
|
if df.shape[0] > 0:
|
|
246
|
-
first_row_tokens = tokenize(
|
|
247
|
-
" ".join(str(x) for x in df.iloc[0].tolist())
|
|
248
|
-
)
|
|
254
|
+
first_row_tokens = tokenize(" ".join(str(x) for x in df.iloc[0].tolist()))
|
|
249
255
|
|
|
250
256
|
# 5. Classify: is_headerless, is_header_orphan, is_data_orphan
|
|
251
257
|
raw_columns = [str(c) for c in df.columns]
|
|
252
|
-
is_headerless = df.attrs.get(
|
|
253
|
-
|
|
254
|
-
tables_meta.append(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
258
|
+
is_headerless = df.attrs.get("is_headerless", False)
|
|
259
|
+
|
|
260
|
+
tables_meta.append(
|
|
261
|
+
TableMeta(
|
|
262
|
+
idx=idx,
|
|
263
|
+
df=df,
|
|
264
|
+
start_page=start_page,
|
|
265
|
+
pages=pages,
|
|
266
|
+
width=df.shape[1],
|
|
267
|
+
header_tokens=header_tokens,
|
|
268
|
+
first_row_tokens=first_row_tokens,
|
|
269
|
+
raw_columns=raw_columns,
|
|
270
|
+
vert_center=None, # Set if bbox available
|
|
271
|
+
vert_top=None, # Normalized 0-1, 0=top of page
|
|
272
|
+
vert_bottom=None, # Normalized 0-1, 1=bottom of page
|
|
273
|
+
is_header_orphan=False, # True if headers-only, no/few data rows
|
|
274
|
+
is_data_orphan=False, # True if data-only, no real headers
|
|
275
|
+
numeric_like_cols=is_numeric_like_colnames(raw_columns),
|
|
276
|
+
row_count=df.shape[0],
|
|
277
|
+
is_headerless=is_headerless,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
272
280
|
return tables_meta
|
|
273
281
|
|
|
274
282
|
def inject(self, doc: Any, logical_tables: List[LogicalTable]) -> Any:
|
|
@@ -287,6 +295,7 @@ class MyParserAdapter:
|
|
|
287
295
|
|
|
288
296
|
return doc
|
|
289
297
|
|
|
298
|
+
|
|
290
299
|
# Use it:
|
|
291
300
|
stitcher = TableStitcher(adapter=MyParserAdapter())
|
|
292
301
|
doc = stitcher.stitch(doc)
|
|
@@ -336,6 +345,7 @@ except StitchingError as e:
|
|
|
336
345
|
|
|
337
346
|
```python
|
|
338
347
|
import logging
|
|
348
|
+
|
|
339
349
|
logging.getLogger("table_stitcher").setLevel(logging.INFO)
|
|
340
350
|
```
|
|
341
351
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "table-stitcher"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Reassemble tables split across page boundaries in PDF extraction"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -40,7 +40,10 @@ dev = [
|
|
|
40
40
|
"docling-core>=2.50,<3",
|
|
41
41
|
"pyyaml>=6.0",
|
|
42
42
|
"reportlab>=4.0",
|
|
43
|
-
|
|
43
|
+
# Exact pin: ruff format output changes between releases (0.16 started
|
|
44
|
+
# formatting markdown code blocks), so a range lets a new ruff release
|
|
45
|
+
# break lint on untouched files. CI installs this same pin.
|
|
46
|
+
"ruff==0.16.2",
|
|
44
47
|
"build>=1.0",
|
|
45
48
|
"twine>=5.0",
|
|
46
49
|
"pre-commit>=3.0",
|
|
@@ -77,6 +77,12 @@ class TableStitcher:
|
|
|
77
77
|
self.logger = logging.getLogger("table_stitcher")
|
|
78
78
|
self.adapter = adapter
|
|
79
79
|
self.config = config or MultiPageConfig()
|
|
80
|
+
self.last_logical_tables: list[LogicalTable] = []
|
|
81
|
+
"""
|
|
82
|
+
The ``LogicalTable`` results of the most recent ``stitch()`` call,
|
|
83
|
+
including per-row page associations (``LogicalTable.row_pages``)
|
|
84
|
+
populated during injection. Reset at the start of every ``stitch()``.
|
|
85
|
+
"""
|
|
80
86
|
self._validate_config()
|
|
81
87
|
|
|
82
88
|
# -------------------------------------------------------------------------
|
|
@@ -164,6 +170,8 @@ class TableStitcher:
|
|
|
164
170
|
Returns:
|
|
165
171
|
The document with merged tables.
|
|
166
172
|
"""
|
|
173
|
+
self.last_logical_tables = []
|
|
174
|
+
|
|
167
175
|
if doc is None:
|
|
168
176
|
if raise_on_error:
|
|
169
177
|
raise StitchingError("Input document is None")
|
|
@@ -213,6 +221,10 @@ class TableStitcher:
|
|
|
213
221
|
raise StitchingError(f"Failed to merge tables: {e}") from e
|
|
214
222
|
return doc
|
|
215
223
|
|
|
224
|
+
# Exposed for consumers needing per-table merge results — notably
|
|
225
|
+
# LogicalTable.row_pages, which injection fills in below.
|
|
226
|
+
self.last_logical_tables = logical_tables
|
|
227
|
+
|
|
216
228
|
multi_page_tables = [lt for lt in logical_tables if len(lt.pages) > 1]
|
|
217
229
|
|
|
218
230
|
if not multi_page_tables:
|
|
@@ -104,9 +104,9 @@ A few structural constants live at the top of `docling.py` rather than
|
|
|
104
104
|
in `MultiPageConfig`:
|
|
105
105
|
|
|
106
106
|
```python
|
|
107
|
-
_MAX_HEADER_CELL_LEN = 30
|
|
108
|
-
_DATA_PATTERNS
|
|
109
|
-
_AUTO_COLNAME_RE
|
|
107
|
+
_MAX_HEADER_CELL_LEN = 30 # header cells typically short; data cells longer
|
|
108
|
+
_DATA_PATTERNS # regex list for "this cell is data, not header"
|
|
109
|
+
_AUTO_COLNAME_RE # "Column_N" / "Unnamed: N" parser placeholders
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
These are **adapter-intrinsic** — tuning them changes how the adapter
|
|
@@ -223,6 +223,18 @@ def _extract_y_bounds_from_prov(prov_list: list[Any]) -> Optional[tuple[float, f
|
|
|
223
223
|
return None
|
|
224
224
|
|
|
225
225
|
|
|
226
|
+
def _first_page_no(prov: Any) -> Optional[int]:
|
|
227
|
+
"""Resolved page number of the first prov entry carrying one, else None."""
|
|
228
|
+
if prov is None:
|
|
229
|
+
return None
|
|
230
|
+
entries = prov if isinstance(prov, list) else [prov]
|
|
231
|
+
for p in entries:
|
|
232
|
+
page_no = getattr(p, "page_no", None)
|
|
233
|
+
if page_no is not None:
|
|
234
|
+
return page_no
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
226
238
|
def _resolve_page_height(prov_list: list[Any], doc: Any, fallback: float = 842.0) -> float:
|
|
227
239
|
"""
|
|
228
240
|
Look up the actual page height for the first prov entry from the document.
|
|
@@ -421,7 +433,7 @@ def _extract_original_header_rows(
|
|
|
421
433
|
|
|
422
434
|
def _index_member_rows(
|
|
423
435
|
member_data: list[Optional[TableData]],
|
|
424
|
-
) -> dict[tuple, list[list[TableCell]]]:
|
|
436
|
+
) -> dict[tuple, list[tuple[int, list[TableCell]]]]:
|
|
425
437
|
"""
|
|
426
438
|
Index every member fragment's original grid rows by their expanded
|
|
427
439
|
text-vector, for col_span reconstruction during injection.
|
|
@@ -440,17 +452,24 @@ def _index_member_rows(
|
|
|
440
452
|
across the value columns. (The anchor's own header rows are reconstructed
|
|
441
453
|
separately and never looked up here.) Values are buckets because a row can
|
|
442
454
|
legitimately repeat; identical text-vectors imply identical span structure,
|
|
443
|
-
|
|
455
|
+
but NOT identical geometry — re-emitted cells carry their source bbox, so
|
|
456
|
+
occurrences are not interchangeable. Buckets are filled in fragment (page)
|
|
457
|
+
order and popped FIFO, which aligns the k-th DataFrame occurrence with the
|
|
458
|
+
k-th source occurrence because the merged DataFrame preserves fragment
|
|
459
|
+
concatenation order.
|
|
460
|
+
|
|
461
|
+
Bucket entries are ``(fragment_idx, row)`` pairs so the re-emitted row's
|
|
462
|
+
page association can be recorded (``fragment_idx`` indexes ``member_data``).
|
|
444
463
|
"""
|
|
445
|
-
index: dict[tuple, list[list[TableCell]]] = {}
|
|
446
|
-
for data in member_data:
|
|
464
|
+
index: dict[tuple, list[tuple[int, list[TableCell]]]] = {}
|
|
465
|
+
for frag_idx, data in enumerate(member_data):
|
|
447
466
|
if not data or not data.grid:
|
|
448
467
|
continue
|
|
449
468
|
for row in data.grid:
|
|
450
469
|
if not row:
|
|
451
470
|
continue
|
|
452
471
|
key = tuple((getattr(c, "text", "") or "") if c else "" for c in row)
|
|
453
|
-
index.setdefault(key, []).append(row)
|
|
472
|
+
index.setdefault(key, []).append((frag_idx, row))
|
|
454
473
|
return index
|
|
455
474
|
|
|
456
475
|
|
|
@@ -458,7 +477,9 @@ def _reemit_body_row(
|
|
|
458
477
|
orig_row: list[TableCell], table_row_idx: int, has_row_headers: bool
|
|
459
478
|
) -> tuple[list[TableCell], list[TableCell]]:
|
|
460
479
|
"""
|
|
461
|
-
Re-emit an original grid body row at a new row offset, preserving col_span
|
|
480
|
+
Re-emit an original grid body row at a new row offset, preserving col_span
|
|
481
|
+
and the source cell's bbox (page-local coordinates; see the merged table's
|
|
482
|
+
prov for page association).
|
|
462
483
|
|
|
463
484
|
Returns ``(grid_row, distinct_cells)`` where ``grid_row`` repeats each
|
|
464
485
|
spanning cell across the columns it covers (Docling grid convention) and
|
|
@@ -480,6 +501,7 @@ def _reemit_body_row(
|
|
|
480
501
|
col_span = getattr(cell, "col_span", 1) or 1
|
|
481
502
|
new_cell = TableCell(
|
|
482
503
|
text=getattr(cell, "text", "") or "",
|
|
504
|
+
bbox=getattr(cell, "bbox", None),
|
|
483
505
|
row_span=1,
|
|
484
506
|
col_span=col_span,
|
|
485
507
|
column_header=False,
|
|
@@ -539,6 +561,8 @@ def _dataframe_to_docling_data(
|
|
|
539
561
|
df: pd.DataFrame,
|
|
540
562
|
original_data: Optional[TableData] = None,
|
|
541
563
|
member_data: Optional[list[Optional[TableData]]] = None,
|
|
564
|
+
member_pages: Optional[list[Optional[int]]] = None,
|
|
565
|
+
row_pages_out: Optional[dict[int, int]] = None,
|
|
542
566
|
) -> TableData:
|
|
543
567
|
"""
|
|
544
568
|
Converts a pandas DataFrame back into Docling's TableData structure.
|
|
@@ -554,6 +578,13 @@ def _dataframe_to_docling_data(
|
|
|
554
578
|
re-emitted from their original grid cells, preserving col_span. Rows the
|
|
555
579
|
merger transformed (stitched continuations, folded overflow) fall back to a
|
|
556
580
|
flat 1x1 rebuild from the DataFrame.
|
|
581
|
+
|
|
582
|
+
When ``row_pages_out`` is provided (with ``member_pages`` aligned
|
|
583
|
+
index-for-index with ``member_data``), it is filled with the page
|
|
584
|
+
association for rows that carry geometry: grid row index -> resolved
|
|
585
|
+
page_no. Header rows reused from the anchor map to the anchor's page
|
|
586
|
+
(``member_pages[0]``); re-emitted body rows map to their source fragment's
|
|
587
|
+
page; flat-rebuilt rows get no entry (see ``LogicalTable.row_pages``).
|
|
557
588
|
"""
|
|
558
589
|
if df.empty:
|
|
559
590
|
cols = list(df.columns) if len(df.columns) > 0 else ["Column_0"]
|
|
@@ -588,6 +619,13 @@ def _dataframe_to_docling_data(
|
|
|
588
619
|
num_header_rows = len(orig_header_rows)
|
|
589
620
|
grid: list[list[TableCell]] = list(orig_header_rows)
|
|
590
621
|
table_cells: list[TableCell] = list(orig_header_cells)
|
|
622
|
+
# Reused header cells keep the anchor's geometry, so they map to the
|
|
623
|
+
# anchor's page (member_pages[0] — the anchor is always members[0]).
|
|
624
|
+
if row_pages_out is not None and member_pages:
|
|
625
|
+
anchor_page = member_pages[0]
|
|
626
|
+
if anchor_page is not None:
|
|
627
|
+
for h_idx in range(num_header_rows):
|
|
628
|
+
row_pages_out[h_idx] = anchor_page
|
|
591
629
|
else:
|
|
592
630
|
# Fall back to building flat 1x1 header from DataFrame columns
|
|
593
631
|
num_header_rows = 1
|
|
@@ -639,7 +677,7 @@ def _dataframe_to_docling_data(
|
|
|
639
677
|
# adjacent values are never fused.
|
|
640
678
|
bucket = body_index.get(tuple(row_vals))
|
|
641
679
|
if bucket:
|
|
642
|
-
orig_row = bucket.pop(0)
|
|
680
|
+
frag_idx, orig_row = bucket.pop(0)
|
|
643
681
|
if header_sigs and _is_reprinted_header(orig_row, header_sigs):
|
|
644
682
|
# Reprinted header from a continuation page — already present as
|
|
645
683
|
# the header block; drop it instead of duplicating into the body.
|
|
@@ -648,6 +686,10 @@ def _dataframe_to_docling_data(
|
|
|
648
686
|
grid_row, distinct = _reemit_body_row(orig_row, table_row_idx, has_row_headers)
|
|
649
687
|
grid.append(grid_row)
|
|
650
688
|
table_cells.extend(distinct)
|
|
689
|
+
if row_pages_out is not None and member_pages:
|
|
690
|
+
page = member_pages[frag_idx] if frag_idx < len(member_pages) else None
|
|
691
|
+
if page is not None:
|
|
692
|
+
row_pages_out[table_row_idx] = page
|
|
651
693
|
emitted += 1
|
|
652
694
|
continue
|
|
653
695
|
|
|
@@ -1051,12 +1093,25 @@ class DoclingAdapter:
|
|
|
1051
1093
|
member_data = [
|
|
1052
1094
|
table_snapshots[m]["data"] for m in lt.members if m in table_snapshots
|
|
1053
1095
|
]
|
|
1096
|
+
# Page per fragment, same filter as member_data so indices stay
|
|
1097
|
+
# aligned. Feeds LogicalTable.row_pages: resolved page_no rather
|
|
1098
|
+
# than a prov index, so the map survives downstream prov
|
|
1099
|
+
# manipulation by consumers.
|
|
1100
|
+
member_pages = [
|
|
1101
|
+
_first_page_no(table_snapshots[m]["prov"])
|
|
1102
|
+
for m in lt.members
|
|
1103
|
+
if m in table_snapshots
|
|
1104
|
+
]
|
|
1054
1105
|
|
|
1106
|
+
row_pages: dict[int, int] = {}
|
|
1055
1107
|
anchor_table.data = _dataframe_to_docling_data(
|
|
1056
1108
|
lt.df,
|
|
1057
1109
|
original_data=original_data,
|
|
1058
1110
|
member_data=member_data,
|
|
1111
|
+
member_pages=member_pages,
|
|
1112
|
+
row_pages_out=row_pages,
|
|
1059
1113
|
)
|
|
1114
|
+
lt.row_pages = row_pages
|
|
1060
1115
|
|
|
1061
1116
|
for satellite_idx in lt.members[1:]:
|
|
1062
1117
|
satellite_table = doc.tables[satellite_idx]
|
|
@@ -619,13 +619,34 @@ def _build_orphan_merged_table(
|
|
|
619
619
|
)
|
|
620
620
|
|
|
621
621
|
|
|
622
|
+
def _dedupe_labels(labels: list[str]) -> list[str]:
|
|
623
|
+
"""Make labels unique by suffixing repeats (``B, B`` -> ``B, B.1``)."""
|
|
624
|
+
used: set[str] = set()
|
|
625
|
+
counts: dict[str, int] = {}
|
|
626
|
+
out: list[str] = []
|
|
627
|
+
for label in labels:
|
|
628
|
+
candidate = label
|
|
629
|
+
while candidate in used:
|
|
630
|
+
counts[label] = counts.get(label, 0) + 1
|
|
631
|
+
candidate = f"{label}.{counts[label]}"
|
|
632
|
+
used.add(candidate)
|
|
633
|
+
out.append(candidate)
|
|
634
|
+
return out
|
|
635
|
+
|
|
636
|
+
|
|
622
637
|
def _build_generic_merged_table(
|
|
623
638
|
members: list[int], meta_by_idx: dict[int, TableMeta], cfg: MultiPageConfig
|
|
624
639
|
) -> tuple[pd.DataFrame, set[int], list[str]]:
|
|
625
640
|
"""Build merged table for the general case."""
|
|
626
641
|
base = meta_by_idx[members[0]]
|
|
627
642
|
merged_df = base.df.copy()
|
|
628
|
-
|
|
643
|
+
# Duplicate header labels are normal in the wild (rowspan/colspan
|
|
644
|
+
# parsers, 13F voting-authority triplets), but pd.concat cannot align
|
|
645
|
+
# frames on a non-unique column Index. Merge under deduped labels and
|
|
646
|
+
# restore the originals on the way out.
|
|
647
|
+
original_cols = [str(c) for c in base.df.columns]
|
|
648
|
+
canonical_cols = _dedupe_labels(original_cols)
|
|
649
|
+
merged_df.columns = canonical_cols
|
|
629
650
|
merged_pages = set(base.pages)
|
|
630
651
|
warnings: list[str] = []
|
|
631
652
|
prev = base
|
|
@@ -648,6 +669,7 @@ def _build_generic_merged_table(
|
|
|
648
669
|
merged_pages.update(m.pages)
|
|
649
670
|
prev = m
|
|
650
671
|
|
|
672
|
+
merged_df.columns = original_cols + [str(c) for c in merged_df.columns[len(original_cols) :]]
|
|
651
673
|
return merged_df, merged_pages, warnings
|
|
652
674
|
|
|
653
675
|
|
|
@@ -163,3 +163,15 @@ class LogicalTable:
|
|
|
163
163
|
merge_reason: str = ""
|
|
164
164
|
merge_traces: list[MergeTrace] = field(default_factory=list)
|
|
165
165
|
warnings: list[str] = field(default_factory=list)
|
|
166
|
+
row_pages: dict[int, int] = field(default_factory=dict)
|
|
167
|
+
"""
|
|
168
|
+
Page association for the merged table's cell geometry, keyed by grid row
|
|
169
|
+
index (an index into the injected ``TableData.grid``, header rows
|
|
170
|
+
included). The value is the resolved ``page_no`` the row's cell bboxes
|
|
171
|
+
(when present) are valid on. A missing key means the row has no single
|
|
172
|
+
source page: it was transformed by the merger (stitched continuation,
|
|
173
|
+
folded overflow — which can legitimately span two pages) and carries no
|
|
174
|
+
geometry, or its source fragment exposed no page number. Header rows
|
|
175
|
+
preserved from the anchor map to the anchor's page. Populated during
|
|
176
|
+
injection; empty for tables that were not merged.
|
|
177
|
+
"""
|