table-stitcher 0.3.0__tar.gz → 0.4.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.
Files changed (133) hide show
  1. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/CHANGELOG.md +24 -0
  2. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/PKG-INFO +1 -1
  3. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/pyproject.toml +1 -1
  4. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/adapters/docling.py +181 -0
  5. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/merger.py +16 -0
  6. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/models.py +20 -0
  7. table_stitcher-0.4.0/tests/test_intervening_content_guard.py +159 -0
  8. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/test_merger.py +60 -0
  9. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.claude/settings.json +0 -0
  10. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  13. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/dependabot.yml +0 -0
  14. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/pull_request_template.md +0 -0
  15. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/workflows/ci.yml +0 -0
  16. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/workflows/release.yml +0 -0
  17. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.github/workflows/upstream-smoke.yml +0 -0
  18. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.gitignore +0 -0
  19. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/.pre-commit-config.yaml +0 -0
  20. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/CONTRIBUTING.md +0 -0
  21. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/LICENSE +0 -0
  22. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/README.md +0 -0
  23. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/SECURITY.md +0 -0
  24. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/examples/basic_pipeline.py +0 -0
  25. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/examples/system_controller.py +0 -0
  26. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/scripts/regenerate_docling_snapshots.py +0 -0
  27. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/scripts/release_gate.sh +0 -0
  28. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/__init__.py +0 -0
  29. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/adapters/README.md +0 -0
  30. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/adapters/__init__.py +0 -0
  31. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/adapters/base.py +0 -0
  32. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/src/table_stitcher/py.typed +0 -0
  33. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/README.md +0 -0
  34. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/__init__.py +0 -0
  35. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/fixtures/tablemeta/headerless-width-drift.yaml +0 -0
  36. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/__init__.py +0 -0
  37. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/_tools/__init__.py +0 -0
  38. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/_tools/regenerate_expected.py +0 -0
  39. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/conftest.py +0 -0
  40. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/_synth/__init__.py +0 -0
  41. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/_synth/generate.py +0 -0
  42. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/.gitkeep +0 -0
  43. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.docling.json +0 -0
  44. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.expected.yaml +0 -0
  45. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.pdf +0 -0
  46. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.docling.json +0 -0
  47. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.expected.yaml +0 -0
  48. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.pdf +0 -0
  49. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.docling.json +0 -0
  50. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.expected.yaml +0 -0
  51. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.pdf +0 -0
  52. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.docling.json +0 -0
  53. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.expected.yaml +0 -0
  54. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.pdf +0 -0
  55. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.docling.json +0 -0
  56. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.expected.yaml +0 -0
  57. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.pdf +0 -0
  58. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.docling.json +0 -0
  59. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.expected.yaml +0 -0
  60. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.pdf +0 -0
  61. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.docling.json +0 -0
  62. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.expected.yaml +0 -0
  63. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.pdf +0 -0
  64. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.docling.json +0 -0
  65. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.expected.yaml +0 -0
  66. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.pdf +0 -0
  67. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.docling.json +0 -0
  68. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.expected.yaml +0 -0
  69. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.pdf +0 -0
  70. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.docling.json +0 -0
  71. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.expected.yaml +0 -0
  72. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.pdf +0 -0
  73. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.docling.json +0 -0
  74. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.expected.yaml +0 -0
  75. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.pdf +0 -0
  76. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/.gitkeep +0 -0
  77. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.docling.json +0 -0
  78. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.expected.yaml +0 -0
  79. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.pdf +0 -0
  80. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.docling.json +0 -0
  81. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.expected.yaml +0 -0
  82. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.pdf +0 -0
  83. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.docling.json +0 -0
  84. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.expected.yaml +0 -0
  85. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.pdf +0 -0
  86. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.docling.json +0 -0
  87. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.expected.yaml +0 -0
  88. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.pdf +0 -0
  89. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/orphan-pair/.gitkeep +0 -0
  90. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.docling.json +0 -0
  91. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.expected.yaml +0 -0
  92. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.pdf +0 -0
  93. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.docling.json +0 -0
  94. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.expected.yaml +0 -0
  95. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.pdf +0 -0
  96. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.docling.json +0 -0
  97. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.expected.yaml +0 -0
  98. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.pdf +0 -0
  99. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.docling.json +0 -0
  100. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.expected.yaml +0 -0
  101. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.pdf +0 -0
  102. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.docling.json +0 -0
  103. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.expected.yaml +0 -0
  104. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.pdf +0 -0
  105. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.docling.json +0 -0
  106. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.expected.yaml +0 -0
  107. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.pdf +0 -0
  108. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.docling.json +0 -0
  109. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.expected.yaml +0 -0
  110. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.pdf +0 -0
  111. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.docling.json +0 -0
  112. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.expected.yaml +0 -0
  113. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.pdf +0 -0
  114. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.docling.json +0 -0
  115. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.expected.yaml +0 -0
  116. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.pdf +0 -0
  117. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.docling.json +0 -0
  118. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.expected.yaml +0 -0
  119. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.pdf +0 -0
  120. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.docling.json +0 -0
  121. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.expected.yaml +0 -0
  122. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.pdf +0 -0
  123. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/spillover/note-overflow.synth.docling.json +0 -0
  124. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/spillover/note-overflow.synth.expected.yaml +0 -0
  125. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/spillover/note-overflow.synth.pdf +0 -0
  126. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/width-drift/.gitkeep +0 -0
  127. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.docling.json +0 -0
  128. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.expected.yaml +0 -0
  129. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.pdf +0 -0
  130. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/integration/test_fixtures.py +0 -0
  131. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/test_docling_adapter.py +0 -0
  132. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/test_public_api.py +0 -0
  133. {table_stitcher-0.3.0 → table_stitcher-0.4.0}/tests/test_tablemeta_fixtures.py +0 -0
@@ -7,6 +7,30 @@ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] — 2026-05-29
11
+
12
+ ### Added
13
+
14
+ - **Intervening-content guard** (`block_on_intervening_content`, default `True`).
15
+ Two tables that share a column schema but belong to different sections — a
16
+ heading sits between them in reading order — are no longer stitched into one.
17
+ A genuine page-split continuation has nothing but page furniture between its
18
+ fragments, so a section heading between them is a reliable "separate tables"
19
+ signal. The docling adapter computes a per-table `TableMeta.content_before`
20
+ signal; both merge paths (`_classify_sequential_pair` and
21
+ `should_force_orphan_merge`) consult it.
22
+ - Running headers mislabeled as headings (e.g. a journal banner labeled
23
+ `page_header` on one page and `section_header` on another, or a repeated
24
+ "Summary of benefits" banner above every page of a multi-page table) are
25
+ detected as furniture via near-identical (Jaccard ≥ 0.8) recurrence across
26
+ pages, so they do not block legitimate continuations.
27
+ - Only `section_header`/`title` nodes block; plain paragraphs, list items,
28
+ captions, footnotes and figures are deliberately ignored, since real PDFs
29
+ routinely scatter those between fragments of a single continued table.
30
+ - Fixes over-eager merging of same-schema per-section tables (e.g. an
31
+ insurance policy's eight `Prestige | Elite | Classic` benefit grids being
32
+ collapsed into one).
33
+
10
34
  ## [0.3.0] — 2026-05-06
11
35
 
12
36
  ### 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.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
@@ -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.0"
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
  )
@@ -556,6 +557,176 @@ def _get_ref_pointer(ref_obj: Any) -> str:
556
557
  # -------------------------------------------------------------------
557
558
 
558
559
 
560
+ # -------------------------------------------------------------------
561
+ # Intervening-content detection (reading-order furniture filtering)
562
+ #
563
+ # Philosophy: a table split across a page break has nothing but page
564
+ # furniture (running headers/footers, page numbers) between its fragments.
565
+ # If *substantive* body content — a heading, paragraph, list item, or real
566
+ # figure — sits between two fragments in reading order, they are separate
567
+ # tables that merely share a column schema. These helpers classify the body
568
+ # nodes between consecutive tables so the merger can refuse such merges.
569
+ # -------------------------------------------------------------------
570
+
571
+ # Only a structural section boundary reliably means "these are separate
572
+ # tables." Plain prose, list items, captions, footnotes and figures all turn
573
+ # up *between* fragments of genuine continuations on real-world PDFs (cell text
574
+ # extracted as body nodes, interleaved multi-column reading order, repeated
575
+ # legends), so blocking on them regresses legitimate merges. A new heading
576
+ # between two tables, by contrast, is a clean separator.
577
+ _BLOCKING_LABELS = {"section_header", "title"}
578
+
579
+ _CONTINUATION_RE = re.compile(r"\bcont(?:inued|inuation|'?d|\.)?\b", re.IGNORECASE)
580
+
581
+
582
+ def _label_str(item: Any) -> str:
583
+ """Normalize a docling item label (enum or str) to a lowercase string."""
584
+ lab = getattr(item, "label", None)
585
+ return str(getattr(lab, "value", lab) or "").lower()
586
+
587
+
588
+ def _norm_text(item: Any) -> str:
589
+ """Whitespace-collapsed lowercase text of an item."""
590
+ return " ".join(str(getattr(item, "text", "") or "").split()).lower()
591
+
592
+
593
+ def _flatten_body_refs(doc: Any) -> list[str]:
594
+ """Return body reference pointers in reading order (DFS through groups)."""
595
+ seq: list[str] = []
596
+ groups = getattr(doc, "groups", []) or []
597
+ seen_groups: set[int] = set()
598
+
599
+ def walk(node: Any) -> None:
600
+ children = getattr(node, "children", None) or []
601
+ for child in children:
602
+ ref = _get_ref_pointer(child)
603
+ if not ref:
604
+ continue
605
+ if ref.startswith("#/groups/"):
606
+ try:
607
+ gi = int(ref.split("/")[-1])
608
+ except ValueError:
609
+ continue
610
+ # Guard against malformed self-referential group cycles.
611
+ if gi in seen_groups or not (0 <= gi < len(groups)):
612
+ continue
613
+ seen_groups.add(gi)
614
+ walk(groups[gi])
615
+ else:
616
+ seq.append(ref)
617
+
618
+ body = getattr(doc, "body", None)
619
+ if body is not None:
620
+ walk(body)
621
+ return seq
622
+
623
+
624
+ def _resolve_ref(doc: Any, ref: str) -> Optional[Any]:
625
+ """Resolve a ``#/kind/N`` pointer to its item, or None."""
626
+ try:
627
+ _, kind, n_str = ref.split("/")
628
+ n = int(n_str)
629
+ except (ValueError, AttributeError):
630
+ return None
631
+ coll = {
632
+ "texts": getattr(doc, "texts", None),
633
+ "tables": getattr(doc, "tables", None),
634
+ "pictures": getattr(doc, "pictures", None),
635
+ }.get(kind)
636
+ if not coll or n >= len(coll):
637
+ return None
638
+ return coll[n]
639
+
640
+
641
+ def _detect_running_furniture(doc: Any, cfg: MultiPageConfig) -> set[str]:
642
+ """
643
+ Identify headings that are actually running headers, not section boundaries.
644
+
645
+ Only headings (``_BLOCKING_LABELS``) can block a merge, so only headings
646
+ need to be exempted. A heading is a running header when near-identical text
647
+ appears on a *different* page — e.g. a repeated ``Summary of benefits``
648
+ banner above every page of a multi-page table, or a journal name that one
649
+ page labels ``page_header`` and another mislabels ``section_header`` (the
650
+ parser inconsistency this protects against).
651
+
652
+ Similarity uses symmetric Jaccard with a high threshold (near-duplicate).
653
+ A real, unique heading such as ``38e - Trip postponement`` has no
654
+ near-duplicate on another page, so it correctly stays a blocker — unlike a
655
+ looser containment metric, which a short subset string (a TOC entry, say)
656
+ would spuriously satisfy.
657
+ """
658
+ texts = getattr(doc, "texts", []) or []
659
+ # (ref, tokens, page, label) for every text node — the comparison pool.
660
+ nodes: list[tuple[str, set, Any, str]] = []
661
+ for i, item in enumerate(texts):
662
+ prov = getattr(item, "prov", None) or []
663
+ page = getattr(prov[0], "page_no", None) if prov else None
664
+ nodes.append((f"#/texts/{i}", tokenize(_norm_text(item)), page, _label_str(item)))
665
+
666
+ furniture: set[str] = set()
667
+ for ref, toks, page, label in nodes:
668
+ if label not in _BLOCKING_LABELS or not toks or page is None:
669
+ continue
670
+ for ref2, toks2, page2, _ in nodes:
671
+ if ref2 == ref or page2 is None or page2 == page or not toks2:
672
+ continue
673
+ if jaccard(toks, toks2) >= 0.8:
674
+ furniture.add(ref)
675
+ break
676
+ return furniture
677
+
678
+
679
+ def _ref_is_blocking(doc: Any, ref: str, furniture: set[str], cfg: MultiPageConfig) -> bool:
680
+ """Whether a body node between two tables marks a real table boundary.
681
+
682
+ Only a non-furniture section heading qualifies (see ``_BLOCKING_LABELS``).
683
+ Figures, list items and plain paragraphs are deliberately ignored: on real
684
+ PDFs they routinely appear between fragments of a single continued table.
685
+ """
686
+ if ref in furniture or not ref.startswith("#/texts/"):
687
+ return False
688
+ item = _resolve_ref(doc, ref)
689
+ if item is None:
690
+ return False
691
+ if _label_str(item) not in _BLOCKING_LABELS:
692
+ return False
693
+ text = str(getattr(item, "text", "") or "")
694
+ if not text.strip():
695
+ return False
696
+ # A "(continued)" heading marks a continuation, not a new table.
697
+ if _CONTINUATION_RE.search(text):
698
+ return False
699
+ return True
700
+
701
+
702
+ def _compute_content_before(doc: Any, cfg: MultiPageConfig) -> dict[int, bool]:
703
+ """
704
+ For each table (keyed by docling index), whether substantive body content
705
+ separates it from the previous table in reading order. Tables absent from
706
+ the body reading order (orphans) are omitted, disabling the guard for them.
707
+ """
708
+ seq = _flatten_body_refs(doc)
709
+ if not seq:
710
+ return {}
711
+ furniture = _detect_running_furniture(doc, cfg)
712
+
713
+ result: dict[int, bool] = {}
714
+ prev_table_seen = False
715
+ blocking_seen = False
716
+ for ref in seq:
717
+ if ref.startswith("#/tables/"):
718
+ try:
719
+ t_idx = int(ref.split("/")[-1])
720
+ except ValueError:
721
+ continue
722
+ result[t_idx] = prev_table_seen and blocking_seen
723
+ prev_table_seen = True
724
+ blocking_seen = False
725
+ elif not blocking_seen and _ref_is_blocking(doc, ref, furniture, cfg):
726
+ blocking_seen = True
727
+ return result
728
+
729
+
559
730
  class DoclingAdapter:
560
731
  """
561
732
  Table-stitcher adapter for Docling (docling-core).
@@ -569,6 +740,15 @@ class DoclingAdapter:
569
740
  total = len(doc.tables)
570
741
  skipped = 0
571
742
 
743
+ # Reading-order map: which tables have substantive body content before
744
+ # them (used by the merger's intervening-content guard). Computed once.
745
+ content_before_map: dict[int, bool] = {}
746
+ if cfg.block_on_intervening_content:
747
+ try:
748
+ content_before_map = _compute_content_before(doc, cfg)
749
+ except Exception as e: # never let the guard break extraction
750
+ log.warning(f"Intervening-content detection failed: {e}")
751
+
572
752
  for idx, table in enumerate(doc.tables):
573
753
  try:
574
754
  df = _grid_to_dataframe(table, doc)
@@ -640,6 +820,7 @@ class DoclingAdapter:
640
820
  row_count=df.shape[0],
641
821
  continuation_content=continuation_content,
642
822
  is_headerless=is_headerless,
823
+ content_before=content_before_map.get(idx),
643
824
  )
644
825
  )
645
826
 
@@ -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
@@ -0,0 +1,159 @@
1
+ """
2
+ Adapter-level tests for the intervening-content guard.
3
+
4
+ These build real ``DoclingDocument`` objects (via the docling-core builder API)
5
+ so they exercise the *producer* side — ``_detect_running_furniture`` and
6
+ ``_compute_content_before`` in the docling adapter — not just the merger's
7
+ consumption of ``content_before``.
8
+
9
+ Intent (why these matter):
10
+ - Two tables that share a column schema but belong to *different sections*
11
+ (a heading sits between them) must NOT be stitched into one. This is the
12
+ GreatEastern COVID-endorsement bug: eight per-benefit plan grids, all with a
13
+ ``Prestige | Elite | Classic`` header, were merged across page breaks.
14
+ - A genuine continuation whose only intervening content is a *running header*
15
+ (the same heading repeated atop each page) must STILL merge.
16
+ """
17
+
18
+ from docling_core.types.doc import (
19
+ BoundingBox,
20
+ CoordOrigin,
21
+ DocItemLabel,
22
+ DoclingDocument,
23
+ ProvenanceItem,
24
+ Size,
25
+ TableCell,
26
+ TableData,
27
+ )
28
+
29
+ from table_stitcher import stitch_tables
30
+ from table_stitcher.adapters.docling import _compute_content_before
31
+ from table_stitcher.models import MultiPageConfig
32
+
33
+ PLAN_HEADER = ["", "Prestige plan", "Elite plan", "Classic plan"]
34
+
35
+
36
+ def _table_data(header, rows):
37
+ cells, grid = [], []
38
+ hrow = []
39
+ for j, h in enumerate(header):
40
+ c = TableCell(
41
+ text=str(h),
42
+ row_span=1,
43
+ col_span=1,
44
+ column_header=True,
45
+ row_header=False,
46
+ start_row_offset_idx=0,
47
+ end_row_offset_idx=1,
48
+ start_col_offset_idx=j,
49
+ end_col_offset_idx=j + 1,
50
+ )
51
+ hrow.append(c)
52
+ cells.append(c)
53
+ grid.append(hrow)
54
+ for i, row in enumerate(rows):
55
+ grow = []
56
+ for j, v in enumerate(row):
57
+ c = TableCell(
58
+ text=str(v),
59
+ row_span=1,
60
+ col_span=1,
61
+ column_header=False,
62
+ row_header=False,
63
+ start_row_offset_idx=i + 1,
64
+ end_row_offset_idx=i + 2,
65
+ start_col_offset_idx=j,
66
+ end_col_offset_idx=j + 1,
67
+ )
68
+ grow.append(c)
69
+ cells.append(c)
70
+ grid.append(grow)
71
+ return TableData(num_rows=len(rows) + 1, num_cols=len(header), table_cells=cells, grid=grid)
72
+
73
+
74
+ def _prov(page, top):
75
+ return ProvenanceItem(
76
+ page_no=page,
77
+ bbox=BoundingBox(l=50, t=top, r=550, b=top + 18, coord_origin=CoordOrigin.TOPLEFT),
78
+ charspan=(0, 0),
79
+ )
80
+
81
+
82
+ def _new_doc(pages=2):
83
+ doc = DoclingDocument(name="synthetic")
84
+ for p in range(1, pages + 1):
85
+ doc.add_page(page_no=p, size=Size(width=600, height=800))
86
+ return doc
87
+
88
+
89
+ def _is_blanked(table):
90
+ """A satellite merged away by inject() becomes num_rows=0 with empty prov."""
91
+ return (getattr(table.data, "num_rows", 0) or 0) == 0 and not (table.prov or [])
92
+
93
+
94
+ def test_heading_between_same_schema_tables_blocks_merge():
95
+ """The endorsement bug: a unique heading between two plan grids => separate."""
96
+ doc = _new_doc()
97
+ doc.add_table(
98
+ data=_table_data(PLAN_HEADER, [["Repatriation", "S$5,000", "S$5,000", "S$5,000"]]),
99
+ prov=_prov(1, 600),
100
+ )
101
+ doc.add_text(
102
+ label=DocItemLabel.SECTION_HEADER, text="38d - Trip cancellation", prov=_prov(2, 80)
103
+ )
104
+ doc.add_text(
105
+ label=DocItemLabel.TEXT, text="Cover under section 15 is extended ...", prov=_prov(2, 110)
106
+ )
107
+ doc.add_table(
108
+ data=_table_data(PLAN_HEADER, [["Trip cancellation", "S$8,000", "S$5,000", "S$3,000"]]),
109
+ prov=_prov(2, 300),
110
+ )
111
+
112
+ cmap = _compute_content_before(doc, MultiPageConfig())
113
+ assert cmap.get(1) is True, "heading before the 2nd table should be detected as a boundary"
114
+
115
+ stitch_tables(doc)
116
+ assert not _is_blanked(doc.tables[0]) and not _is_blanked(doc.tables[1]), (
117
+ "tables in different sections must not be merged"
118
+ )
119
+
120
+
121
+ def test_running_header_between_fragments_still_merges():
122
+ """A repeated heading atop each page is furniture, not a boundary => merge."""
123
+ doc = _new_doc()
124
+ doc.add_text(label=DocItemLabel.SECTION_HEADER, text="Summary of benefits", prov=_prov(1, 60))
125
+ doc.add_table(
126
+ data=_table_data(PLAN_HEADER, [["Death", "100%", "100%", "100%"]]), prov=_prov(1, 600)
127
+ )
128
+ # Same heading repeated at the top of page 2 — a running header.
129
+ doc.add_text(label=DocItemLabel.SECTION_HEADER, text="Summary of benefits", prov=_prov(2, 60))
130
+ doc.add_table(
131
+ data=_table_data(PLAN_HEADER, [["Disability", "100%", "100%", "100%"]]), prov=_prov(2, 90)
132
+ )
133
+
134
+ cmap = _compute_content_before(doc, MultiPageConfig())
135
+ assert cmap.get(1) is False, "a repeated running header must not count as a boundary"
136
+
137
+ stitch_tables(doc)
138
+ assert _is_blanked(doc.tables[1]), "a true continuation should still be stitched"
139
+
140
+
141
+ def test_guard_disabled_restores_legacy_merge():
142
+ """With the flag off, the heading no longer blocks (legacy behaviour)."""
143
+ doc = _new_doc()
144
+ doc.add_table(
145
+ data=_table_data(PLAN_HEADER, [["Repatriation", "S$5,000", "S$5,000", "S$5,000"]]),
146
+ prov=_prov(1, 600),
147
+ )
148
+ doc.add_text(
149
+ label=DocItemLabel.SECTION_HEADER, text="38d - Trip cancellation", prov=_prov(2, 80)
150
+ )
151
+ doc.add_table(
152
+ data=_table_data(PLAN_HEADER, [["Trip cancellation", "S$8,000", "S$5,000", "S$3,000"]]),
153
+ prov=_prov(2, 300),
154
+ )
155
+
156
+ stitch_tables(doc, config=MultiPageConfig(block_on_intervening_content=False))
157
+ assert _is_blanked(doc.tables[1]), (
158
+ "legacy header-similarity merge should still happen when disabled"
159
+ )
@@ -30,6 +30,7 @@ def _make_meta(
30
30
  is_headerless: bool = False,
31
31
  vert_top: float = None,
32
32
  vert_bottom: float = None,
33
+ content_before: bool = None,
33
34
  ) -> TableMeta:
34
35
  """Build a minimal TableMeta for testing."""
35
36
  return TableMeta(
@@ -51,6 +52,7 @@ def _make_meta(
51
52
  numeric_like_cols=is_numeric_like_colnames([str(c) for c in df.columns]),
52
53
  row_count=df.shape[0],
53
54
  is_headerless=is_headerless,
55
+ content_before=content_before,
54
56
  )
55
57
 
56
58
 
@@ -789,3 +791,61 @@ class TestStitchSplitCells:
789
791
  assert out.shape == (1, 4)
790
792
  # Continuation folded into the 4th column (Notes, by positional match).
791
793
  assert out.iloc[0, 3] == "first\nsecond line"
794
+
795
+
796
+ # ---------------------------------------------------------------------------
797
+ # Intervening-content guard: a paragraph/heading between two same-schema
798
+ # tables means they are separate tables, not one split across a page break.
799
+ # ---------------------------------------------------------------------------
800
+
801
+
802
+ class TestInterveningContentGuard:
803
+ """``content_before`` blocks merges that header similarity would allow."""
804
+
805
+ @staticmethod
806
+ def _benefit(idx, page, label, content_before=None):
807
+ # Single-row plan grid with the identical Prestige/Elite/Classic header
808
+ # shared by every COVID endorsement benefit table — header Jaccard 1.0.
809
+ df = pd.DataFrame(
810
+ [[label, "S$8,000", "S$5,000", "S$3,000"]],
811
+ columns=["", "Prestige plan", "Elite plan", "Classic plan"],
812
+ )
813
+ return _make_meta(idx, df, start_page=page, content_before=content_before)
814
+
815
+ def test_identical_headers_merge_without_guard_signal(self):
816
+ # Baseline: with content_before=None the strict-header path still merges
817
+ # (this is the over-eager behaviour the guard is designed to stop).
818
+ a = self._benefit(0, 1, "Trip cancellation")
819
+ b = self._benefit(1, 2, "Trip postponement")
820
+ logical = merge_multipage_tables([a, b], MultiPageConfig())
821
+ assert len(logical) == 1
822
+ assert "header_similarity_strict" in logical[0].merge_reason
823
+
824
+ def test_content_between_blocks_merge(self):
825
+ # A heading/paragraph sits before the second fragment -> stay separate.
826
+ a = self._benefit(0, 1, "Trip cancellation")
827
+ b = self._benefit(1, 2, "Trip postponement", content_before=True)
828
+ logical = merge_multipage_tables([a, b], MultiPageConfig())
829
+ assert len(logical) == 2
830
+
831
+ def test_furniture_only_gap_still_merges(self):
832
+ # content_before=False means only furniture (running header) sat between
833
+ # the fragments -> a genuine continuation, still merged.
834
+ a = self._benefit(0, 1, "Summary part 1")
835
+ b = self._benefit(1, 2, "Summary part 2", content_before=False)
836
+ logical = merge_multipage_tables([a, b], MultiPageConfig())
837
+ assert len(logical) == 1
838
+
839
+ def test_flag_disables_guard(self):
840
+ a = self._benefit(0, 1, "Trip cancellation")
841
+ b = self._benefit(1, 2, "Trip postponement", content_before=True)
842
+ cfg = MultiPageConfig(block_on_intervening_content=False)
843
+ logical = merge_multipage_tables([a, b], cfg)
844
+ assert len(logical) == 1
845
+
846
+ def test_none_is_backward_compatible(self):
847
+ # Adapters that don't populate the field (None) must not be affected.
848
+ a = self._benefit(0, 1, "Trip cancellation")
849
+ b = self._benefit(1, 2, "Trip postponement", content_before=None)
850
+ logical = merge_multipage_tables([a, b], MultiPageConfig())
851
+ assert len(logical) == 1
File without changes
File without changes