fix(pdf): drop text boxes only when replaced by a table HTML (#18363)

## Problem
In the Go PDF pipeline, `processTablesWithReplacements`
(`internal/deepdoc/parser/pdf/table/table_post.go`) marked **every** box
overlapping a DLA table region for removal, then `insertTableBoxes`
re-inserted an HTML box only when `buildTableHTMLs` produced one.

When DLA over-labels a **text** box as a table but TSR produces **no
cells** (so `buildTableHTMLs` skips the table and `htmls[ti]` is unset),
the box was removed with nothing re-inserted — the original text was
**silently dropped**.
This commit is contained in:
Jack
2026-08-17 17:07:56 +08:00
committed by GitHub
parent 6f0fff5b77
commit bd01c78d2b
2 changed files with 135 additions and 3 deletions

View File

@@ -318,11 +318,19 @@ func processTablesWithReplacements(
removeSet map[int]bool,
replacements []replacement,
) []pdf.TextBox {
for _, r := range replacements {
removeSet[r.boxIdx] = true
}
anchors := findTableAnchorsWithReplacements(boxes, tables, replacements)
htmls := buildTableHTMLs(boxes, tables)
// A box is removed only when it is actually replaced by a table HTML box.
// A DLA-tagged region that produced no table HTML (no cells, or a
// degenerate table whose ConstructTable returned "") keeps its original
// text as prose instead of being silently dropped. Marking per replacement
// (rather than un-marking empties) also avoids duplicating content when one
// box is covered by both an empty and a real table.
for _, r := range replacements {
if html, ok := htmls[r.tableIdx]; ok && html != "" {
removeSet[r.boxIdx] = true
}
}
anchorList := buildAndSortAnchors(anchors)
return insertTableBoxes(boxes, tables, removeSet, anchorList, htmls)
}

View File

@@ -800,3 +800,127 @@ func TestExtractTableAndReplace_MergeTablesAcrossPages(t *testing.T) {
t.Error("non-table boxes should be preserved in original order")
}
}
// TestProcessTablesWithReplacements_KeepsTextWhenNoCells pins the contract that
// a text box mislabeled LayoutTypeTable by DLA is kept as prose when TSR yields
// no cells (HTML missing), instead of being silently dropped.
func TestProcessTablesWithReplacements_KeepsTextWhenNoCells(t *testing.T) {
const body = "REFERENCE [1] body text that must not vanish"
boxes := []pdf.TextBox{
{Text: "para one", PageNumber: 1, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 100, Top: 0, Bottom: 10},
{Text: body, PageNumber: 1, LayoutType: pdf.LayoutTypeTable, X0: 0, X1: 100, Top: 12, Bottom: 22},
{Text: "para three", PageNumber: 1, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 100, Top: 24, Bottom: 34},
}
// DLA table region but no TSR cells → buildTableHTMLs skips, htmls[0] unset.
tables := []pdf.TableItem{{}}
removeSet := map[int]bool{}
replacements := []replacement{{tableIdx: 0, boxIdx: 1}}
out := processTablesWithReplacements(boxes, tables, removeSet, replacements)
expectedTexts := []string{"para one", body, "para three"}
if len(out) != len(expectedTexts) {
t.Fatalf("over-labeled 'table' box was dropped: want %d boxes, got %d: %+v", len(expectedTexts), len(out), out)
}
for i, want := range expectedTexts {
if out[i].Text != want {
t.Errorf("output box %d: want text %q, got %q (%+v)", i, want, out[i].Text, out)
}
}
}
// TestProcessTablesWithReplacements_NoDupWhenCoveredByEmptyAndRealTable pins the
// boundary where a single box is covered by TWO replacements — one whose table
// has no cells (empty HTML) and one whose table has real cells (non-empty HTML)
// — so the box must NOT be kept while the real table's HTML is also inserted
// (that would duplicate the content). The box must be removed and only the real
// table's HTML inserted.
func TestProcessTablesWithReplacements_NoDupWhenCoveredByEmptyAndRealTable(t *testing.T) {
const body = "SHARED BODY spanning two table regions"
boxes := []pdf.TextBox{
{Text: "para one", PageNumber: 0, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 100, Top: 0, Bottom: 10},
{Text: body, PageNumber: 0, LayoutType: pdf.LayoutTypeTable, X0: 0, X1: 400, Top: 12, Bottom: 100},
{Text: "para three", PageNumber: 0, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 100, Top: 110, Bottom: 120},
}
// Both tables occupy the same region as box 1.
region := pdf.Position{PageNumbers: []int{0}, Left: 0, Right: 400, Top: 12, Bottom: 100}
tables := []pdf.TableItem{
{
// DLA region but no TSR cells → buildTableHTMLs skips, htmls[0] unset.
Positions: []pdf.Position{region},
RegionLeft: 0, RegionRight: 400, RegionTop: 12, RegionBottom: 100,
},
{
// Real table with cells → htmls[1] non-empty.
Positions: []pdf.Position{region},
RegionLeft: 0, RegionRight: 400, RegionTop: 12, RegionBottom: 100,
Scale: 1.0,
Cells: []pdf.TSRCell{{Text: "cell1"}},
},
}
removeSet := map[int]bool{}
// box 1 is the replacement target for BOTH tables.
replacements := []replacement{{tableIdx: 0, boxIdx: 1}, {tableIdx: 1, boxIdx: 1}}
out := processTablesWithReplacements(boxes, tables, removeSet, replacements)
// The box must be removed (replaced by the real table's HTML), not kept
// alongside it — otherwise the content is duplicated.
for _, b := range out {
if b.Text == body {
t.Fatalf("box covered by a real table was kept, duplicating content: %+v", out)
}
}
// Exactly one table HTML box (table 1) should be present.
var htmlCount int
for _, b := range out {
if b.LayoutType == pdf.LayoutTypeTable && strings.Contains(b.Text, "cell1") {
htmlCount++
}
}
if htmlCount != 1 {
t.Errorf("expected exactly one table HTML box, got %d: %+v", htmlCount, out)
}
if len(out) != 3 {
t.Errorf("expected 3 boxes (2 text + 1 HTML), got %d: %+v", len(out), out)
}
}
// TestProcessTablesWithReplacements_KeepsTextWhenCellsButEmptyHTML guards the
// defensive case where a table has cells but ConstructTable returns an empty
// string (e.g. degenerate/orphaned cells). The box must be kept as prose rather
// than silently dropped. This is reachable when the target box is NOT collected
// as a table box (so the Y/X fallback in ConstructTable cannot recover text),
// e.g. a DLA-overlabeled text box whose layout type was not upgraded to Table.
func TestProcessTablesWithReplacements_KeepsTextWhenCellsButEmptyHTML(t *testing.T) {
const body = "PROSE that must survive a degenerate table"
boxes := []pdf.TextBox{
{Text: "para one", PageNumber: 0, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 100, Top: 0, Bottom: 10},
{Text: body, PageNumber: 0, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 400, Top: 12, Bottom: 100},
{Text: "para three", PageNumber: 0, LayoutType: pdf.LayoutTypeText, X0: 0, X1: 100, Top: 110, Bottom: 120},
}
tables := []pdf.TableItem{
{
// Cells present but without text, and the target box is LayoutTypeText
// (not collected as a table box) → ConstructTable yields "" → htmls[0]="".
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 400, Top: 12, Bottom: 100}},
RegionLeft: 0, RegionRight: 400, RegionTop: 12, RegionBottom: 100,
Scale: 1.0,
Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30}},
},
}
removeSet := map[int]bool{}
replacements := []replacement{{tableIdx: 0, boxIdx: 1}}
out := processTablesWithReplacements(boxes, tables, removeSet, replacements)
if len(out) != 3 {
t.Fatalf("degenerate-table box was dropped: want 3 boxes, got %d: %+v", len(out), out)
}
for _, b := range out {
if b.Text == body {
return // original text preserved
}
}
t.Errorf("degenerate-table box text missing from output: %+v", out)
}