table-stitcher 0.4.4__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/workflows/ci.yml +4 -2
  2. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/CHANGELOG.md +35 -0
  3. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/PKG-INFO +2 -2
  4. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/pyproject.toml +5 -2
  5. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/__init__.py +12 -0
  6. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/adapters/docling.py +62 -7
  7. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/models.py +12 -0
  8. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/test_docling_adapter.py +153 -0
  9. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/test_public_api.py +53 -0
  10. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.claude/settings.json +0 -0
  11. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  12. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  13. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  14. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/dependabot.yml +0 -0
  15. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/pull_request_template.md +0 -0
  16. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/workflows/release.yml +0 -0
  17. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.github/workflows/upstream-smoke.yml +0 -0
  18. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.gitignore +0 -0
  19. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/.pre-commit-config.yaml +0 -0
  20. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/CONTRIBUTING.md +0 -0
  21. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/LICENSE +0 -0
  22. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/README.md +0 -0
  23. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/SECURITY.md +0 -0
  24. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/examples/basic_pipeline.py +0 -0
  25. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/examples/system_controller.py +0 -0
  26. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/scripts/regenerate_docling_snapshots.py +0 -0
  27. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/scripts/release_gate.sh +0 -0
  28. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/adapters/README.md +0 -0
  29. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/adapters/__init__.py +0 -0
  30. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/adapters/base.py +0 -0
  31. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/merger.py +0 -0
  32. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/src/table_stitcher/py.typed +0 -0
  33. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/README.md +0 -0
  34. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/__init__.py +0 -0
  35. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/fixtures/tablemeta/headerless-width-drift.yaml +0 -0
  36. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/__init__.py +0 -0
  37. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/_tools/__init__.py +0 -0
  38. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/_tools/regenerate_expected.py +0 -0
  39. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/conftest.py +0 -0
  40. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/_synth/__init__.py +0 -0
  41. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/_synth/generate.py +0 -0
  42. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/.gitkeep +0 -0
  43. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.docling.json +0 -0
  44. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.expected.yaml +0 -0
  45. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.pdf +0 -0
  46. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.docling.json +0 -0
  47. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.expected.yaml +0 -0
  48. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.pdf +0 -0
  49. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.docling.json +0 -0
  50. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.expected.yaml +0 -0
  51. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.pdf +0 -0
  52. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.docling.json +0 -0
  53. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.expected.yaml +0 -0
  54. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.pdf +0 -0
  55. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.docling.json +0 -0
  56. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.expected.yaml +0 -0
  57. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.pdf +0 -0
  58. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.docling.json +0 -0
  59. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.expected.yaml +0 -0
  60. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.pdf +0 -0
  61. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.docling.json +0 -0
  62. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.expected.yaml +0 -0
  63. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.pdf +0 -0
  64. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.docling.json +0 -0
  65. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.expected.yaml +0 -0
  66. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.pdf +0 -0
  67. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.docling.json +0 -0
  68. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.expected.yaml +0 -0
  69. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.pdf +0 -0
  70. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.docling.json +0 -0
  71. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.expected.yaml +0 -0
  72. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.pdf +0 -0
  73. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.docling.json +0 -0
  74. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.expected.yaml +0 -0
  75. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.pdf +0 -0
  76. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/.gitkeep +0 -0
  77. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.docling.json +0 -0
  78. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.expected.yaml +0 -0
  79. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.pdf +0 -0
  80. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.docling.json +0 -0
  81. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.expected.yaml +0 -0
  82. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.pdf +0 -0
  83. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.docling.json +0 -0
  84. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.expected.yaml +0 -0
  85. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.pdf +0 -0
  86. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.docling.json +0 -0
  87. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.expected.yaml +0 -0
  88. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.pdf +0 -0
  89. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/.gitkeep +0 -0
  90. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.docling.json +0 -0
  91. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.expected.yaml +0 -0
  92. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.pdf +0 -0
  93. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.docling.json +0 -0
  94. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.expected.yaml +0 -0
  95. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.pdf +0 -0
  96. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.docling.json +0 -0
  97. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.expected.yaml +0 -0
  98. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.pdf +0 -0
  99. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.docling.json +0 -0
  100. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.expected.yaml +0 -0
  101. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.pdf +0 -0
  102. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.docling.json +0 -0
  103. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.expected.yaml +0 -0
  104. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.pdf +0 -0
  105. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.docling.json +0 -0
  106. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.expected.yaml +0 -0
  107. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.pdf +0 -0
  108. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.docling.json +0 -0
  109. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.expected.yaml +0 -0
  110. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.pdf +0 -0
  111. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.docling.json +0 -0
  112. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.expected.yaml +0 -0
  113. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.pdf +0 -0
  114. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.docling.json +0 -0
  115. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.expected.yaml +0 -0
  116. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.pdf +0 -0
  117. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.docling.json +0 -0
  118. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.expected.yaml +0 -0
  119. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.pdf +0 -0
  120. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.docling.json +0 -0
  121. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.expected.yaml +0 -0
  122. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/simple-continuation/sample-table.corp.pdf +0 -0
  123. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/spillover/note-overflow.synth.docling.json +0 -0
  124. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/spillover/note-overflow.synth.expected.yaml +0 -0
  125. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/spillover/note-overflow.synth.pdf +0 -0
  126. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/.gitkeep +0 -0
  127. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.docling.json +0 -0
  128. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.expected.yaml +0 -0
  129. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.pdf +0 -0
  130. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/integration/test_fixtures.py +0 -0
  131. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/test_intervening_content_guard.py +0 -0
  132. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/test_merger.py +0 -0
  133. {table_stitcher-0.4.4 → table_stitcher-0.5.0}/tests/test_tablemeta_fixtures.py +0 -0
@@ -19,11 +19,13 @@ jobs:
19
19
  python-version: "3.12"
20
20
  cache: pip
21
21
  - name: install ruff
22
- # Pin via pyproject's dev extra so local + CI run the same version.
22
+ # Keep this pin in sync with the dev extra in pyproject.toml so local
23
+ # + CI run the same version. Exact pin because ruff format output
24
+ # changes between releases and would break lint on untouched files.
23
25
  # No need to install the full dev tree (docling, reportlab) just to lint.
24
26
  run: |
25
27
  python -m pip install --upgrade pip
26
- pip install "ruff>=0.6"
28
+ pip install "ruff==0.16.2"
27
29
  - name: ruff check
28
30
  run: ruff check .
29
31
  - name: ruff format --check
@@ -7,6 +7,41 @@ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] — 2026-08-23
11
+
12
+ ### Fixed
13
+
14
+ - **Cell bounding boxes were dropped on multi-page merge**
15
+ (`adapters/docling.py`). When N per-page fragments merged into one logical
16
+ table, every `TableCell` in the injected result came back with `bbox=None`,
17
+ breaking word-level grounding for multi-page tables. `_reemit_body_row`
18
+ rebuilt each untouched body row's cells without copying the source cell's
19
+ `bbox`; it is now carried across, so untouched body rows — the large
20
+ majority on a clean multi-page table — keep their geometry. Header rows
21
+ reused from the anchor already preserved theirs; rows the merger transformed
22
+ (stitched continuations, folded overflow) genuinely have none and stay
23
+ `bbox=None`. The FIFO alignment between repeated identical-text rows and
24
+ their source occurrences is now a documented invariant: it is what guarantees
25
+ a repeated row gets its own page's boxes, not a sibling's.
26
+
27
+ ### Added
28
+
29
+ - **Row-level page association for restored geometry** (`models.py`,
30
+ `adapters/docling.py`). A merged table's `prov` lists all N source pages, but
31
+ pages share one coordinate space, so a restored `bbox` alone cannot say which
32
+ page it belongs to. `LogicalTable.row_pages` now maps grid row index (header
33
+ rows included) to the resolved `page_no` the row's cell boxes are valid on —
34
+ resolved page numbers rather than prov indices, so the map survives
35
+ downstream prov manipulation. A missing key means the row has no single
36
+ source page (transformed rows, which carry no geometry). Recorded during
37
+ injection, the only point where each row's source fragment is known; a
38
+ consumer cannot reconstruct the mapping after the fact.
39
+ - **`TableStitcher.last_logical_tables`** exposes the `LogicalTable` results of
40
+ the most recent `stitch()` call — previously discarded after injection,
41
+ which would have left `row_pages` unreachable through the public API.
42
+ Consumers needing the map should instantiate `TableStitcher` directly; the
43
+ `stitch_tables()` convenience function does not expose the stitcher instance.
44
+
10
45
  ## [0.4.4] — 2026-08-13
11
46
 
12
47
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: table-stitcher
3
- Version: 0.4.4
3
+ Version: 0.5.0
4
4
  Summary: Reassemble tables split across page boundaries in PDF extraction
5
5
  Project-URL: Homepage, https://github.com/pebbleroad/table-stitcher
6
6
  Project-URL: Repository, https://github.com/pebbleroad/table-stitcher
@@ -29,7 +29,7 @@ Requires-Dist: pre-commit>=3.0; extra == 'dev'
29
29
  Requires-Dist: pytest>=7.0; extra == 'dev'
30
30
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
31
31
  Requires-Dist: reportlab>=4.0; extra == 'dev'
32
- Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Requires-Dist: ruff==0.16.2; extra == 'dev'
33
33
  Requires-Dist: twine>=5.0; extra == 'dev'
34
34
  Provides-Extra: docling
35
35
  Requires-Dist: docling-core<3,>=2.50; extra == 'docling'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "table-stitcher"
7
- version = "0.4.4"
7
+ version = "0.5.0"
8
8
  description = "Reassemble tables split across page boundaries in PDF extraction"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -40,7 +40,10 @@ dev = [
40
40
  "docling-core>=2.50,<3",
41
41
  "pyyaml>=6.0",
42
42
  "reportlab>=4.0",
43
- "ruff>=0.6",
43
+ # Exact pin: ruff format output changes between releases (0.16 started
44
+ # formatting markdown code blocks), so a range lets a new ruff release
45
+ # break lint on untouched files. CI installs this same pin.
46
+ "ruff==0.16.2",
44
47
  "build>=1.0",
45
48
  "twine>=5.0",
46
49
  "pre-commit>=3.0",
@@ -77,6 +77,12 @@ class TableStitcher:
77
77
  self.logger = logging.getLogger("table_stitcher")
78
78
  self.adapter = adapter
79
79
  self.config = config or MultiPageConfig()
80
+ self.last_logical_tables: list[LogicalTable] = []
81
+ """
82
+ The ``LogicalTable`` results of the most recent ``stitch()`` call,
83
+ including per-row page associations (``LogicalTable.row_pages``)
84
+ populated during injection. Reset at the start of every ``stitch()``.
85
+ """
80
86
  self._validate_config()
81
87
 
82
88
  # -------------------------------------------------------------------------
@@ -164,6 +170,8 @@ class TableStitcher:
164
170
  Returns:
165
171
  The document with merged tables.
166
172
  """
173
+ self.last_logical_tables = []
174
+
167
175
  if doc is None:
168
176
  if raise_on_error:
169
177
  raise StitchingError("Input document is None")
@@ -213,6 +221,10 @@ class TableStitcher:
213
221
  raise StitchingError(f"Failed to merge tables: {e}") from e
214
222
  return doc
215
223
 
224
+ # Exposed for consumers needing per-table merge results — notably
225
+ # LogicalTable.row_pages, which injection fills in below.
226
+ self.last_logical_tables = logical_tables
227
+
216
228
  multi_page_tables = [lt for lt in logical_tables if len(lt.pages) > 1]
217
229
 
218
230
  if not multi_page_tables:
@@ -223,6 +223,18 @@ def _extract_y_bounds_from_prov(prov_list: list[Any]) -> Optional[tuple[float, f
223
223
  return None
224
224
 
225
225
 
226
+ def _first_page_no(prov: Any) -> Optional[int]:
227
+ """Resolved page number of the first prov entry carrying one, else None."""
228
+ if prov is None:
229
+ return None
230
+ entries = prov if isinstance(prov, list) else [prov]
231
+ for p in entries:
232
+ page_no = getattr(p, "page_no", None)
233
+ if page_no is not None:
234
+ return page_no
235
+ return None
236
+
237
+
226
238
  def _resolve_page_height(prov_list: list[Any], doc: Any, fallback: float = 842.0) -> float:
227
239
  """
228
240
  Look up the actual page height for the first prov entry from the document.
@@ -421,7 +433,7 @@ def _extract_original_header_rows(
421
433
 
422
434
  def _index_member_rows(
423
435
  member_data: list[Optional[TableData]],
424
- ) -> dict[tuple, list[list[TableCell]]]:
436
+ ) -> dict[tuple, list[tuple[int, list[TableCell]]]]:
425
437
  """
426
438
  Index every member fragment's original grid rows by their expanded
427
439
  text-vector, for col_span reconstruction during injection.
@@ -440,17 +452,24 @@ def _index_member_rows(
440
452
  across the value columns. (The anchor's own header rows are reconstructed
441
453
  separately and never looked up here.) Values are buckets because a row can
442
454
  legitimately repeat; identical text-vectors imply identical span structure,
443
- so any occurrence is interchangeable.
455
+ but NOT identical geometry — re-emitted cells carry their source bbox, so
456
+ occurrences are not interchangeable. Buckets are filled in fragment (page)
457
+ order and popped FIFO, which aligns the k-th DataFrame occurrence with the
458
+ k-th source occurrence because the merged DataFrame preserves fragment
459
+ concatenation order.
460
+
461
+ Bucket entries are ``(fragment_idx, row)`` pairs so the re-emitted row's
462
+ page association can be recorded (``fragment_idx`` indexes ``member_data``).
444
463
  """
445
- index: dict[tuple, list[list[TableCell]]] = {}
446
- for data in member_data:
464
+ index: dict[tuple, list[tuple[int, list[TableCell]]]] = {}
465
+ for frag_idx, data in enumerate(member_data):
447
466
  if not data or not data.grid:
448
467
  continue
449
468
  for row in data.grid:
450
469
  if not row:
451
470
  continue
452
471
  key = tuple((getattr(c, "text", "") or "") if c else "" for c in row)
453
- index.setdefault(key, []).append(row)
472
+ index.setdefault(key, []).append((frag_idx, row))
454
473
  return index
455
474
 
456
475
 
@@ -458,7 +477,9 @@ def _reemit_body_row(
458
477
  orig_row: list[TableCell], table_row_idx: int, has_row_headers: bool
459
478
  ) -> tuple[list[TableCell], list[TableCell]]:
460
479
  """
461
- Re-emit an original grid body row at a new row offset, preserving col_span.
480
+ Re-emit an original grid body row at a new row offset, preserving col_span
481
+ and the source cell's bbox (page-local coordinates; see the merged table's
482
+ prov for page association).
462
483
 
463
484
  Returns ``(grid_row, distinct_cells)`` where ``grid_row`` repeats each
464
485
  spanning cell across the columns it covers (Docling grid convention) and
@@ -480,6 +501,7 @@ def _reemit_body_row(
480
501
  col_span = getattr(cell, "col_span", 1) or 1
481
502
  new_cell = TableCell(
482
503
  text=getattr(cell, "text", "") or "",
504
+ bbox=getattr(cell, "bbox", None),
483
505
  row_span=1,
484
506
  col_span=col_span,
485
507
  column_header=False,
@@ -539,6 +561,8 @@ def _dataframe_to_docling_data(
539
561
  df: pd.DataFrame,
540
562
  original_data: Optional[TableData] = None,
541
563
  member_data: Optional[list[Optional[TableData]]] = None,
564
+ member_pages: Optional[list[Optional[int]]] = None,
565
+ row_pages_out: Optional[dict[int, int]] = None,
542
566
  ) -> TableData:
543
567
  """
544
568
  Converts a pandas DataFrame back into Docling's TableData structure.
@@ -554,6 +578,13 @@ def _dataframe_to_docling_data(
554
578
  re-emitted from their original grid cells, preserving col_span. Rows the
555
579
  merger transformed (stitched continuations, folded overflow) fall back to a
556
580
  flat 1x1 rebuild from the DataFrame.
581
+
582
+ When ``row_pages_out`` is provided (with ``member_pages`` aligned
583
+ index-for-index with ``member_data``), it is filled with the page
584
+ association for rows that carry geometry: grid row index -> resolved
585
+ page_no. Header rows reused from the anchor map to the anchor's page
586
+ (``member_pages[0]``); re-emitted body rows map to their source fragment's
587
+ page; flat-rebuilt rows get no entry (see ``LogicalTable.row_pages``).
557
588
  """
558
589
  if df.empty:
559
590
  cols = list(df.columns) if len(df.columns) > 0 else ["Column_0"]
@@ -588,6 +619,13 @@ def _dataframe_to_docling_data(
588
619
  num_header_rows = len(orig_header_rows)
589
620
  grid: list[list[TableCell]] = list(orig_header_rows)
590
621
  table_cells: list[TableCell] = list(orig_header_cells)
622
+ # Reused header cells keep the anchor's geometry, so they map to the
623
+ # anchor's page (member_pages[0] — the anchor is always members[0]).
624
+ if row_pages_out is not None and member_pages:
625
+ anchor_page = member_pages[0]
626
+ if anchor_page is not None:
627
+ for h_idx in range(num_header_rows):
628
+ row_pages_out[h_idx] = anchor_page
591
629
  else:
592
630
  # Fall back to building flat 1x1 header from DataFrame columns
593
631
  num_header_rows = 1
@@ -639,7 +677,7 @@ def _dataframe_to_docling_data(
639
677
  # adjacent values are never fused.
640
678
  bucket = body_index.get(tuple(row_vals))
641
679
  if bucket:
642
- orig_row = bucket.pop(0)
680
+ frag_idx, orig_row = bucket.pop(0)
643
681
  if header_sigs and _is_reprinted_header(orig_row, header_sigs):
644
682
  # Reprinted header from a continuation page — already present as
645
683
  # the header block; drop it instead of duplicating into the body.
@@ -648,6 +686,10 @@ def _dataframe_to_docling_data(
648
686
  grid_row, distinct = _reemit_body_row(orig_row, table_row_idx, has_row_headers)
649
687
  grid.append(grid_row)
650
688
  table_cells.extend(distinct)
689
+ if row_pages_out is not None and member_pages:
690
+ page = member_pages[frag_idx] if frag_idx < len(member_pages) else None
691
+ if page is not None:
692
+ row_pages_out[table_row_idx] = page
651
693
  emitted += 1
652
694
  continue
653
695
 
@@ -1051,12 +1093,25 @@ class DoclingAdapter:
1051
1093
  member_data = [
1052
1094
  table_snapshots[m]["data"] for m in lt.members if m in table_snapshots
1053
1095
  ]
1096
+ # Page per fragment, same filter as member_data so indices stay
1097
+ # aligned. Feeds LogicalTable.row_pages: resolved page_no rather
1098
+ # than a prov index, so the map survives downstream prov
1099
+ # manipulation by consumers.
1100
+ member_pages = [
1101
+ _first_page_no(table_snapshots[m]["prov"])
1102
+ for m in lt.members
1103
+ if m in table_snapshots
1104
+ ]
1054
1105
 
1106
+ row_pages: dict[int, int] = {}
1055
1107
  anchor_table.data = _dataframe_to_docling_data(
1056
1108
  lt.df,
1057
1109
  original_data=original_data,
1058
1110
  member_data=member_data,
1111
+ member_pages=member_pages,
1112
+ row_pages_out=row_pages,
1059
1113
  )
1114
+ lt.row_pages = row_pages
1060
1115
 
1061
1116
  for satellite_idx in lt.members[1:]:
1062
1117
  satellite_table = doc.tables[satellite_idx]
@@ -163,3 +163,15 @@ class LogicalTable:
163
163
  merge_reason: str = ""
164
164
  merge_traces: list[MergeTrace] = field(default_factory=list)
165
165
  warnings: list[str] = field(default_factory=list)
166
+ row_pages: dict[int, int] = field(default_factory=dict)
167
+ """
168
+ Page association for the merged table's cell geometry, keyed by grid row
169
+ index (an index into the injected ``TableData.grid``, header rows
170
+ included). The value is the resolved ``page_no`` the row's cell bboxes
171
+ (when present) are valid on. A missing key means the row has no single
172
+ source page: it was transformed by the merger (stitched continuation,
173
+ folded overflow — which can legitimately span two pages) and carries no
174
+ geometry, or its source fragment exposed no page number. Header rows
175
+ preserved from the anchor map to the anchor's page. Populated during
176
+ injection; empty for tables that were not merged.
177
+ """
@@ -3,6 +3,7 @@ Tests for the Docling adapter — DataFrame conversion, injection, and pruning.
3
3
  """
4
4
 
5
5
  from types import SimpleNamespace
6
+ from typing import Optional
6
7
 
7
8
  import pandas as pd
8
9
  import pytest
@@ -388,6 +389,158 @@ class TestBodySpanPreservation:
388
389
  assert all(cell.col_span == 1 for cell in td.grid[1])
389
390
 
390
391
 
392
+ class TestBboxPreservation:
393
+ """Re-emitted body rows must carry their source cells' bounding boxes.
394
+
395
+ The merge rebuilds the anchor's TableData from the merged DataFrame; rows
396
+ the merger left untouched are re-emitted from the original grid cells.
397
+ Dropping bbox there loses word-level grounding for every multi-page table.
398
+ """
399
+
400
+ @staticmethod
401
+ def _cell(text, r, c, *, header=False, bbox=None):
402
+ return TableCell(
403
+ text=text,
404
+ bbox=bbox,
405
+ row_span=1,
406
+ col_span=1,
407
+ column_header=header,
408
+ row_header=False,
409
+ start_row_offset_idx=r,
410
+ end_row_offset_idx=r + 1,
411
+ start_col_offset_idx=c,
412
+ end_col_offset_idx=c + 1,
413
+ )
414
+
415
+ def _fragment_with_bboxes(self) -> TableData:
416
+ from docling_core.types.doc import BoundingBox
417
+
418
+ c = self._cell
419
+ h = [c("Name", 0, 0, header=True), c("Value", 0, 1, header=True)]
420
+ r1 = [
421
+ c("alpha", 1, 0, bbox=BoundingBox(l=10, t=100, r=60, b=110)),
422
+ c("1", 1, 1, bbox=BoundingBox(l=70, t=100, r=90, b=110)),
423
+ ]
424
+ return TableData(num_rows=2, num_cols=2, table_cells=h + r1, grid=[h, r1])
425
+
426
+ def test_reemitted_rows_keep_bbox(self):
427
+ original = self._fragment_with_bboxes()
428
+ merged_df = pd.DataFrame([["alpha", "1"]], columns=["Name", "Value"])
429
+
430
+ td = _dataframe_to_docling_data(merged_df, original_data=original, member_data=[original])
431
+
432
+ body_row = td.grid[1]
433
+ assert body_row[0].bbox is not None
434
+ assert body_row[0].bbox.l == 10 and body_row[0].bbox.t == 100
435
+ assert body_row[1].bbox is not None
436
+ assert body_row[1].bbox.l == 70
437
+
438
+ def test_flat_rebuild_rows_have_no_bbox(self):
439
+ """Transformed rows (no member-row match) have no geometry to carry."""
440
+ original = self._fragment_with_bboxes()
441
+ merged_df = pd.DataFrame([["alpha (stitched)", "1"]], columns=["Name", "Value"])
442
+
443
+ td = _dataframe_to_docling_data(merged_df, original_data=original, member_data=[original])
444
+
445
+ assert all(cell.bbox is None for cell in td.grid[1])
446
+
447
+
448
+ class TestRowPageMap:
449
+ """row_pages: grid row index -> resolved page_no for restored geometry.
450
+
451
+ A merged table's prov lists all N source pages, but pages share one
452
+ coordinate space, so a restored bbox is ambiguous without a per-row page
453
+ association. The map is recorded at merge time (the only point where each
454
+ row's source fragment is known) and keyed by grid row index — resolved
455
+ page_no, not prov index, so it survives downstream prov manipulation.
456
+ A missing key is the honest signal for a transformed row.
457
+ """
458
+
459
+ @staticmethod
460
+ def _cell(text, r, c, *, header=False):
461
+ return TableCell(
462
+ text=text,
463
+ row_span=1,
464
+ col_span=1,
465
+ column_header=header,
466
+ row_header=False,
467
+ start_row_offset_idx=r,
468
+ end_row_offset_idx=r + 1,
469
+ start_col_offset_idx=c,
470
+ end_col_offset_idx=c + 1,
471
+ )
472
+
473
+ def _one_col_fragment(self, texts: list[str], *, header: Optional[str] = None) -> TableData:
474
+ rows = []
475
+ r = 0
476
+ if header is not None:
477
+ rows.append([self._cell(header, 0, 0, header=True)])
478
+ r = 1
479
+ for i, t in enumerate(texts):
480
+ rows.append([self._cell(t, r + i, 0)])
481
+ flat = [c for row in rows for c in row]
482
+ return TableData(num_rows=len(rows), num_cols=1, table_cells=flat, grid=rows)
483
+
484
+ def test_rebuild_maps_rows_to_source_pages(self):
485
+ anchor = self._one_col_fragment(["alpha"], header="Name")
486
+ satellite = self._one_col_fragment(["beta"])
487
+ merged_df = pd.DataFrame([["alpha"], ["beta"], ["gamma (stitched)"]], columns=["Name"])
488
+
489
+ row_pages: dict[int, int] = {}
490
+ _dataframe_to_docling_data(
491
+ merged_df,
492
+ original_data=anchor,
493
+ member_data=[anchor, satellite],
494
+ member_pages=[1, 2],
495
+ row_pages_out=row_pages,
496
+ )
497
+
498
+ # Grid: row 0 = anchor header, row 1 = alpha (page 1),
499
+ # row 2 = beta (page 2), row 3 = transformed -> no key.
500
+ assert row_pages == {0: 1, 1: 1, 2: 2}
501
+
502
+ def test_unknown_member_page_yields_no_key(self):
503
+ anchor = self._one_col_fragment(["alpha"], header="Name")
504
+ satellite = self._one_col_fragment(["beta"])
505
+ merged_df = pd.DataFrame([["alpha"], ["beta"]], columns=["Name"])
506
+
507
+ row_pages: dict[int, int] = {}
508
+ _dataframe_to_docling_data(
509
+ merged_df,
510
+ original_data=anchor,
511
+ member_data=[anchor, satellite],
512
+ member_pages=[1, None],
513
+ row_pages_out=row_pages,
514
+ )
515
+
516
+ assert row_pages == {0: 1, 1: 1} # beta's fragment has no page -> no key
517
+
518
+ def test_inject_populates_logical_table_row_pages(self):
519
+ doc = _build_doc_with_tables(2)
520
+ doc.tables[0].prov = [SimpleNamespace(page_no=1, bbox=None)]
521
+ doc.tables[1].prov = [SimpleNamespace(page_no=2, bbox=None)]
522
+
523
+ # Rows V0 / V1 match the fragments' original body rows untouched.
524
+ merged_df = pd.DataFrame({"H0": ["V0", "V1"]})
525
+ lt = LogicalTable(0, [0, 1], [1, 2], merged_df)
526
+
527
+ adapter = DoclingAdapter()
528
+ adapter.inject(doc, [lt])
529
+
530
+ # Grid row 0 = anchor header (page 1), row 1 = V0 (page 1),
531
+ # row 2 = V1 (page 2).
532
+ assert lt.row_pages == {0: 1, 1: 1, 2: 2}
533
+
534
+ def test_single_member_table_has_empty_row_pages(self):
535
+ doc = _build_doc_with_tables(1)
536
+ lt = LogicalTable(0, [0], [1], pd.DataFrame({"H0": ["V0"]}))
537
+
538
+ adapter = DoclingAdapter()
539
+ adapter.inject(doc, [lt])
540
+
541
+ assert lt.row_pages == {}
542
+
543
+
391
544
  class TestReprintedHeaderDedup:
392
545
  """Reprinted continuation-page headers are dropped from the injected body,
393
546
  but column_header-flagged rows that don't match the header (Docling
@@ -81,3 +81,56 @@ def test_table_stitcher_class_signature():
81
81
 
82
82
  def test_stitching_error_is_exception():
83
83
  assert issubclass(table_stitcher.StitchingError, Exception)
84
+
85
+
86
+ def test_last_logical_tables_exposed_after_stitch():
87
+ """`TableStitcher.last_logical_tables` surfaces the merge results.
88
+
89
+ `stitch()` returns only the document, but grounding consumers need the
90
+ per-table merge results — notably `LogicalTable.row_pages`, filled in
91
+ during injection. The stitcher keeps its most recent results reachable.
92
+ """
93
+ import pandas as pd
94
+
95
+ from table_stitcher.merger import is_numeric_like_colnames, tokenize
96
+ from table_stitcher.models import TableMeta
97
+
98
+ def _meta(idx: int, start_page: int, *, headerless: bool) -> TableMeta:
99
+ df = pd.DataFrame({"Name": ["Alice"], "Age": ["30"]})
100
+ return TableMeta(
101
+ idx=idx,
102
+ df=df,
103
+ start_page=start_page,
104
+ pages=[start_page],
105
+ width=df.shape[1],
106
+ header_tokens=tokenize(" ".join(str(c) for c in df.columns)),
107
+ first_row_tokens=tokenize(" ".join(str(x) for x in df.iloc[0].tolist())),
108
+ raw_columns=[str(c) for c in df.columns],
109
+ vert_center=None,
110
+ vert_top=None,
111
+ vert_bottom=None,
112
+ is_header_orphan=False,
113
+ is_data_orphan=False,
114
+ numeric_like_cols=is_numeric_like_colnames([str(c) for c in df.columns]),
115
+ row_count=df.shape[0],
116
+ is_headerless=headerless,
117
+ )
118
+
119
+ class _StubAdapter:
120
+ def extract(self, doc, config):
121
+ return [_meta(0, 1, headerless=False), _meta(1, 2, headerless=True)]
122
+
123
+ def inject(self, doc, logical_tables):
124
+ for lt in logical_tables:
125
+ lt.row_pages = {0: 1}
126
+ return doc
127
+
128
+ stitcher = table_stitcher.TableStitcher(_StubAdapter())
129
+ assert stitcher.last_logical_tables == []
130
+
131
+ stitcher.stitch(object())
132
+
133
+ assert len(stitcher.last_logical_tables) == 1
134
+ lt = stitcher.last_logical_tables[0]
135
+ assert lt.members == [0, 1]
136
+ assert lt.row_pages == {0: 1}
File without changes
File without changes