Files
ragflow/internal/deepdoc/parser/pdf/table/table_merge_test.go

454 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package table
import (
"testing"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
)
func TestCrossPageTableMerge(t *testing.T) {
// Page 0 table: 2 cells, positioned at page 0.
pg0 := pdf.TableItem{
Positions: []pdf.Position{
{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 800},
},
Scale: 1.0,
Cells: []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "pg0_r0c0"},
{X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "pg0_r0c1"},
},
}
// Page 1 table: 2 cells, same X range, positioned at page 1.
pg1 := pdf.TableItem{
Positions: []pdf.Position{
{PageNumbers: []int{1}, Left: 50, Right: 500, Top: 100, Bottom: 300},
},
Scale: 1.0,
Cells: []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "pg1_r0c0"},
{X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "pg1_r0c1"},
},
}
tables := []pdf.TableItem{pg0, pg1}
// mergeTablesAcrossPages merges tables on consecutive pages with X overlap.
merged := MergeTablesAcrossPages(tables, nil)
if len(merged) != 1 {
t.Fatalf("expected 1 merged table, got %d", len(merged))
}
if len(merged[0].Cells) != 4 {
t.Errorf("expected 4 merged cells, got %d", len(merged[0].Cells))
}
if len(merged[0].Positions) != 2 {
t.Errorf("expected 2 merged positions, got %d", len(merged[0].Positions))
}
t.Logf("Merged %d cells across %d pages", len(merged[0].Cells), len(merged[0].Positions))
}
// TestMergeTablesAcrossPages_NoOverlap verifies that non-adjacent or
// non-overlapping tables are NOT merged.
func TestMergeTablesAcrossPages_NoOverlap(t *testing.T) {
// Tables with no X overlap should NOT be merged.
tables := []pdf.TableItem{
{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 100, Top: 100, Bottom: 500}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "left"}},
},
{
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 500, Right: 600, Top: 100, Bottom: 500}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "right"}},
},
}
merged := MergeTablesAcrossPages(tables, nil)
if len(merged) != 2 {
t.Fatalf("non-overlapping tables: expected 2 tables, got %d", len(merged))
}
}
// TestMergeTablesAcrossPages_NonConsecutive verifies that tables on
// non-consecutive pages are NOT merged.
func TestMergeTablesAcrossPages_NonConsecutive(t *testing.T) {
tables := []pdf.TableItem{
{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "page0"}},
},
{
Positions: []pdf.Position{{PageNumbers: []int{3}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "page3"}},
},
}
merged := MergeTablesAcrossPages(tables, nil)
if len(merged) != 2 {
t.Fatalf("non-consecutive pages: expected 2 tables, got %d", len(merged))
}
}
// TestMergeTablesAcrossPages_SingleTable verifies that a single table
// passes through unchanged.
func TestMergeTablesAcrossPages_SingleTable(t *testing.T) {
tables := []pdf.TableItem{
{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "only"}},
},
}
merged := MergeTablesAcrossPages(tables, nil)
if len(merged) != 1 {
t.Fatalf("single table: expected 1 table, got %d", len(merged))
}
}
func TestMergeTablesAcrossPages_EmptyPositions(t *testing.T) {
// Tables with empty Positions should be preserved (not dropped).
tables := []pdf.TableItem{
{
Positions: []pdf.Position{},
Cells: []pdf.TSRCell{{Text: "posless"}},
},
{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "normal"}},
},
}
merged := MergeTablesAcrossPages(tables, nil)
if len(merged) != 2 {
t.Fatalf("empty Positions: expected 2 tables (preserved), got %d", len(merged))
}
// Tables with Positions come first (from items list), positionless tables are appended.
if len(merged[0].Positions) == 0 {
t.Error("expected table with Positions first in result")
}
if len(merged[1].Positions) != 0 {
t.Error("expected positionless table second in result")
}
if merged[1].Cells[0].Text != "posless" {
t.Errorf("positionless table content lost: got %q", merged[1].Cells[0].Text)
}
}
func TestMergeTablesAcrossPages_LargeYGap(t *testing.T) {
// Tables with large Y gap should NOT be merged.
medianHeights := map[int]float64{0: 10}
tables := []pdf.TableItem{
{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 150}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "page0"}},
},
{
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 50, Right: 500, Top: 5000, Bottom: 5100}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "page1_far"}},
},
}
merged := MergeTablesAcrossPages(tables, medianHeights)
if len(merged) != 2 {
t.Fatalf("large Y gap: expected 2 tables (not merged), got %d", len(merged))
}
}
func TestMergeTablesAcrossPages_NoMedianHeights(t *testing.T) {
// Without medianHeights, mh defaults to 10 and threshold is 230.
// yDis = (10 + 120 - 150 - 150) / 2 = -85, which is <= 230, so they merge.
tables := []pdf.TableItem{
{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 150}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "page0"}},
},
{
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 50, Right: 500, Top: 10, Bottom: 120}},
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "page1_near"}},
},
}
merged := MergeTablesAcrossPages(tables, nil)
if len(merged) != 1 {
t.Fatalf("no medianHeights: expected 1 merged table, got %d", len(merged))
}
if len(merged[0].Cells) != 2 {
t.Errorf("expected 2 cells after merge, got %d", len(merged[0].Cells))
}
}
// TestMergeTablesAcrossPages_RebuildsGridAcrossPages verifies that after a
// cross-page merge the merged table's Grid contains rows from BOTH pages,
// not just the anchor (page-0) grid. This catches the regression where
// ConstructTable reads the stale anchor Grid and drops all continuation rows.
func TestMergeTablesAcrossPages_RebuildsGridAcrossPages(t *testing.T) {
pageGrid := func(rows [][]string) [][]pdf.TSRCell {
g := make([][]pdf.TSRCell, len(rows))
for r, row := range rows {
g[r] = make([]pdf.TSRCell, len(row))
for c := range row {
g[r][c] = pdf.TSRCell{
X0: float64(c) * 100, Y0: float64(r) * 30,
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
Text: row[c],
}
}
}
return g
}
pg0 := pdf.TableItem{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
Scale: 1.0,
Grid: pageGrid([][]string{{"a", "b"}, {"c", "d"}}),
}
pg1 := pdf.TableItem{
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
Scale: 1.0,
Grid: pageGrid([][]string{{"e", "f"}, {"g", "h"}}),
}
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
if len(merged) != 1 {
t.Fatalf("expected 1 merged table, got %d", len(merged))
}
// Anchor has 2 rows, continuation has 2 rows → merged Grid must be 4.
if len(merged[0].Grid) != 4 {
t.Fatalf("merged Grid must contain rows from both pages (want 4), got %d", len(merged[0].Grid))
}
// Continuation rows must appear after anchor rows, in page order.
if merged[0].Grid[0][0].Text != "a" || merged[0].Grid[2][0].Text != "e" {
t.Errorf("row order wrong after stacking: %s / %s", merged[0].Grid[0][0].Text, merged[0].Grid[2][0].Text)
}
// Continuation rows must be Y-shifted strictly below the anchor rows so
// Y-monotonic downstream logic (span detection, ordering) stays correct.
// Catches a regression where the shift is dropped but stacking is kept:
// row order would still be correct by page order, so only this assertion
// would fail.
if merged[0].Grid[2][0].Y0 <= merged[0].Grid[1][0].Y1 {
t.Errorf("continuation row was not shifted below the anchor rows (Grid[2][0].Y0=%v <= Grid[1][0].Y1=%v)",
merged[0].Grid[2][0].Y0, merged[0].Grid[1][0].Y1)
}
}
// TestMergeTablesAcrossPages_JaggedContinuationFallsBackToAnchorGrid verifies
// that when a continuation page's grid is not column-uniform with the anchor
// (a jagged cross-page stack), MergeTablesAcrossPages does NOT rebuild a
// non-uniform Grid. Instead it keeps the anchor-only Grid, so ConstructTable
// emits a structurally valid (if continuation-dropping) table rather than
// malformed HTML. This is the same safe degrade as the len(anchor.Grid)==0
// path, and keeps the merge decision (and the appended continuation Cells)
// unchanged.
//
// Each case below is a jagged stack the OLD guard (which only compared the
// first row of each continuation grid) would have wrongly allowed to rebuild.
// The first case differs on the first row; the second matches on the first
// row but narrows on an interior row — exactly the gap the per-row
// uniformColCount check closes.
func TestMergeTablesAcrossPages_JaggedContinuationFallsBackToAnchorGrid(t *testing.T) {
pageGrid := func(rows [][]string) [][]pdf.TSRCell {
g := make([][]pdf.TSRCell, len(rows))
for r, row := range rows {
g[r] = make([]pdf.TSRCell, len(row))
for c := range row {
g[r][c] = pdf.TSRCell{
X0: float64(c) * 100, Y0: float64(r) * 30,
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
Text: row[c],
}
}
}
return g
}
cells := func(rows [][]string) []pdf.TSRCell {
var cs []pdf.TSRCell
for r, row := range rows {
for c := range row {
cs = append(cs, pdf.TSRCell{
X0: float64(c) * 100, Y0: float64(r) * 30,
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
Text: row[c],
})
}
}
return cs
}
cases := []struct {
name string
anchorRows [][]string
contRows [][]string
contCellText string
}{
{
name: "first row column count differs",
anchorRows: [][]string{{"a", "b", "c"}, {"d", "e", "f"}},
contRows: [][]string{{"g", "h"}, {"i", "j"}},
contCellText: "g",
},
{
name: "interior row column count differs",
anchorRows: [][]string{{"a", "b", "c"}, {"d", "e", "f"}},
contRows: [][]string{{"g", "h", "i"}, {"j", "k"}},
contCellText: "g",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pg0 := pdf.TableItem{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 300, Top: 0, Bottom: 60}},
Scale: 1.0,
Grid: pageGrid(tc.anchorRows),
Cells: cells(tc.anchorRows),
}
pg1 := pdf.TableItem{
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 300, Top: 0, Bottom: 60}},
Scale: 1.0,
Grid: pageGrid(tc.contRows),
Cells: cells(tc.contRows),
}
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
if len(merged) != 1 {
t.Fatalf("expected 1 merged table, got %d", len(merged))
}
// Columns differ (anchor 3 vs continuation jagged) → rebuild must
// be skipped → Grid stays anchor-only, NOT a 4-row jagged grid.
if len(merged[0].Grid) != len(tc.anchorRows) {
t.Fatalf("jagged continuation must fall back to anchor-only Grid (want %d rows), got %d", len(tc.anchorRows), len(merged[0].Grid))
}
// Anchor rows preserved; continuation NOT stacked into the Grid.
if merged[0].Grid[0][0].Text != tc.anchorRows[0][0] || merged[0].Grid[1][0].Text != tc.anchorRows[1][0] {
t.Errorf("anchor rows corrupted after jagged fallback: %s / %s", merged[0].Grid[0][0].Text, merged[0].Grid[1][0].Text)
}
// Continuation Cells are still appended (pre-fix behaviour) — the
// merge decision is unchanged; only the Grid stays uniform.
hasCont := false
for _, c := range merged[0].Cells {
if c.Text == tc.contCellText {
hasCont = true
break
}
}
if !hasCont {
t.Errorf("continuation Cells should still be appended even when Grid rebuild is skipped")
}
})
}
}
// TestMergeTablesAcrossPages_ThreePageCumulativeShift verifies that with three
// consecutive pages the per-page grids stack cumulatively: each continuation
// page sits strictly below the previous page's last row, and the Y shift
// accumulates (page2 below page1 below page0). Catches a regression where
// stackGrids resets prevMaxY to the anchor instead of carrying the prior
// page's shifted bottom forward.
func TestMergeTablesAcrossPages_ThreePageCumulativeShift(t *testing.T) {
pageGrid := func(rows [][]string) [][]pdf.TSRCell {
g := make([][]pdf.TSRCell, len(rows))
for r, row := range rows {
g[r] = make([]pdf.TSRCell, len(row))
for c := range row {
g[r][c] = pdf.TSRCell{
X0: float64(c) * 100, Y0: float64(r) * 30,
X1: float64(c)*100 + 100, Y1: float64(r)*30 + 30,
Text: row[c],
}
}
}
return g
}
// Three pages, 2 rows × 2 cols each, identical layout.
pages := []pdf.TableItem{
{Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 200, Top: 0, Bottom: 60}}, Scale: 1.0, Grid: pageGrid([][]string{{"a", "b"}, {"c", "d"}})},
{Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 200, Top: 0, Bottom: 60}}, Scale: 1.0, Grid: pageGrid([][]string{{"e", "f"}, {"g", "h"}})},
{Positions: []pdf.Position{{PageNumbers: []int{2}, Left: 0, Right: 200, Top: 0, Bottom: 60}}, Scale: 1.0, Grid: pageGrid([][]string{{"i", "j"}, {"k", "l"}})},
}
merged := MergeTablesAcrossPages(pages, nil)
if len(merged) != 1 {
t.Fatalf("expected 1 merged table, got %d", len(merged))
}
// 3 pages × 2 rows → 6 rows, in page order.
if len(merged[0].Grid) != 6 {
t.Fatalf("3-page merge must stack all rows (want 6), got %d", len(merged[0].Grid))
}
if merged[0].Grid[0][0].Text != "a" || merged[0].Grid[2][0].Text != "e" || merged[0].Grid[4][0].Text != "i" {
t.Errorf("row order wrong after 3-page stacking: %s / %s / %s",
merged[0].Grid[0][0].Text, merged[0].Grid[2][0].Text, merged[0].Grid[4][0].Text)
}
// Cumulative Y shift: page1 strictly below page0, page2 strictly below
// page1, and page2 below page1 (monotonic accumulation).
if merged[0].Grid[2][0].Y0 <= merged[0].Grid[1][0].Y1 {
t.Errorf("page1 not shifted below page0 (Grid[2][0].Y0=%v <= Grid[1][0].Y1=%v)",
merged[0].Grid[2][0].Y0, merged[0].Grid[1][0].Y1)
}
if merged[0].Grid[4][0].Y0 <= merged[0].Grid[3][0].Y1 {
t.Errorf("page2 not shifted below page1 (Grid[4][0].Y0=%v <= Grid[3][0].Y1=%v)",
merged[0].Grid[4][0].Y0, merged[0].Grid[3][0].Y1)
}
if merged[0].Grid[4][0].Y0 <= merged[0].Grid[2][0].Y0 {
t.Errorf("Y shift not cumulative: page2 (Y0=%v) must be below page1 (Y0=%v)",
merged[0].Grid[4][0].Y0, merged[0].Grid[2][0].Y0)
}
}
// TestMergeTablesAcrossPages_GridlessAnchorUnchanged verifies that when the
// anchor has no Grid, MergeTablesAcrossPages skips the rebuild entirely and
// leaves anchor.Grid empty (nil) so ConstructTable falls back to the Cells
// path — the same pre-fix behaviour. This locks the no-regression promise the
// fix relies on for Grid-less tables (and the 7 pre-existing tests that set no
// Grid). The cross-page merge decision itself is unchanged: continuation
// Cells and Positions are still appended.
func TestMergeTablesAcrossPages_GridlessAnchorUnchanged(t *testing.T) {
cells := func(texts []string) []pdf.TSRCell {
cs := make([]pdf.TSRCell, len(texts))
for i, txt := range texts {
cs[i] = pdf.TSRCell{
X0: 0, Y0: float64(i) * 30, X1: 100, Y1: float64(i)*30 + 30,
Text: txt,
}
}
return cs
}
// Anchor has Cells but NO Grid; continuation also Grid-less.
pg0 := pdf.TableItem{
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
Scale: 1.0,
Cells: cells([]string{"a", "b", "c", "d"}),
}
pg1 := pdf.TableItem{
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 200, Top: 0, Bottom: 60}},
Scale: 1.0,
Cells: cells([]string{"e", "f", "g", "h"}),
}
merged := MergeTablesAcrossPages([]pdf.TableItem{pg0, pg1}, nil)
if len(merged) != 1 {
t.Fatalf("expected 1 merged table, got %d", len(merged))
}
// Guard: anchor has no Grid → rebuild skipped entirely → Grid stays empty.
if len(merged[0].Grid) != 0 {
t.Fatalf("Grid-less anchor must keep Grid empty (rebuild skipped), got %d rows", len(merged[0].Grid))
}
// Merge decision unchanged: all continuation Cells still appended.
have := map[string]bool{}
for _, c := range merged[0].Cells {
have[c.Text] = true
}
for _, want := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
if !have[want] {
t.Errorf("merged Cells missing %q (merge decision changed for Grid-less anchor)", want)
}
}
// Positions from both pages present → the cross-page merge did happen.
pages := map[int]bool{}
for _, p := range merged[0].Positions {
for _, pn := range p.PageNumbers {
pages[pn] = true
}
}
if !pages[0] || !pages[1] {
t.Errorf("cross-page merge did not combine both pages' positions: %v", pages)
}
}