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

@@ -376,8 +376,9 @@ setup_cgo_env() {
;;
Darwin)
export CGO_LDFLAGS="$CGO_LDFLAGS \
-framework CoreFoundation -framework Security \
-framework SystemConfiguration -liconv -lresolv"
-framework CoreGraphics -framework CoreFoundation \
-framework Security -framework SystemConfiguration \
-liconv -lresolv -lc++"
;;
esac

View File

@@ -455,6 +455,18 @@ class RAGFlowPdfParser:
return best_angle, best_img, results
@staticmethod
def _map_clockwise_rotated_point_to_original(x, y, angle, width, height):
if angle == 0:
return x, y
if angle == 90:
return y, height - x
if angle == 180:
return width - x, height - y
if angle == 270:
return width - y, x
return x, y
def _table_transformer_job(self, ZM, auto_rotate=True):
"""
Process table structure recognition.
@@ -480,7 +492,7 @@ class RAGFlowPdfParser:
assert len(self.page_layout) == len(self.page_images)
# Collect layout info for all tables
table_layouts = [] # [(page, table_layout, left, top, right, bott), ...]
table_layouts = []
table_index = 0
for p, tbls in enumerate(self.page_layout): # for page
@@ -488,16 +500,17 @@ class RAGFlowPdfParser:
tbcnt.append(len(tbls))
if not tbls:
continue
for tb in tbls: # for table
for page_table_index, tb in enumerate(tbls): # for table
left, top, right, bott = tb["x0"] - MARGIN, tb["top"] - MARGIN, tb["x1"] + MARGIN, tb["bottom"] + MARGIN
left *= ZM
top *= ZM
right *= ZM
bott *= ZM
pos.append((left, top, p, table_index)) # Add page and table_index
layoutno = f"table-{page_table_index}"
pos.append((left, top, p, table_index, layoutno))
# Record table layout info
table_layouts.append({"page": p, "table_index": table_index, "layout": tb, "coords": (left, top, right, bott)})
table_layouts.append({"page": p, "table_index": table_index, "layoutno": layoutno, "layout": tb, "coords": (left, top, right, bott)})
# Crop table image
table_img = self.page_images[p].crop((left, top, right, bott))
@@ -538,7 +551,28 @@ class RAGFlowPdfParser:
if auto_rotate:
self._ocr_rotated_tables(ZM, table_layouts, recos, tbcnt)
# Process TSR results (keep original logic but handle rotated coordinates)
def _map_tsr_component_to_page_space(component, table_pos):
crop_left, crop_top, page, table_index, _ = table_pos
rotation_info = self.table_rotations.get(table_index, {})
angle = rotation_info.get("best_angle", 0)
original_pos = rotation_info.get("original_pos", (crop_left, crop_top, crop_left, crop_top))
width = original_pos[2] - original_pos[0]
height = original_pos[3] - original_pos[1]
points = [
(component["x0_rotated"], component["top_rotated"]),
(component["x1_rotated"], component["top_rotated"]),
(component["x0_rotated"], component["bottom_rotated"]),
(component["x1_rotated"], component["bottom_rotated"]),
]
mapped = [self._map_clockwise_rotated_point_to_original(x, y, angle, width, height) for x, y in points]
xs = [p[0] for p in mapped]
ys = [p[1] for p in mapped]
component["x0"] = min(xs) / ZM + crop_left / ZM
component["x1"] = max(xs) / ZM + crop_left / ZM
component["top"] = min(ys) / ZM + crop_top / ZM + self.page_cum_height[page]
component["bottom"] = max(ys) / ZM + crop_top / ZM + self.page_cum_height[page]
# Process TSR results and align structure boxes with page-cumulative OCR boxes.
tbcnt = np.cumsum(tbcnt)
for i in range(len(tbcnt) - 1): # for page
pg = []
@@ -551,11 +585,10 @@ class RAGFlowPdfParser:
it["top_rotated"] = it["top"]
it["bottom_rotated"] = it["bottom"]
# For rotated tables, coordinate transformation to page space requires rotation
# Since we already re-OCR'd on rotated image, keep simple processing here
it["pn"] = poss[j][2] # page number
it["layoutno"] = j
it["layoutno"] = poss[j][4]
it["table_index"] = poss[j][3] # table index
_map_tsr_component_to_page_space(it, poss[j])
pg.append(it)
self.tb_cpns.extend(pg)
@@ -568,7 +601,7 @@ class RAGFlowPdfParser:
headers = gather(r".*header$")
rows = gather(r".* (row|header)")
spans = gather(r".*spanning")
clmns = sorted([r for r in self.tb_cpns if re.match(r"table column$", r["label"])], key=lambda x: (x["pn"], x["layoutno"], x["x0_rotated"] if "x0_rotated" in x else x["x0"]))
clmns = sorted([r for r in self.tb_cpns if re.match(r"table column$", r["label"])], key=lambda x: (x["pn"], x["layoutno"], x["x0"]))
clmns = Recognizer.layouts_cleanup(self.boxes, clmns, 5, 0.5)
for b in self.boxes:
@@ -648,28 +681,12 @@ class RAGFlowPdfParser:
insert_at += 1
return insert_at
def _map_rotated_point(x, y, angle, width, height):
# Map a point from rotated image coords back to original image coords.
if angle == 0:
return x, y
if angle == 90:
# clockwise 90: original->rotated (x', y') = (y, width - x)
# inverse:
return width - y, x
if angle == 180:
return width - x, height - y
if angle == 270:
# clockwise 270: original->rotated (x', y') = (height - y, x)
# inverse:
return y, height - x
return x, y
def _insert_ocr_boxes(ocr_results, page_index, table_x0, table_top, insert_at, table_index, best_angle, table_w_px, table_h_px):
def _insert_ocr_boxes(ocr_results, page_index, crop_left, crop_top, insert_at, table_index, layoutno, best_angle, table_w_px, table_h_px):
added = 0
for bbox, (text, conf) in ocr_results:
if conf < 0.5:
continue
mapped = [_map_rotated_point(p[0], p[1], best_angle, table_w_px, table_h_px) for p in bbox]
mapped = [self._map_clockwise_rotated_point_to_original(p[0], p[1], best_angle, table_w_px, table_h_px) for p in bbox]
x_coords = [p[0] for p in mapped]
y_coords = [p[1] for p in mapped]
box_x0 = min(x_coords) / ZM
@@ -678,13 +695,13 @@ class RAGFlowPdfParser:
box_bottom = max(y_coords) / ZM
new_box = {
"text": text,
"x0": box_x0 + table_x0,
"x1": box_x1 + table_x0,
"top": box_top + table_top + self.page_cum_height[page_index],
"bottom": box_bottom + table_top + self.page_cum_height[page_index],
"x0": box_x0 + crop_left / ZM,
"x1": box_x1 + crop_left / ZM,
"top": box_top + crop_top / ZM + self.page_cum_height[page_index],
"bottom": box_bottom + crop_top / ZM + self.page_cum_height[page_index],
"page_number": page_index + self.page_from,
"layout_type": "table",
"layoutno": f"table-{table_index}",
"layoutno": layoutno,
"_rotated": True,
"_rotation_angle": best_angle,
"_table_index": table_index,
@@ -702,6 +719,7 @@ class RAGFlowPdfParser:
table_index = tbl_info["table_index"]
page = tbl_info["page"]
layout = tbl_info["layout"]
layoutno = tbl_info["layoutno"]
left, top, right, bott = tbl_info["coords"]
rotation_info = self.table_rotations.get(table_index, {})
@@ -738,10 +756,11 @@ class RAGFlowPdfParser:
added = _insert_ocr_boxes(
ocr_results,
page,
table_x0,
table_top,
left,
top,
insert_at,
table_index,
layoutno,
best_angle,
table_w_px,
table_h_px,

View File

@@ -108,8 +108,8 @@ func (p *Parser) processOneTable(ctx context.Context, pageImg image.Image, boxes
p.ocrTableCells(ctx, cells, tsrImg, docAnalyzer)
}
for i := range cells {
cells[i].X0, cells[i].Y0 = util.MapRotatedPointToOriginal(cells[i].X0, cells[i].Y0, bestAngle, origW, origH)
cells[i].X1, cells[i].Y1 = util.MapRotatedPointToOriginal(cells[i].X1, cells[i].Y1, bestAngle, origW, origH)
cells[i].X0, cells[i].Y0, cells[i].X1, cells[i].Y1 = util.MapRotatedRectToOriginal(
cells[i].X0, cells[i].Y0, cells[i].X1, cells[i].Y1, bestAngle, origW, origH)
}
}
firstCellTop := 1e9

View File

@@ -0,0 +1,97 @@
package pdf
import (
"context"
"image"
"testing"
tbl "ragflow/internal/deepdoc/parser/pdf/table"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
)
type orientationScoringDoc struct{}
func (d *orientationScoringDoc) DLA(_ context.Context, _ image.Image) ([]pdf.DLARegion, error) {
return nil, nil
}
func (d *orientationScoringDoc) TSR(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) {
return nil, nil
}
func (d *orientationScoringDoc) OCRDetect(_ context.Context, img image.Image) ([]pdf.OCRBox, error) {
regions := 1
if img.Bounds().Dy() > img.Bounds().Dx() {
regions = 5
}
boxes := make([]pdf.OCRBox, regions)
for i := range boxes {
x0 := float64((i + 1) * 10)
boxes[i] = pdf.OCRBox{
X0: x0, Y0: 10,
X1: x0 + 5, Y1: 10,
X2: x0 + 5, Y2: 30,
X3: x0, Y3: 30,
}
}
return boxes, nil
}
func (d *orientationScoringDoc) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) {
return nil, nil
}
func (d *orientationScoringDoc) Health() bool { return true }
type staticTableBuilder struct {
cells []pdf.TSRCell
}
func (b *staticTableBuilder) Name() string { return "static" }
func (b *staticTableBuilder) DetectCells(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) {
return append([]pdf.TSRCell(nil), b.cells...), nil
}
func (b *staticTableBuilder) GroupCells(cells []pdf.TSRCell) [][]pdf.TSRCell {
if len(cells) == 0 {
return nil
}
return [][]pdf.TSRCell{{cells[0]}}
}
func TestProcessOneTable_AutoRotateNormalizesCellBounds(t *testing.T) {
autoRotate := true
cfg := pdf.DefaultParserConfig()
cfg.AutoRotateTables = &autoRotate
cfg.SkipOCR = true
p := NewParser(cfg)
pageImg := image.NewRGBA(image.Rect(0, 0, 320, 220))
boxes := []pdf.TextBox{
{X0: 10, X1: 60, Top: 10, Bottom: 30, Text: "cell", LayoutType: pdf.LayoutTypeTable},
}
match := tbl.TableMatch{
Region: pdf.DLARegion{X0: 10, Y0: 10, X1: 210, Y1: 110, Label: pdf.LayoutTypeTable},
BoxIdx: []int{0},
}
builder := &staticTableBuilder{
cells: []pdf.TSRCell{
{X0: 10, Y0: 20, X1: 60, Y1: 80, Label: "table row"},
},
}
item := p.processOneTable(context.Background(), pageImg, boxes, 0, &orientationScoringDoc{}, builder, match, pdf.DlaScale)
if len(item.Cells) != 1 {
t.Fatalf("cells = %d, want 1", len(item.Cells))
}
got := item.Cells[0]
if got.X0 != 20 || got.Y0 != 45 || got.X1 != 80 || got.Y1 != 95 {
t.Errorf("cell bounds = (%.0f,%.0f,%.0f,%.0f), want (20,45,80,95)",
got.X0, got.Y0, got.X1, got.Y1)
}
if got.X0 > got.X1 || got.Y0 > got.Y1 {
t.Fatalf("cell bounds are inverted: (%.0f,%.0f,%.0f,%.0f)", got.X0, got.Y0, got.X1, got.Y1)
}
}

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()

View File

@@ -0,0 +1,306 @@
import importlib.util
import sys
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
def _load_pdf_parser(monkeypatch):
repo_root = Path(__file__).resolve().parents[4]
_stub_module(monkeypatch, "pdfplumber")
_stub_module(monkeypatch, "pypdf", PdfReader=object)
_stub_module(monkeypatch, "huggingface_hub", snapshot_download=lambda **_kwargs: "")
_stub_module(monkeypatch, "xgboost", Booster=object)
_stub_module(monkeypatch, "sklearn")
_stub_module(monkeypatch, "sklearn.cluster", KMeans=object)
_stub_module(monkeypatch, "sklearn.metrics", silhouette_score=lambda *_args, **_kwargs: 0)
common_mod = _stub_module(monkeypatch, "common")
common_mod.__path__ = [str(repo_root / "common")]
_stub_module(monkeypatch, "common.constants", MAXIMUM_PAGE_NUMBER=1024)
_stub_module(monkeypatch, "common.file_utils", get_project_base_directory=lambda: str(repo_root))
_stub_module(monkeypatch, "common.settings", PARALLEL_DEVICES=1)
_stub_module(monkeypatch, "common.misc_utils", thread_pool_exec=lambda fn, *args, **kwargs: fn(*args, **kwargs))
deepdoc_mod = _stub_module(monkeypatch, "deepdoc")
deepdoc_mod.__path__ = [str(repo_root / "deepdoc")]
parser_mod = _stub_module(monkeypatch, "deepdoc.parser")
parser_mod.__path__ = [str(repo_root / "deepdoc" / "parser")]
_stub_module(monkeypatch, "deepdoc.parser.utils", extract_pdf_outlines=lambda *_args, **_kwargs: [])
_stub_module(
monkeypatch,
"deepdoc.vision",
OCR=object,
AscendLayoutRecognizer=object,
LayoutRecognizer=object,
Recognizer=_FakeRecognizer,
TableStructureRecognizer=object,
)
rag_mod = _stub_module(monkeypatch, "rag")
rag_mod.__path__ = [str(repo_root / "rag")]
_stub_module(monkeypatch, "rag.nlp", rag_tokenizer=SimpleNamespace(tokenize=lambda text: text))
prompts_mod = _stub_module(monkeypatch, "rag.prompts")
prompts_mod.__path__ = [str(repo_root / "rag" / "prompts")]
_stub_module(monkeypatch, "rag.prompts.generator", vision_llm_describe_prompt="")
module_name = "test_pdf_parser_unit_module"
module_path = repo_root / "deepdoc" / "parser" / "pdf_parser.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, module_name, module)
spec.loader.exec_module(module)
return module
def _stub_module(monkeypatch, name, **attrs):
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
monkeypatch.setitem(sys.modules, name, module)
return module
class _FakeRecognizer:
@staticmethod
def sort_Y_firstly(arr, _threshold):
return sorted(arr, key=lambda item: (item["top"], item["x0"]))
@staticmethod
def layouts_cleanup(_boxes, layouts, _far=2, _thr=0.7):
return layouts
@staticmethod
def overlapped_area(a, b, ratio=True):
x0 = max(a["x0"], b["x0"])
x1 = min(a["x1"], b["x1"])
top = max(a["top"], b["top"])
bottom = min(a["bottom"], b["bottom"])
if x1 <= x0 or bottom <= top:
return 0
area = (x1 - x0) * (bottom - top)
if ratio:
area /= (a["x1"] - a["x0"]) * (a["bottom"] - a["top"])
return area
@staticmethod
def find_overlapped_with_threshold(box, boxes, thr=0.3):
best_i = None
best = thr
best_reverse = 0
for i, candidate in enumerate(boxes):
overlap = _FakeRecognizer.overlapped_area(box, candidate)
reverse = _FakeRecognizer.overlapped_area(candidate, box)
if (overlap, reverse) < (best, best_reverse):
continue
best_i = i
best = overlap
best_reverse = reverse
return best_i
@staticmethod
def find_horizontally_tightest_fit(box, boxes):
min_distance = 1000000
min_i = None
for i, candidate in enumerate(boxes):
if box.get("layoutno", "0") != candidate.get("layoutno", "0"):
continue
distance = min(
abs(box["x0"] - candidate["x0"]),
abs(box["x1"] - candidate["x1"]),
abs(box["x0"] + box["x1"] - candidate["x1"] - candidate["x0"]) / 2,
)
if distance < min_distance:
min_distance = distance
min_i = i
return min_i
class _FakeImage:
def __init__(self, width=300, height=400):
self.size = (width, height)
def crop(self, box):
left, top, right, bottom = box
return _FakeImage(right - left, bottom - top)
def __array__(self, dtype=None):
import numpy as np
return np.zeros((int(self.size[1]), int(self.size[0]), 3), dtype=dtype or np.uint8)
class _FakeTableDetector:
def __init__(self, zoom, angle=0, crop_width=140, crop_height=90):
self.zoom = zoom
self.angle = angle
self.crop_width = crop_width * zoom
self.crop_height = crop_height * zoom
def __call__(self, _imgs):
z = self.zoom
rows = [
_scale_bbox((15, 20, 125, 35), z),
_scale_bbox((15, 50, 125, 65), z),
]
columns = [
_scale_bbox((15, 20, 55, 65), z),
_scale_bbox((80, 20, 125, 65), z),
]
rows = [_rotate_bbox_clockwise(row, self.angle, self.crop_width, self.crop_height) for row in rows]
columns = [_rotate_bbox_clockwise(column, self.angle, self.crop_width, self.crop_height) for column in columns]
return [
[
_component("table row", rows[0]),
_component("table row", rows[1]),
_component("table column", columns[0]),
_component("table column", columns[1]),
]
]
class _FakeOcr:
def __init__(self, angle, crop_width=140, crop_height=90):
self.angle = angle
self.crop_width = crop_width
self.crop_height = crop_height
def __call__(self, _img_array):
boxes = [
("A1", (15, 20, 55, 35)),
("B2", (80, 50, 125, 65)),
]
return [
(
_bbox_points(_rotate_bbox_clockwise(bbox, self.angle, self.crop_width, self.crop_height)),
(text, 0.99),
)
for text, bbox in boxes
]
def _component(label, bbox):
x0, top, x1, bottom = bbox
return {"label": label, "x0": x0, "x1": x1, "top": top, "bottom": bottom}
def _scale_bbox(bbox, zoom):
x0, top, x1, bottom = bbox
return x0 * zoom, top * zoom, x1 * zoom, bottom * zoom
def _bbox_points(bbox):
x0, top, x1, bottom = bbox
return [(x0, top), (x1, top), (x1, bottom), (x0, bottom)]
def _rotate_bbox_clockwise(bbox, angle, width, height):
points = [_rotate_point_clockwise(x, y, angle, width, height) for x, y in _bbox_points(bbox)]
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return min(xs), min(ys), max(xs), max(ys)
def _rotate_point_clockwise(x, y, angle, width, height):
if angle == 0:
return x, y
if angle == 90:
return height - y, x
if angle == 180:
return width - x, height - y
if angle == 270:
return y, width - x
raise ValueError(f"unsupported angle: {angle}")
@pytest.mark.p1
@pytest.mark.parametrize(("page_index", "page_offset", "zoom"), [(0, 0, 1), (1, 500, 2)])
def test_table_transformer_maps_tsr_crop_coordinates_to_page_coordinates(monkeypatch, page_index, page_offset, zoom):
module = _load_pdf_parser(monkeypatch)
parser = module.RAGFlowPdfParser.__new__(module.RAGFlowPdfParser)
parser.page_from = 0
parser.page_cum_height = [0] if page_index == 0 else [0, page_offset]
parser.page_images = [_FakeImage() for _ in range(page_index + 1)]
parser.page_layout = [[] for _ in range(page_index + 1)]
parser.page_layout[page_index] = [{"type": "table", "x0": 100, "top": 200, "x1": 220, "bottom": 270}]
parser.tbl_det = _FakeTableDetector(zoom)
parser.boxes = [
{
"text": "A1",
"layout_type": "table",
"layoutno": "table-0",
"page_number": page_index,
"x0": 105,
"x1": 145,
"top": page_offset + 210,
"bottom": page_offset + 225,
},
{
"text": "B2",
"layout_type": "table",
"layoutno": "table-0",
"page_number": page_index,
"x0": 170,
"x1": 215,
"top": page_offset + 240,
"bottom": page_offset + 255,
},
]
parser._table_transformer_job(ZM=zoom, auto_rotate=False)
assert [box["R"] for box in parser.boxes] == [0, 1]
assert [box["R_top"] for box in parser.boxes] == [page_offset + 210, page_offset + 240]
assert [box["C"] for box in parser.boxes] == [0, 1]
assert [box["C_left"] for box in parser.boxes] == [105, 170]
@pytest.mark.p1
@pytest.mark.parametrize("angle", [90, 180, 270])
def test_table_transformer_keeps_rotated_ocr_and_tsr_coordinates_aligned(monkeypatch, angle):
module = _load_pdf_parser(monkeypatch)
parser = module.RAGFlowPdfParser.__new__(module.RAGFlowPdfParser)
parser.page_from = 0
parser.page_cum_height = [0]
parser.page_images = [_FakeImage()]
parser.page_layout = [[{"type": "table", "x0": 100, "top": 200, "x1": 220, "bottom": 270}]]
parser.tbl_det = _FakeTableDetector(zoom=1, angle=angle)
parser.ocr = _FakeOcr(angle)
parser._evaluate_table_orientation = lambda table_img: (
angle,
_FakeImage(table_img.size[1], table_img.size[0]) if angle in (90, 270) else _FakeImage(*table_img.size),
{},
)
parser.boxes = [
{
"text": "old A1",
"layout_type": "table",
"layoutno": "table-0",
"page_number": 0,
"x0": 105,
"x1": 145,
"top": 210,
"bottom": 225,
},
{
"text": "old B2",
"layout_type": "table",
"layoutno": "table-0",
"page_number": 0,
"x0": 170,
"x1": 215,
"top": 240,
"bottom": 255,
},
]
parser._table_transformer_job(ZM=1, auto_rotate=True)
assert [box["text"] for box in parser.boxes] == ["A1", "B2"]
assert [box["layoutno"] for box in parser.boxes] == ["table-0", "table-0"]
assert [box["R"] for box in parser.boxes] == [0, 1]
assert [box["R_top"] for box in parser.boxes] == [210, 240]
assert [box["C"] for box in parser.boxes] == [0, 1]
assert [box["C_left"] for box in parser.boxes] == [105, 170]