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.
Files changed (133) hide show
  1. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/CHANGELOG.md +39 -0
  2. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/PKG-INFO +1 -1
  3. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/pyproject.toml +1 -1
  4. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/docling.py +295 -6
  5. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/merger.py +16 -0
  6. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/models.py +20 -0
  7. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_docling_adapter.py +88 -0
  8. table_stitcher-0.4.1/tests/test_intervening_content_guard.py +159 -0
  9. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_merger.py +60 -0
  10. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.claude/settings.json +0 -0
  11. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  12. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  13. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  14. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/dependabot.yml +0 -0
  15. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/pull_request_template.md +0 -0
  16. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/workflows/ci.yml +0 -0
  17. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/workflows/release.yml +0 -0
  18. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.github/workflows/upstream-smoke.yml +0 -0
  19. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.gitignore +0 -0
  20. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/.pre-commit-config.yaml +0 -0
  21. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/CONTRIBUTING.md +0 -0
  22. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/LICENSE +0 -0
  23. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/README.md +0 -0
  24. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/SECURITY.md +0 -0
  25. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/examples/basic_pipeline.py +0 -0
  26. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/examples/system_controller.py +0 -0
  27. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/scripts/regenerate_docling_snapshots.py +0 -0
  28. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/scripts/release_gate.sh +0 -0
  29. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/__init__.py +0 -0
  30. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/README.md +0 -0
  31. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/__init__.py +0 -0
  32. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/adapters/base.py +0 -0
  33. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/src/table_stitcher/py.typed +0 -0
  34. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/README.md +0 -0
  35. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/__init__.py +0 -0
  36. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/fixtures/tablemeta/headerless-width-drift.yaml +0 -0
  37. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/__init__.py +0 -0
  38. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/_tools/__init__.py +0 -0
  39. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/_tools/regenerate_expected.py +0 -0
  40. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/conftest.py +0 -0
  41. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/_synth/__init__.py +0 -0
  42. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/_synth/generate.py +0 -0
  43. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/.gitkeep +0 -0
  44. {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
  45. {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
  46. {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
  47. {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
  48. {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
  49. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.pdf +0 -0
  50. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.docling.json +0 -0
  51. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.expected.yaml +0 -0
  52. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.pdf +0 -0
  53. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.docling.json +0 -0
  54. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.expected.yaml +0 -0
  55. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.pdf +0 -0
  56. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.docling.json +0 -0
  57. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.expected.yaml +0 -0
  58. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.pdf +0 -0
  59. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.docling.json +0 -0
  60. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.expected.yaml +0 -0
  61. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.pdf +0 -0
  62. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.docling.json +0 -0
  63. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.expected.yaml +0 -0
  64. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.pdf +0 -0
  65. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.docling.json +0 -0
  66. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.expected.yaml +0 -0
  67. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.pdf +0 -0
  68. {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
  69. {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
  70. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.pdf +0 -0
  71. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.docling.json +0 -0
  72. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.expected.yaml +0 -0
  73. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.pdf +0 -0
  74. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.docling.json +0 -0
  75. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.expected.yaml +0 -0
  76. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.pdf +0 -0
  77. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/.gitkeep +0 -0
  78. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.docling.json +0 -0
  79. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.expected.yaml +0 -0
  80. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.pdf +0 -0
  81. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.docling.json +0 -0
  82. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.expected.yaml +0 -0
  83. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.pdf +0 -0
  84. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.docling.json +0 -0
  85. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.expected.yaml +0 -0
  86. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.pdf +0 -0
  87. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.docling.json +0 -0
  88. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.expected.yaml +0 -0
  89. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.pdf +0 -0
  90. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/orphan-pair/.gitkeep +0 -0
  91. {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
  92. {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
  93. {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
  94. {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
  95. {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
  96. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.pdf +0 -0
  97. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.docling.json +0 -0
  98. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.expected.yaml +0 -0
  99. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.pdf +0 -0
  100. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.docling.json +0 -0
  101. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.expected.yaml +0 -0
  102. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.pdf +0 -0
  103. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.docling.json +0 -0
  104. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.expected.yaml +0 -0
  105. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.pdf +0 -0
  106. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.docling.json +0 -0
  107. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.expected.yaml +0 -0
  108. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.pdf +0 -0
  109. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.docling.json +0 -0
  110. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.expected.yaml +0 -0
  111. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.pdf +0 -0
  112. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.docling.json +0 -0
  113. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.expected.yaml +0 -0
  114. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.pdf +0 -0
  115. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.docling.json +0 -0
  116. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.expected.yaml +0 -0
  117. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.pdf +0 -0
  118. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.docling.json +0 -0
  119. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.expected.yaml +0 -0
  120. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.pdf +0 -0
  121. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/simple-continuation/sample-table.corp.docling.json +0 -0
  122. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/simple-continuation/sample-table.corp.expected.yaml +0 -0
  123. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/simple-continuation/sample-table.corp.pdf +0 -0
  124. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/spillover/note-overflow.synth.docling.json +0 -0
  125. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/spillover/note-overflow.synth.expected.yaml +0 -0
  126. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/spillover/note-overflow.synth.pdf +0 -0
  127. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/.gitkeep +0 -0
  128. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.docling.json +0 -0
  129. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.expected.yaml +0 -0
  130. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.pdf +0 -0
  131. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/integration/test_fixtures.py +0 -0
  132. {table_stitcher-0.3.0 → table_stitcher-0.4.1}/tests/test_public_api.py +0 -0
  133. {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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "table-stitcher"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  description = "Reassemble tables split across page boundaries in PDF extraction"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 j, val in enumerate(row):
506
- if pd.isna(val) or val is None:
507
- text_val = ""
508
- else:
509
- text_val = str(val)
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