table-stitcher 0.4.0__tar.gz → 0.4.2__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.0 → table_stitcher-0.4.2}/CHANGELOG.md +28 -0
  2. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/PKG-INFO +1 -1
  3. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/pyproject.toml +1 -1
  4. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/scripts/release_gate.sh +8 -0
  5. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/__init__.py +8 -1
  6. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/adapters/docling.py +114 -6
  7. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/test_docling_adapter.py +88 -0
  8. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.claude/settings.json +0 -0
  9. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  11. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  12. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/dependabot.yml +0 -0
  13. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/pull_request_template.md +0 -0
  14. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/workflows/ci.yml +0 -0
  15. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/workflows/release.yml +0 -0
  16. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.github/workflows/upstream-smoke.yml +0 -0
  17. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.gitignore +0 -0
  18. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/.pre-commit-config.yaml +0 -0
  19. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/CONTRIBUTING.md +0 -0
  20. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/LICENSE +0 -0
  21. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/README.md +0 -0
  22. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/SECURITY.md +0 -0
  23. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/examples/basic_pipeline.py +0 -0
  24. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/examples/system_controller.py +0 -0
  25. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/scripts/regenerate_docling_snapshots.py +0 -0
  26. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/adapters/README.md +0 -0
  27. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/adapters/__init__.py +0 -0
  28. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/adapters/base.py +0 -0
  29. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/merger.py +0 -0
  30. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/models.py +0 -0
  31. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/src/table_stitcher/py.typed +0 -0
  32. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/README.md +0 -0
  33. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/__init__.py +0 -0
  34. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/fixtures/tablemeta/headerless-width-drift.yaml +0 -0
  35. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/__init__.py +0 -0
  36. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/_tools/__init__.py +0 -0
  37. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/_tools/regenerate_expected.py +0 -0
  38. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/conftest.py +0 -0
  39. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/_synth/__init__.py +0 -0
  40. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/_synth/generate.py +0 -0
  41. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/.gitkeep +0 -0
  42. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.docling.json +0 -0
  43. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.expected.yaml +0 -0
  44. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/kaop-study-mixed-3pg.pt2.pdf +0 -0
  45. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.docling.json +0 -0
  46. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.expected.yaml +0 -0
  47. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/distinct-tables-no-merge/lab-panels-3pg.corp.pdf +0 -0
  48. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.docling.json +0 -0
  49. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.expected.yaml +0 -0
  50. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/false-merge/category-rows-thematic-2pg.corp.pdf +0 -0
  51. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.docling.json +0 -0
  52. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.expected.yaml +0 -0
  53. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/15-page-druglist.corp.pdf +0 -0
  54. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.docling.json +0 -0
  55. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.expected.yaml +0 -0
  56. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/gene-symbols-6pg.pt2.pdf +0 -0
  57. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.docling.json +0 -0
  58. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.expected.yaml +0 -0
  59. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/rct-study-table-5pg.pt2.pdf +0 -0
  60. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.docling.json +0 -0
  61. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.expected.yaml +0 -0
  62. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/rrt-outcomes-2pg.pt2.pdf +0 -0
  63. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.docling.json +0 -0
  64. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.expected.yaml +0 -0
  65. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/headerless-continuation/uveitis-case-series-5pg.pt2.pdf +0 -0
  66. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.docling.json +0 -0
  67. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.expected.yaml +0 -0
  68. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/covid-misc-labs-4pg.pt2.pdf +0 -0
  69. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.docling.json +0 -0
  70. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.expected.yaml +0 -0
  71. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/lit-review-3pg.pt2.pdf +0 -0
  72. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.docling.json +0 -0
  73. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.expected.yaml +0 -0
  74. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/inconsistent-header-detection/retirement-portfolio.corp.pdf +0 -0
  75. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/.gitkeep +0 -0
  76. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.docling.json +0 -0
  77. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.expected.yaml +0 -0
  78. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/biological-process-2pg.pt2.pdf +0 -0
  79. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.docling.json +0 -0
  80. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.expected.yaml +0 -0
  81. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/loose-header-layout/symptoms-mediators-4pg.pt2.pdf +0 -0
  82. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.docling.json +0 -0
  83. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.expected.yaml +0 -0
  84. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/multilingual/corporate-history-2pg.edinet.pdf +0 -0
  85. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.docling.json +0 -0
  86. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.expected.yaml +0 -0
  87. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/multilingual/subsidiaries-4pg.edinet.pdf +0 -0
  88. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/orphan-pair/.gitkeep +0 -0
  89. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.docling.json +0 -0
  90. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.expected.yaml +0 -0
  91. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/orphan-pair/varicose-veins-new-table-header-7pg.pt2.pdf +0 -0
  92. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.docling.json +0 -0
  93. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.expected.yaml +0 -0
  94. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/page-gap-too-large/unrelated-tables-gap4.synth.pdf +0 -0
  95. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.docling.json +0 -0
  96. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.expected.yaml +0 -0
  97. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/4-page-substance-list.corp.pdf +0 -0
  98. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.docling.json +0 -0
  99. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.expected.yaml +0 -0
  100. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/cell-markers-4pg.pt2.pdf +0 -0
  101. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.docling.json +0 -0
  102. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.expected.yaml +0 -0
  103. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/challenge-categories-2pg.pt2.pdf +0 -0
  104. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.docling.json +0 -0
  105. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.expected.yaml +0 -0
  106. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/fungal-taxonomy-4pg.pt2.pdf +0 -0
  107. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.docling.json +0 -0
  108. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.expected.yaml +0 -0
  109. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/rowspan-insurance-payout.corp.pdf +0 -0
  110. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.docling.json +0 -0
  111. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.expected.yaml +0 -0
  112. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/search-strategies-2pg.pt2.pdf +0 -0
  113. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.docling.json +0 -0
  114. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.expected.yaml +0 -0
  115. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/study-sample-7pg.pt2.pdf +0 -0
  116. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.docling.json +0 -0
  117. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.expected.yaml +0 -0
  118. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/repeated-header/themes-exemplars-3pg.pt2.pdf +0 -0
  119. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/simple-continuation/sample-table.corp.docling.json +0 -0
  120. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/simple-continuation/sample-table.corp.expected.yaml +0 -0
  121. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/simple-continuation/sample-table.corp.pdf +0 -0
  122. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/spillover/note-overflow.synth.docling.json +0 -0
  123. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/spillover/note-overflow.synth.expected.yaml +0 -0
  124. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/spillover/note-overflow.synth.pdf +0 -0
  125. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/width-drift/.gitkeep +0 -0
  126. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.docling.json +0 -0
  127. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.expected.yaml +0 -0
  128. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/fixtures/width-drift/abx-literature-review-7pg.pt2.pdf +0 -0
  129. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/integration/test_fixtures.py +0 -0
  130. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/test_intervening_content_guard.py +0 -0
  131. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/test_merger.py +0 -0
  132. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/test_public_api.py +0 -0
  133. {table_stitcher-0.4.0 → table_stitcher-0.4.2}/tests/test_tablemeta_fixtures.py +0 -0
@@ -7,6 +7,34 @@ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] — 2026-06-08
11
+
12
+ ### Fixed
13
+
14
+ - **`__version__` was hardcoded and stale** (`__init__.py`). It read `"0.2.0"`
15
+ regardless of the installed release, since nothing tied it to the version in
16
+ `pyproject.toml`. It is now derived from the installed distribution metadata
17
+ via `importlib.metadata.version("table-stitcher")`, so it always reflects the
18
+ actual release (falling back to `"0.0.0+unknown"` when run from an
19
+ uninstalled source tree). The release gate now also asserts
20
+ `__version__` matches the `pyproject.toml` version, so the two can't drift
21
+ again.
22
+
23
+ ## [0.4.1] — 2026-06-08
24
+
25
+ ### Fixed
26
+
27
+ - **Spanning body cells duplicated across columns on multi-page merge**
28
+ (`adapters/docling.py`). Docling repeats a `col_span=N` cell's text across
29
+ every column it covers; the merge round-trip rebuilt those as `N` separate
30
+ `col_span=1` cells, leaking a full-width description into every value column
31
+ and displacing the real values (a repeated `col_span` header behaved the same
32
+ way). Injection now matches each merged row back to its source grid row and
33
+ re-emits the original spans; rows the merger transformed (stitched
34
+ continuations, folded overflow) fall back to the flat 1x1 rebuild. The match
35
+ uses the original span metadata, never value equality, so coincidentally-equal
36
+ adjacent values (e.g. two plan columns sharing a cap) stay separate cells.
37
+
10
38
  ## [0.4.0] — 2026-05-29
11
39
 
12
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: table-stitcher
3
- Version: 0.4.0
3
+ Version: 0.4.2
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.4.0"
7
+ version = "0.4.2"
8
8
  description = "Reassemble tables split across page boundaries in PDF extraction"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -69,15 +69,23 @@ else
69
69
  fi
70
70
 
71
71
  echo "==> Smoke-testing installed wheel outside checkout"
72
+ EXPECTED_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
73
+ export EXPECTED_VERSION
72
74
  (
73
75
  cd "$TMP_ENV"
74
76
  "$TMP_ENV/venv/bin/python" - <<'PY'
77
+ import os
75
78
  import pathlib
79
+
76
80
  import table_stitcher
77
81
 
78
82
  package_path = pathlib.Path(table_stitcher.__file__).resolve()
83
+ expected = os.environ["EXPECTED_VERSION"]
79
84
  assert "site-packages" in str(package_path), package_path
80
85
  assert table_stitcher.__version__, "missing __version__"
86
+ assert table_stitcher.__version__ == expected, (
87
+ f"__version__ {table_stitcher.__version__!r} != pyproject version {expected!r}"
88
+ )
81
89
  assert callable(table_stitcher.stitch_tables)
82
90
  print(f"installed {table_stitcher.__version__} from {package_path}")
83
91
  PY
@@ -24,13 +24,20 @@ Usage (custom parser):
24
24
 
25
25
  import logging
26
26
  import time
27
+ from importlib.metadata import PackageNotFoundError
28
+ from importlib.metadata import version as _pkg_version
27
29
  from typing import Any, Optional
28
30
 
29
31
  from .adapters.base import TableStitcherAdapter
30
32
  from .merger import merge_multipage_tables
31
33
  from .models import LogicalTable, MergeTrace, MultiPageConfig, TableMeta
32
34
 
33
- __version__ = "0.2.0"
35
+ # Single source of truth: the installed distribution's version (from
36
+ # pyproject.toml). Derived rather than hardcoded so it can never drift.
37
+ try:
38
+ __version__ = _pkg_version("table-stitcher")
39
+ except PackageNotFoundError: # running from a source tree without an install
40
+ __version__ = "0.0.0+unknown"
34
41
  __all__ = [
35
42
  "stitch_tables",
36
43
  "extract_table_meta",
@@ -419,9 +419,90 @@ def _extract_original_header_rows(
419
419
  return header_rows, header_cells
420
420
 
421
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
+
422
502
  def _dataframe_to_docling_data(
423
503
  df: pd.DataFrame,
424
504
  original_data: Optional[TableData] = None,
505
+ member_data: Optional[list[Optional[TableData]]] = None,
425
506
  ) -> TableData:
426
507
  """
427
508
  Converts a pandas DataFrame back into Docling's TableData structure.
@@ -431,6 +512,12 @@ def _dataframe_to_docling_data(
431
512
  are preserved exactly. Only the data rows are rebuilt from the DataFrame.
432
513
  This prevents the lossy roundtrip that would flatten complex headers into
433
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.
434
521
  """
435
522
  if df.empty:
436
523
  cols = list(df.columns) if len(df.columns) > 0 else ["Column_0"]
@@ -499,16 +586,28 @@ def _dataframe_to_docling_data(
499
586
  break
500
587
 
501
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
+
502
593
  for i, (_, row) in enumerate(df.iterrows()):
503
- grid_row: list[TableCell] = []
504
594
  table_row_idx = num_header_rows + i
505
595
 
506
- for j, val in enumerate(row):
507
- if pd.isna(val) or val is None:
508
- text_val = ""
509
- else:
510
- 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
511
608
 
609
+ grid_row: list[TableCell] = []
610
+ for j, text_val in enumerate(row_vals):
512
611
  row_header = j == 0 and has_row_headers
513
612
 
514
613
  cell = TableCell(
@@ -899,9 +998,18 @@ class DoclingAdapter:
899
998
 
900
999
  original_data = getattr(anchor_table, "data", None)
901
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
+
902
1009
  anchor_table.data = _dataframe_to_docling_data(
903
1010
  lt.df,
904
1011
  original_data=original_data,
1012
+ member_data=member_data,
905
1013
  )
906
1014
 
907
1015
  for satellite_idx in lt.members[1:]:
@@ -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
 
File without changes
File without changes