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

323 lines
13 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"
)
// ---- boxOverlapsCell ----
func TestBoxMatchesCell_FullOverlap(t *testing.T) {
// Box is entirely inside cell → ≥85% of box area inside cell → match.
cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50}
box := pdf.TextBox{X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "hello"}
if !BoxMatchesCell(cell, box, false) {
t.Error("full overlap should return true")
}
// Box is still entirely inside cell → box→cell = 100% ≥ 85% → match.
box2 := pdf.TextBox{X0: 10, X1: 90, Top: 10, Bottom: 40, Text: "partial"}
if !BoxMatchesCell(cell, box2, false) {
t.Error("box entirely inside cell (100% of box) should match")
}
}
func TestBoxMatchesCell_NoOverlap(t *testing.T) {
cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50}
box := pdf.TextBox{X0: 200, X1: 300, Top: 10, Bottom: 40, Text: "away"}
if BoxMatchesCell(cell, box, false) {
t.Error("no X overlap should return false")
}
}
func TestBoxMatchesCell_PartialOverlap(t *testing.T) {
// Box is entirely inside cell (100% of box area) → matches.
// boxOverlapsCell uses box→cell overlap (≥85% of box area inside cell).
cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50}
box := pdf.TextBox{X0: 0, X1: 30, Top: 0, Bottom: 25, Text: "small"}
if !BoxMatchesCell(cell, box, false) {
t.Error("box entirely inside cell should match")
}
// Box straddles cell boundary (< 85% of box inside cell) → no match.
box2 := pdf.TextBox{X0: 80, X1: 180, Top: 0, Bottom: 25, Text: "spill"}
if BoxMatchesCell(cell, box2, false) {
t.Error("box straddling boundary (<85% inside) should NOT match")
}
}
func TestBoxMatchesCell_ZeroArea(t *testing.T) {
cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 0, Y1: 50}
box := pdf.TextBox{X0: 0, X1: 10, Top: 0, Bottom: 10, Text: "x"}
if BoxMatchesCell(cell, box, false) {
t.Error("zero cell area should return false")
}
}
// ---- fillCellTextFromBoxes ----
func TestFillCellTextFromBoxes_Simple(t *testing.T) {
// Box covering entire cell (>85%) → match
cells := []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 100, Y1: 50},
{X0: 100, Y0: 0, X1: 200, Y1: 50},
}
boxes := []pdf.TextBox{
{X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "cell1"},
{X0: 100, X1: 200, Top: 0, Bottom: 50, Text: "cell2"},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "cell1" {
t.Errorf("cell 0: got %q, want 'cell1'", cells[0].Text)
}
if cells[1].Text != "cell2" {
t.Errorf("cell 1: got %q, want 'cell2'", cells[1].Text)
}
}
func TestFillCellTextFromBoxes_MultipleBoxesPerCell(t *testing.T) {
// Two boxes, each covering >85% of the cell → concatenated
// (boxes must overlap the cell near-completely to match individually)
cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}}
boxes := []pdf.TextBox{
{X0: 0, X1: 95, Top: 0, Bottom: 47, Text: "part1"},
{X0: 5, X1: 100, Top: 3, Bottom: 50, Text: "part2"},
}
FillCellTextFromBoxes(cells, boxes)
// Both boxes cover >85% → both match → concatenated with space
if cells[0].Text == "" {
t.Error("expected non-empty cell text")
}
}
func TestFillCellTextFromBoxes_EmptyBoxText(t *testing.T) {
cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}}
boxes := []pdf.TextBox{
{X0: 5, X1: 95, Top: 5, Bottom: 45, Text: " "},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "" {
t.Errorf("empty box text: got %q, want empty", cells[0].Text)
}
}
func TestFillCellTextFromBoxes_NoMatchingBox(t *testing.T) {
cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}}
boxes := []pdf.TextBox{
{X0: 500, X1: 600, Top: 500, Bottom: 550, Text: "far away"},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "" {
t.Errorf("no match: got %q, want empty", cells[0].Text)
}
}
// TestBoxMatchesCell_FilledCellWeakOverlapRejected locks the Go-only 0.85
// guard: a cell that already carries text (e.g. per-cell OCR in the rotated
// path) rejects a box whose area is only partially inside the cell.
// Cell (0,0)-(100,50); box (40,5)-(140,15).
//
// overlap = (40,100)x(5,15) = 60*10 = 600
// box area = (140-40)*(15-5) = 100*10 = 1000 → ratio 0.6
//
// 0.6 < 0.85 → rejected.
func TestBoxMatchesCell_FilledCellWeakOverlapRejected(t *testing.T) {
cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "ocr"}
box := pdf.TextBox{X0: 40, X1: 140, Top: 5, Bottom: 15, Text: "元"}
if BoxMatchesCell(cell, box, false) {
t.Error("filled cell should reject box overlapping only 60% (needs >=85%)")
}
}
// TestBoxMatchesCell_FilledCellStrongOverlapAccepted locks that a box almost
// entirely inside an already-filled cell still matches (so per-cell OCR text
// can be overridden by a confident box). Box fully inside → ratio 1.0.
func TestBoxMatchesCell_FilledCellStrongOverlapAccepted(t *testing.T) {
cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "ocr"}
box := pdf.TextBox{X0: 5, X1: 95, Top: 5, Bottom: 45, Text: "text"}
if !BoxMatchesCell(cell, box, false) {
t.Error("filled cell should accept box fully inside (>=85%)")
}
}
// TestFillCellTextFromBoxes_EmptyCellJoinsMultiplePartialBoxes proves the 0.85
// guard only bites cells that ENTER FillCellTextFromBoxes with text. For a cell
// that starts empty, every overlapping box (here each 60%) is matched at the
// 0.3 threshold and all are joined — no in-cell text loss in the normal path.
// cell (0,0)-(100,50); box1 (40,5)-(140,15) ratio 0.6; box2 (-40,30)-(60,45) ratio 0.6.
func TestFillCellTextFromBoxes_EmptyCellJoinsMultiplePartialBoxes(t *testing.T) {
cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}}
boxes := []pdf.TextBox{
{X0: 40, X1: 140, Top: 5, Bottom: 15, Text: "part1"},
{X0: -40, X1: 60, Top: 30, Bottom: 45, Text: "part2"},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "part1 part2" {
t.Errorf("empty cell should join all overlapping boxes: got %q, want 'part1 part2'", cells[0].Text)
}
}
// TestFillCellTextFromBoxes_PrefilledCellDropsSecondaryBox documents the
// deliberate go_intentional divergence from Python.
//
// Scenario: a cell already holds per-cell OCR text ("Total"). A separate
// detected text box ("元") physically sits in the same cell but only overlaps
// 60% of its area. Go applies the 0.85 guard (cell entered with text) and
// DROPS the secondary box, keeping "Total".
//
// Python has no per-cell OCR and no filled-cell threshold: both fragments
// would be joined via find_overlapped_with_threshold(thr=0.3), yielding
// "Total 元". This test LOCKS Go's current behavior so the divergence stays
// visible; it is registered as go_intentional, not a regression target.
func TestFillCellTextFromBoxes_PrefilledCellDropsSecondaryBox(t *testing.T) {
cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "Total"}} // pre-filled by per-cell OCR
boxes := []pdf.TextBox{
{X0: 40, X1: 140, Top: 5, Bottom: 15, Text: "元"}, // 60% inside
}
FillCellTextFromBoxes(cells, boxes)
// Go: 0.85 guard drops the 60%-overlap box. Python would keep "Total 元".
if cells[0].Text != "Total" {
t.Errorf("go_intentional divergence: got %q, want 'Total' (Python would yield 'Total 元')", cells[0].Text)
}
}
// =============================================================================
// Implementation divergences in box→cell ASSIGNMENT (NOT architecture: both
// sides build the grid from TSR rows×columns via cross-product — see
// deepdoc_table_builder.go GroupCells vs Python construct_table). The divergences
// are purely in how a box is assigned to a (row,column) cell. Registered in
// testdata/parity/known_diffs.json as go_bug (#1, #2, #3).
// =============================================================================
// TestFillCellTextFromBoxes_BoxOverlappingTwoCells_SingleAssignment is the
// regression test for go_bug #1. Python assigns each box to EXACTLY ONE cell
// (greedy best row + tightest column); Go must no longer duplicate a
// straddling box into both cells.
//
// Two side-by-side cells; one box straddles the boundary, overlapping BOTH at
// 50% of its area. cell A (0,0)-(100,50); cell B (100,0)-(200,50).
// box (50,5)-(150,15): both columns are equally tight (dis=50), so Python
// keeps the first (C0). The box lands in exactly ONE cell.
func TestFillCellTextFromBoxes_BoxOverlappingTwoCells_SingleAssignment(t *testing.T) {
cells := []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 100, Y1: 50},
{X0: 100, Y0: 0, X1: 200, Y1: 50},
}
boxes := []pdf.TextBox{
{X0: 50, X1: 150, Top: 5, Bottom: 15, Text: "shared"},
}
FillCellTextFromBoxes(cells, boxes)
count := 0
for _, c := range cells {
if c.Text == "shared" {
count++
}
}
if count != 1 {
t.Errorf("regression #1: box duplicated into %d cells; Python assigns it to exactly ONE cell", count)
}
}
// TestFillCellTextFromBoxes_PythonAssignsGoRejects_2DThreshold_FIXED is the
// regression test for go_bug #2. Python's 0.3 tests the 1-D VERTICAL overlap
// with the (full-width) row, and the column is chosen by
// find_horizontally_tightest_fit (no threshold). Go must match: a box whose
// row-vertical overlap is >= 0.3 and that is tightest to a column must be
// assigned, even if its 2-D overlap with that single cell is < 0.3.
//
// Grid: row R0 Y=(0,30); col C0 X=(0,50), col C1 X=(50,200).
// cell (R0,C0) = (0,0)-(50,30); cell (R0,C1) = (50,0)-(200,30).
// box (0,0)-(100,100): vertical overlap with R0 = 30/100 = 30% (≥0.3 row
// match); horizontally tightest to C0 (left edge aligns). Python → (R0,C0).
func TestFillCellTextFromBoxes_PythonAssignsGoRejects_2DThreshold_FIXED(t *testing.T) {
cells := []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 50, Y1: 30}, // (R0,C0)
{X0: 50, Y0: 0, X1: 200, Y1: 30}, // (R0,C1)
}
boxes := []pdf.TextBox{
{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: "tall"},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "tall" {
t.Errorf("regression #2: Go dropped the box (cell0=%q); Python fills (R0,C0) via row-vertical-0.3 ∩ column-tightest", cells[0].Text)
}
if cells[1].Text != "" {
t.Errorf("regression #2: box leaked into (R0,C1)=%q; should land only in (R0,C0)", cells[1].Text)
}
}
// TestFillCellTextFromBoxes_EqualBoxRatioSingleAssignment is the regression
// test for go_bug #3. Go must no longer inject a box into ALL cells it
// overlaps; it assigns the box to the single tightest column, so a box
// overlapping a small cell A and a large cell B lands in ONLY A.
//
// Small cell A (0,0)-(50,25) area 1250; large cell B (50,0)-(200,50) area 7500.
// box (0,0)-(100,25): overlaps both, but is tightest to A (left edge aligns,
// dis=0 vs B's dis=50). Python keeps only A; Go must too.
func TestFillCellTextFromBoxes_EqualBoxRatioSingleAssignment(t *testing.T) {
cells := []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 50, Y1: 25}, // A (small)
{X0: 50, Y0: 0, X1: 200, Y1: 50}, // B (large)
}
boxes := []pdf.TextBox{
{X0: 0, X1: 100, Top: 0, Bottom: 25, Text: "wide"},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "wide" {
t.Errorf("regression #3: small cell A should contain 'wide', got %q", cells[0].Text)
}
if cells[1].Text == "wide" {
t.Errorf("regression #3: box leaked into large cell B; Python keeps only small cell A")
}
}
// TestFillCellTextFromBoxes_RowSelectionIgnores2DCellOverlap strengthens the
// go_bug #2 fix on a 2×2 grid. A tall box overlaps ONLY row R0 vertically
// (≥0.3) but its 2-D intersection with every single cell is < 0.3, so the old
// 2-D 0.3 filter dropped it entirely. The row/column selection must still fill
// (R0,C0) — the tightest column in the matched row — and leave R1 empty.
func TestFillCellTextFromBoxes_RowSelectionIgnores2DCellOverlap(t *testing.T) {
cells := []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 50, Y1: 30}, // (R0,C0)
{X0: 50, Y0: 0, X1: 200, Y1: 30}, // (R0,C1)
{X0: 0, Y0: 30, X1: 50, Y1: 60}, // (R1,C0)
{X0: 50, Y0: 30, X1: 200, Y1: 60}, // (R1,C1)
}
boxes := []pdf.TextBox{
{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: "tall"}, // vertical overlap with R0 = 30/100 = 0.3
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "tall" {
t.Errorf("regression #2 (2x2): (R0,C0) should be 'tall', got %q", cells[0].Text)
}
if cells[1].Text != "" || cells[2].Text != "" || cells[3].Text != "" {
t.Errorf("regression #2 (2x2): box leaked to R0C1/R1 = %q/%q/%q; must stay only in (R0,C0)",
cells[1].Text, cells[2].Text, cells[3].Text)
}
}
// TestFillCellTextFromBoxes_RowSelectionTiebreak guards the row-selection
// _ov tie-break (inter/rowArea) in FillCellTextFromBoxes. When a box overlaps
// two rows with the SAME vertical-overlap ratio (ov), the row with the higher
// _ov (smaller row area for equal vertical overlap) wins — mirroring Python's
// (ov, _ov) ordering in find_overlapped_with_threshold.
//
// row0 y[0,40] (height 40), row1 y[40,100] (height 60). Box y[0,80] (height 80)
// overlaps BOTH rows by 40 → ov = 40/80 = 0.5 for each. _ov: row0 = 40/40 = 1.0,
// row1 = 40/60 ≈ 0.667. Python keeps row0; Go must too.
func TestFillCellTextFromBoxes_RowSelectionTiebreak(t *testing.T) {
cells := []pdf.TSRCell{
{X0: 0, Y0: 0, X1: 100, Y1: 40}, // row0 (higher _ov)
{X0: 0, Y0: 40, X1: 100, Y1: 100}, // row1 (lower _ov)
}
boxes := []pdf.TextBox{
{X0: 0, X1: 100, Top: 0, Bottom: 80, Text: "row0"},
}
FillCellTextFromBoxes(cells, boxes)
if cells[0].Text != "row0" {
t.Errorf("row tiebreak: box should land in row0 (higher _ov), got cell0=%q", cells[0].Text)
}
if cells[1].Text != "" {
t.Errorf("row tiebreak: box leaked into row1 (lower _ov), got %q", cells[1].Text)
}
}