fix: align pdf table structure coordinates (#17016)

### What problem does this PR solve?

Table structure recognition rows, columns, headers, and spans are
produced in cropped table image coordinates, while OCR boxes are matched
later in page-cumulative coordinates. Comparing those boxes without
normalization can skip or misassign table row and column metadata.

Closes #16992.

### What is changed?

- Map TSR components from cropped or rotated table-image coordinates
back into page-cumulative coordinates before matching OCR boxes.
- Reuse one inverse rotation transform for rotated OCR boxes and TSR
components.
- Keep TSR layout ids in the same `table-N` form used by table OCR
boxes.
- Sort columns by mapped page x-coordinate after coordinate
normalization.
- Add focused unit coverage for page offsets, zoom scaling, and
90/180/270 degree rotated tables.

### Type of change

- [x] Bug fix
- [x] Test coverage

### How has this been tested?

- `uv run --group test pytest
test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py -q`
- `uv run --no-sync --group test pytest
--confcutdir=test/unit_test/deepdoc/parser
test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py -q`
- `uv run ruff check deepdoc/parser/pdf_parser.py
test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py`
- `uv run --no-sync python -m py_compile deepdoc/parser/pdf_parser.py
test/unit_test/deepdoc/parser/test_pdf_parser_table_coordinates.py`
- `git diff --check`

A later dependency-sync attempt was blocked while resolving the
`en-core-web-sm` wheel from GitHub, and the repository-level unit-test
conftest can try to download missing NLTK `wordnet` data when it is not
already present locally. The focused parser test above does not require
that data fixture.

---------

Co-authored-by: zq <zhouquan1511@163.com>
This commit is contained in:
zcxGGmu
2026-07-18 18:22:33 +08:00
committed by GitHub
parent 10c00a9614
commit a7b193d77b
7 changed files with 511 additions and 38 deletions

View File

@@ -535,6 +535,29 @@ func MapRotatedPointToOriginal(x, y float64, angle int, origW, origH int) (float
}
}
// MapRotatedRectToOriginal maps a rotated-image rectangle back into original
// image coordinates and normalizes the resulting bounds. For 90°/270° rotation,
// mapping only two diagonal corners can invert X/Y bounds; mapping all four
// corners preserves the enclosing rectangle.
func MapRotatedRectToOriginal(x0, y0, x1, y1 float64, angle int, origW, origH int) (float64, float64, float64, float64) {
points := [][2]float64{
{x0, y0},
{x1, y0},
{x0, y1},
{x1, y1},
}
minX, minY := math.Inf(1), math.Inf(1)
maxX, maxY := math.Inf(-1), math.Inf(-1)
for _, p := range points {
x, y := MapRotatedPointToOriginal(p[0], p[1], angle, origW, origH)
minX = math.Min(minX, x)
minY = math.Min(minY, y)
maxX = math.Max(maxX, x)
maxY = math.Max(maxY, y)
}
return minX, minY, maxX, maxY
}
// CropImageRegion crops a pdf.DLARegion from an image with a 3% margin
// (matching Python's _table_transformer_job: w*0.03, h*0.03).
func CropImageRegion(img image.Image, r pdf.DLARegion) (image.Image, error) {

View File

@@ -245,6 +245,33 @@ func TestMapRotatedPointToOriginal(t *testing.T) {
}
}
func TestMapRotatedRectToOriginal_NormalizesBounds(t *testing.T) {
tests := []struct {
name string
angle int
wantX0, wantY0 float64
wantX1, wantY1 float64
}{
{name: "zero", angle: 0, wantX0: 10, wantY0: 20, wantX1: 60, wantY1: 80},
{name: "ninety", angle: 90, wantX0: 20, wantY0: 39, wantX1: 80, wantY1: 89},
{name: "one-eighty", angle: 180, wantX0: 139, wantY0: 19, wantX1: 189, wantY1: 79},
{name: "two-seventy", angle: 270, wantX0: 119, wantY0: 10, wantX1: 179, wantY1: 60},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotX0, gotY0, gotX1, gotY1 := MapRotatedRectToOriginal(10, 20, 60, 80, tt.angle, 200, 100)
if gotX0 != tt.wantX0 || gotY0 != tt.wantY0 || gotX1 != tt.wantX1 || gotY1 != tt.wantY1 {
t.Errorf("got (%.0f,%.0f,%.0f,%.0f), want (%.0f,%.0f,%.0f,%.0f)",
gotX0, gotY0, gotX1, gotY1, tt.wantX0, tt.wantY0, tt.wantX1, tt.wantY1)
}
if gotX0 > gotX1 || gotY0 > gotY1 {
t.Fatalf("mapped rectangle is inverted: (%.0f,%.0f,%.0f,%.0f)", gotX0, gotY0, gotX1, gotY1)
}
})
}
}
func colorEqual(a, b color.Color) bool {
ar, ag, ab, aa := a.RGBA()
br, bg, bb, ba := b.RGBA()