Skip to content

fix(table): decide MergeSplitTokens per table, not per row - #18

Merged
hallelx2 merged 1 commit into
mainfrom
halleluyaholudele/hal-548-table-wide-merge
Aug 2, 2026
Merged

fix(table): decide MergeSplitTokens per table, not per row#18
hallelx2 merged 1 commit into
mainfrom
halleluyaholudele/hal-548-table-wide-merge

Conversation

@hallelx2

@hallelx2 hallelx2 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Follow-up to #17, found by looking at output on a real filing rather than at the unit tests.

The bug I shipped

Merging was decided row by row. A column boundary is a property of the table, so that was wrong in a way the tests could not see.

On 3M's 2018 10-K page 58, the header band contains the split (December 3 + 1,); the data rows below it do not. So the header merged and the data rows did not:

|   | December 31, | December 31, |          <- 3 cells
| Cash and cash equivalents | $ | 2,853 | $ | 3,053 |   <- 5 cells

The grid sheared. The header's second date ended up above the first column of figures. That is worse than the split it set out to fix — and it is exactly the kind of damage that passes a spot check while ruining the table for anything consuming it positionally.

The fix

Decide once per boundary, across every row, then apply uniformly:

|   | December 31, | December 31, |
| Cash and cash equivalents | $2,853 | $3,053 |
| Marketable securities — current | 380 | 1,076 |
| Less: Accumulated depreciation | (16,135) | (16,048) |

Rectangular, aligned, and the $ now reads with its value.

The trade-off: one row containing a split collapses that boundary for the whole table. That is the right direction — rectangularity matters more to a consumer than per-cell purity, and the merged result reads correctly anyway.

Verification

  • go build, go vet, go test ./... -count=1 -race — green.
  • All goldens unchanged (flag still defaults off).
  • 3M pages 56–60: 103/103 negatives intact, grid rectangular.
  • TestMergeSplitTokensKeepsTableRectangular uses a table whose first row splits and whose second does not, and asserts equal column counts — not just the merged text. Asserting only the text is what let the shearing through the first time.

Relates to HAL-548

Summary by Sourcery

Make merge-split decisions at the table level instead of per row to keep column boundaries consistent and tables rectangular.

Bug Fixes:

  • Ensure split token merging is applied uniformly across all rows so headers and data rows share the same column structure.

Enhancements:

  • Guard merge logic with a table-wide analysis of column boundaries and skip merging when no boundaries are identified as splits.

Tests:

  • Add a regression test that verifies merged tables remain rectangular with equal column counts across rows when a split occurs in only one row.

Follow-up to #17, caught by looking at the output on a real filing
rather than at the unit tests.

Merging was decided row by row. A column boundary is a property of the
TABLE, so that was wrong in a way the tests could not see: on 3M 2018
10-K page 58 the header band contains the split ("December 3" + "1,")
while the data rows below it do not. The header merged, the data rows
did not, and the rows came out with different column counts -- so the
header second date sat above the first column of figures.

  before   |   | December 31, | December 31, |      <- 3 cells
           | Cash | $ | 2,853 | $ | 3,053 |          <- 5 cells

A sheared grid is a worse outcome than the split it set out to fix, and
it is exactly the kind of damage that looks fine in a spot check and
ruins the table for anything consuming it positionally.

Now the decision is made once per boundary across every row, then
applied uniformly, so the table stays rectangular:

  after    |   | December 31, | December 31, |
           | Cash and cash equivalents | $2,853 | $3,053 |

The trade is that one row containing a split collapses that boundary for
the whole table. That is the right direction: rectangularity matters more
to a consumer than per-cell purity, and the merged result reads correctly
anyway.

TestMergeSplitTokensKeepsTableRectangular pins it with a table whose
first row splits and whose second does not, asserting equal column counts
rather than only the merged text -- the shearing is what the previous
tests missed.
@sourcery-ai

sourcery-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR changes mergeSplitTokens to decide which column boundaries to merge once per table (across all rows) instead of per row, ensuring rectangular tables, and adds a regression test capturing this invariant.

File-Level Changes

Change Details Files
Make mergeSplitTokens compute column-boundary merge decisions at table scope and apply them uniformly across all rows to keep tables rectangular.
  • Compute the maximum column count across all rows and early-return if fewer than two columns exist.
  • Scan each column boundary across every row to determine whether any cell pair at that boundary splits a token, storing decisions in a drop[] slice.
  • Short-circuit if no boundaries are marked for dropping.
  • Refactor the row-building loop to iterate up to the table-wide column count, guarding against ragged input rows.
  • When a boundary is dropped, merge the current cell’s text and bounding box into the previous cell, carefully handling zero and missing BBoxes and preserving rectangular output.
page.go
Add a regression test asserting that mergeSplitTokens keeps the table rectangular when only some rows contain splits at a given boundary.
  • Construct a two-row test fixture where the first row has adjacent glyphs across a boundary and the second has a wide gutter at the same boundary.
  • Invoke mergeSplitTokens and assert both text rows and cell rows have equal lengths to detect grid shearing.
  • Assert that both rows merge across the boundary, confirming that a boundary decision applies to the whole table and not per row.
merge_split_test.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@hallelx2, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 21f291d3-810d-4238-9228-30eeb5eb677a

📥 Commits

Reviewing files that changed from the base of the PR and between abe3742 and 5e1247f.

📒 Files selected for processing (2)
  • merge_split_test.go
  • page.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • In the drop computation loop you index rows[ri][ci] and rows[ri][ci+1] without checking that ci/ci+1 are within len(rows[ri]), which will panic for ragged tables where rows is shorter than cells in some columns.
  • The new for ci := 0; ci < cols; ci++ iteration appends empty cells for short rows, changing row length semantics; if some consumers rely on original row lengths, consider explicitly documenting or constraining this behavior (e.g., by enforcing len(rows) == len(cells) or trimming trailing empties in the output).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the `drop` computation loop you index `rows[ri][ci]` and `rows[ri][ci+1]` without checking that `ci`/`ci+1` are within `len(rows[ri])`, which will panic for ragged tables where `rows` is shorter than `cells` in some columns.
- The new `for ci := 0; ci < cols; ci++` iteration appends empty cells for short rows, changing row length semantics; if some consumers rely on original row lengths, consider explicitly documenting or constraining this behavior (e.g., by enforcing `len(rows) == len(cells)` or trimming trailing empties in the output).

## Individual Comments

### Comment 1
<location path="page.go" line_range="756" />
<code_context>
+			if l.IsZero() || r.IsZero() {
+				continue
+			}
+			if rows[ri][ci] == "" || rows[ri][ci+1] == "" {
+				continue
+			}
</code_context>
<issue_to_address>
**issue (bug_risk):** Potential out-of-bounds access on rows[ri] when ci extends beyond that row’s length.

Because cols is based on the max row length, the outer loop can reach ci values that exceed the length of rows[ri]. In this pre-scan you access rows[ri][ci] and rows[ri][ci+1] without checking against len(rows[ri]), so shorter rows will panic. Please add bounds checks (like those used for cells) or limit this logic to ci < len(rows[ri]) - 1 when consulting rows.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread page.go
if l.IsZero() || r.IsZero() {
continue
}
if rows[ri][ci] == "" || rows[ri][ci+1] == "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Potential out-of-bounds access on rows[ri] when ci extends beyond that row’s length.

Because cols is based on the max row length, the outer loop can reach ci values that exceed the length of rows[ri]. In this pre-scan you access rows[ri][ci] and rows[ri][ci+1] without checking against len(rows[ri]), so shorter rows will panic. Please add bounds checks (like those used for cells) or limit this logic to ci < len(rows[ri]) - 1 when consulting rows.

@hallelx2
hallelx2 merged commit be5bb9c into main Aug 2, 2026
5 checks passed
@hallelx2
hallelx2 deleted the halleluyaholudele/hal-548-table-wide-merge branch August 2, 2026 09:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant