table-stitcher 0.3.0__tar.gz → 0.4.1__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.3.0 → table_stitcher-0.4.1}/CHANGELOG.md +39 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/PKG-INFO +1 -1
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/pyproject.toml +1 -1
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/docling.py +295 -6
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/merger.py +16 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/models.py +20 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_docling_adapter.py +88 -0
- table_stitcher-0.4.1/tests/test_intervening_content_guard.py +159 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_merger.py +60 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.claude/settings.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/dependabot.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/pull_request_template.md +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/workflows/ci.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/workflows/release.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/workflows/upstream-smoke.yml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.gitignore +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.pre-commit-config.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/CONTRIBUTING.md +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/LICENSE +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/README.md +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/SECURITY.md +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/examples/basic_pipeline.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/examples/system_controller.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/scripts/regenerate_docling_snapshots.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/scripts/release_gate.sh +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/__init__.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/README.md +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/__init__.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/base.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/py.typed +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/README.md +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/__init__.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/fixtures/tablemeta/headerless-width-drift.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/__init__.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/_tools/__init__.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/_tools/regenerate_expected.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/conftest.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/_synth/__init__.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/_synth/generate.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/.gitkeep +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/.gitkeep +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/orphan-pair/.gitkeep +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/simple-continuation/sample-table.corp.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/simple-continuation/sample-table.corp.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/simple-continuation/sample-table.corp.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/spillover/note-overflow.synth.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/spillover/note-overflow.synth.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/spillover/note-overflow.synth.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/.gitkeep +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.docling.json +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.expected.yaml +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.pdf +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/test_fixtures.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_public_api.py +0 -0
- {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_tablemeta_fixtures.py +0 -0
|
@@ -7,6 +7,45 @@ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] — 2026-06-08
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Spanning body cells duplicated across columns on multi-page merge**
|
|
15
|
+
(`adapters/docling.py`). Docling repeats a `col_span=N` cell's text across
|
|
16
|
+
every column it covers; the merge round-trip rebuilt those as `N` separate
|
|
17
|
+
`col_span=1` cells, leaking a full-width description into every value column
|
|
18
|
+
and displacing the real values (a repeated `col_span` header behaved the same
|
|
19
|
+
way). Injection now matches each merged row back to its source grid row and
|
|
20
|
+
re-emits the original spans; rows the merger transformed (stitched
|
|
21
|
+
continuations, folded overflow) fall back to the flat 1x1 rebuild. The match
|
|
22
|
+
uses the original span metadata, never value equality, so coincidentally-equal
|
|
23
|
+
adjacent values (e.g. two plan columns sharing a cap) stay separate cells.
|
|
24
|
+
|
|
25
|
+
## [0.4.0] — 2026-05-29
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Intervening-content guard** (`block_on_intervening_content`, default `True`).
|
|
30
|
+
Two tables that share a column schema but belong to different sections — a
|
|
31
|
+
heading sits between them in reading order — are no longer stitched into one.
|
|
32
|
+
A genuine page-split continuation has nothing but page furniture between its
|
|
33
|
+
fragments, so a section heading between them is a reliable "separate tables"
|
|
34
|
+
signal. The docling adapter computes a per-table `TableMeta.content_before`
|
|
35
|
+
signal; both merge paths (`_classify_sequential_pair` and
|
|
36
|
+
`should_force_orphan_merge`) consult it.
|
|
37
|
+
- Running headers mislabeled as headings (e.g. a journal banner labeled
|
|
38
|
+
`page_header` on one page and `section_header` on another, or a repeated
|
|
39
|
+
"Summary of benefits" banner above every page of a multi-page table) are
|
|
40
|
+
detected as furniture via near-identical (Jaccard ≥ 0.8) recurrence across
|
|
41
|
+
pages, so they do not block legitimate continuations.
|
|
42
|
+
- Only `section_header`/`title` nodes block; plain paragraphs, list items,
|
|
43
|
+
captions, footnotes and figures are deliberately ignored, since real PDFs
|
|
44
|
+
routinely scatter those between fragments of a single continued table.
|
|
45
|
+
- Fixes over-eager merging of same-schema per-section tables (e.g. an
|
|
46
|
+
insurance policy's eight `Prestige | Elite | Classic` benefit grids being
|
|
47
|
+
collapsed into one).
|
|
48
|
+
|
|
10
49
|
## [0.3.0] — 2026-05-06
|
|
11
50
|
|
|
12
51
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: table-stitcher
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -19,6 +19,7 @@ from docling_core.types.doc import (
|
|
|
19
19
|
from ..merger import (
|
|
20
20
|
first_row_has_number,
|
|
21
21
|
is_numeric_like_colnames,
|
|
22
|
+
jaccard,
|
|
22
23
|
normalize_col_name,
|
|
23
24
|
tokenize,
|
|
24
25
|
)
|
|
@@ -418,9 +419,90 @@ def _extract_original_header_rows(
|
|
|
418
419
|
return header_rows, header_cells
|
|
419
420
|
|
|
420
421
|
|
|
422
|
+
def _index_member_rows(
|
|
423
|
+
member_data: list[Optional[TableData]],
|
|
424
|
+
) -> dict[tuple, list[list[TableCell]]]:
|
|
425
|
+
"""
|
|
426
|
+
Index every member fragment's original grid rows by their expanded
|
|
427
|
+
text-vector, for col_span reconstruction during injection.
|
|
428
|
+
|
|
429
|
+
Docling repeats a spanning cell's text across each column it covers, so the
|
|
430
|
+
text-vector of an original grid row equals the DataFrame row that
|
|
431
|
+
``_grid_to_dataframe`` produced for it (for any row the merger left
|
|
432
|
+
untouched). Keying on that vector lets ``_dataframe_to_docling_data``
|
|
433
|
+
recover the original col_span structure instead of flattening every cell to
|
|
434
|
+
1x1 — which duplicates a spanning cell into every column it covered on a
|
|
435
|
+
multi-page merge.
|
|
436
|
+
|
|
437
|
+
All rows are indexed, including header rows: a satellite fragment's repeated
|
|
438
|
+
header (e.g. a ``col_span=6`` banner) arrives in the merged DataFrame as a
|
|
439
|
+
body row, and must match its original spanning cell rather than duplicate
|
|
440
|
+
across the value columns. (The anchor's own header rows are reconstructed
|
|
441
|
+
separately and never looked up here.) Values are buckets because a row can
|
|
442
|
+
legitimately repeat; identical text-vectors imply identical span structure,
|
|
443
|
+
so any occurrence is interchangeable.
|
|
444
|
+
"""
|
|
445
|
+
index: dict[tuple, list[list[TableCell]]] = {}
|
|
446
|
+
for data in member_data:
|
|
447
|
+
if not data or not data.grid:
|
|
448
|
+
continue
|
|
449
|
+
for row in data.grid:
|
|
450
|
+
if not row:
|
|
451
|
+
continue
|
|
452
|
+
key = tuple((getattr(c, "text", "") or "") if c else "" for c in row)
|
|
453
|
+
index.setdefault(key, []).append(row)
|
|
454
|
+
return index
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _reemit_body_row(
|
|
458
|
+
orig_row: list[TableCell], table_row_idx: int, has_row_headers: bool
|
|
459
|
+
) -> tuple[list[TableCell], list[TableCell]]:
|
|
460
|
+
"""
|
|
461
|
+
Re-emit an original grid body row at a new row offset, preserving col_span.
|
|
462
|
+
|
|
463
|
+
Returns ``(grid_row, distinct_cells)`` where ``grid_row`` repeats each
|
|
464
|
+
spanning cell across the columns it covers (Docling grid convention) and
|
|
465
|
+
``distinct_cells`` lists each origin cell once (for ``table_cells``).
|
|
466
|
+
|
|
467
|
+
row_span is intentionally clamped to 1: the merged DataFrame represents one
|
|
468
|
+
logical row per grid row, so a multi-row body span cannot be expressed
|
|
469
|
+
without desynchronizing the rebuilt grid. (Body row_spans are rare; col_span
|
|
470
|
+
is the case that corrupts multi-page merges.)
|
|
471
|
+
"""
|
|
472
|
+
grid_row: list[Optional[TableCell]] = []
|
|
473
|
+
distinct: list[TableCell] = []
|
|
474
|
+
for c_idx, cell in enumerate(orig_row):
|
|
475
|
+
if cell is None:
|
|
476
|
+
grid_row.append(None)
|
|
477
|
+
continue
|
|
478
|
+
start_col = getattr(cell, "start_col_offset_idx", c_idx)
|
|
479
|
+
if start_col == c_idx:
|
|
480
|
+
col_span = getattr(cell, "col_span", 1) or 1
|
|
481
|
+
new_cell = TableCell(
|
|
482
|
+
text=getattr(cell, "text", "") or "",
|
|
483
|
+
row_span=1,
|
|
484
|
+
col_span=col_span,
|
|
485
|
+
column_header=False,
|
|
486
|
+
row_header=(c_idx == 0 and has_row_headers)
|
|
487
|
+
or bool(getattr(cell, "row_header", False)),
|
|
488
|
+
start_row_offset_idx=table_row_idx,
|
|
489
|
+
end_row_offset_idx=table_row_idx + 1,
|
|
490
|
+
start_col_offset_idx=c_idx,
|
|
491
|
+
end_col_offset_idx=c_idx + col_span,
|
|
492
|
+
)
|
|
493
|
+
distinct.append(new_cell)
|
|
494
|
+
grid_row.append(new_cell)
|
|
495
|
+
else:
|
|
496
|
+
# Continuation column of a span originating to the left: repeat the
|
|
497
|
+
# same cell object, which was already appended at ``start_col``.
|
|
498
|
+
grid_row.append(grid_row[start_col] if start_col < len(grid_row) else None)
|
|
499
|
+
return grid_row, distinct
|
|
500
|
+
|
|
501
|
+
|
|
421
502
|
def _dataframe_to_docling_data(
|
|
422
503
|
df: pd.DataFrame,
|
|
423
504
|
original_data: Optional[TableData] = None,
|
|
505
|
+
member_data: Optional[list[Optional[TableData]]] = None,
|
|
424
506
|
) -> TableData:
|
|
425
507
|
"""
|
|
426
508
|
Converts a pandas DataFrame back into Docling's TableData structure.
|
|
@@ -430,6 +512,12 @@ def _dataframe_to_docling_data(
|
|
|
430
512
|
are preserved exactly. Only the data rows are rebuilt from the DataFrame.
|
|
431
513
|
This prevents the lossy roundtrip that would flatten complex headers into
|
|
432
514
|
simple 1x1 cells.
|
|
515
|
+
|
|
516
|
+
When ``member_data`` (the original ``TableData`` of every fragment in the
|
|
517
|
+
logical table) is provided, body rows the merger left untouched are
|
|
518
|
+
re-emitted from their original grid cells, preserving col_span. Rows the
|
|
519
|
+
merger transformed (stitched continuations, folded overflow) fall back to a
|
|
520
|
+
flat 1x1 rebuild from the DataFrame.
|
|
433
521
|
"""
|
|
434
522
|
if df.empty:
|
|
435
523
|
cols = list(df.columns) if len(df.columns) > 0 else ["Column_0"]
|
|
@@ -498,16 +586,28 @@ def _dataframe_to_docling_data(
|
|
|
498
586
|
break
|
|
499
587
|
|
|
500
588
|
# --- Build data rows from merged DataFrame ---
|
|
589
|
+
# Index member fragments' original body rows so spanning cells survive the
|
|
590
|
+
# round-trip (see _index_member_body_rows).
|
|
591
|
+
body_index = _index_member_rows(member_data) if member_data else {}
|
|
592
|
+
|
|
501
593
|
for i, (_, row) in enumerate(df.iterrows()):
|
|
502
|
-
grid_row: list[TableCell] = []
|
|
503
594
|
table_row_idx = num_header_rows + i
|
|
504
595
|
|
|
505
|
-
for
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
596
|
+
row_vals = ["" if (pd.isna(v) or v is None) else str(v) for v in row]
|
|
597
|
+
|
|
598
|
+
# Re-emit untouched rows from their original grid cells (preserves
|
|
599
|
+
# col_span); only matches when widths align, so coincidentally-equal
|
|
600
|
+
# adjacent values are never fused.
|
|
601
|
+
bucket = body_index.get(tuple(row_vals))
|
|
602
|
+
if bucket:
|
|
603
|
+
orig_row = bucket.pop(0)
|
|
604
|
+
grid_row, distinct = _reemit_body_row(orig_row, table_row_idx, has_row_headers)
|
|
605
|
+
grid.append(grid_row)
|
|
606
|
+
table_cells.extend(distinct)
|
|
607
|
+
continue
|
|
510
608
|
|
|
609
|
+
grid_row: list[TableCell] = []
|
|
610
|
+
for j, text_val in enumerate(row_vals):
|
|
511
611
|
row_header = j == 0 and has_row_headers
|
|
512
612
|
|
|
513
613
|
cell = TableCell(
|
|
@@ -556,6 +656,176 @@ def _get_ref_pointer(ref_obj: Any) -> str:
|
|
|
556
656
|
# -------------------------------------------------------------------
|
|
557
657
|
|
|
558
658
|
|
|
659
|
+
# -------------------------------------------------------------------
|
|
660
|
+
# Intervening-content detection (reading-order furniture filtering)
|
|
661
|
+
#
|
|
662
|
+
# Philosophy: a table split across a page break has nothing but page
|
|
663
|
+
# furniture (running headers/footers, page numbers) between its fragments.
|
|
664
|
+
# If *substantive* body content — a heading, paragraph, list item, or real
|
|
665
|
+
# figure — sits between two fragments in reading order, they are separate
|
|
666
|
+
# tables that merely share a column schema. These helpers classify the body
|
|
667
|
+
# nodes between consecutive tables so the merger can refuse such merges.
|
|
668
|
+
# -------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
# Only a structural section boundary reliably means "these are separate
|
|
671
|
+
# tables." Plain prose, list items, captions, footnotes and figures all turn
|
|
672
|
+
# up *between* fragments of genuine continuations on real-world PDFs (cell text
|
|
673
|
+
# extracted as body nodes, interleaved multi-column reading order, repeated
|
|
674
|
+
# legends), so blocking on them regresses legitimate merges. A new heading
|
|
675
|
+
# between two tables, by contrast, is a clean separator.
|
|
676
|
+
_BLOCKING_LABELS = {"section_header", "title"}
|
|
677
|
+
|
|
678
|
+
_CONTINUATION_RE = re.compile(r"\bcont(?:inued|inuation|'?d|\.)?\b", re.IGNORECASE)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _label_str(item: Any) -> str:
|
|
682
|
+
"""Normalize a docling item label (enum or str) to a lowercase string."""
|
|
683
|
+
lab = getattr(item, "label", None)
|
|
684
|
+
return str(getattr(lab, "value", lab) or "").lower()
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _norm_text(item: Any) -> str:
|
|
688
|
+
"""Whitespace-collapsed lowercase text of an item."""
|
|
689
|
+
return " ".join(str(getattr(item, "text", "") or "").split()).lower()
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _flatten_body_refs(doc: Any) -> list[str]:
|
|
693
|
+
"""Return body reference pointers in reading order (DFS through groups)."""
|
|
694
|
+
seq: list[str] = []
|
|
695
|
+
groups = getattr(doc, "groups", []) or []
|
|
696
|
+
seen_groups: set[int] = set()
|
|
697
|
+
|
|
698
|
+
def walk(node: Any) -> None:
|
|
699
|
+
children = getattr(node, "children", None) or []
|
|
700
|
+
for child in children:
|
|
701
|
+
ref = _get_ref_pointer(child)
|
|
702
|
+
if not ref:
|
|
703
|
+
continue
|
|
704
|
+
if ref.startswith("#/groups/"):
|
|
705
|
+
try:
|
|
706
|
+
gi = int(ref.split("/")[-1])
|
|
707
|
+
except ValueError:
|
|
708
|
+
continue
|
|
709
|
+
# Guard against malformed self-referential group cycles.
|
|
710
|
+
if gi in seen_groups or not (0 <= gi < len(groups)):
|
|
711
|
+
continue
|
|
712
|
+
seen_groups.add(gi)
|
|
713
|
+
walk(groups[gi])
|
|
714
|
+
else:
|
|
715
|
+
seq.append(ref)
|
|
716
|
+
|
|
717
|
+
body = getattr(doc, "body", None)
|
|
718
|
+
if body is not None:
|
|
719
|
+
walk(body)
|
|
720
|
+
return seq
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _resolve_ref(doc: Any, ref: str) -> Optional[Any]:
|
|
724
|
+
"""Resolve a ``#/kind/N`` pointer to its item, or None."""
|
|
725
|
+
try:
|
|
726
|
+
_, kind, n_str = ref.split("/")
|
|
727
|
+
n = int(n_str)
|
|
728
|
+
except (ValueError, AttributeError):
|
|
729
|
+
return None
|
|
730
|
+
coll = {
|
|
731
|
+
"texts": getattr(doc, "texts", None),
|
|
732
|
+
"tables": getattr(doc, "tables", None),
|
|
733
|
+
"pictures": getattr(doc, "pictures", None),
|
|
734
|
+
}.get(kind)
|
|
735
|
+
if not coll or n >= len(coll):
|
|
736
|
+
return None
|
|
737
|
+
return coll[n]
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _detect_running_furniture(doc: Any, cfg: MultiPageConfig) -> set[str]:
|
|
741
|
+
"""
|
|
742
|
+
Identify headings that are actually running headers, not section boundaries.
|
|
743
|
+
|
|
744
|
+
Only headings (``_BLOCKING_LABELS``) can block a merge, so only headings
|
|
745
|
+
need to be exempted. A heading is a running header when near-identical text
|
|
746
|
+
appears on a *different* page — e.g. a repeated ``Summary of benefits``
|
|
747
|
+
banner above every page of a multi-page table, or a journal name that one
|
|
748
|
+
page labels ``page_header`` and another mislabels ``section_header`` (the
|
|
749
|
+
parser inconsistency this protects against).
|
|
750
|
+
|
|
751
|
+
Similarity uses symmetric Jaccard with a high threshold (near-duplicate).
|
|
752
|
+
A real, unique heading such as ``38e - Trip postponement`` has no
|
|
753
|
+
near-duplicate on another page, so it correctly stays a blocker — unlike a
|
|
754
|
+
looser containment metric, which a short subset string (a TOC entry, say)
|
|
755
|
+
would spuriously satisfy.
|
|
756
|
+
"""
|
|
757
|
+
texts = getattr(doc, "texts", []) or []
|
|
758
|
+
# (ref, tokens, page, label) for every text node — the comparison pool.
|
|
759
|
+
nodes: list[tuple[str, set, Any, str]] = []
|
|
760
|
+
for i, item in enumerate(texts):
|
|
761
|
+
prov = getattr(item, "prov", None) or []
|
|
762
|
+
page = getattr(prov[0], "page_no", None) if prov else None
|
|
763
|
+
nodes.append((f"#/texts/{i}", tokenize(_norm_text(item)), page, _label_str(item)))
|
|
764
|
+
|
|
765
|
+
furniture: set[str] = set()
|
|
766
|
+
for ref, toks, page, label in nodes:
|
|
767
|
+
if label not in _BLOCKING_LABELS or not toks or page is None:
|
|
768
|
+
continue
|
|
769
|
+
for ref2, toks2, page2, _ in nodes:
|
|
770
|
+
if ref2 == ref or page2 is None or page2 == page or not toks2:
|
|
771
|
+
continue
|
|
772
|
+
if jaccard(toks, toks2) >= 0.8:
|
|
773
|
+
furniture.add(ref)
|
|
774
|
+
break
|
|
775
|
+
return furniture
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _ref_is_blocking(doc: Any, ref: str, furniture: set[str], cfg: MultiPageConfig) -> bool:
|
|
779
|
+
"""Whether a body node between two tables marks a real table boundary.
|
|
780
|
+
|
|
781
|
+
Only a non-furniture section heading qualifies (see ``_BLOCKING_LABELS``).
|
|
782
|
+
Figures, list items and plain paragraphs are deliberately ignored: on real
|
|
783
|
+
PDFs they routinely appear between fragments of a single continued table.
|
|
784
|
+
"""
|
|
785
|
+
if ref in furniture or not ref.startswith("#/texts/"):
|
|
786
|
+
return False
|
|
787
|
+
item = _resolve_ref(doc, ref)
|
|
788
|
+
if item is None:
|
|
789
|
+
return False
|
|
790
|
+
if _label_str(item) not in _BLOCKING_LABELS:
|
|
791
|
+
return False
|
|
792
|
+
text = str(getattr(item, "text", "") or "")
|
|
793
|
+
if not text.strip():
|
|
794
|
+
return False
|
|
795
|
+
# A "(continued)" heading marks a continuation, not a new table.
|
|
796
|
+
if _CONTINUATION_RE.search(text):
|
|
797
|
+
return False
|
|
798
|
+
return True
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _compute_content_before(doc: Any, cfg: MultiPageConfig) -> dict[int, bool]:
|
|
802
|
+
"""
|
|
803
|
+
For each table (keyed by docling index), whether substantive body content
|
|
804
|
+
separates it from the previous table in reading order. Tables absent from
|
|
805
|
+
the body reading order (orphans) are omitted, disabling the guard for them.
|
|
806
|
+
"""
|
|
807
|
+
seq = _flatten_body_refs(doc)
|
|
808
|
+
if not seq:
|
|
809
|
+
return {}
|
|
810
|
+
furniture = _detect_running_furniture(doc, cfg)
|
|
811
|
+
|
|
812
|
+
result: dict[int, bool] = {}
|
|
813
|
+
prev_table_seen = False
|
|
814
|
+
blocking_seen = False
|
|
815
|
+
for ref in seq:
|
|
816
|
+
if ref.startswith("#/tables/"):
|
|
817
|
+
try:
|
|
818
|
+
t_idx = int(ref.split("/")[-1])
|
|
819
|
+
except ValueError:
|
|
820
|
+
continue
|
|
821
|
+
result[t_idx] = prev_table_seen and blocking_seen
|
|
822
|
+
prev_table_seen = True
|
|
823
|
+
blocking_seen = False
|
|
824
|
+
elif not blocking_seen and _ref_is_blocking(doc, ref, furniture, cfg):
|
|
825
|
+
blocking_seen = True
|
|
826
|
+
return result
|
|
827
|
+
|
|
828
|
+
|
|
559
829
|
class DoclingAdapter:
|
|
560
830
|
"""
|
|
561
831
|
Table-stitcher adapter for Docling (docling-core).
|
|
@@ -569,6 +839,15 @@ class DoclingAdapter:
|
|
|
569
839
|
total = len(doc.tables)
|
|
570
840
|
skipped = 0
|
|
571
841
|
|
|
842
|
+
# Reading-order map: which tables have substantive body content before
|
|
843
|
+
# them (used by the merger's intervening-content guard). Computed once.
|
|
844
|
+
content_before_map: dict[int, bool] = {}
|
|
845
|
+
if cfg.block_on_intervening_content:
|
|
846
|
+
try:
|
|
847
|
+
content_before_map = _compute_content_before(doc, cfg)
|
|
848
|
+
except Exception as e: # never let the guard break extraction
|
|
849
|
+
log.warning(f"Intervening-content detection failed: {e}")
|
|
850
|
+
|
|
572
851
|
for idx, table in enumerate(doc.tables):
|
|
573
852
|
try:
|
|
574
853
|
df = _grid_to_dataframe(table, doc)
|
|
@@ -640,6 +919,7 @@ class DoclingAdapter:
|
|
|
640
919
|
row_count=df.shape[0],
|
|
641
920
|
continuation_content=continuation_content,
|
|
642
921
|
is_headerless=is_headerless,
|
|
922
|
+
content_before=content_before_map.get(idx),
|
|
643
923
|
)
|
|
644
924
|
)
|
|
645
925
|
|
|
@@ -718,9 +998,18 @@ class DoclingAdapter:
|
|
|
718
998
|
|
|
719
999
|
original_data = getattr(anchor_table, "data", None)
|
|
720
1000
|
|
|
1001
|
+
# Original TableData of every fragment, captured in
|
|
1002
|
+
# table_snapshots before any mutation, so injection can recover
|
|
1003
|
+
# each untouched body row's col_span (see
|
|
1004
|
+
# _index_member_body_rows).
|
|
1005
|
+
member_data = [
|
|
1006
|
+
table_snapshots[m]["data"] for m in lt.members if m in table_snapshots
|
|
1007
|
+
]
|
|
1008
|
+
|
|
721
1009
|
anchor_table.data = _dataframe_to_docling_data(
|
|
722
1010
|
lt.df,
|
|
723
1011
|
original_data=original_data,
|
|
1012
|
+
member_data=member_data,
|
|
724
1013
|
)
|
|
725
1014
|
|
|
726
1015
|
for satellite_idx in lt.members[1:]:
|
|
@@ -312,6 +312,11 @@ def should_force_orphan_merge(h: TableMeta, d: TableMeta, cfg: MultiPageConfig)
|
|
|
312
312
|
return False, ""
|
|
313
313
|
if abs(h.width - d.width) > cfg.max_width_difference:
|
|
314
314
|
return False, ""
|
|
315
|
+
# Intervening-content guard — sibling of the one in _classify_sequential_pair.
|
|
316
|
+
# Pass 2 reaches this without going through that function, so the guard must
|
|
317
|
+
# be repeated here: a heading before the data fragment means a new table.
|
|
318
|
+
if cfg.block_on_intervening_content and d.content_before:
|
|
319
|
+
return False, "content_between_tables"
|
|
315
320
|
|
|
316
321
|
layout = layout_suggests_continuation(h, d, cfg)
|
|
317
322
|
if h.is_header_orphan and d.is_data_orphan:
|
|
@@ -705,6 +710,17 @@ def _classify_sequential_pair(
|
|
|
705
710
|
if page_gap < 1 or page_gap > cfg.max_page_gap:
|
|
706
711
|
return False, "page_gap_out_of_range", False, []
|
|
707
712
|
|
|
713
|
+
# --- Intervening-content guard ---
|
|
714
|
+
# A genuine page-split continuation has nothing but page furniture between
|
|
715
|
+
# its fragments. Substantive body content (a heading, paragraph, list item,
|
|
716
|
+
# or figure) between tA and tB means they are separate tables that merely
|
|
717
|
+
# share a column schema, not one table split across a page break. The
|
|
718
|
+
# adapter computes this in reading order (furniture, captions and footnotes
|
|
719
|
+
# are already filtered out); ``None`` means position unknown, so we defer to
|
|
720
|
+
# the other signals rather than block.
|
|
721
|
+
if cfg.block_on_intervening_content and tB.content_before:
|
|
722
|
+
return False, "content_between_tables", False, []
|
|
723
|
+
|
|
708
724
|
# --- Spillover (checked before width guards since spillover can cross
|
|
709
725
|
# width boundaries legitimately: 1-col fragment follows N-col table) ---
|
|
710
726
|
if is_spillover_fragment(tA, tB, cfg):
|
|
@@ -92,6 +92,19 @@ class MultiPageConfig:
|
|
|
92
92
|
The structural signal (1 col following N cols) is strong enough for most cases.
|
|
93
93
|
"""
|
|
94
94
|
|
|
95
|
+
# --- Intervening-Content Guard ---
|
|
96
|
+
block_on_intervening_content: bool = True
|
|
97
|
+
"""
|
|
98
|
+
If True, refuse to merge two fragments when substantive body content
|
|
99
|
+
(a heading, paragraph, list item, or figure) appears between them in
|
|
100
|
+
reading order. A genuine page-split continuation has nothing between its
|
|
101
|
+
fragments except page furniture (running headers/footers, page numbers),
|
|
102
|
+
which is filtered out, as are table-attached captions/footnotes and
|
|
103
|
+
``(continued)`` markers. Requires an adapter that populates
|
|
104
|
+
``TableMeta.content_before``; when it is left ``None`` the guard is a
|
|
105
|
+
no-op, so non-docling adapters are unaffected.
|
|
106
|
+
"""
|
|
107
|
+
|
|
95
108
|
# --- Cell Stitching ---
|
|
96
109
|
stitch_separator: str = "\n"
|
|
97
110
|
"""Character(s) used to join split cell content."""
|
|
@@ -118,6 +131,13 @@ class TableMeta:
|
|
|
118
131
|
row_count: int
|
|
119
132
|
continuation_content: list[dict] = field(default_factory=list)
|
|
120
133
|
is_headerless: bool = False
|
|
134
|
+
content_before: Optional[bool] = None
|
|
135
|
+
"""
|
|
136
|
+
Whether substantive (non-furniture) body content immediately precedes this
|
|
137
|
+
fragment in reading order, since the previous table fragment. ``None`` means
|
|
138
|
+
the adapter could not place the table in reading order (e.g. an orphan
|
|
139
|
+
table), in which case the intervening-content guard is skipped for it.
|
|
140
|
+
"""
|
|
121
141
|
|
|
122
142
|
|
|
123
143
|
@dataclass
|
|
@@ -300,6 +300,94 @@ class TestHeaderPreservation:
|
|
|
300
300
|
assert td.num_rows == 3 # 1 header + 2 data
|
|
301
301
|
|
|
302
302
|
|
|
303
|
+
class TestBodySpanPreservation:
|
|
304
|
+
"""Body col_span cells must survive the merge round-trip, not duplicate.
|
|
305
|
+
|
|
306
|
+
Docling repeats a spanning cell's text across every column it covers, so a
|
|
307
|
+
naive grid -> DataFrame -> grid rebuild flattens a ``col_span=N`` body cell
|
|
308
|
+
into N duplicate ``col_span=1`` cells — leaking a full-width description
|
|
309
|
+
into every value column and displacing the real values. Passing the member
|
|
310
|
+
fragments' original TableData lets injection re-emit the original spans.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _cell(text, r, c, *, col_span=1, header=False):
|
|
315
|
+
return TableCell(
|
|
316
|
+
text=text,
|
|
317
|
+
row_span=1,
|
|
318
|
+
col_span=col_span,
|
|
319
|
+
column_header=header,
|
|
320
|
+
row_header=False,
|
|
321
|
+
start_row_offset_idx=r,
|
|
322
|
+
end_row_offset_idx=r + 1,
|
|
323
|
+
start_col_offset_idx=c,
|
|
324
|
+
end_col_offset_idx=c + col_span,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _fragment(self) -> TableData:
|
|
328
|
+
"""3-col fragment: flat header, a col_span=3 description row, a data row,
|
|
329
|
+
and a row with coincidentally-equal adjacent values (separate cells)."""
|
|
330
|
+
c = self._cell
|
|
331
|
+
# Header row 0
|
|
332
|
+
h = [
|
|
333
|
+
c("Section", 0, 0, header=True),
|
|
334
|
+
c("Plan A", 0, 1, header=True),
|
|
335
|
+
c("Plan B", 0, 2, header=True),
|
|
336
|
+
]
|
|
337
|
+
# Row 1: description spanning all 3 cols — grid repeats the same object.
|
|
338
|
+
desc = c("See important notes below", 1, 0, col_span=3)
|
|
339
|
+
r1 = [desc, desc, desc]
|
|
340
|
+
# Row 2: ordinary data row.
|
|
341
|
+
r2 = [c("1", 2, 0), c("100", 2, 1), c("200", 2, 2)]
|
|
342
|
+
# Row 3: two value columns share a cap value, but are SEPARATE cells.
|
|
343
|
+
r3 = [c("2", 3, 0), c("150", 3, 1), c("150", 3, 2)]
|
|
344
|
+
grid = [h, r1, r2, r3]
|
|
345
|
+
flat = h + [desc, r2[0], r2[1], r2[2], r3[0], r3[1], r3[2]]
|
|
346
|
+
return TableData(num_rows=4, num_cols=3, table_cells=flat, grid=grid)
|
|
347
|
+
|
|
348
|
+
def test_body_colspan_preserved_not_duplicated(self):
|
|
349
|
+
original = self._fragment()
|
|
350
|
+
# The DataFrame as _grid_to_dataframe would produce it: the spanning
|
|
351
|
+
# description duplicated across all three columns.
|
|
352
|
+
merged_df = pd.DataFrame(
|
|
353
|
+
[
|
|
354
|
+
["See important notes below"] * 3,
|
|
355
|
+
["1", "100", "200"],
|
|
356
|
+
["2", "150", "150"],
|
|
357
|
+
],
|
|
358
|
+
columns=["Section", "Plan A", "Plan B"],
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
td = _dataframe_to_docling_data(merged_df, original_data=original, member_data=[original])
|
|
362
|
+
|
|
363
|
+
# Description row: one origin cell with col_span=3, repeated across the
|
|
364
|
+
# grid row — NOT three distinct duplicated cells.
|
|
365
|
+
desc_row = td.grid[1]
|
|
366
|
+
assert desc_row[0].col_span == 3
|
|
367
|
+
assert desc_row[0].text == "See important notes below"
|
|
368
|
+
assert desc_row[1] is desc_row[0] and desc_row[2] is desc_row[0]
|
|
369
|
+
distinct_desc = [
|
|
370
|
+
cell for cell in td.table_cells if cell.text == "See important notes below"
|
|
371
|
+
]
|
|
372
|
+
assert len(distinct_desc) == 1
|
|
373
|
+
|
|
374
|
+
# Coincidentally-equal values stay as two separate col_span=1 cells.
|
|
375
|
+
last_row = td.grid[3]
|
|
376
|
+
assert last_row[1].text == last_row[2].text == "150"
|
|
377
|
+
assert last_row[1].col_span == 1 and last_row[2].col_span == 1
|
|
378
|
+
assert last_row[1] is not last_row[2]
|
|
379
|
+
|
|
380
|
+
def test_without_member_data_falls_back_to_flat(self):
|
|
381
|
+
"""No member_data -> previous behaviour: flat 1x1 body cells."""
|
|
382
|
+
original = self._fragment()
|
|
383
|
+
merged_df = pd.DataFrame(
|
|
384
|
+
[["See important notes below"] * 3],
|
|
385
|
+
columns=["Section", "Plan A", "Plan B"],
|
|
386
|
+
)
|
|
387
|
+
td = _dataframe_to_docling_data(merged_df, original_data=original)
|
|
388
|
+
assert all(cell.col_span == 1 for cell in td.grid[1])
|
|
389
|
+
|
|
390
|
+
|
|
303
391
|
class TestAdapterProtocol:
|
|
304
392
|
"""Verify DoclingAdapter satisfies the protocol."""
|
|
305
393
|
|