diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9625a8e69..8e2463b7fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -226,8 +226,14 @@ jobs: -v "${PWD}/internal/cpp/resource:/usr/share/infinity/resource" \ infiniflow/infinity_builder:ubuntu22_clang20 sudo docker exec "${BUILDER_CONTAINER}" bash -c 'git config --global safe.directory "*" && cd /ragflow && ./build.sh --cpp' + uv sync --python 3.13 --group test --frozen ./build.sh --go + - name: Prepare Python test environment + run: | + uv sync --python 3.13 --group test --frozen + uv pip install -e sdk/python + - name: Run Go unit tests # Runs after `./build.sh --go`, which guarantees the C++ static # library (librag_tokenizer_c_api.a) is present on disk. The Go @@ -250,10 +256,7 @@ jobs: PKGS=$(go list ./... 2>/dev/null \ | grep -v '/internal/storage$' \ | grep -v '/internal/tokenizer$' \ - | grep -v '/internal/handler$' \ - | grep -v '/internal/deepdoc/parser/pdf/pdfium' \ - | grep -v '/internal/deepdoc/parser/pdf/pdfoxide' \ - | grep -v '/internal/deepdoc/parser/pdf' || true) + | grep -v '/internal/handler$' || true) if [ -z "$PKGS" ]; then ./build.sh --test else @@ -266,11 +269,6 @@ jobs: sudo docker pull ubuntu:24.04 sudo DOCKER_BUILDKIT=1 docker build --build-arg NEED_MIRROR=1 --build-arg HTTPS_PROXY=${HTTPS_PROXY} --build-arg HTTP_PROXY=${HTTP_PROXY} -f Dockerfile -t ${RAGFLOW_IMAGE} . - - name: Prepare Python test environment - run: | - uv sync --python 3.13 --group test --frozen - uv pip install -e sdk/python - - name: Prepare function test environment working-directory: docker run: | @@ -672,8 +670,14 @@ jobs: -v "${PWD}/internal/cpp/resource:/usr/share/infinity/resource" \ infiniflow/infinity_builder:ubuntu22_clang20 sudo docker exec "${BUILDER_CONTAINER}" bash -c 'git config --global safe.directory "*" && cd /ragflow && ./build.sh --cpp' + uv sync --python 3.13 --group test --frozen ./build.sh --go + - name: Prepare Python test environment + run: | + uv sync --python 3.13 --group test --frozen + uv pip install -e sdk/python + - name: Run Go unit tests # Runs after `./build.sh --go`, which guarantees the C++ static # library (librag_tokenizer_c_api.a) is present on disk. The Go @@ -696,10 +700,7 @@ jobs: PKGS=$(go list ./... 2>/dev/null \ | grep -v '/internal/storage$' \ | grep -v '/internal/tokenizer$' \ - | grep -v '/internal/handler$' \ - | grep -v '/internal/deepdoc/parser/pdf/pdfium' \ - | grep -v '/internal/deepdoc/parser/pdf/pdfoxide' \ - | grep -v '/internal/deepdoc/parser/pdf' || true) + | grep -v '/internal/handler$' || true) if [ -z "$PKGS" ]; then ./build.sh --test else @@ -712,11 +713,6 @@ jobs: sudo docker pull ubuntu:24.04 sudo DOCKER_BUILDKIT=1 docker build --build-arg NEED_MIRROR=1 --build-arg HTTPS_PROXY=${HTTPS_PROXY} --build-arg HTTP_PROXY=${HTTP_PROXY} -f Dockerfile -t ${RAGFLOW_IMAGE} . - - name: Prepare Python test environment - run: | - uv sync --python 3.13 --group test --frozen - uv pip install -e sdk/python - - name: Prepare function test environment working-directory: docker run: | diff --git a/build.sh b/build.sh index d483c4be37..a67d992882 100755 --- a/build.sh +++ b/build.sh @@ -26,6 +26,14 @@ STRIP_SYMBOLS="" OFFICE_OXIDE_PREFIX="${HOME}/.office_oxide" OFFICE_OXIDE_VERSION="0.1.2" +# pdfium native library settings (from pypdfium2_raw PyPI wheel) +PDFIUM_PREFIX="${HOME}/.pdfium" +PDFIUM_VERSION="0.5.0" + +# pdf_oxide native library settings (from GitHub Release) +PDF_OXIDE_PREFIX="${HOME}/.pdf_oxide" +PDF_OXIDE_VERSION="0.3.63" + echo -e "${GREEN}=== RAGFlow Go Server Build Script ===${NC}" # Function to print section headers @@ -103,7 +111,7 @@ check_cpp_deps() { check_go_deps() { print_section "Checking go dependencies" - + command -v go >/dev/null 2>&1 || { echo -e "${RED}Error: go is required but not installed.${NC}"; exit 1; } echo "✓ Required tools are available" @@ -182,24 +190,163 @@ check_office_oxide_deps() { echo -e "${GREEN}✓ office_oxide native library installed${NC}" } +# Check / install pdfium native library (libpdfium.so from pypdfium2_raw wheel). +check_pdfium_deps() { + # 1. Check .venv (uv sync provides pypdfium2_raw). + local venv_py="${PROJECT_ROOT}/.venv/bin/python3" + if [ -x "$venv_py" ]; then + local venv_so=$("$venv_py" -c "import pypdfium2_raw,os;print(os.path.join(os.path.dirname(pypdfium2_raw.__file__),'libpdfium.so'))" 2>/dev/null) + if [ -n "$venv_so" ] && [ -f "$venv_so" ]; then + echo " pdfium → ${venv_so} (.venv)" + export CGO_LDFLAGS="$CGO_LDFLAGS -L$(dirname "$venv_so") -Wl,-rpath,$(dirname "$venv_so")" + export LD_LIBRARY_PATH="$(dirname "$venv_so"):${LD_LIBRARY_PATH}" + return 0 + fi + fi + + # 2. Check cache. + local lib_path="${PDFIUM_PREFIX}/libpdfium.so" + if [ -f "$lib_path" ]; then + echo " pdfium → ${PDFIUM_PREFIX}" + return 0 + fi + + echo " pdfium not found, installing..." + + # 3. Map platform to PyPI wheel platform tag. + local whl_platform + case "$(uname -s)" in + Linux) + case "$(uname -m)" in + x86_64) whl_platform="manylinux_2_17_x86_64.manylinux2014_x86_64" ;; + aarch64|arm64) whl_platform="manylinux_2_17_aarch64.manylinux2014_aarch64" ;; + *) echo " pdfium → unsupported arch"; return 1 ;; + esac + ;; + Darwin) + case "$(uname -m)" in + x86_64) whl_platform="macosx_11_0_x86_64" ;; + arm64) whl_platform="macosx_11_0_arm64" ;; + *) echo " pdfium → unsupported arch"; return 1 ;; + esac + ;; + *) echo " pdfium → unsupported OS"; return 1 ;; + esac + + # 4. Download .whl from PyPI and extract libpdfium.so (zero pip dependency). + local whl_url + whl_url=$(curl -fsSL "https://pypi.org/pypi/pypdfium2_raw/${PDFIUM_VERSION}/json" 2>/dev/null \ + | grep -o '"url":"[^"]*'${whl_platform}'[^"]*"' | head -1 | cut -d'"' -f4) + + if [ -n "$whl_url" ] && { command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; }; then + local tmp_whl="$(mktemp)" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$whl_url" -o "$tmp_whl" + else + wget -q "$whl_url" -O "$tmp_whl" + fi + mkdir -p "${PDFIUM_PREFIX}" + # Wheel is a zip; extract libpdfium.so via python3 or unzip. + if command -v python3 >/dev/null 2>&1; then + python3 -c " +import zipfile, os, shutil +with zipfile.ZipFile('$tmp_whl') as z: + for n in z.namelist(): + if n.endswith('libpdfium.so'): + z.extract(n, '${PDFIUM_PREFIX}') + os.rename(os.path.join('${PDFIUM_PREFIX}', n), '$lib_path') + # Remove empty pypdfium2_raw dir + d = os.path.join('${PDFIUM_PREFIX}', 'pypdfium2_raw') + if os.path.isdir(d): shutil.rmtree(d, ignore_errors=True) + break +" 2>/dev/null + elif command -v unzip >/dev/null 2>&1; then + unzip -q -o "$tmp_whl" -d "${PDFIUM_PREFIX}" 'pypdfium2_raw/libpdfium.so' 2>/dev/null + [ -f "${PDFIUM_PREFIX}/pypdfium2_raw/libpdfium.so" ] && mv "${PDFIUM_PREFIX}/pypdfium2_raw/libpdfium.so" "$lib_path" + rm -rf "${PDFIUM_PREFIX}/pypdfium2_raw" + fi + rm -f "$tmp_whl" + fi + + if [ -f "$lib_path" ]; then + echo -e "${GREEN}✓ pdfium installed to ${PDFIUM_PREFIX}${NC}" + else + echo " pdfium → install failed (requires .venv, curl/wget + python3, or pre-cached ~/.pdfium)" + return 1 + fi +} + +# Check / install pdf_oxide native library (Rust -> C FFI library). +check_pdf_oxide_deps() { + local lib_path="${PDF_OXIDE_PREFIX}/libpdf_oxide.so" + + if [ -f "$lib_path" ]; then + echo " pdf_oxide → ${PDF_OXIDE_PREFIX} (shared)" + return 0 + fi + + # Also check for static library (user's local installation). + local static_path="${PDF_OXIDE_PREFIX}/libpdf_oxide.a" + if [ -f "$static_path" ]; then + echo " pdf_oxide → ${PDF_OXIDE_PREFIX} (static)" + return 0 + fi + + echo " pdf_oxide not found, installing..." + + # Map platform to the release asset name. + local asset_name + case "$(uname -s)" in + Linux) + case "$(uname -m)" in + x86_64) asset_name="libpdf_oxide-v${PDF_OXIDE_VERSION}-linux-x86_64" ;; + aarch64|arm64) asset_name="libpdf_oxide-v${PDF_OXIDE_VERSION}-linux-aarch64" ;; + *) echo " pdf_oxide → unsupported arch"; return 1 ;; + esac + ;; + Darwin) + case "$(uname -m)" in + x86_64) asset_name="libpdf_oxide-v${PDF_OXIDE_VERSION}-darwin-x86_64" ;; + arm64) asset_name="libpdf_oxide-v${PDF_OXIDE_VERSION}-darwin-arm64" ;; + *) echo " pdf_oxide → unsupported arch"; return 1 ;; + esac + ;; + *) echo " pdf_oxide → unsupported OS"; return 1 ;; + esac + + local release_url="https://github.com/yfedoseev/pdf_oxide/releases/download/v${PDF_OXIDE_VERSION}/${asset_name}.tar.gz" + + mkdir -p "${PDF_OXIDE_PREFIX}" + _download_and_extract "$release_url" "${PDF_OXIDE_PREFIX}" + + if [ -f "$lib_path" ]; then + echo -e "${GREEN}✓ pdf_oxide installed to ${PDF_OXIDE_PREFIX}${NC}" + else + echo " pdf_oxide → install failed" + return 1 + fi +} + # Build C++ static library build_cpp() { print_section "Building C++ static library" - + mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" - + echo "Running cmake..." cmake .. -DCMAKE_BUILD_TYPE=Release - + echo "Building librag_tokenizer_c_api.a..." - make rag_tokenizer_c_api -j$(nproc) - + local jobs + jobs="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)" + make rag_tokenizer_c_api -j"$jobs" + if [ ! -f "$BUILD_DIR/librag_tokenizer_c_api.a" ]; then echo -e "${RED}Error: Failed to build C++ static library${NC}" exit 1 fi - + echo -e "${GREEN}✓ C++ static library built successfully${NC}" } @@ -217,7 +364,9 @@ build_cpp_test() { fi echo "Building rag_analyzer_c_test..." - make rag_analyzer_c_test -j$(nproc) + local jobs + jobs="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)" + make rag_analyzer_c_test -j"$jobs" if [ ! -f "$BUILD_DIR/rag_analyzer_c_test" ]; then echo -e "${RED}Error: Failed to build rag_analyzer_c_test${NC}" @@ -296,17 +445,35 @@ build_go() { echo -e "${GREEN}✓ Go ingestor built successfully: $INGESTOR_BINARY${NC}" } -# Configure CGO flags for the office_oxide native library and the runtime -# rpath used by test binaries. Call before any `go build` / `go test` step -# that links against office_oxide. +# Configure CGO flags for native libraries (office_oxide, pdfium, pdf_oxide). +# Call before any `go build` / `go test` step that links against these libraries. setup_cgo_env() { + # ── office_oxide ────────────────────────────────────────────────── check_office_oxide_deps export CGO_CFLAGS="-I${OFFICE_OXIDE_PREFIX}/include/office_oxide_c${CGO_CFLAGS:+ $CGO_CFLAGS}" - echo "Exporting CGO_CFLAGS: $CGO_CFLAGS" - export CGO_LDFLAGS="-L${OFFICE_OXIDE_PREFIX}/lib -loffice_oxide -Wl,-rpath,${OFFICE_OXIDE_PREFIX}/lib${CGO_LDFLAGS:+ $CGO_LDFLAGS}" - echo "Exporting CGO_LDFLAGS: $CGO_LDFLAGS" - # Make the .so discoverable to test binaries spawned without rpath. + export CGO_LDFLAGS="-L${OFFICE_OXIDE_PREFIX}/lib -loffice_oxide -Wl,-rpath,${OFFICE_OXIDE_PREFIX}/lib" export LD_LIBRARY_PATH="${OFFICE_OXIDE_PREFIX}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + + # ── pdfium ──────────────────────────────────────────────────────── + check_pdfium_deps || return 1 + if [ -f "${PDFIUM_PREFIX}/libpdfium.so" ]; then + export CGO_LDFLAGS="$CGO_LDFLAGS -L${PDFIUM_PREFIX} -Wl,-rpath,${PDFIUM_PREFIX}" + export LD_LIBRARY_PATH="${PDFIUM_PREFIX}:${LD_LIBRARY_PATH}" + fi + + # ── pdf_oxide ───────────────────────────────────────────────────── + check_pdf_oxide_deps || return 1 + if [ -f "${PDF_OXIDE_PREFIX}/libpdf_oxide.so" ]; then + export CGO_LDFLAGS="$CGO_LDFLAGS -L${PDF_OXIDE_PREFIX} -lpdf_oxide -Wl,-rpath,${PDF_OXIDE_PREFIX}" + export LD_LIBRARY_PATH="${PDF_OXIDE_PREFIX}:${LD_LIBRARY_PATH}" + elif [ -f "${PDF_OXIDE_PREFIX}/libpdf_oxide.a" ]; then + export CGO_LDFLAGS="$CGO_LDFLAGS ${PDF_OXIDE_PREFIX}/libpdf_oxide.a" + fi + + echo "CGO_CFLAGS: $CGO_CFLAGS" + echo "Exporting CGO_CFLAGS: $CGO_CFLAGS" + echo "CGO_LDFLAGS: $CGO_LDFLAGS" + echo "Exporting CGO_LDFLAGS: $CGO_LDFLAGS" } # Run Go unit tests with the same CGO env as `build_go`. Pass any extra args @@ -328,7 +495,7 @@ run_go_tests() { # Clean build artifacts clean() { print_section "Cleaning build artifacts" - + rm -rf "$BUILD_DIR" rm -f "$RAGFLOW_SERVER_BINARY" rm -f "$ADMIN_SERVER_BINARY" diff --git a/internal/deepdoc/parser/pdf/generate_test.go b/internal/deepdoc/parser/pdf/batch_smoke_test.go similarity index 88% rename from internal/deepdoc/parser/pdf/generate_test.go rename to internal/deepdoc/parser/pdf/batch_smoke_test.go index 246acf733f..c3870a0ba4 100644 --- a/internal/deepdoc/parser/pdf/generate_test.go +++ b/internal/deepdoc/parser/pdf/batch_smoke_test.go @@ -10,7 +10,6 @@ import ( "math" "os" "path/filepath" - "ragflow/internal/deepdoc/parser/pdf/tools" "regexp" "sort" "strconv" @@ -18,6 +17,11 @@ import ( "testing" "time" "unicode/utf8" + + inf "ragflow/internal/deepdoc/parser/pdf/inference" + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + "ragflow/internal/deepdoc/parser/pdf/tool" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // TestBatchResults runs Parse() on real PDFs and writes: @@ -47,14 +51,14 @@ func TestBatchResults(t *testing.T) { } pdfs := all[:min(count, len(all))] - ddClient, err := NewDeepDocClient(os.Getenv("DEEPDOC_URL")) + ddClient, err := inf.NewInferenceClient(os.Getenv("DEEPDOC_URL")) if err != nil { t.Fatal(err) } if !ddClient.Health() { t.Fatalf("DeepDoc service not available at %s (DLA+TSR required)", ddClient.baseURL) } - deepDoc := DocAnalyzer(ddClient) + deepDoc := pdf.DocAnalyzer(ddClient) variant := variantFromEnv() t.Logf("DeepDoc available — DLA+TSR%s enabled (%d PDFs)", @@ -149,7 +153,7 @@ func filterSingle(pdfs []string, name string, t *testing.T) []string { } // extractPageStats returns (charCount, boxCount) for all pages in engine. -func extractPageStats(eng PDFEngine) (chars, boxes int) { +func extractPageStats(eng pdf.PDFEngine) (chars, boxes int) { np, _ := eng.PageCount() for pg := 0; pg < np; pg++ { pgChars, err := eng.ExtractChars(pg) @@ -157,7 +161,7 @@ func extractPageStats(eng PDFEngine) (chars, boxes int) { continue } chars += len(pgChars) - boxes += len(charsToBoxes(pgChars, pg, false)) + boxes += len(lyt.CharsToBoxes(pgChars, pg, false)) } return } @@ -172,9 +176,9 @@ func textLenFromOutput(data []byte) int { // ── main processing loop ──────────────────────────────────────────── -func processPDFs(t *testing.T, pdfDir string, pdfs []string, deepDoc DocAnalyzer, variant string, dirs outputDirs) []tools.BatchResult { +func processPDFs(t *testing.T, pdfDir string, pdfs []string, deepDoc pdf.DocAnalyzer, variant string, dirs outputDirs) []tool.BatchResult { t.Helper() - var results []tools.BatchResult + var results []tool.BatchResult totalChars := 0 skipOCR := os.Getenv("BATCH_SKIP_OCR") == "1" @@ -193,7 +197,7 @@ func processPDFs(t *testing.T, pdfDir string, pdfs []string, deepDoc DocAnalyzer // ── parse ── res, err := parseOne(pdfDir, name, deepDoc, skipOCR) if err != nil { - results = append(results, tools.BatchResult{File: name, Error: err.Error()}) + results = append(results, tool.BatchResult{File: name, Error: err.Error()}) t.Logf("%s — %v", label, err) continue } @@ -213,11 +217,11 @@ func processPDFs(t *testing.T, pdfDir string, pdfs []string, deepDoc DocAnalyzer } type parseOneResult struct { - tools.BatchResult - result ParseResult + tool.BatchResult + result pdf.ParseResult } -func parseOne(pdfDir, name string, deepDoc DocAnalyzer, skipOCR bool) (*parseOneResult, error) { +func parseOne(pdfDir, name string, deepDoc pdf.DocAnalyzer, skipOCR bool) (*parseOneResult, error) { data, err := os.ReadFile(filepath.Join(pdfDir, name)) if err != nil { return nil, fmt.Errorf("read: %w", err) @@ -232,7 +236,7 @@ func parseOne(pdfDir, name string, deepDoc DocAnalyzer, skipOCR bool) (*parseOne pageCount, _ := eng.PageCount() chars, _ := extractPageStats(eng) - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() cfg.SkipOCR = skipOCR p := NewParser(cfg, deepDoc) t0 := time.Now() @@ -248,7 +252,7 @@ func parseOne(pdfDir, name string, deepDoc DocAnalyzer, skipOCR bool) (*parseOne } return &parseOneResult{ - BatchResult: tools.BatchResult{ + BatchResult: tool.BatchResult{ File: name, Pages: pageCount, Chars: chars, @@ -263,17 +267,17 @@ func parseOne(pdfDir, name string, deepDoc DocAnalyzer, skipOCR bool) (*parseOne }, nil } -func tryLoadCached(dirs outputDirs, name string) *tools.BatchResult { +func tryLoadCached(dirs outputDirs, name string) *tool.BatchResult { textPath := filepath.Join(dirs.text, name+".txt") tablesPath := filepath.Join(dirs.tables, name+".json") - if !tools.FileExists(textPath) || !tools.FileExists(tablesPath) { + if !tool.FileExists(textPath) || !tool.FileExists(tablesPath) { return nil } data, err := os.ReadFile(textPath) if err != nil { return nil } - var r tools.BatchResult + var r tool.BatchResult r.File = name if idx := strings.LastIndex(string(data), "\n#@meta"); idx >= 0 { if json.Unmarshal(data[idx+7:], &r) == nil { @@ -301,7 +305,7 @@ func htmlToRows(html string) [][]string { return rows } -func writeOutputs(dirs outputDirs, name string, parsed *ParseResult, res *parseOneResult) { +func writeOutputs(dirs outputDirs, name string, parsed *pdf.ParseResult, res *parseOneResult) { // ── text + #@meta ── var sb strings.Builder for _, s := range parsed.Sections { @@ -317,11 +321,11 @@ func writeOutputs(dirs outputDirs, name string, parsed *ParseResult, res *parseO // ── tables JSON — extract rows from section HTML (matching Python html_to_rows) ── type slimTable struct { - Rows [][]string `json:"rows"` - Positions []Position `json:"positions,omitempty"` + Rows [][]string `json:"rows"` + Positions []pdf.Position `json:"positions,omitempty"` } // Collect all table sections in order (index-matched to TableItems). - var tableSections []Section + var tableSections []pdf.Section for _, s := range parsed.Sections { if s.LayoutType == "table" && strings.HasPrefix(s.Text, "") { tableSections = append(tableSections, s) diff --git a/internal/deepdoc/parser/pdf/chunk_test.go b/internal/deepdoc/parser/pdf/chunk_test.go deleted file mode 100644 index a5c4d3022e..0000000000 --- a/internal/deepdoc/parser/pdf/chunk_test.go +++ /dev/null @@ -1,89 +0,0 @@ -//go:build cgo - -package parser - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "ragflow/internal/deepdoc/parser/pdf/tools" -) - -// TestParse_ChunkEquivalence verifies that chunked processing produces -// the same output as processing all pages at once. Uses chunkSize=1 -// (every page is its own chunk) on a multi-page fixture to maximize -// chunk boundary stress. -func TestParse_ChunkEquivalence(t *testing.T) { - data, err := readTestPDF(t, "03_multipage.pdf") - if err != nil { - t.Fatal(err) - } - - parse := func(chunkSize int) *ParseResult { - eng, err := NewEngine(data) - if err != nil { - t.Fatal(err) - } - defer eng.Close() - cfg := DefaultParserConfig() - cfg.ChunkSize = chunkSize - p := NewParser(cfg, &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatal(err) - } - return result - } - - // No chunking (all pages at once). - full := parse(9999) - // Aggressive chunking (1 page per chunk). - chunked := parse(1) - - // Compare section counts. - if len(full.Sections) != len(chunked.Sections) { - t.Logf("section count: full=%d chunked=%d (small diff acceptable at chunk boundaries)", - len(full.Sections), len(chunked.Sections)) - } - - // Compare text content via CharSimilarity. - fullText := sectionsText(full.Sections) - chunkedText := sectionsText(chunked.Sections) - charSim := tools.CharSimilarity(fullText, chunkedText) - t.Logf("CharSimilarity: %.1f%%", charSim) - if charSim < 95 { - t.Errorf("chunk equivalence too low: CharSim=%.1f%% (want >= 95%%)", charSim) - } - - // Compare metrics (should be identical or very close). - t.Logf("Metrics: full=%+v chunked=%+v", full.Metrics, chunked.Metrics) - if full.Metrics.BoxesInitial != chunked.Metrics.BoxesInitial { - t.Errorf("BoxesInitial: full=%d chunked=%d", - full.Metrics.BoxesInitial, chunked.Metrics.BoxesInitial) - } - - // Bug fix regression: PageImages must survive chunked merge. - if len(full.PageImages) == 0 { - t.Error("full parse: PageImages should not be empty (3-page document)") - } - if len(chunked.PageImages) == 0 { - t.Error("chunked parse: PageImages should be preserved across chunks") - } -} - -func readTestPDF(t *testing.T, name string) ([]byte, error) { - t.Helper() - return os.ReadFile(filepath.Join("testdata", "pdfs", name)) -} - -func sectionsText(sections []Section) string { - var sb strings.Builder - for _, s := range sections { - sb.WriteString(s.Text) - sb.WriteByte('\n') - } - return sb.String() -} diff --git a/internal/deepdoc/parser/pdf/cleanup.go b/internal/deepdoc/parser/pdf/cleanup.go deleted file mode 100644 index 03df9dc7df..0000000000 --- a/internal/deepdoc/parser/pdf/cleanup.go +++ /dev/null @@ -1,74 +0,0 @@ -package parser - -import ( - "strings" - "unicode" -) - -// ---- MergeSameBullet (Python: pdf_parser.py _merge_same_bullet) ---- - -// MergeSameBullet merges adjacent boxes that start with the same bullet/number -// character, combining their text with a newline separator. -func MergeSameBullet(boxes []TextBox, tok Tokenizer) []TextBox { - if len(boxes) < 2 { - return boxes - } - // Build output via two-pointer collect: O(n) instead of O(n²) slice-element removal. - out := make([]TextBox, 0, len(boxes)) - i := 0 - for i < len(boxes) { - if strings.TrimSpace(boxes[i].Text) == "" { - i++ - continue - } - // Start a merge chain from position i. - cur := boxes[i] - i++ - for i < len(boxes) { - if strings.TrimSpace(boxes[i].Text) == "" { - i++ - continue - } - nxt := boxes[i] - firstCur := firstRuneString(cur.Text) - firstNxt := firstRuneString(nxt.Text) - - // Conditions to NOT merge: - if firstCur != firstNxt || - unicode.Is(unicode.Latin, firstCur) || - isChinese(firstCur, tok) || - cur.Top > nxt.Bottom { - break - } - - // Merge nxt into cur. - cur.Text = cur.Text + "\n" + nxt.Text - cur.X0 = min(cur.X0, nxt.X0) - cur.X1 = max(cur.X1, nxt.X1) - cur.Bottom = nxt.Bottom - i++ - } - out = append(out, cur) - } - return out -} - -// ---- Helpers ---- - -func firstRuneString(s string) rune { - s = strings.TrimSpace(s) - if s == "" { - return 0 - } - return []rune(s)[0] -} - -// isChinese checks if a rune is a Chinese character (CJK Unified Ideograph). -func isChinese(r rune, tok Tokenizer) bool { - if tok != nil { - return strings.Contains(tok.Tag(string(r)), "n") - } - return (r >= 0x4E00 && r <= 0x9FFF) || - (r >= 0x3400 && r <= 0x4DBF) || - (r >= 0x20000 && r <= 0x2A6DF) -} diff --git a/internal/deepdoc/parser/pdf/crop_integration_test.go b/internal/deepdoc/parser/pdf/crop_integration_test.go index 34b43e5491..72e90d3cbb 100644 --- a/internal/deepdoc/parser/pdf/crop_integration_test.go +++ b/internal/deepdoc/parser/pdf/crop_integration_test.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build cgo && manual package parser @@ -9,6 +9,7 @@ import ( "image/png" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) @@ -25,8 +26,8 @@ func TestParse_CropSectionImages(t *testing.T) { } defer eng.Close() - cfg := DefaultParserConfig() - p := NewParser(cfg, &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + cfg := pdf.DefaultParserConfig() + p := NewParser(cfg, &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) @@ -78,7 +79,7 @@ func TestCrop_Regression_SnapshotPDFs(t *testing.T) { } defer eng.Close() - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) diff --git a/internal/deepdoc/parser/pdf/deepdoc_test.go b/internal/deepdoc/parser/pdf/deepdoc_test.go deleted file mode 100644 index 9274897a7e..0000000000 --- a/internal/deepdoc/parser/pdf/deepdoc_test.go +++ /dev/null @@ -1,904 +0,0 @@ -//go:build cgo - -package parser - -import ( - "context" - "fmt" - "image" - "strings" - "testing" -) - -// ── MockDocAnalyzer tests ────────────────────────────────────────────── - -func TestMockDocAnalyzer(t *testing.T) { - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 0, Y0: 0, X1: 100, Y1: 100, Label: "table", Confidence: 0.95}, - }, - TSRCells: []TSRCell{ - {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, - }, - } - - if !mock.Health() { - t.Error("mock should be healthy") - } - regions, _ := mock.DLA(context.Background(), nil) - if len(regions) != 1 || regions[0].Label != "table" { - t.Error("mock DLA returned wrong data") - } - cells, _ := mock.TSR(context.Background(), nil) - if len(cells) != 1 || cells[0].Text != "A" { - t.Error("mock TSR returned wrong data") - } - // OCRDetect + OCRRecognize replaces deprecated OCR — tested in TestOCR_scanPage/TestOCR_fallback. - _ = mock.OCRDetect - _ = mock.OCRRecognize - - // Unhealthy mock - mock2 := &MockDocAnalyzer{Healthy: false} - if mock2.Health() { - t.Error("unhealthy mock should return false") - } -} - -// ── groupTSRCellsToRows ──────────────────────────────────────────────── - -func TestGroupTSRCellsToRows(t *testing.T) { - t.Run("empty", func(t *testing.T) { - if rows := groupTSRCellsToRows(nil); rows != nil { - t.Error("nil → nil") - } - if rows := groupTSRCellsToRows([]TSRCell{}); rows != nil { - t.Error("empty → nil") - } - }) - - t.Run("single cell", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "A"}} - rows := groupTSRCellsToRows(cells) - if len(rows) != 1 || rows[0][0].Text != "A" { - t.Error("single cell not preserved") - } - }) - - t.Run("two rows two cols", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, - {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, - {X0: 0, Y0: 50, X1: 50, Y1: 80, Text: "C"}, - {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, - } - rows := groupTSRCellsToRows(cells) - if len(rows) != 2 { - t.Fatalf("2 rows expected, got %d", len(rows)) - } - if rows[0][0].Text != "A" || rows[0][1].Text != "B" { - t.Errorf("row0: %v", cellTexts(rows[0])) - } - if rows[1][0].Text != "C" || rows[1][1].Text != "D" { - t.Errorf("row1: %v", cellTexts(rows[1])) - } - }) - - t.Run("unsorted input", func(t *testing.T) { - cells := []TSRCell{ - {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, - {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, - {X0: 0, Y0: 50, X1: 50, Y1: 80, Text: "C"}, - {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, - } - rows := groupTSRCellsToRows(cells) - if len(rows) != 2 { - t.Fatalf("unsorted: 2 rows expected, got %d", len(rows)) - } - if rows[0][0].Text != "A" || rows[0][1].Text != "B" { - t.Errorf("unsorted row0: %v", cellTexts(rows[0])) - } - }) - - t.Run("tall merged cell", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 50, Y1: 100, Text: "merged"}, - {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, - {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, - } - rows := groupTSRCellsToRows(cells) - // merged cell starts Y0=0 → row 0; Y0=50 cell → row 1 - if len(rows) != 2 { - t.Fatalf("merged cell: 2 rows expected, got %d", len(rows)) - } - }) - - t.Run("large gap different rows", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "top"}, - {X0: 0, Y0: 200, X1: 50, Y1: 230, Text: "far"}, - } - rows := groupTSRCellsToRows(cells) - if len(rows) != 2 { - t.Fatalf("large gap: 2 rows expected, got %d", len(rows)) - } - }) -} - -// ── fillCellTextFromBoxes ────────────────────────────────────────────── - -func TestFillCellTextFromBoxes(t *testing.T) { - t.Run("exact match", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50}, - {X0: 100, Y0: 0, X1: 200, Y1: 50}, - } - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "A"}, - {X0: 100, X1: 200, Top: 0, Bottom: 50, Text: "B"}, - } - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "A" || cells[1].Text != "B" { - t.Errorf("got %q/%q, want A/B", cells[0].Text, cells[1].Text) - } - }) - - t.Run("empty cells", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50}, - {X0: 100, Y0: 0, X1: 200, Y1: 50}, - } - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "only first"}, - } - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "only first" { - t.Errorf("cell[0]: got %q", cells[0].Text) - } - if cells[1].Text != "" { - t.Errorf("cell[1] should be empty, got %q", cells[1].Text) - } - }) - - t.Run("partial cell coverage — empty cell filled from any overlapping box", func(t *testing.T) { - // Box covers 40% of cell area. Old code rejected (<85% cell coverage). - // New code: cell is empty → accepts box (≥30% box area inside cell). - cells := []TSRCell{{X0: 0, Y0: 0, X1: 200, Y1: 50}} - boxes := []TextBox{{X0: 0, X1: 80, Top: 0, Bottom: 50, Text: "partial"}} - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "partial" { - t.Errorf("empty cell should be filled from overlapping box, got %q", cells[0].Text) - } - }) - - t.Run("box inside cell >85%", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 500, Y1: 300}} - boxes := []TextBox{{X0: 10, X1: 490, Top: 10, Bottom: 290, Text: "inside"}} - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "inside" { - t.Errorf("got %q", cells[0].Text) - } - }) - - t.Run("concatenate two boxes to same cell", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 200, Y1: 100}} - boxes := []TextBox{ - {X0: 5, X1: 195, Top: 2, Bottom: 98, Text: "hello"}, - {X0: 5, X1: 195, Top: 2, Bottom: 98, Text: "world"}, - } - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "hello world" { - t.Errorf("got %q, want 'hello world'", cells[0].Text) - } - }) - - t.Run("empty inputs", func(t *testing.T) { - fillCellTextFromBoxes(nil, nil) - fillCellTextFromBoxes([]TSRCell{}, []TextBox{}) - c := []TSRCell{{X0: 0, Y0: 0, X1: 1, Y1: 1}} - fillCellTextFromBoxes(c, nil) - if c[0].Text != "" { - t.Error("no boxes → text empty") - } - }) -} - -// ── regionOverlapsBox ────────────────────────────────────────────────── - -func TestRegionOverlapsBox(t *testing.T) { - scale := 3.0 - tests := []struct { - name string - region DLARegion - box TextBox - expected bool - }{ - {"full overlap", DLARegion{X0: 0, Y0: 300, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 500, Top: 100, Bottom: 760, Text: "x", PageNumber: 0}, true}, - {"no overlap", DLARegion{X0: 0, Y0: 3000, X1: 1500, Y1: 5000, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 500, Top: 0, Bottom: 10, Text: "x", PageNumber: 0}, false}, - {"no Y overlap", DLARegion{X0: 150, Y0: 300, X1: 1650, Y1: 336, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 550, Top: 500, Bottom: 520, Text: "x", PageNumber: 0}, false}, - {"zero area box", DLARegion{X0: 0, Y0: 300, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.9}, TextBox{X0: 50, X1: 50, Top: 50, Bottom: 50, Text: "x", PageNumber: 0}, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := regionOverlapsBox(tt.region, tt.box, scale); got != tt.expected { - t.Errorf("= %v, want %v", got, tt.expected) - } - }) - } -} - -// ── enrichWithDeepDoc noop ───────────────────────────────────────────── - -func TestEnrichWithDeepDoc_Noop(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "text"}, - } - eng := &mockEngine{pageCount: 1} - - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: false, Model: ModelSaas}) - tables := p.enrichWithDeepDoc(context.Background(), eng, boxes, nil) - if len(tables) != 0 { - t.Error("unhealthy DeepDoc → 0 Tables") - } -} - -// ── extractTableBoxesFromImage with mock ─────────────────────────────── - -func TestExtractTableBoxes_Mock(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 80, X1: 500, Top: 200, Bottom: 550, Text: "cell 1"}, - {PageNumber: 0, X0: 80, X1: 500, Top: 550, Bottom: 760, Text: "cell 2"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 180, Text: "heading"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 780, Bottom: 850, Text: "below"}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 250, Y0: 600, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.95}, - }, - TSRCells: []TSRCell{ - {X0: 0, Y0: 0, X1: 600, Y1: 400, Text: "A1"}, - {X0: 600, Y0: 0, X1: 1240, Y1: 400, Text: "B1"}, - {X0: 0, Y0: 410, X1: 600, Y1: 800, Text: "A2"}, - {X0: 600, Y0: 410, X1: 1240, Y1: 800, Text: "B2"}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - dummyImg := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) - - tables := p.extractTableBoxesFromImage(context.Background(), boxes, dummyImg, 0, 0) - if len(tables) != 1 { - t.Fatalf("expected 1 TableItem, got %d", len(tables)) - } - tbl := tables[0] - if len(tbl.Cells) != 4 { - t.Errorf("expected 4 cells, got %d", len(tbl.Cells)) - } - // Rows populated later by constructTable via extractTableAndReplace. - if tbl.ImageB64 == "" { - t.Error("ImageB64 empty") - } - if len(tbl.Positions) != 2 { - t.Errorf("expected 2 Positions, got %d", len(tbl.Positions)) - } -} - -func TestExtractTableBoxes_NoTables(t *testing.T) { - mock := &MockDocAnalyzer{Healthy: true, DLARegions: []DLARegion{}} - p := NewParser(DefaultParserConfig(), mock) - dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) - tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) - if len(tables) != 0 { - t.Errorf("0 tables expected, got %d", len(tables)) - } -} - -func TestExtractTableBoxes_NonTableRegions(t *testing.T) { - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 150, Y0: 300, X1: 1650, Y1: 336, Label: "text", Confidence: 0.9}, - {X0: 150, Y0: 600, X1: 1650, Y1: 900, Label: "figure", Confidence: 0.8}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - dummy := image.NewRGBA(image.Rect(0, 0, 2000, 2000)) - tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) - if len(tables) != 0 { - t.Errorf("non-table regions → 0 tables, got %d", len(tables)) - } -} - -func TestExtractTableBoxes_NoOverlap(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 550, Top: 10, Bottom: 30, Text: "far away"}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 150, Y0: 1500, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.95}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - dummy := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) - tables := p.extractTableBoxesFromImage(context.Background(), boxes, dummy, 0, 0) - if len(tables) != 0 { - t.Errorf("no overlap → 0 tables, got %d", len(tables)) - } -} - -func TestExtractTableBoxes_TSRError(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 80, X1: 500, Top: 210, Bottom: 660, Text: "cell"}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 250, Y0: 600, X1: 1500, Y1: 2000, Label: "table", Confidence: 0.95}, - }, - TSRCells: nil, // TSR returns nothing - } - p := NewParser(DefaultParserConfig(), mock) - dummy := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) - tables := p.extractTableBoxesFromImage(context.Background(), boxes, dummy, 0, 0) - if len(tables) != 1 { - t.Fatalf("TSR failure: expected 1 TableItem with image+positions, got %d", len(tables)) - } - if tables[0].ImageB64 == "" { - t.Error("should have image despite TSR failure") - } - if len(tables[0].Positions) == 0 { - t.Error("should have positions despite TSR failure") - } - if len(tables[0].Rows) != 0 { - t.Errorf("TSR failure → 0 rows, got %d", len(tables[0].Rows)) - } -} - -func TestGroupTSRCellsToRows_SameHeight(t *testing.T) { - // All cells have identical height → medianH is that value → threshold = medianH/2 - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, - {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, - {X0: 0, Y0: 31, X1: 50, Y1: 61, Text: "C"}, // gap = 31-30=1 < 30/2=15 → same row? NO, Y0=31 is right at edge - } - rows := groupTSRCellsToRows(cells) - // medianH=30, threshold=15. C.Y0=31 > curY+threshold?" curY=0, 31 > 15 → new row. - // So A,B in row 0, C in row 1. - if len(rows) != 2 { - t.Fatalf("expected 2 rows, got %d", len(rows)) - } - if len(rows[0]) != 2 || len(rows[1]) != 1 { - t.Errorf("row sizes: %d %d, want 2 1", len(rows[0]), len(rows[1])) - } -} - -func TestFillCellTextFromBoxes_WhitespaceTrim(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 100}} - boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: " hello "}} - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "hello" { - t.Errorf("got %q, want 'hello'", cells[0].Text) - } -} - -func TestFillCellTextFromBoxes_EmptyBoxIgnored(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 100}} - boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: " "}} // all whitespace - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "" { - t.Errorf("whitespace text should produce empty, got %q", cells[0].Text) - } -} - -func TestExtractTableBoxes_DLAError(t *testing.T) { - // DLA returns only non-table regions → 0 tables - mock := &MockDocAnalyzer{Healthy: true, DLARegions: []DLARegion{ - {X0: 0, Y0: 0, X1: 100, Y1: 100, Label: "text", Confidence: 0.9}, - }} - p := NewParser(DefaultParserConfig(), mock) - dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) - tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) - if len(tables) != 0 { - t.Errorf("non-table DLA → 0 tables, got %d", len(tables)) - } -} - -func TestAnnotateBoxLayouts(t *testing.T) { - boxes := []TextBox{ - {X0: 50, X1: 200, Top: 100, Bottom: 200, Text: "title text"}, - {X0: 250, X1: 500, Top: 100, Bottom: 200, Text: "body"}, - {X0: 50, X1: 500, Top: 300, Bottom: 600, Text: "table content"}, - {X0: 50, X1: 500, Top: 700, Bottom: 800, Text: "unmatched"}, - } - regions := []DLARegion{ - {X0: 150, Y0: 300, X1: 600, Y1: 600, Label: "title", Confidence: 0.9}, // PDF pts: X50-200,Y100-200 → only box[0] - {X0: 750, Y0: 300, X1: 1500, Y1: 600, Label: "text", Confidence: 0.8}, // PDF pts: X250-500,Y100-200 → box[1] - {X0: 150, Y0: 900, X1: 1500, Y1: 1800, Label: "table", Confidence: 0.95}, // PDF pts: X50-500,Y300-600 → box[2] - } - scale := 3.0 - annotateBoxLayouts(boxes, regions, scale, 0) - - if boxes[0].LayoutType != "title" { - t.Errorf("box[0] = %q, want title", boxes[0].LayoutType) - } - if boxes[1].LayoutType != "text" { - t.Errorf("box[1] = %q, want text", boxes[1].LayoutType) - } - if boxes[2].LayoutType != "table" { - t.Errorf("box[2] = %q, want table", boxes[2].LayoutType) - } - if boxes[3].LayoutType != "" { - t.Errorf("box[3] = %q, want empty (no matching region)", boxes[3].LayoutType) - } -} - -func TestAnnotateBoxLayouts_Figure(t *testing.T) { - // Figure region → box gets "figure" layout type (no TSR needed) - boxes := []TextBox{ - {X0: 50, X1: 500, Top: 100, Bottom: 400, Text: "chart image"}, - } - regions := []DLARegion{ - {X0: 50, Y0: 200, X1: 2000, Y1: 1000, Label: "figure", Confidence: 0.85}, - } - annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutType != "figure" { - t.Errorf("LayoutType = %q, want 'figure'", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_Empty(t *testing.T) { - boxes := []TextBox{{Text: "x"}} - annotateBoxLayouts(boxes, nil, 3.0, 0) - if boxes[0].LayoutType != "" { - t.Error("empty regions → no annotation") - } -} - -func TestBoxesToSections_PassesLayoutType(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题", LayoutType: "title"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 200, Bottom: 212, Text: "表格", LayoutType: "table"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 300, Bottom: 312, Text: "正文", LayoutType: "text"}, - } - sections := boxesToSections(boxes, nil) - if len(sections) != 3 { - t.Fatalf("expected 3 sections, got %d", len(sections)) - } - if sections[0].LayoutType != "title" { - t.Errorf("section[0].LayoutType = %q, want 'title'", sections[0].LayoutType) - } - if sections[1].LayoutType != "table" { - t.Errorf("section[1].LayoutType = %q, want 'table'", sections[1].LayoutType) - } - if sections[2].LayoutType != "text" { - t.Errorf("section[2].LayoutType = %q, want 'text'", sections[2].LayoutType) - } -} - -func TestBoxesToSections_PreservesTableLayout(t *testing.T) { - // boxesToSections should produce sections for all boxes regardless of LayoutType. - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题", LayoutType: "title"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 200, Bottom: 212, Text: "表格文字", LayoutType: "table"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 300, Bottom: 312, Text: "正文", LayoutType: "text"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 400, Bottom: 412, Text: ""}, - } - sections := boxesToSections(boxes, nil) - if len(sections) != 3 { - t.Errorf("expected 3 sections (1 empty skipped), got %d", len(sections)) - } - for _, s := range sections { - if strings.Contains(s.Text, "@@") { - t.Error("section text should NOT contain position tag") - } - } - t.Logf("boxesToSections: %d sections (all LayoutTypes passed through)", len(sections)) -} - -func TestEnrichWithDeepDoc_PreservesBoxes(t *testing.T) { - // Simulate enrichWithDeepDoc's write-back logic: - // 1. Create pageBoxes as copies of p.boxes[idx] - // 2. annotateBoxLayouts(pageBoxes, regions) — modifies copies - // 3. Write LayoutType back to p.boxes[idx] - // This test validates step 3 works. - - original := []TextBox{ - {PageNumber: 0, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "title", LayoutType: ""}, - {PageNumber: 0, X0: 50, X1: 200, Top: 100, Bottom: 200, Text: "text before", LayoutType: ""}, - {PageNumber: 0, X0: 50, X1: 500, Top: 250, Bottom: 700, Text: "table cell", LayoutType: ""}, - {PageNumber: 0, X0: 50, X1: 200, Top: 750, Bottom: 800, Text: "text after", LayoutType: ""}, - {PageNumber: 1, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "page2", LayoutType: ""}, - } - - byPage := map[int][]int{0: {0, 1, 2, 3}, 1: {4}} // indices into original - - regions := []DLARegion{ - {X0: 150, Y0: 150, X1: 600, Y1: 240, Label: "title", Confidence: 0.9}, // PDF: X50-200,Y50-80 → box[0] - {X0: 150, Y0: 750, X1: 1500, Y1: 2100, Label: "table", Confidence: 0.95}, // PDF: X50-500,Y250-700 → box[2] - } - - // Step 1-2: copy + annotate - for _, indices := range byPage { - pageBoxes := make([]TextBox, len(indices)) - for i, idx := range indices { - pageBoxes[i] = original[idx] - } - annotateBoxLayouts(pageBoxes, regions, 3.0, 0) - - // Step 3: write back (this is what enrichWithDeepDoc now does) - for i, idx := range indices { - if pageBoxes[i].LayoutType != "" { - original[idx].LayoutType = pageBoxes[i].LayoutType - } - } - } - - if original[0].LayoutType != "title" { - t.Errorf("box[0] LayoutType = %q, want 'title'", original[0].LayoutType) - } - if original[2].LayoutType != "table" { - t.Errorf("box[2] LayoutType = %q, want 'table'", original[2].LayoutType) - } - if original[1].LayoutType != "" { - t.Errorf("box[1] LayoutType = %q, want '' (no matching region)", original[1].LayoutType) - } - // All boxes still present - if len(original) != 5 { - t.Errorf("all boxes preserved: got %d, want 5", len(original)) - } - t.Logf("Write-back verified: box[0]=%q box[2]=%q", original[0].LayoutType, original[2].LayoutType) -} - -func TestBoxesToSections_PositionsFromTag(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题段落"}, - } - sections := boxesToSections(boxes, nil) - if sections[0].PositionTag == "" { - t.Error("PositionTag should not be empty") - } - if len(sections[0].Positions) == 0 { - t.Error("Positions should be parsed from PositionTag — BUG: ExtractPositions not called") - } - if len(sections[0].Positions) > 0 { - pos := sections[0].Positions[0] - if pos.Left != 50 || pos.Right != 550 || pos.Top != 100 || pos.Bottom != 112 { - t.Errorf("position coords wrong: got (%.0f,%.0f,%.0f,%.0f)", pos.Left, pos.Right, pos.Top, pos.Bottom) - } - } - t.Logf("Positions: %v", sections[0].Positions) -} - -func TestParse_TableLinkedToSections(t *testing.T) { - // Simulate enrichWithDeepDoc → extractTableAndReplace → boxesToSections: - // table boxes are popped and replaced with one HTML box. - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "heading"}, - {PageNumber: 0, X0: 50, X1: 500, Top: 250, Bottom: 400, Text: "table text", LayoutType: "table"}, - {PageNumber: 0, X0: 50, X1: 200, Top: 450, Bottom: 480, Text: "after"}, - } - tableItem := TableItem{ - Cells: []TSRCell{ - {X0: 0, Y0: 0, X1: 200, Y1: 50, Label: "table row"}, - {X0: 0, Y0: 51, X1: 200, Y1: 100, Label: "table row"}, - }, - Positions: []Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 250, Bottom: 400}}, - Scale: 1.0, - } - - boxes = extractTableAndReplace(boxes, []TableItem{tableItem}) - sections := boxesToSections(boxes, nil) - - // 3 boxes (heading, table, after) → 3 sections (heading, HTML, after). - if len(sections) != 3 { - t.Errorf("expected 3 sections, got %d", len(sections)) - } - tableFound := false - for _, s := range sections { - if s.LayoutType == "table" && strings.Contains(s.Text, "
") { - tableFound = true - } - } - if !tableFound { - t.Errorf("expected at least one section with HTML table") - for _, s := range sections { - t.Logf(" section text=%q LayoutType=%q", s.Text[:min(40, len(s.Text))], s.LayoutType) - } - } -} - -func cellTexts(cells []TSRCell) []string { - t := make([]string, len(cells)) - for i, c := range cells { - t[i] = c.Text - } - return t -} - -// ── cropImageRegion ──────────────────────────────────────────────────── - -func TestCropImageRegion(t *testing.T) { - img := image.NewRGBA(image.Rect(0, 0, 200, 300)) - - t.Run("normal crop", func(t *testing.T) { - r := DLARegion{X0: 10, Y0: 20, X1: 100, Y1: 150} - cropped, err := cropImageRegion(img, r) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // 3% proportional margin: 90×3%≈3px, 130×3%≈4px → 95×137 - if cropped.Bounds().Dx() != 95 || cropped.Bounds().Dy() != 137 { - t.Errorf("size %v, want 95x137", cropped.Bounds()) - } - }) - - t.Run("x0 >= x1 returns error", func(t *testing.T) { - // 3% proportional margin on each side: if the gap is too small after margin expansion, x0 ≥ x1 triggers error. - r := DLARegion{X0: 110, Y0: 20, X1: 50, Y1: 150} - _, err := cropImageRegion(img, r) - if err == nil { - t.Fatal("expected error for x0 >= x1, got nil") - } - }) - - t.Run("y0 >= y1 returns error", func(t *testing.T) { - r := DLARegion{X0: 10, Y0: 150, X1: 100, Y1: 20} - _, err := cropImageRegion(img, r) - if err == nil { - t.Fatal("expected error for y0 >= y1, got nil") - } - }) - - t.Run("region fully outside image bounds", func(t *testing.T) { - // Clamped to image bounds → zero-width/height → error. - r := DLARegion{X0: 300, Y0: 400, X1: 500, Y1: 600} - _, err := cropImageRegion(img, r) - if err == nil { - t.Fatal("expected error for region outside image bounds") - } - }) -} - -// ── extractTableBoxesFromImage: invalid DLA region ───────────────────── - -func TestExtractTableBoxes_InvalidRegion(t *testing.T) { - // DLA returns a table region with x1 < x0. The pipeline should skip - // this table gracefully (Python raises ValueError from PIL.Image.crop). - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 500, Y0: 100, X1: 100, Y1: 300, Label: "table", Confidence: 0.9}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) - tables := p.extractTableBoxesFromImage(context.Background(), nil, dummy, 0, 0) - if len(tables) != 0 { - t.Errorf("invalid DLA region should be skipped, got %d tables", len(tables)) - } -} - -// ── DLA → figure end-to-end ─────────────────────────────────────────── - -func TestParse_CollectsFigures(t *testing.T) { - // End-to-end: Parse() with mock DeepDoc that labels a box as "figure". - // Verify p.Figures is populated. - - eng := &mockEngine{pageCount: 1, chars: map[int][]TextChar{0: {{X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "chart image"}}}} - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 50, Y0: 200, X1: 2000, Y1: 1000, Label: "figure", Confidence: 0.85}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(result.Sections) == 0 { - t.Fatal("expected at least 1 section") - } - if len(result.Figures) != 1 { - t.Fatalf("expected 1 figure, got %d", len(result.Figures)) - } - if result.Figures[0].LayoutType != "figure" { - t.Errorf("figure LayoutType = %q, want 'figure'", result.Figures[0].LayoutType) - } - if result.Figures[0].Text == "" { - t.Error("figure Text should not be empty") - } -} - -func TestParse_NoFigures(t *testing.T) { - // Parse() with no DLA figure regions → p.Figures should be empty. - - eng := &mockEngine{pageCount: 1, chars: map[int][]TextChar{0: {{X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "just text"}}}} - mock := &MockDocAnalyzer{ - DLARegions: []DLARegion{ - {X0: 150, Y0: 300, X1: 1500, Y1: 600, Label: "text", Confidence: 0.8}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(result.Figures) != 0 { - t.Fatalf("expected 0 figures, got %d", len(result.Figures)) - } -} - -func TestParse_NoDeepDoc_NoFigures(t *testing.T) { - // Parse() with mock DeepDoc → Figures should be empty (no DLA-detected figures). - - eng := &mockEngine{pageCount: 1, chars: map[int][]TextChar{0: {{X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "text"}}}} - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) - - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(result.Figures) != 0 { - t.Fatalf("expected 0 Figures (no DLA-detected figures), got %d", len(result.Figures)) - } -} - -// ── Parse + ocrMergeChars (full-page detect) ────────────────────────── - -func TestParse_UsesOCRDetectForEmbeddedChars(t *testing.T) { - // When DeepDoc is available and the page has embedded chars, - // Parse should use ocrMergeChars (detect → merge → recognize). - eng := &mockEngine{ - pageCount: 1, - chars: map[int][]TextChar{0: { - {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}, - }}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - OCRBoxes: []OCRBox{ - {X0: 5, Y0: 5, X1: 50, Y1: 5, X2: 50, Y2: 50, X3: 5, Y3: 50}, - }, - } - p := NewParser(DefaultParserConfig(), mock) - - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(result.Sections) == 0 { - t.Fatal("expected at least 1 section") - } - // The box should come from OCR detect, not charsToBoxes. - // Verifying that ocrMergeChars was used (sections exist). - if result.Metrics.BoxesInitial == 0 { - t.Error("expected BoxesInitial > 0 (OCR detect path)") - } -} - -func TestParse_FallsBackToCharsToBoxes_NoDeepDoc(t *testing.T) { - // Without DeepDoc, Parse should use charsToBoxes (unchanged behavior). - eng := &mockEngine{ - pageCount: 1, - chars: map[int][]TextChar{0: { - {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}, - }}, - } - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) - - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(result.Sections) == 0 { - t.Fatal("expected at least 1 section (charsToBoxes)") - } -} - -func TestParse_FallsBackToCharsToBoxes_EmptyOCRBoxes(t *testing.T) { - // OCRDetect returns no boxes → falls through to charsToBoxes. - eng := &mockEngine{ - pageCount: 1, - chars: map[int][]TextChar{0: { - {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}, - }}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - OCRBoxes: []OCRBox{}, // empty detect - } - p := NewParser(DefaultParserConfig(), mock) - - result, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse: %v", err) - } - if len(result.Sections) == 0 { - t.Fatal("expected at least 1 section (charsToBoxes fallback)") - } -} - -// ── Error path coverage ──────────────────────────────────────────────── - -func TestMockDocAnalyzer_DLAError_DoesNotCrash(t *testing.T) { - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{ - Healthy: true, - DLAErr: fmt.Errorf("DLA service unavailable"), - }) - eng := &mockEngine{pageCount: 1} - img := image.NewRGBA(image.Rect(0, 0, 100, 100)) - pageImages := map[int]image.Image{0: img} - boxes := []TextBox{ - {PageNumber: 0, X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "text"}, - } - // enrichWithDeepDoc should return nil (not panic) on DLA error. - tables := p.enrichWithDeepDoc(context.Background(), eng, boxes, pageImages) - if len(tables) != 0 { - t.Errorf("DLA error should produce 0 tables, got %d", len(tables)) - } -} - -func TestMockDocAnalyzer_TSRError_DoesNotCrash(t *testing.T) { - // TSR error: DLA succeeds, TSR fails. The table region is detected - // but no cells are returned — the table is skipped gracefully. - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 0, Y0: 0, X1: 400, Y1: 400, Label: "table", Confidence: 0.95}, - }, - TSRErr: fmt.Errorf("TSR model timeout"), - }) - eng := &mockEngine{pageCount: 1} - img := image.NewRGBA(image.Rect(0, 0, 100, 100)) - pageImages := map[int]image.Image{0: img} - boxes := []TextBox{ - {PageNumber: 0, X0: 10, X1: 90, Top: 10, Bottom: 90, Text: "in table region"}, - } - tables := p.enrichWithDeepDoc(context.Background(), eng, boxes, pageImages) - // DLA detects the table region → 1 TableItem is created. TSR failure - // means it has no cells, but the pipeline must not panic. - if len(tables) != 1 { - t.Errorf("TSR error: expected 1 table (DLA region found), got %d", len(tables)) - } - if len(tables[0].Cells) != 0 { - t.Errorf("TSR error: Cells should be empty, got %d", len(tables[0].Cells)) - } -} - -func TestMockDocAnalyzer_OCRDetectError_DoesNotCrash(t *testing.T) { - // OCRDetect failure path: extractPages uses ocrDetectAndRecognize which - // calls doc.OCRDetect. When it fails, the page is skipped gracefully. - mock := &MockDocAnalyzer{Healthy: true, OCRDetectErr: fmt.Errorf("OCR model OOM")} - eng := &mockEngine{ - pageCount: 1, - chars: map[int][]TextChar{}, // empty → triggers OCR path - } - p := NewParser(DefaultParserConfig(), mock) - _, err := p.Parse(context.Background(), eng) - if err != nil { - t.Fatalf("Parse returned error: %v", err) - } - // Parse should succeed — the page with OCRDetect error is just skipped. -} - -// TestTSRLabels verifies Go defaultTSRLabels matches Python's table_structure_recognizer.py labels. -// Order must be exact — the ONNX model returns class IDs that index into this array. -func TestTSRLabels(t *testing.T) { - want := []string{ - "table", "table column", "table row", - "table column header", "table projected row header", - "table spanning cell", - } - if len(defaultTSRLabels) != len(want) { - t.Fatalf("defaultTSRLabels length %d, want %d", len(defaultTSRLabels), len(want)) - } - for i := range want { - if defaultTSRLabels[i] != want[i] { - t.Errorf("defaultTSRLabels[%d] = %q, want %q", i, defaultTSRLabels[i], want[i]) - } - } -} diff --git a/internal/deepdoc/parser/pdf/dla_realworld_test.go b/internal/deepdoc/parser/pdf/dla_real_world_test.go similarity index 98% rename from internal/deepdoc/parser/pdf/dla_realworld_test.go rename to internal/deepdoc/parser/pdf/dla_real_world_test.go index f80517d977..e64512dee0 100644 --- a/internal/deepdoc/parser/pdf/dla_realworld_test.go +++ b/internal/deepdoc/parser/pdf/dla_real_world_test.go @@ -12,7 +12,7 @@ import ( // TestDLARealWorldCompare runs DLA on fixture PDFs and verifies // region count, label types, and structural invariants. func TestDLARealWorldCompare(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) outDir := filepath.Join("testdata", "output", "render_compare") os.MkdirAll(outDir, 0755) diff --git a/internal/deepdoc/parser/pdf/dla_tsr_compare_test.go b/internal/deepdoc/parser/pdf/dla_tsr_compare_test.go index 698c462d25..31584945a5 100644 --- a/internal/deepdoc/parser/pdf/dla_tsr_compare_test.go +++ b/internal/deepdoc/parser/pdf/dla_tsr_compare_test.go @@ -9,6 +9,8 @@ import ( "image/png" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" "testing" ) @@ -22,7 +24,7 @@ import ( // 2. Run Python: python3 tools/dla_tsr_compare.py // 3. Diff the JSON: diff testdata/output/render_compare/go_dla.json testdata/output/render_compare/py_dla.json func TestDLATSRResponseCompare(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() @@ -35,7 +37,7 @@ func TestDLATSRResponseCompare(t *testing.T) { os.MkdirAll(outDir, 0755) // Save rendered image as JPEG (matching what DLA/TSR actually send). - jpegData, err := encodeJPEG(pageImg) + jpegData, err := util.EncodeJPEG(pageImg) if err != nil { t.Fatalf("encode jpeg: %v", err) } @@ -57,7 +59,7 @@ func TestDLATSRResponseCompare(t *testing.T) { } // ── TSR (crop first table region) ── - var tableRegion *DLARegion + var tableRegion *pdf.DLARegion for i := range regions { if regions[i].Label == "table" { tableRegion = ®ions[i] @@ -72,7 +74,7 @@ func TestDLATSRResponseCompare(t *testing.T) { int(tableRegion.X1), int(tableRegion.Y1)) cropPath := filepath.Join(outDir, "tsr_input.jpeg") - cropJPEG, _ := encodeJPEG(cropped) + cropJPEG, _ := util.EncodeJPEG(cropped) os.WriteFile(cropPath, cropJPEG, 0644) cells, err := client.TSR(context.Background(), cropped) @@ -104,7 +106,7 @@ func TestDLATSRResponseCompare(t *testing.T) { int(b.X0), int(b.Y0), int(b.X2), int(b.Y2)) cropPath := filepath.Join(outDir, "ocr_rec_input.jpeg") - recJPEG, _ := encodeJPEG(cropped) + recJPEG, _ := util.EncodeJPEG(cropped) os.WriteFile(cropPath, recJPEG, 0644) texts, err := client.OCRRecognize(context.Background(), cropped) diff --git a/internal/deepdoc/parser/pdf/garbled_test.go b/internal/deepdoc/parser/pdf/garbled_test.go deleted file mode 100644 index 1ff188e719..0000000000 --- a/internal/deepdoc/parser/pdf/garbled_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package parser - -import ( - "testing" -) - -func TestIsGarbledChar(t *testing.T) { - tests := []struct { - name string - ch string - want bool - }{ - {"empty", "", false}, - {"normal ascii", "A", false}, - {"normal chinese", "你", false}, - {"PUA char E000", "", true}, - {"PUA char F8FF", "", true}, - {"replacement char", "�", true}, - {"null control", "\x00", true}, - {"tab", "\t", false}, - {"newline", "\n", false}, - {"C1 control", "€", true}, - {"C1 control 9F", "Ÿ", true}, - {"normal single byte", "z", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := IsGarbledChar(tt.ch) - if got != tt.want { - t.Errorf("IsGarbledChar(%q) = %v, want %v", tt.ch, got, tt.want) - } - }) - } -} - -func TestIsGarbledText(t *testing.T) { - tests := []struct { - name string - text string - threshold float64 - want bool - }{ - {"empty", "", 0.5, false}, - {"normal text", "正常文本", 0.5, false}, - {"cid pattern", "(cid:123)", 0.5, true}, - {"all garbled", "", 0.5, true}, - {"one garbled in many", "ABDEFGHI", 0.5, false}, - {"half garbled strict", "AB", 0.5, true}, - {"half garbled loose", "AB", 0.7, false}, - {"english text", "Hello World", 0.5, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := IsGarbledText(tt.text, tt.threshold) - if got != tt.want { - t.Errorf("IsGarbledText(%q, %v) = %v, want %v", tt.text, tt.threshold, got, tt.want) - } - }) - } -} - -func TestHasSubsetFontPrefix(t *testing.T) { - tests := []struct { - name string - fontName string - want bool - }{ - {"subset prefix", "DY1+ZLQDm1-1", true}, - {"short subset", "AB+SimSun", true}, - {"no prefix", "SimSun", false}, - {"empty", "", false}, - {"just plus", "+SimSun", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HasSubsetFontPrefix(tt.fontName) - if got != tt.want { - t.Errorf("HasSubsetFontPrefix(%q) = %v, want %v", tt.fontName, got, tt.want) - } - }) - } -} - -func TestIsGarbledByFontEncoding(t *testing.T) { - t.Run("too few chars", func(t *testing.T) { - chars := make([]TextChar, 10) - if IsGarbledByFontEncoding(chars, 20) { - t.Error("should return false when below minChars threshold") - } - }) - - t.Run("subset font with ascii — garbled", func(t *testing.T) { - // Simulate CJK PDF with broken font encoding: all chars have subset font prefix, - // virtually no CJK, almost all ASCII punctuation - var chars []TextChar - for i := 0; i < 30; i++ { - chars = append(chars, TextChar{ - Text: "!", - FontName: "DY1+SimSun", - }) - } - // Add some CJK (but below 5%) - chars = append(chars, TextChar{Text: "你", FontName: "DY1+SimSun"}) - if !IsGarbledByFontEncoding(chars, 20) { - t.Error("should detect garbled font encoding") - } - }) - - t.Run("regular CJK text — not garbled", func(t *testing.T) { - var chars []TextChar - for i := 0; i < 30; i++ { - chars = append(chars, TextChar{ - Text: "测试文本内容", - FontName: "SimSun", - }) - } - if IsGarbledByFontEncoding(chars, 20) { - t.Error("should not flag regular CJK text as garbled") - } - }) - - t.Run("fullwidth chars from subset font — not garbled", func(t *testing.T) { - // Fullwidth characters (U+FF01-U+FF5E) are legitimate CJK typographic forms. - // They should count as cjkLike, preventing false garbled detection. - var chars []TextChar - for i := 0; i < 30; i++ { - chars = append(chars, TextChar{ - Text: "ABCDEF", // U+FF21-U+FF26 fullwidth uppercase - FontName: "DY1+SimSun", - }) - } - if IsGarbledByFontEncoding(chars, 20) { - t.Error("fullwidth chars from subset font should NOT be garbled") - } - }) - - t.Run("normal English text — not garbled", func(t *testing.T) { - var chars []TextChar - for i := 0; i < 30; i++ { - chars = append(chars, TextChar{ - Text: "Hello world text content here", - FontName: "Times-Roman", - }) - } - if IsGarbledByFontEncoding(chars, 20) { - t.Error("should not flag regular English text as garbled") - } - }) -} - -func TestDetectGarbled(t *testing.T) { - // Normal CJK text - chars := make([]TextChar, 30) - for i := range chars { - chars[i] = TextChar{Text: "正常文本", FontName: "SimSun"} - } - if DetectGarbled(chars) { - t.Error("normal CJK should not be garbled") - } - - // Subset font with punctuation - var garbled []TextChar - for i := 0; i < 30; i++ { - garbled = append(garbled, TextChar{Text: "!", FontName: "DY1+SimSun"}) - } - if !DetectGarbled(garbled) { - t.Error("subset font with punctuation should be garbled") - } -} - -// ── pdf_oxide ### detection tests ───────────────────────────────────── - -func TestPdfOxideUnmappedGarbled_Empty(t *testing.T) { - if pdfOxideUnmappedGarbled("") { - t.Error("empty text should not be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_NormalText(t *testing.T) { - if pdfOxideUnmappedGarbled("这是一段正常的中文文本没有任何问题") { - t.Error("normal Chinese text should not be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_SingleHash(t *testing.T) { - // A single # is not enough (could be a phone number or reference). - if pdfOxideUnmappedGarbled("参考 #123 的文献") { - t.Error("single # should not be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_TripleHashCluster(t *testing.T) { - // Two ### sequences => garbled. - if !pdfOxideUnmappedGarbled("我信###D_8-.###$#(") { - t.Error("two ### clusters should be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_QuadHash(t *testing.T) { - // One #### counts as one ### cluster. Need two for trigger. - // But density may also be high enough. - if !pdfOxideUnmappedGarbled("text####abc####def") { - t.Error("two #### clusters should be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_SingleTriple(t *testing.T) { - // Single ### cluster => garbled. In a 200-char sample "###" is impossible - // in normal text (URLs/markdown use at most "##"). - if !pdfOxideUnmappedGarbled("hello###world normal text here") { - t.Error("single ### cluster should be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_HighDensity(t *testing.T) { - // 10 # chars mixed among 40+ non-space chars = 25% → garbled. - text := "#a#b#c#d#e#f#g#h#i#j" + " extra normal chars padding to reach minimum" - if !pdfOxideUnmappedGarbled(text) { - t.Error("high # density should be garbled") - } -} - -func TestPdfOxideUnmappedGarbled_RealWorldGarbled(t *testing.T) { - // Simulates the garbled page from 1例3个月...pdf: - // Chinese text mixed with ###D_ style unmapped glyph patterns. - garbled := "和蔘语言###D_8-.*/*护理全科##%&$ 80引用\"\"###$#(点向患儿" - if !pdfOxideUnmappedGarbled(garbled) { - t.Error("real-world garbled text with ### clusters should be detected") - } -} diff --git a/internal/deepdoc/parser/pdf/image_utils.go b/internal/deepdoc/parser/pdf/image_utils.go deleted file mode 100644 index e609ef0644..0000000000 --- a/internal/deepdoc/parser/pdf/image_utils.go +++ /dev/null @@ -1,26 +0,0 @@ -package parser - -import ( - "bytes" - "image" - "image/jpeg" - "image/png" -) - -// ── image encoding helpers ───────────────────────────────────────────── - -func encodePNG(img image.Image) ([]byte, error) { - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func encodeJPEG(img image.Image) ([]byte, error) { - var buf bytes.Buffer - if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { - return nil, err - } - return buf.Bytes(), nil -} diff --git a/internal/deepdoc/parser/pdf/deepdoc.go b/internal/deepdoc/parser/pdf/inference/client.go similarity index 67% rename from internal/deepdoc/parser/pdf/deepdoc.go rename to internal/deepdoc/parser/pdf/inference/client.go index 8acf78e16c..e7e5e48b43 100644 --- a/internal/deepdoc/parser/pdf/deepdoc.go +++ b/internal/deepdoc/parser/pdf/inference/client.go @@ -1,4 +1,4 @@ -package parser +package inference import ( "bytes" @@ -15,29 +15,33 @@ import ( "sync" "time" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" + "github.com/cenkalti/backoff/v5" ) -// DeepDocClient wraps the DeepDoc HTTP API. -type DeepDocClient struct { +// InferenceClient wraps the DeepDoc HTTP API. +type InferenceClient struct { baseURL string httpClient *http.Client - modelOnce sync.Once - model ModelType // Label tables for class_id → label string mapping. - // Set by the service layer (Oss/Saas) to reflect the model's taxonomy. + // Set by the service layer (model-specific) to reflect the model's taxonomy. DLALabels []string TSRLabels []string } -// NewDeepDocClient creates a client. baseURL must be provided by the caller +// BaseURL returns the configured DeepDoc service URL. +func (c *InferenceClient) BaseURL() string { return c.baseURL } + +// NewInferenceClient creates a client. baseURL must be provided by the caller // (e.g. from the DEEPDOC_URL environment variable). Returns an error if empty. -func NewDeepDocClient(baseURL string) (*DeepDocClient, error) { +func NewInferenceClient(baseURL string) (*InferenceClient, error) { if baseURL == "" { return nil, fmt.Errorf("deepdoc client: baseURL is required (set DEEPDOC_URL)") } - return &DeepDocClient{ + return &InferenceClient{ baseURL: baseURL, httpClient: &http.Client{ Timeout: 120 * time.Second, @@ -45,27 +49,31 @@ func NewDeepDocClient(baseURL string) (*DeepDocClient, error) { }, nil } -// Default DLA/TSR label tables. Service constructors replace these with -// model-specific labels (OSS 6-class TSR, SaaS 2-class, etc.). -var defaultDLALabels = []string{ - LayoutTypeTitle, LayoutTypeText, LayoutTypeReference, - LayoutTypeFigure, DLALabelFigureCaption, - LayoutTypeTable, DLALabelTableCaption, DLALabelTableCaption, - LayoutTypeEquation, DLALabelFigureCaption, +// Default DLA/TSR label tables used as fallback when no model-specific +// labels are injected by a TableBuilder constructor. +func DefaultDLALabels() []string { + return []string{ + pdf.LayoutTypeTitle, pdf.LayoutTypeText, pdf.LayoutTypeReference, + pdf.LayoutTypeFigure, pdf.DLALabelFigureCaption, + pdf.LayoutTypeTable, pdf.DLALabelTableCaption, pdf.DLALabelTableCaption, + pdf.LayoutTypeEquation, pdf.DLALabelFigureCaption, + } } -var defaultTSRLabels = []string{ - "table", "table column", "table row", - "table column header", "table projected row header", - "table spanning cell", +func DefaultTSRLabels() []string { + return []string{ + "table", "table column", "table row", + "table column header", "table projected row header", + "table spanning cell", + } } type bboxesResponse struct { BBoxes [][]float64 `json:"bboxes"` } -// DLA analyses a full page image and returns labelled regions. -func (c *DeepDocClient) DLA(ctx context.Context, pageImage image.Image) ([]DLARegion, error) { - data, err := encodeJPEG(pageImage) +// DLA analyzes a full page image and returns labeled regions. +func (c *InferenceClient) DLA(ctx context.Context, pageImage image.Image) ([]pdf.DLARegion, error) { + data, err := util.EncodeJPEG(pageImage) if err != nil { return nil, fmt.Errorf("dla: encode: %w", err) } @@ -73,20 +81,20 @@ func (c *DeepDocClient) DLA(ctx context.Context, pageImage image.Image) ([]DLARe if err := c.post(ctx, "/predict/dla", data, "dla.jpeg", &resp); err != nil { return nil, fmt.Errorf("dla: %w", err) } - regions := make([]DLARegion, 0, len(resp.BBoxes)) + regions := make([]pdf.DLARegion, 0, len(resp.BBoxes)) for _, b := range resp.BBoxes { if len(b) < 6 { continue } labels := c.DLALabels if labels == nil { - labels = defaultDLALabels + labels = DefaultDLALabels() } label := "" if clsID := int(b[5]); clsID >= 0 && clsID < len(labels) { label = labels[clsID] } - regions = append(regions, DLARegion{ + regions = append(regions, pdf.DLARegion{ X0: b[0], Y0: b[1], X1: b[2], Y1: b[3], Confidence: b[4], Label: label, @@ -96,8 +104,8 @@ func (c *DeepDocClient) DLA(ctx context.Context, pageImage image.Image) ([]DLARe } // TSR recognises table structure from a cropped image. -func (c *DeepDocClient) TSR(ctx context.Context, cropped image.Image) ([]TSRCell, error) { - data, err := encodeJPEG(cropped) +func (c *InferenceClient) TSR(ctx context.Context, cropped image.Image) ([]pdf.TSRCell, error) { + data, err := util.EncodeJPEG(cropped) if err != nil { return nil, fmt.Errorf("tsr: encode: %w", err) } @@ -105,14 +113,14 @@ func (c *DeepDocClient) TSR(ctx context.Context, cropped image.Image) ([]TSRCell if err := c.post(ctx, "/predict/tsr", data, "tsr.jpeg", &resp); err != nil { return nil, fmt.Errorf("tsr: %w", err) } - cells := make([]TSRCell, 0, len(resp.BBoxes)) + cells := make([]pdf.TSRCell, 0, len(resp.BBoxes)) for _, b := range resp.BBoxes { if len(b) < 5 { continue } tlabels := c.TSRLabels if tlabels == nil { - tlabels = defaultTSRLabels + tlabels = DefaultTSRLabels() } label := "" if len(b) >= 6 { @@ -120,7 +128,7 @@ func (c *DeepDocClient) TSR(ctx context.Context, cropped image.Image) ([]TSRCell label = tlabels[cls] } } - cells = append(cells, TSRCell{ + cells = append(cells, pdf.TSRCell{ X0: b[0], Y0: b[1], X1: b[2], Y1: b[3], Label: label, }) @@ -144,8 +152,8 @@ type ocrRecognizeResponse struct { // OCRDetect detects text regions (bounding boxes) in an image. // DeepDoc /predict/ocr with operator=det returns quad boxes: [[[x0,y0],[x1,y1],[x2,y2],[x3,y3]], ...] -func (c *DeepDocClient) OCRDetect(ctx context.Context, cropped image.Image) ([]OCRBox, error) { - data, err := encodeJPEG(cropped) +func (c *InferenceClient) OCRDetect(ctx context.Context, cropped image.Image) ([]pdf.OCRBox, error) { + data, err := util.EncodeJPEG(cropped) if err != nil { return nil, fmt.Errorf("ocr detect: encode: %w", err) } @@ -168,14 +176,14 @@ func (c *DeepDocClient) OCRDetect(ctx context.Context, cropped image.Image) ([]O return nil, fmt.Errorf("ocr detect: %w", err) } - var boxes []OCRBox + var boxes []pdf.OCRBox for _, outer := range result.Output { for _, page := range outer { for _, box := range page { if len(box) < 4 { continue } - boxes = append(boxes, OCRBox{ + boxes = append(boxes, pdf.OCRBox{ X0: box[0][0], Y0: box[0][1], X1: box[1][0], Y1: box[1][1], X2: box[2][0], Y2: box[2][1], @@ -189,8 +197,8 @@ func (c *DeepDocClient) OCRDetect(ctx context.Context, cropped image.Image) ([]O // OCRRecognize recognizes text in a cropped image region. // DeepDoc /predict/ocr with operator=rec returns [[["text", confidence], ...]] -func (c *DeepDocClient) OCRRecognize(ctx context.Context, cropped image.Image) ([]OCRText, error) { - data, err := encodeJPEG(cropped) +func (c *InferenceClient) OCRRecognize(ctx context.Context, cropped image.Image) ([]pdf.OCRText, error) { + data, err := util.EncodeJPEG(cropped) if err != nil { return nil, fmt.Errorf("ocr rec: encode: %w", err) } @@ -198,14 +206,14 @@ func (c *DeepDocClient) OCRRecognize(ctx context.Context, cropped image.Image) ( if err := c.post(ctx, "/predict/ocr", data, "ocr_rec.jpeg", &result, "operator", "rec"); err != nil { return nil, fmt.Errorf("ocr rec: %w", err) } - var texts []OCRText + var texts []pdf.OCRText for _, page := range result.Output { for _, item := range page { for _, pair := range item { if len(pair) >= 2 { text, _ := pair[0].(string) conf, _ := pair[1].(float64) - texts = append(texts, OCRText{Text: text, Confidence: conf}) + texts = append(texts, pdf.OCRText{Text: text, Confidence: conf}) } } } @@ -216,8 +224,8 @@ func (c *DeepDocClient) OCRRecognize(ctx context.Context, cropped image.Image) ( // OCRRecognizeBatch recognizes text in multiple cropped image regions. // Returns a slice of results and a parallel slice of errors (nil on success). // A nil cropped image in the input produces nil results and a non-nil error. -func (c *DeepDocClient) OCRRecognizeBatch(ctx context.Context, cropped []image.Image) ([][]OCRText, []error) { - results := make([][]OCRText, len(cropped)) +func (c *InferenceClient) OCRRecognizeBatch(ctx context.Context, cropped []image.Image) ([][]pdf.OCRText, []error) { + results := make([][]pdf.OCRText, len(cropped)) errs := make([]error, len(cropped)) // Process images concurrently with a bounded worker pool to avoid @@ -247,7 +255,7 @@ func (c *DeepDocClient) OCRRecognizeBatch(ctx context.Context, cropped []image.I } // Health checks whether the DeepDoc service is reachable. -func (c *DeepDocClient) Health() bool { +func (c *InferenceClient) Health() bool { resp, err := c.httpClient.Get(c.baseURL + "/health") if err != nil { return false @@ -256,48 +264,7 @@ func (c *DeepDocClient) Health() bool { return resp.StatusCode == 200 } -// ModelType probes the DeepDoc /model endpoint once and caches the model flavour. -// The /model endpoint is expected to return JSON like {"model":"oss","version":"1.0"}. -// When the endpoint is unreachable or model is not "oss", ModelSaas is returned. -// Uses sync.Once so the call is safe for concurrent use. -func (c *DeepDocClient) ModelType() ModelType { - c.modelOnce.Do(func() { - c.model = ModelSaas - resp, err := c.httpClient.Get(c.baseURL + "/model") - if err != nil { - return - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return - } - var h struct { - Model string `json:"model"` - } - if err := json.NewDecoder(resp.Body).Decode(&h); err != nil { - slog.Warn("deepdoc /model: failed to decode response, falling back to SaaS", - "err", err) - return - } - if h.Model == "oss" { - c.model = ModelOSS - } - }) - return c.model -} - -// NewTableBuilderFor creates the right TableBuilder for the given -// DocAnalyzer, chosen by ModelType(). -func NewTableBuilderFor(doc DocAnalyzer) TableBuilder { - switch doc.ModelType() { - case ModelOSS: - return NewOssDeepDocService(doc) - default: - return NewSaasDeepDocService(doc) - } -} - -func (c *DeepDocClient) post(ctx context.Context, endpoint string, imgData []byte, filename string, result interface{}, extraFields ...string) error { +func (c *InferenceClient) post(ctx context.Context, endpoint string, imgData []byte, filename string, result interface{}, extraFields ...string) error { // Build multipart body once — the image data is idempotent. var body bytes.Buffer w := multipart.NewWriter(&body) diff --git a/internal/deepdoc/parser/pdf/deepdoc_http_test.go b/internal/deepdoc/parser/pdf/inference/client_test.go similarity index 97% rename from internal/deepdoc/parser/pdf/deepdoc_http_test.go rename to internal/deepdoc/parser/pdf/inference/client_test.go index a831e03202..24ccf2c349 100644 --- a/internal/deepdoc/parser/pdf/deepdoc_http_test.go +++ b/internal/deepdoc/parser/pdf/inference/client_test.go @@ -1,4 +1,4 @@ -package parser +package inference import ( "context" @@ -11,11 +11,11 @@ import ( "testing" ) -// mustNewDeepDocClient wraps NewDeepDocClient for test convenience. +// mustNewDeepDocClient wraps NewInferenceClient for test convenience. // Fails the test if the URL is empty. -func mustNewDeepDocClient(t *testing.T, baseURL string) *DeepDocClient { +func mustNewDeepDocClient(t *testing.T, baseURL string) *InferenceClient { t.Helper() - client, err := NewDeepDocClient(baseURL) + client, err := NewInferenceClient(baseURL) if err != nil { t.Fatalf("NewDeepDocClient(%q): %v", baseURL, err) } diff --git a/internal/deepdoc/parser/pdf/oss_deepdoc_service_integration_test.go b/internal/deepdoc/parser/pdf/inference_client_integration_test.go similarity index 58% rename from internal/deepdoc/parser/pdf/oss_deepdoc_service_integration_test.go rename to internal/deepdoc/parser/pdf/inference_client_integration_test.go index f46a30dd1a..fc3d343772 100644 --- a/internal/deepdoc/parser/pdf/oss_deepdoc_service_integration_test.go +++ b/internal/deepdoc/parser/pdf/inference_client_integration_test.go @@ -4,42 +4,22 @@ package parser import ( "context" - "os" "strings" "testing" + + tbl "ragflow/internal/deepdoc/parser/pdf/table" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) -// mustConnectOssDeepDoc returns a DeepDocClient pointed at the OSS service; -// skips the test if unavailable or if the service reports a non-OSS model type. -func mustConnectOssDeepDoc(t *testing.T) *DeepDocClient { - t.Helper() - url := os.Getenv("OSSDEEPDOC_URL") - if url == "" { - url = "http://localhost:9390" - } - client, err := NewDeepDocClient(url) - if err != nil { - t.Fatal(err) - } - if !client.Health() { - t.Fatalf("OssDeepDoc not available at %s", url) - } - if client.ModelType() != ModelOSS { - t.Skipf("DeepDoc at %s is %q, not oss — skipping OSS-specific test", url, client.ModelType()) - } - return client -} - -// TestIntegration_OssDeepDoc_TableStructure verifies that parsing a PDF -// through the OssDeepDoc TableBuilder produces tables with the expected -// row/column structure. -func TestIntegration_OssDeepDoc_TableStructure(t *testing.T) { - client := mustConnectOssDeepDoc(t) +// TestIntegration_DeepDoc_TableStructure verifies that parsing a PDF +// through the OSS TableBuilder produces tables with the expected row/column structure. +func TestIntegration_DeepDoc_TableStructure(t *testing.T) { + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() - cfg := DefaultParserConfig() - cfg.TableBuilder = NewOssDeepDocService(client) + cfg := pdf.DefaultParserConfig() + cfg.TableBuilder = tbl.NewDeepDocTableBuilder(client) p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -67,15 +47,15 @@ func TestIntegration_OssDeepDoc_TableStructure(t *testing.T) { } } -// TestIntegration_OssDeepDoc_TableRows verifies each table has non-empty +// TestIntegration_DeepDoc_TableRows verifies each table has non-empty // rows with the expected grid structure. -func TestIntegration_OssDeepDoc_TableRows(t *testing.T) { - client := mustConnectOssDeepDoc(t) +func TestIntegration_DeepDoc_TableRows(t *testing.T) { + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() - cfg := DefaultParserConfig() - cfg.TableBuilder = NewOssDeepDocService(client) + cfg := pdf.DefaultParserConfig() + cfg.TableBuilder = tbl.NewDeepDocTableBuilder(client) p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -106,17 +86,17 @@ func TestIntegration_OssDeepDoc_TableRows(t *testing.T) { } } -// TestIntegration_OssDeepDoc_Idempotency verifies that parsing the same PDF +// TestIntegration_DeepDoc_Idempotency verifies that parsing the same PDF // twice produces the same table row structure. -func TestIntegration_OssDeepDoc_Idempotency(t *testing.T) { - client := mustConnectOssDeepDoc(t) +func TestIntegration_DeepDoc_Idempotency(t *testing.T) { + client := mustConnectInferenceClient(t) - parseOnce := func() *ParseResult { + parseOnce := func() *pdf.ParseResult { eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() - cfg := DefaultParserConfig() - cfg.TableBuilder = NewOssDeepDocService(client) + cfg := pdf.DefaultParserConfig() + cfg.TableBuilder = tbl.NewDeepDocTableBuilder(client) p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -140,15 +120,15 @@ func TestIntegration_OssDeepDoc_Idempotency(t *testing.T) { } } -// TestIntegration_OssDeepDoc_EmptyPage verifies that a page with no tables +// TestIntegration_DeepDoc_EmptyPage verifies that a page with no tables // does not crash. -func TestIntegration_OssDeepDoc_EmptyPage(t *testing.T) { - client := mustConnectOssDeepDoc(t) +func TestIntegration_DeepDoc_EmptyPage(t *testing.T) { + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "01_english_simple.pdf") defer eng.Close() - cfg := DefaultParserConfig() - cfg.TableBuilder = NewOssDeepDocService(client) + cfg := pdf.DefaultParserConfig() + cfg.TableBuilder = tbl.NewDeepDocTableBuilder(client) p := NewParser(cfg, client) _, err := p.Parse(context.Background(), eng) if err != nil { diff --git a/internal/deepdoc/parser/pdf/layout/boxes_sections.go b/internal/deepdoc/parser/pdf/layout/boxes_sections.go new file mode 100644 index 0000000000..70853df981 --- /dev/null +++ b/internal/deepdoc/parser/pdf/layout/boxes_sections.go @@ -0,0 +1,174 @@ +package layout + +import ( + "sort" + "strings" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" +) + +// ResolvePageSpan computes the ending page and bottom coordinate for a box +// that may span multiple pages. When pageHeights is nil or the box fits +// within its starting page the returned (toPage, bottom) equal the inputs. +// +// Zero or negative page heights are treated as invalid: the span stops at +// the preceding page, guarding against infinite loops caused by corrupted +// page images. +func ResolvePageSpan(pageNum int, bottom float64, pageHeights map[int]float64) (toPage int, newBottom float64) { + toPage = pageNum + newBottom = bottom + if pageHeights == nil { + return + } + ph, ok := pageHeights[pageNum] + if !ok || ph <= 0 || bottom <= ph { + return + } + remaining := bottom + for remaining > ph && ph > 0 { + nextPh, ok := pageHeights[toPage+1] + if !ok || nextPh <= 0 { + // Unknown or invalid next page height — extend by the + // last known height once and stop (Python: _line_tag + // while-loop break path). + remaining -= ph + toPage++ + break + } + remaining -= ph + ph = nextPh + toPage++ + } + newBottom = remaining + return +} + +// boxesToSections converts layout boxes to section format with position tags. +// +// pageHeights provides the PDF-point height of each page (image height / zoom). +// Boxes that extend beyond their page produce multi-page position tags +// (Python's _line_tag while-loop detection via resolvePageSpan). +// +// Python equivalent: output consumed by naive.py::chunk() +func BoxesToSections(boxes []pdf.TextBox, pageHeights map[int]float64) []pdf.Section { + sections := make([]pdf.Section, 0, len(boxes)) + for _, b := range boxes { + t := strings.TrimSpace(b.Text) + if t == "" { + continue + } + toPage, bottom := ResolvePageSpan(b.PageNumber, b.Bottom, pageHeights) + + var posTag string + var pageNums []int + if b.PageNumber == toPage { + posTag = util.FormatPositionTag(b.PageNumber, b.X0, b.X1, b.Top, bottom) + pageNums = []int{b.PageNumber} + } else { + posTag = util.FormatPositionTagRange(b.PageNumber, toPage, b.X0, b.X1, b.Top, bottom) + pageNums = make([]int, 0, toPage-b.PageNumber+1) + for p := b.PageNumber; p <= toPage; p++ { + pageNums = append(pageNums, p) + } + } + sections = append(sections, pdf.Section{ + Text: t, + PositionTag: posTag, + LayoutType: b.LayoutType, + Positions: []pdf.Position{{PageNumbers: pageNums, Left: b.X0, Right: b.X1, Top: b.Top, Bottom: bottom}}, + }) + } + return sections +} + +// NormalizeSectionPositions ensures each Section's Positions field is populated +// by parsing PositionTag when Positions is empty. Sections that already have +// Positions populated are left unchanged. +// +// This mirrors the Python normalize_pdf_items_metadata — canonicalizing +// position metadata from the string tag format into the typed []Position form. +// +// Callers should invoke this AFTER Parse() returns, just before consuming +// Sections (e.g., before serialization to JSON or passing to the chunker). +// The normalization is intentionally NOT embedded inside the parser pipeline +// because Sections may come from multiple sources (deepdoc, MinerU, Docling, +// JSON deserialization, etc.). +func NormalizeSectionPositions(sections []pdf.Section) { + for i := range sections { + if len(sections[i].Positions) == 0 && sections[i].PositionTag != "" { + sections[i].Positions = util.ExtractPositions(sections[i].PositionTag) + } + } +} + +// SortByPageThenY sorts boxes by page → vertical key → x0. +func SortByPageThenY(boxes []pdf.TextBox, sortByTop bool) { + key := func(b pdf.TextBox) float64 { return b.Bottom } + if sortByTop { + key = func(b pdf.TextBox) float64 { return b.Top } + } + sort.Slice(boxes, func(i, j int) bool { + if boxes[i].PageNumber != boxes[j].PageNumber { + return boxes[i].PageNumber < boxes[j].PageNumber + } + if key(boxes[i]) != key(boxes[j]) { + return key(boxes[i]) < key(boxes[j]) + } + return boxes[i].X0 < boxes[j].X0 + }) +} + +// SectionsToMarkdown converts Sections to a markdown string. +// +// Title sections get a "## " prefix. +// Figure sections produce an "![Image](data:image/png;base64,...)" tag. +// Text and all other sections are appended verbatim. +// +// This mirrors the Python parser.py:665-671 markdown output path. +func SectionsToMarkdown(sections []pdf.Section) string { + var b strings.Builder + for _, s := range sections { + if s.LayoutType == pdf.LayoutTypeTitle { + b.WriteString("\n## ") + } + if s.LayoutType == pdf.LayoutTypeFigure && s.Image != "" { + b.WriteString("\n![Image](data:image/png;base64,") + b.WriteString(s.Image) + b.WriteString(")") + continue + } + b.WriteString(s.Text) + b.WriteString("\n") + } + return b.String() +} + +// SectionsToJSON converts Sections to a Python-compatible JSON dict format. +// +// Each dict has keys: text, layout_type, doc_type_kwd, _pdf_positions, image. +// The _pdf_positions key mirrors Python's PDF_POSITIONS_KEY constant — +// the canonical position format consumed by the chunker's extract_pdf_positions. +// +// This mirrors the Python parser.py:662 set_output("json", bboxes) path. +func SectionsToJSON(sections []pdf.Section) []map[string]any { + result := make([]map[string]any, len(sections)) + for i, s := range sections { + positions := make([][]any, len(s.Positions)) + for j, p := range s.Positions { + pages := make([]any, len(p.PageNumbers)) + for k, pn := range p.PageNumbers { + pages[k] = pn + } + positions[j] = []any{pages, p.Left, p.Right, p.Top, p.Bottom} + } + result[i] = map[string]any{ + "text": s.Text, + "layout_type": s.LayoutType, + "doc_type_kwd": s.DocTypeKwd, + "_pdf_positions": positions, + "image": s.Image, + } + } + return result +} diff --git a/internal/deepdoc/parser/pdf/layout/boxes_sections_test.go b/internal/deepdoc/parser/pdf/layout/boxes_sections_test.go new file mode 100644 index 0000000000..4677edcd9e --- /dev/null +++ b/internal/deepdoc/parser/pdf/layout/boxes_sections_test.go @@ -0,0 +1,392 @@ +package layout + +import ( + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func TestBoxesToSections_CrossPagePositionTag(t *testing.T) { + // Page 0: 267 PDF-points tall (800px at zoom=3). + // Box bottom=400 > 267 → spills into page 1 by 133pt. + boxes := []pdf.TextBox{ + {X0: 100, X1: 500, Top: 200, Bottom: 400, PageNumber: 0, Text: "跨页表格"}, + } + pageHeights := map[int]float64{0: 267.0} + + sections := BoxesToSections(boxes, pageHeights) + if len(sections) != 1 { + t.Fatalf("expected 1 section, got %d", len(sections)) + } + s := sections[0] + + // Python: @@1-2\t100.0\t500.0\t200.0\t133.0## + // Page 0→1 becomes 1-indexed → pages 1-2. + if s.PositionTag != "@@1-2\t100.0\t500.0\t200.0\t133.0##" { + t.Errorf("PositionTag: got %q, want '@@1-2\\t100.0\\t500.0\\t200.0\\t133.0##'", s.PositionTag) + } + if len(s.Positions) != 1 { + t.Fatalf("expected 1 pdf.Position, got %d", len(s.Positions)) + } + p := s.Positions[0] + if len(p.PageNumbers) != 2 || p.PageNumbers[0] != 0 || p.PageNumbers[1] != 1 { + t.Errorf("PageNumbers: got %v, want [0, 1]", p.PageNumbers) + } + if p.Top != 200 || p.Bottom != 133 { + t.Errorf("coords: top=%v (want 200), bottom=%v (want 133 = 400-267)", p.Top, p.Bottom) + } +} + +// TestBoxesToSections_SinglePageUnchanged verifies single-page boxes are +// unaffected by the cross-page change. +func TestBoxesToSections_SinglePageUnchanged(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 50, X1: 200, Top: 10, Bottom: 30, PageNumber: 0, Text: "普通文本"}, + } + pageHeights := map[int]float64{0: 267.0} + + sections := BoxesToSections(boxes, pageHeights) + if len(sections) != 1 { + t.Fatalf("expected 1 section, got %d", len(sections)) + } + // Single page: tag should be @@1, not @@1-1 + if sections[0].PositionTag != "@@1\t50.0\t200.0\t10.0\t30.0##" { + t.Errorf("single-page PositionTag: got %q", sections[0].PositionTag) + } + if len(sections[0].Positions[0].PageNumbers) != 1 { + t.Errorf("single-page PageNumbers: got %v, want [0]", sections[0].Positions[0].PageNumbers) + } +} + +func TestResolvePageSpan_SinglePage(t *testing.T) { + // Box fits within the page → toPage unchanged, bottom unchanged. + toPage, bottom := ResolvePageSpan(0, 30, map[int]float64{0: 267}) + if toPage != 0 || bottom != 30 { + t.Errorf("got toPage=%d bottom=%v, want 0, 30", toPage, bottom) + } +} + +func TestResolvePageSpan_CrossPage(t *testing.T) { + // Box bottom=400 exceeds page 0 height=267 → spans to page 1. + toPage, bottom := ResolvePageSpan(0, 400, map[int]float64{0: 267}) + if toPage != 1 { + t.Errorf("toPage = %d, want 1", toPage) + } + if bottom != 133 { + t.Errorf("bottom = %v, want 133 (400-267)", bottom) + } +} + +func TestResolvePageSpan_MultiPage(t *testing.T) { + // Box bottom=600, page 0=267, page 1=200, page 2=200. + heights := map[int]float64{0: 267, 1: 200, 2: 200} + toPage, bottom := ResolvePageSpan(0, 600, heights) + if toPage != 2 { + t.Errorf("toPage = %d, want 2", toPage) + } + if bottom != 133 { + t.Errorf("bottom = %v, want 133 (600-267-200)", bottom) + } +} + +func TestResolvePageSpan_NilHeights(t *testing.T) { + toPage, bottom := ResolvePageSpan(0, 400, nil) + if toPage != 0 || bottom != 400 { + t.Errorf("got toPage=%d bottom=%v, want 0, 400 (nil=no cross-page)", toPage, bottom) + } +} + +func TestResolvePageSpan_ZeroHeightGuard(t *testing.T) { + // Zero-height pages must not cause an infinite loop. + // Page 0=200, page 1=0, page 2=0, page 3=300 — box bottom=500. + heights := map[int]float64{0: 200, 1: 0, 2: 0, 3: 300} + toPage, bottom := ResolvePageSpan(0, 500, heights) + // 500-200=300 remaining; page1=0 → break at unknown/invalid; toPage=1, bottom=300. + // (the break path treats zero/unknown as "assume same height once and stop") + if toPage != 1 { + t.Errorf("toPage = %d, want 1 (stopped at first zero-height page)", toPage) + } + if bottom != 300 { + t.Errorf("bottom = %v, want 300 (500-200)", bottom) + } +} + +func TestResolvePageSpan_UnknownNextPage(t *testing.T) { + // Next page not in map → assume same height once, then stop. + heights := map[int]float64{0: 267} + toPage, bottom := ResolvePageSpan(0, 500, heights) + if toPage != 1 { + t.Errorf("toPage = %d, want 1 (one fallback extension)", toPage) + } + if bottom != 233 { + t.Errorf("bottom = %v, want 233 (500-267)", bottom) + } +} + +func TestResolvePageSpan_NegativePh(t *testing.T) { + heights := map[int]float64{0: 200, 1: -10, 2: 200} + toPage, bottom := ResolvePageSpan(0, 500, heights) + if toPage != 1 { + t.Errorf("toPage = %d, want 1 (stopped at negative-height page)", toPage) + } + if bottom != 300 { + t.Errorf("bottom = %v, want 300 (500-200)", bottom) + } +} + +func TestNormalizeSectionPositions_ValidTag(t *testing.T) { + sections := []pdf.Section{ + {Text: "test", PositionTag: "@@1\t50.0\t300.0\t200.0\t400.0##", Positions: nil}, + } + NormalizeSectionPositions(sections) + + s := sections[0] + if len(s.Positions) != 1 { + t.Fatalf("expected 1 Position, got %d", len(s.Positions)) + } + p := s.Positions[0] + if len(p.PageNumbers) != 1 || p.PageNumbers[0] != 0 { + t.Errorf("PageNumbers: got %v, want [0]", p.PageNumbers) + } + if p.Left != 50.0 || p.Right != 300.0 || p.Top != 200.0 || p.Bottom != 400.0 { + t.Errorf("coords: got (%.1f, %.1f, %.1f, %.1f), want (50.0, 300.0, 200.0, 400.0)", + p.Left, p.Right, p.Top, p.Bottom) + } +} + +func TestNormalizeSectionPositions_EmptyTag(t *testing.T) { + sections := []pdf.Section{ + {Text: "test", PositionTag: "", Positions: nil}, + } + NormalizeSectionPositions(sections) + + if len(sections[0].Positions) != 0 { + t.Errorf("expected empty Positions, got %v", sections[0].Positions) + } +} + +func TestNormalizeSectionPositions_AlreadyPopulated(t *testing.T) { + existing := []pdf.Position{{PageNumbers: []int{0}, Left: 10, Right: 20, Top: 30, Bottom: 40}} + sections := []pdf.Section{ + {Text: "test", PositionTag: "@@1\t50.0\t300.0\t200.0\t400.0##", Positions: existing}, + } + NormalizeSectionPositions(sections) + + // Should NOT overwrite existing Positions + if len(sections[0].Positions) != 1 { + t.Fatalf("expected 1 Position, got %d", len(sections[0].Positions)) + } + p := sections[0].Positions[0] + if p.Left != 10 || p.Top != 30 { + t.Errorf("Positions were overwritten: got left=%.1f top=%.1f, want left=10 top=30", p.Left, p.Top) + } +} + +func TestNormalizeSectionPositions_MultiPageTag(t *testing.T) { + sections := []pdf.Section{ + {Text: "test", PositionTag: "@@1-2\t50.0\t300.0\t200.0\t400.0##", Positions: nil}, + } + NormalizeSectionPositions(sections) + + p := sections[0].Positions[0] + if len(p.PageNumbers) != 2 || p.PageNumbers[0] != 0 || p.PageNumbers[1] != 1 { + t.Errorf("PageNumbers: got %v, want [0, 1]", p.PageNumbers) + } +} + +func TestNormalizeSectionPositions_MixedSlice(t *testing.T) { + existing := []pdf.Position{{PageNumbers: []int{2}, Left: 1, Right: 2, Top: 3, Bottom: 4}} + sections := []pdf.Section{ + {Text: "has_positions", PositionTag: "@@9\t1.0\t2.0\t3.0\t4.0##", Positions: existing}, + {Text: "no_positions", PositionTag: "@@1\t50.0\t300.0\t200.0\t400.0##", Positions: nil}, + {Text: "no_tag", PositionTag: "", Positions: nil}, + } + NormalizeSectionPositions(sections) + + // has_positions: existing preserved + if sections[0].Positions[0].PageNumbers[0] != 2 { + t.Errorf("existing Positions were overwritten") + } + // no_positions: parsed from tag + if len(sections[1].Positions) != 1 || sections[1].Positions[0].PageNumbers[0] != 0 { + t.Errorf("no_positions not normalized: %v", sections[1].Positions) + } + // no_tag: left empty + if len(sections[2].Positions) != 0 { + t.Errorf("no_tag should remain empty: %v", sections[2].Positions) + } +} + +func TestNormalizeSectionPositions_NilInput(t *testing.T) { + // Should not panic + NormalizeSectionPositions(nil) +} + +func TestNormalizeSectionPositions_EmptySlice(t *testing.T) { + sections := []pdf.Section{} + NormalizeSectionPositions(sections) + if len(sections) != 0 { + t.Error("empty slice should remain empty") + } +} + +func TestSectionsToMarkdown_Title(t *testing.T) { + sections := []pdf.Section{ + {LayoutType: pdf.LayoutTypeTitle, Text: "标题", Image: ""}, + } + got := SectionsToMarkdown(sections) + want := "\n## 标题\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSectionsToMarkdown_FigureWithImage(t *testing.T) { + sections := []pdf.Section{ + {LayoutType: pdf.LayoutTypeFigure, Text: "图1", Image: "abc123"}, + } + got := SectionsToMarkdown(sections) + want := "\n![Image](data:image/png;base64,abc123)" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSectionsToMarkdown_FigureWithoutImage(t *testing.T) { + sections := []pdf.Section{ + {LayoutType: pdf.LayoutTypeFigure, Text: "图1", Image: ""}, + } + got := SectionsToMarkdown(sections) + want := "图1\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSectionsToMarkdown_Text(t *testing.T) { + sections := []pdf.Section{ + {LayoutType: pdf.LayoutTypeText, Text: "普通内容", Image: ""}, + } + got := SectionsToMarkdown(sections) + want := "普通内容\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSectionsToMarkdown_Table(t *testing.T) { + sections := []pdf.Section{ + {LayoutType: pdf.LayoutTypeTable, Text: "表格内容", Image: ""}, + } + got := SectionsToMarkdown(sections) + want := "表格内容\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSectionsToMarkdown_Mixed(t *testing.T) { + sections := []pdf.Section{ + {LayoutType: pdf.LayoutTypeTitle, Text: "标题", Image: ""}, + {LayoutType: pdf.LayoutTypeText, Text: "内容", Image: ""}, + {LayoutType: pdf.LayoutTypeFigure, Text: "图", Image: "img"}, + {LayoutType: pdf.LayoutTypeTable, Text: "表格", Image: ""}, + } + got := SectionsToMarkdown(sections) + want := "\n## 标题\n内容\n\n![Image](data:image/png;base64,img)表格\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSectionsToMarkdown_EmptySlice(t *testing.T) { + got := SectionsToMarkdown([]pdf.Section{}) + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +func TestSectionsToMarkdown_NilSlice(t *testing.T) { + got := SectionsToMarkdown(nil) + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +func TestSectionsToJSON_SingleSection(t *testing.T) { + sections := []pdf.Section{ + { + Text: "测试内容", + LayoutType: pdf.LayoutTypeText, + DocTypeKwd: "text", + Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 300, Top: 200, Bottom: 400}}, + Image: "abc123", + }, + } + got := SectionsToJSON(sections) + if len(got) != 1 { + t.Fatalf("expected 1 item, got %d", len(got)) + } + m := got[0] + if m["text"] != "测试内容" { + t.Errorf("text: got %v", m["text"]) + } + if m["layout_type"] != "text" { + t.Errorf("layout_type: got %v", m["layout_type"]) + } + if m["doc_type_kwd"] != "text" { + t.Errorf("doc_type_kwd: got %v", m["doc_type_kwd"]) + } + if m["image"] != "abc123" { + t.Errorf("image: got %v", m["image"]) + } + positions, ok := m["_pdf_positions"].([][]any) + if !ok || len(positions) != 1 { + t.Fatalf("_pdf_positions not correct type/length: %T %v", m["_pdf_positions"], m["_pdf_positions"]) + } + pos := positions[0] + pages := pos[0].([]any) + if len(pages) != 1 || pages[0] != 0 { + t.Errorf("pages: got %v", pages) + } + if pos[1] != float64(50) || pos[2] != float64(300) || pos[3] != float64(200) || pos[4] != float64(400) { + t.Errorf("coords: got %v", pos[1:]) + } +} + +func TestSectionsToJSON_MultiPage(t *testing.T) { + sections := []pdf.Section{ + { + Text: "跨页内容", + Positions: []pdf.Position{{PageNumbers: []int{0, 1}, Left: 100, Right: 200, Top: 50, Bottom: 300}}, + }, + } + got := SectionsToJSON(sections) + positions := got[0]["_pdf_positions"].([][]any) + pages := positions[0][0].([]any) + if len(pages) != 2 || pages[0] != 0 || pages[1] != 1 { + t.Errorf("multi-page pages: got %v, want [0, 1]", pages) + } +} + +func TestSectionsToJSON_EmptySlice(t *testing.T) { + got := SectionsToJSON([]pdf.Section{}) + if len(got) != 0 { + t.Errorf("got %d items, want 0", len(got)) + } +} + +func TestSectionsToJSON_NilSlice(t *testing.T) { + got := SectionsToJSON(nil) + if got == nil { + t.Error("expected non-nil slice, got nil") + } + if len(got) != 0 { + t.Errorf("got %d items, want 0", len(got)) + } +} + +// TestCrossPageTableMerge verifies that mergeTablesAcrossPages merges +// two TableItems on consecutive pages with overlapping X positions. +// Python: _extract_table_figure merges cross-page tables by matching layoutno. +// Spanning cells should be annotated with colspan/rowspan in the HTML output. diff --git a/internal/deepdoc/parser/pdf/layout/chars_boxes.go b/internal/deepdoc/parser/pdf/layout/chars_boxes.go new file mode 100644 index 0000000000..e905094169 --- /dev/null +++ b/internal/deepdoc/parser/pdf/layout/chars_boxes.go @@ -0,0 +1,220 @@ +package layout + +import ( + "math" + "regexp" + "sort" + "strings" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" +) + +// CharsToBoxes converts raw characters to initial text boxes by grouping +// characters into lines based on vertical overlap. +// +// Python: pdf_parser.__images__ producing self.boxes +func CharsToBoxes(chars []pdf.TextChar, pageNum int, sortByTop bool) []pdf.TextBox { + if len(chars) == 0 { + return nil + } + + lines := GroupCharsToLines(chars, sortByTop) + + // Page-level column gap threshold from ALL inter-char gaps. + // Falls back to per-line threshold when page has too few gaps. + threshold := pageXGapThreshold(lines) + + boxes := make([]pdf.TextBox, 0, len(lines)) + for _, line := range lines { + thr := threshold + if thr > 100 { + // No significant column gaps on this page → use per-line threshold. + thr = perLineXGapThreshold(line) + } + subLines := splitLineByXGap(line, thr) + for _, sub := range subLines { + box := LineToTextBox(sub) + box.PageNumber = pageNum + boxes = append(boxes, box) + } + } + return boxes +} + +// perLineXGapThreshold computes a dynamic X-gap threshold for column +// splitting within a single line (fallback when page has few gaps). +func perLineXGapThreshold(chars []pdf.TextChar) float64 { + if len(chars) <= 1 { + return 1e9 + } + var gaps []float64 + for i := 1; i < len(chars); i++ { + g := chars[i].X0 - chars[i-1].X1 + gaps = append(gaps, g) + } + if len(gaps) == 0 { + return 1e9 + } + sort.Float64s(gaps) + medianGap := gaps[len(gaps)/2] + if medianGap < 6 { + medianGap = 6 + } + return medianGap * 2.5 +} + +// pageXGapThreshold computes a global X-gap column threshold from all +// inter-char gaps across all lines on the page. 95th percentile catches +// column boundaries while excluding word-level gaps. +// Returns a value > 100 when there are too few gaps for reliable p95, +// signalling the caller to fall back to perLineXGapThreshold. +func pageXGapThreshold(lines [][]pdf.TextChar) float64 { + var allGaps []float64 + for _, line := range lines { + for i := 1; i < len(line); i++ { + g := line[i].X0 - line[i-1].X1 + allGaps = append(allGaps, g) + } + } + if len(allGaps) < 10 { + return 1e9 // too few gaps for reliable p95 → fall back to per-line + } + sort.Float64s(allGaps) + // 95th percentile: only the largest 5% of gaps are column boundaries. + p95 := allGaps[len(allGaps)*95/100] + if p95 < 30 { + p95 = 30 // floor: column gaps are ≥30pt in practice + } + return p95 +} + +// splitLineByXGap splits a character line into sub-lines where X gaps +// meet or exceed the threshold (column boundaries). Uses >= to match the +// p95 boundary value — a gap exactly at the 95th percentile is a column gap, +// not a word gap. +func splitLineByXGap(chars []pdf.TextChar, threshold float64) [][]pdf.TextChar { + if len(chars) <= 1 { + return [][]pdf.TextChar{chars} + } + var result [][]pdf.TextChar + start := 0 + for i := 1; i < len(chars); i++ { + gap := chars[i].X0 - chars[i-1].X1 + if gap >= threshold { + result = append(result, chars[start:i]) + start = i + } + } + result = append(result, chars[start:]) + return result +} + +// ---- internal helpers ---- + +// groupCharsToLines groups characters into horizontal lines based on vertical overlap. +func GroupCharsToLines(chars []pdf.TextChar, sortByTop bool) [][]pdf.TextChar { + if len(chars) == 0 { + return nil + } + + key := func(c pdf.TextChar) float64 { return c.Bottom } + if sortByTop { + key = func(c pdf.TextChar) float64 { return c.Top } + } + + // Sort by vertical key (Bottom or Top) then x0 using sort.SliceStable. + // Guard against NaN: a NaN key sorts after everything else. + sort.SliceStable(chars, func(i, j int) bool { + ki, kj := key(chars[i]), key(chars[j]) + if ki != kj && !math.IsNaN(ki) && !math.IsNaN(kj) { + return ki < kj + } + if math.IsNaN(ki) != math.IsNaN(kj) { + return !math.IsNaN(ki) // non-NaN before NaN + } + return chars[i].X0 < chars[j].X0 + }) + + var lines [][]pdf.TextChar + var currentLine []pdf.TextChar + + for _, c := range chars { + if len(currentLine) == 0 { + currentLine = append(currentLine, c) + continue + } + if verticalOverlap(currentLine[len(currentLine)-1], c) { + currentLine = append(currentLine, c) + } else { + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + currentLine = []pdf.TextChar{c} + } + } + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + return lines +} + +// verticalOverlap checks if two characters are on the same horizontal line. +func verticalOverlap(a, b pdf.TextChar) bool { + mh := math.Max(util.CharHeight(a), util.CharHeight(b)) + if mh <= 0 { + mh = 1.0 + } + return math.Abs(a.Top-b.Top) < mh*0.5 +} + +// lineToTextBox converts a line of characters to a single pdf.TextBox. +// asciiWordPattern matches strings composed entirely of ASCII word +// characters. Python uses re.match (prefix match) — the stricter +// full-string match here is equivalent in practice because each +// pdf.TextChar.Text is a single rune, so prevText+currText ≤ 2 chars. +// Python: pdf_parser.py:1528 re.match(r"[0-9a-zA-Z,.:;!%]+", ...) +var asciiWordPattern = regexp.MustCompile(`^[0-9a-zA-Z,.:;!%]+$`) + +func LineToTextBox(chars []pdf.TextChar) pdf.TextBox { + if len(chars) == 0 { + return pdf.TextBox{} + } + box := pdf.TextBox{ + X0: chars[0].X0, + X1: chars[0].X1, + Top: chars[0].Top, + Bottom: chars[0].Bottom, + } + var textParts []string + for i, c := range chars { + // Insert space between adjacent ASCII words with a visible gap. + // Python: pdf_parser.py:1524-1532 __img_ocr space insertion. + if i > 0 { + prev := chars[i-1] + prevText := strings.TrimSpace(prev.Text) + currText := strings.TrimSpace(c.Text) + if prevText != "" && currText != "" { + gap := c.X0 - prev.X1 + minWidth := math.Min(c.X1-c.X0, prev.X1-prev.X0) + if gap >= minWidth/2 && + asciiWordPattern.MatchString(prevText+currText) { + textParts = append(textParts, " ") + } + } + } + box.X0 = math.Min(box.X0, c.X0) + box.X1 = math.Max(box.X1, c.X1) + box.Top = math.Min(box.Top, c.Top) + box.Bottom = math.Max(box.Bottom, c.Bottom) + textParts = append(textParts, c.Text) + if c.LayoutType != "" { + box.LayoutType = c.LayoutType + } + if c.LayoutNo != "" { + box.LayoutNo = c.LayoutNo + } + } + box.Text = strings.Join(textParts, "") + return box +} diff --git a/internal/deepdoc/parser/pdf/layout/chars_boxes_test.go b/internal/deepdoc/parser/pdf/layout/chars_boxes_test.go new file mode 100644 index 0000000000..354d11d32c --- /dev/null +++ b/internal/deepdoc/parser/pdf/layout/chars_boxes_test.go @@ -0,0 +1,107 @@ +package layout + +import ( + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func TestCharsToBoxes(t *testing.T) { + t.Run("empty chars", func(t *testing.T) { + if boxes := CharsToBoxes(nil, 0, false); boxes != nil { + t.Error("nil chars → nil boxes") + } + if boxes := CharsToBoxes([]pdf.TextChar{}, 0, false); boxes != nil { + t.Error("empty chars → nil boxes") + } + }) + t.Run("single char", func(t *testing.T) { + chars := []pdf.TextChar{ + {X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "A", PageNumber: 0}, + } + boxes := CharsToBoxes(chars, 0, false) + if len(boxes) != 1 { + t.Fatalf("expected 1 box, got %d", len(boxes)) + } + if boxes[0].Text != "A" { + t.Errorf("Text = %q, want 'A'", boxes[0].Text) + } + }) + t.Run("two lines", func(t *testing.T) { + chars := []pdf.TextChar{ + {X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "A", PageNumber: 0}, + {X0: 60, X1: 68, Top: 100, Bottom: 112, Text: "B", PageNumber: 0}, + {X0: 50, X1: 58, Top: 114, Bottom: 126, Text: "C", PageNumber: 0}, + } + boxes := CharsToBoxes(chars, 0, false) + if len(boxes) != 2 { + t.Errorf("expected 2 lines, got %d", len(boxes)) + } + }) + t.Run("preserves whitespace lines", func(t *testing.T) { + chars := []pdf.TextChar{ + {Text: " ", X0: 10, Top: 100, X1: 15, Bottom: 112}, + {Text: "Hello", X0: 10, Top: 120, X1: 50, Bottom: 132}, + } + boxes := CharsToBoxes(chars, 0, false) + if len(boxes) != 2 { + t.Errorf("expected 2 boxes (whitespace preserved), got %d", len(boxes)) + } + }) +} + +func TestGroupCharsToLines(t *testing.T) { + t.Run("empty", func(t *testing.T) { + if lines := GroupCharsToLines(nil, false); lines != nil { + t.Error("nil → nil") + } + }) + t.Run("single char", func(t *testing.T) { + chars := []pdf.TextChar{{X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "A"}} + lines := GroupCharsToLines(chars, false) + if len(lines) != 1 || len(lines[0]) != 1 { + t.Error("single char → single line") + } + }) + t.Run("multi column same line", func(t *testing.T) { + chars := []pdf.TextChar{ + {X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "H"}, + {X0: 60, X1: 68, Top: 100, Bottom: 112, Text: "i"}, + {X0: 300, X1: 308, Top: 100, Bottom: 112, Text: "B"}, + {X0: 50, X1: 58, Top: 114, Bottom: 126, Text: "A"}, + } + lines := GroupCharsToLines(chars, false) + if len(lines) != 2 { + t.Errorf("expected 2 lines, got %d", len(lines)) + } + }) +} + +func TestLineToTextBox(t *testing.T) { + t.Run("basic", func(t *testing.T) { + chars := []pdf.TextChar{ + {X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "H"}, + {X0: 60, X1: 68, Top: 100, Bottom: 112, Text: "i"}, + } + box := LineToTextBox(chars) + if box.Text != "Hi" { + t.Errorf("Text = %q, want 'Hi'", box.Text) + } + if box.X0 != 50 || box.X1 != 68 { + t.Errorf("bbox = [%f, %f], want [50, 68]", box.X0, box.X1) + } + }) + t.Run("empty chars", func(t *testing.T) { + box := LineToTextBox(nil) + if box.Text != "" { + t.Errorf("nil chars → empty box, got %q", box.Text) + } + }) + t.Run("single char", func(t *testing.T) { + chars := []pdf.TextChar{{X0: 10, X1: 18, Top: 100, Bottom: 112, Text: "X"}} + box := LineToTextBox(chars) + if box.Text != "X" { + t.Errorf("Text = %q, want 'X'", box.Text) + } + }) +} diff --git a/internal/deepdoc/parser/pdf/cleanup_test.go b/internal/deepdoc/parser/pdf/layout/cleanup_test.go similarity index 87% rename from internal/deepdoc/parser/pdf/cleanup_test.go rename to internal/deepdoc/parser/pdf/layout/cleanup_test.go index bd90b8bf62..d3b7c708d1 100644 --- a/internal/deepdoc/parser/pdf/cleanup_test.go +++ b/internal/deepdoc/parser/pdf/layout/cleanup_test.go @@ -1,11 +1,12 @@ -package parser +package layout import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) func TestMergeSameBullet(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {Text: "* item 1", Top: 100, Bottom: 112, X0: 50, X1: 200}, {Text: "* item 2", Top: 114, Bottom: 126, X0: 50, X1: 200}, } @@ -16,7 +17,7 @@ func TestMergeSameBullet(t *testing.T) { } func TestMergeSameBulletNoMerge(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {Text: "A item", Top: 100, Bottom: 112, X0: 50, X1: 200}, {Text: "B item", Top: 114, Bottom: 126, X0: 50, X1: 200}, } @@ -28,7 +29,7 @@ func TestMergeSameBulletNoMerge(t *testing.T) { func TestMergeSameBulletChinese(t *testing.T) { // Chinese chars start, should not merge via bullet rule - boxes := []TextBox{ + boxes := []pdf.TextBox{ {Text: "测试文本", Top: 100, Bottom: 112, X0: 50, X1: 200}, {Text: "测试内容", Top: 114, Bottom: 126, X0: 50, X1: 200}, } diff --git a/internal/deepdoc/parser/pdf/layout.go b/internal/deepdoc/parser/pdf/layout/layout.go similarity index 78% rename from internal/deepdoc/parser/pdf/layout.go rename to internal/deepdoc/parser/pdf/layout/layout.go index 12cfc4ae36..1bf08d614b 100644 --- a/internal/deepdoc/parser/pdf/layout.go +++ b/internal/deepdoc/parser/pdf/layout/layout.go @@ -1,12 +1,15 @@ -package parser +package layout import ( "log/slog" "math" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" "regexp" "slices" "sort" "strings" + "unicode" "unicode/utf8" ) @@ -16,7 +19,7 @@ import ( // with silhouette score selection, matching Python's _assign_column(). // // Python: pdf_parser.py:739 _assign_column() -func AssignColumn(boxes []TextBox, zoom float64) []TextBox { +func AssignColumn(boxes []pdf.TextBox, zoom float64) []pdf.TextBox { if len(boxes) == 0 { return boxes } @@ -26,7 +29,7 @@ func AssignColumn(boxes []TextBox, zoom float64) []TextBox { pageGroups[b.PageNumber] = append(pageGroups[b.PageNumber], i) } - result := make([]TextBox, len(boxes)) + result := make([]pdf.TextBox, len(boxes)) copy(result, boxes) // Step A: per-page best k using silhouette score. @@ -71,10 +74,10 @@ func AssignColumn(boxes []TextBox, zoom float64) []TextBox { bestK, bestScore := 1, -1.0 for k := 1; k <= maxTry; k++ { - labels, _ := kmeans1D(x0s, k) + labels, _ := util.KMeans1D(x0s, k) var score float64 if k > 1 { - score = silhouette1D(x0s, labels) + score = util.Silhouette1D(x0s, labels) } // score = 0 for k=1; score = -1 if silhouette undefined. if score > bestScore { @@ -101,7 +104,7 @@ func AssignColumn(boxes []TextBox, zoom float64) []TextBox { x0s[i] = boxes[idx].X0 } - labels, centroids := kmeans1D(x0s, k) + labels, centroids := util.KMeans1D(x0s, k) // Sort centroids by x position, remap labels left→right. type clPair struct { @@ -131,12 +134,12 @@ func AssignColumn(boxes []TextBox, zoom float64) []TextBox { // TextMerge horizontally merges adjacent boxes at similar vertical positions. // // Python: pdf_parser.py:888 _text_merge() -func TextMerge(boxes []TextBox, medianHeights map[int]float64, zoom float64) []TextBox { +func TextMerge(boxes []pdf.TextBox, medianHeights map[int]float64, zoom float64) []pdf.TextBox { if len(boxes) < 2 { return boxes } // Build output via collect: O(n) instead of O(n²) slice-element removal. - out := make([]TextBox, 0, len(boxes)) + out := make([]pdf.TextBox, 0, len(boxes)) i := 0 for i < len(boxes) { cur := boxes[i] @@ -149,14 +152,14 @@ func TextMerge(boxes []TextBox, medianHeights map[int]float64, zoom float64) []T // Python: b.get("layoutno", "0") != b_.get("layoutno", "1") — // asymmetric defaults mean empty/missing layoutno never merge horizontally. if cur.LayoutNo != nxt.LayoutNo || cur.LayoutNo == "" || nxt.LayoutNo == "" || - cur.LayoutType == LayoutTypeTable || cur.LayoutType == LayoutTypeFigure || cur.LayoutType == LayoutTypeEquation { + cur.LayoutType == pdf.LayoutTypeTable || cur.LayoutType == pdf.LayoutTypeFigure || cur.LayoutType == pdf.LayoutTypeEquation { break } mh := medianHeights[cur.PageNumber] if mh <= 0 { mh = 10 } - if math.Abs(BoxYDis(cur, nxt)) < mh/3 { + if math.Abs(util.BoxYDis(cur, nxt)) < mh/3 { cur.X1 = nxt.X1 cur.Top = (cur.Top + nxt.Top) / 2 cur.Bottom = (cur.Bottom + nxt.Bottom) / 2 @@ -176,7 +179,7 @@ func TextMerge(boxes []TextBox, medianHeights map[int]float64, zoom float64) []T // NaiveVerticalMerge vertically merges boxes on the same page/column. // // Python: pdf_parser.py:926 _naive_vertical_merge() -func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWidths map[int]float64, isEnglish bool) []TextBox { +func NaiveVerticalMerge(boxes []pdf.TextBox, medianHeights map[int]float64, medianWidths map[int]float64, isEnglish bool) []pdf.TextBox { if len(boxes) < 2 { return boxes } @@ -195,7 +198,7 @@ func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWi } sort.Ints(pageKeys) - var result []TextBox + var result []pdf.TextBox for _, pg := range pageKeys { indices := groups[pg] sort.Slice(indices, func(i, j int) bool { @@ -205,14 +208,14 @@ func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWi } return bi.X0 < bj.X0 }) - bxs := make([]TextBox, len(indices)) + bxs := make([]pdf.TextBox, len(indices)) for i, idx := range indices { bxs[i] = boxes[idx] } mh := medianHeights[pg] if mh <= 0 { - mh = MedianHeight(bxs) + mh = util.MedianHeight(bxs) } mw := medianWidths[pg] if mw <= 0 { @@ -220,7 +223,7 @@ func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWi } // Collect pattern: build output slice, merging into last element when appropriate. - out := make([]TextBox, 0, len(bxs)) + out := make([]pdf.TextBox, 0, len(bxs)) for i := 0; i < len(bxs); i++ { b := bxs[i] // Cross-page suffix (e.g. page number on previous page): skip. @@ -233,7 +236,7 @@ func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWi // keeps whitespace inline and lets it extend the previous box. if len(out) > 0 { prev := &out[len(out)-1] - if b.Top-prev.Bottom <= mh*1.5 && OverlapX(prev, &b) >= 0.3 { + if b.Top-prev.Bottom <= mh*1.5 && util.OverlapX(prev, &b) >= 0.3 { // TODO: prev.Bottom = math.Max(prev.Bottom, b.Bottom) — direct assignment // can shrink a tall merged box when a short whitespace box overlaps. // Matches Python behavior (also direct assignment). Defer fix until @@ -259,7 +262,7 @@ func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWi out = append(out, b) continue } - ov := OverlapX(prev, &b) + ov := util.OverlapX(prev, &b) if ov < 0.3 { slog.Debug("vm reject", "reason", "ovX", "ov", ov, "threshold", 0.3) out = append(out, b) @@ -308,7 +311,7 @@ func NaiveVerticalMerge(boxes []TextBox, medianHeights map[int]float64, medianWi // FinalReadingOrderMerge sorts boxes by page → column → top → x0. // // Python: pdf_parser.py:1007 _final_reading_order_merge() -func FinalReadingOrderMerge(boxes []TextBox) []TextBox { +func FinalReadingOrderMerge(boxes []pdf.TextBox) []pdf.TextBox { if len(boxes) == 0 { return boxes } @@ -375,7 +378,60 @@ func startsWithOneOf(s, set string) bool { return strings.ContainsRune(set, r) } -// containsRune returns true if the string set contains the given rune. -func containsRune(set string, r rune) bool { - return strings.ContainsRune(set, r) +// MergeSameBullet merges adjacent boxes that start with the same bullet/number +// character, combining their text with a newline separator. +func MergeSameBullet(boxes []pdf.TextBox, tok pdf.Tokenizer) []pdf.TextBox { + if len(boxes) < 2 { + return boxes + } + out := make([]pdf.TextBox, 0, len(boxes)) + i := 0 + for i < len(boxes) { + if strings.TrimSpace(boxes[i].Text) == "" { + i++ + continue + } + cur := boxes[i] + i++ + for i < len(boxes) { + if strings.TrimSpace(boxes[i].Text) == "" { + i++ + continue + } + nxt := boxes[i] + firstCur := firstRuneString(cur.Text) + firstNxt := firstRuneString(nxt.Text) + if firstCur != firstNxt || + unicode.Is(unicode.Latin, firstCur) || + isChinese(firstCur, tok) || + cur.Top > nxt.Bottom { + break + } + cur.Text = cur.Text + "\n" + nxt.Text + cur.X0 = min(cur.X0, nxt.X0) + cur.X1 = max(cur.X1, nxt.X1) + cur.Bottom = nxt.Bottom + i++ + } + out = append(out, cur) + } + return out +} + +func firstRuneString(s string) rune { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + return []rune(s)[0] +} + +// isChinese checks if a rune is a Chinese character (CJK Unified Ideograph). +func isChinese(r rune, tok pdf.Tokenizer) bool { + if tok != nil { + return strings.Contains(tok.Tag(string(r)), "n") + } + return (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0x3400 && r <= 0x4DBF) || + (r >= 0x20000 && r <= 0x2A6DF) } diff --git a/internal/deepdoc/parser/pdf/layout_test.go b/internal/deepdoc/parser/pdf/layout/layout_test.go similarity index 68% rename from internal/deepdoc/parser/pdf/layout_test.go rename to internal/deepdoc/parser/pdf/layout/layout_test.go index b5649aa243..019bdfad86 100644 --- a/internal/deepdoc/parser/pdf/layout_test.go +++ b/internal/deepdoc/parser/pdf/layout/layout_test.go @@ -1,12 +1,14 @@ -package parser +package layout import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" "strings" "testing" ) func TestAssignColumn(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {PageNumber: 0, X0: 50, Text: "col0-left"}, {PageNumber: 0, X0: 55, Text: "col0-mid"}, {PageNumber: 0, X0: 400, Text: "col1"}, @@ -25,7 +27,7 @@ func TestAssignColumn(t *testing.T) { } func TestTextMerge(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "左半", LayoutType: "text", LayoutNo: "1"}, {PageNumber: 0, ColID: 0, X0: 252, X1: 550, Top: 100, Bottom: 112, Text: "右半", LayoutType: "text", LayoutNo: "1"}, } @@ -37,7 +39,7 @@ func TestTextMerge(t *testing.T) { } func TestTextMergeNoMerge_DiffLayout(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {PageNumber: 0, ColID: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "text", LayoutType: "text", LayoutNo: "1"}, {PageNumber: 0, ColID: 0, X0: 252, X1: 550, Top: 100, Bottom: 112, Text: "table", LayoutType: "table", LayoutNo: "2"}, } @@ -49,7 +51,7 @@ func TestTextMergeNoMerge_DiffLayout(t *testing.T) { } func TestFinalReadingOrderMerge(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {PageNumber: 1, ColID: 1, Top: 50, Text: "pg1-col1"}, {PageNumber: 0, ColID: 0, Top: 100, Text: "pg0-col0"}, {PageNumber: 0, ColID: 0, Top: 50, Text: "pg0-col0-top"}, @@ -64,10 +66,10 @@ func TestFinalReadingOrderMerge(t *testing.T) { } func TestContainsRune(t *testing.T) { - if !containsRune("。?!", '。') { + if !strings.ContainsRune("。?!", '。') { t.Error("should find 。") } - if containsRune("abc", 'z') { + if strings.ContainsRune("abc", 'z') { t.Error("should not find z") } } @@ -81,44 +83,8 @@ func TestEndsWithOneOf(t *testing.T) { } } -func TestCharsToBoxes(t *testing.T) { - chars := []TextChar{ - {X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "A", PageNumber: 0}, - {X0: 60, X1: 68, Top: 100, Bottom: 112, Text: "B", PageNumber: 0}, - {X0: 50, X1: 58, Top: 114, Bottom: 126, Text: "C", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) == 0 { - t.Fatal("expected at least 1 box") - } - // A and B should be in the same line, C in a different line - if len(boxes) != 2 { - t.Errorf("expected 2 lines, got %d", len(boxes)) - } -} - -func TestBoxesToSections(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "标题"}, - {PageNumber: 0, X0: 50, X1: 550, Top: 200, Bottom: 212, Text: ""}, - } - sections := boxesToSections(boxes, nil) - if len(sections) != 1 { - t.Errorf("expected 1 section (empty box skipped), got %d", len(sections)) - } - if len(sections) > 0 { - // Text is clean — position tag lives in PositionTag field (matching Python) - if strings.Contains(sections[0].Text, "@@") { - t.Error("section text should NOT contain position tag") - } - if !strings.Contains(sections[0].PositionTag, "##") { - t.Error("position tag should end with ##") - } - } -} - func TestDefaultConfig(t *testing.T) { - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() if cfg.Zoom != 3 { t.Error("default zoom should be 3") } @@ -127,40 +93,10 @@ func TestDefaultConfig(t *testing.T) { } } -func TestHasColor(t *testing.T) { - if !HasColor(TextChar{}) { - t.Error("HasColor should return true by default") - } -} - -func TestGroupCharsToLines_MultiColumn(t *testing.T) { - // Simulate a two-column PDF page. Python's __ocr has no horizontal gap - // check in line grouping — chars at the same vertical position are - // grouped into one line regardless of horizontal distance. Column - // separation happens downstream in AssignColumn + TextMerge. - chars := []TextChar{ - {X0: 50, X1: 58, Top: 100, Bottom: 112, Text: "H"}, - {X0: 60, X1: 68, Top: 100, Bottom: 112, Text: "i"}, - {X0: 300, X1: 308, Top: 100, Bottom: 112, Text: "B"}, - {X0: 310, X1: 318, Top: 100, Bottom: 112, Text: "y"}, - {X0: 50, X1: 58, Top: 114, Bottom: 126, Text: "A"}, - {X0: 60, X1: 68, Top: 114, Bottom: 126, Text: "B"}, - {X0: 300, X1: 308, Top: 114, Bottom: 126, Text: "C"}, - {X0: 310, X1: 318, Top: 114, Bottom: 126, Text: "D"}, - } - - lines := groupCharsToLines(chars, false) - - // Python expects 2 lines (one per vertical position), each spanning both columns. - if len(lines) != 2 { - t.Errorf("expected 2 lines (one per vertical row, spanning both columns), got %d", len(lines)) - } -} - func TestKmeans1D_Boundary(t *testing.T) { t.Run("n equals k", func(t *testing.T) { data := []float64{50.0, 400.0} - labels, centroids := kmeans1D(data, 2) + labels, centroids := util.KMeans1D(data, 2) if len(centroids) != 2 { t.Errorf("n=k=2: expected 2 centroids, got %d — BUG: n<=k early return gives only 1 centroid", len(centroids)) } @@ -171,7 +107,7 @@ func TestKmeans1D_Boundary(t *testing.T) { t.Run("n less than k", func(t *testing.T) { data := []float64{100.0, 200.0, 300.0} - labels, centroids := kmeans1D(data, 4) + labels, centroids := util.KMeans1D(data, 4) if len(centroids) != 3 { t.Errorf("n=3,k=4: expected 3 centroids (one per point), got %d — BUG: n<=k early return gives only 1 centroid", len(centroids)) } @@ -187,7 +123,7 @@ func TestKmeans1D_Boundary(t *testing.T) { t.Run("single point", func(t *testing.T) { data := []float64{100.0} - labels, centroids := kmeans1D(data, 1) + labels, centroids := util.KMeans1D(data, 1) if len(centroids) != 1 || centroids[0] != 100.0 { t.Errorf("single point: unexpected centroids %v", centroids) } @@ -282,7 +218,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { // ASCII comma ',' is in Python's concatting set, Go matches. // When there's NO anti trigger, merge happens by default. // The concatting feature is only needed when it must OVERRIDE an anti trigger. - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "这是第一句话", @@ -308,7 +244,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { // Python: previous line ends with "。" (anti), next line starts with "," // (concatting). Concatting OVERRIDES anti → merge. // Go now matches Python: ',' is in concatting set → merge. - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "前一句话结束。", @@ -331,7 +267,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) t.Run("next line starts with fullwidth comma — should merge", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "这是第一句话", @@ -353,7 +289,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) t.Run("next line starts with period — should merge", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "前文内容", @@ -378,7 +314,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { // Python's _naive_vertical_merge: merge is the DEFAULT. // concatting overrides anti; anti + detach prevent merge. // When none trigger, boxes merge. - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "这是第一句话", @@ -401,7 +337,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) t.Run("detach — horizontally separated boxes", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 100, Top: 100, Bottom: 112, Text: "左列文字", @@ -424,7 +360,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) t.Run("large vertical gap — anti", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "第一句话", @@ -447,7 +383,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) t.Run("english period anti when isEnglish", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "End of sentence.", @@ -470,7 +406,7 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) t.Run("cross-page — should NOT merge", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ { PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "第一页最后一行", @@ -497,14 +433,14 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { if len(result) != 0 { t.Error("expected empty result for nil input") } - result = NaiveVerticalMerge([]TextBox{}, nil, nil, false) + result = NaiveVerticalMerge([]pdf.TextBox{}, nil, nil, false) if len(result) != 0 { t.Error("expected empty result for empty input") } }) t.Run("single box", func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {PageNumber: 0, X0: 50, X1: 250, Top: 100, Bottom: 112, Text: "only", LayoutNo: "1"}, } result := NaiveVerticalMerge(boxes, nil, nil, false) @@ -514,91 +450,6 @@ func TestNaiveVerticalMerge_CommaConcat(t *testing.T) { }) } -// ── charsToBoxes whitespace preservation ──────────────────────────────── -// Whitespace boxes are preserved (not pre-filtered) so they can act as -// gap bridges in NaiveVerticalMerge. - -func TestCharsToBoxes_PreservesWhitespaceLines(t *testing.T) { - chars := []TextChar{ - {Text: " ", X0: 10, Top: 100, X1: 15, Bottom: 112}, // non-breaking space only - {Text: "Hello", X0: 10, Top: 120, X1: 50, Bottom: 132}, // real text - {Text: " ", X0: 10, Top: 140, X1: 15, Bottom: 152}, // spaces only - } - boxes := charsToBoxes(chars, 0, false) - - if len(boxes) != 3 { - t.Fatalf("expected 3 boxes (whitespace preserved for VM gap bridging), got %d", len(boxes)) - } - if boxes[1].Text != "Hello" { - t.Errorf("expected 'Hello', got %q", boxes[1].Text) - } -} - -func TestCharsToBoxes_PreservesAllWhitespace(t *testing.T) { - chars := []TextChar{ - {Text: " ", X0: 10, Top: 100, X1: 15, Bottom: 112}, - {Text: " ", X0: 20, Top: 120, X1: 25, Bottom: 132}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 2 { - t.Fatalf("expected 2 boxes (whitespace preserved), got %d", len(boxes)) - } -} - -func TestCharsToBoxes_EmptyInput(t *testing.T) { - if boxes := charsToBoxes(nil, 0, false); boxes != nil { - t.Errorf("expected nil for nil input, got %d boxes", len(boxes)) - } - if boxes := charsToBoxes([]TextChar{}, 0, false); boxes != nil { - t.Errorf("expected nil for empty input, got %d boxes", len(boxes)) - } -} - -// ---- groupCharsToLines: stable sort for close x0 values ---- - -func TestGroupCharsToLines_StableSort(t *testing.T) { - // Simulate CJK chars with near-identical Top and very close x0 values. - // Non-stable sort can scramble the order, breaking text. - chars := []TextChar{ - {Text: "总", X0: 37.6, X1: 48.0, Top: 60.5, Bottom: 70.9}, - {Text: "结", X0: 48.0, X1: 58.4, Top: 60.5, Bottom: 70.9}, - {Text: "前", X0: 37.6, X1: 48.0, Top: 86.1, Bottom: 96.5}, - {Text: "2", X0: 48.0, X1: 54.0, Top: 86.1, Bottom: 96.5}, - {Text: "个", X0: 53.9, X1: 64.4, Top: 86.1, Bottom: 96.5}, - {Text: "问", X0: 64.4, X1: 74.8, Top: 86.1, Bottom: 96.5}, - {Text: "题", X0: 74.8, X1: 85.2, Top: 86.1, Bottom: 96.5}, - } - - // Run multiple times — if sort is unstable, text order will vary - for run := 0; run < 10; run++ { - copy := make([]TextChar, len(chars)) - for i := range chars { - copy[i] = chars[i] - } - lines := groupCharsToLines(copy, false) - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d", len(lines)) - } - boxes := make([]TextBox, 0) - for _, line := range lines { - boxes = append(boxes, lineToTextBox(line)) - } - // First line must be "总结" in correct order - if !strings.HasPrefix(boxes[0].Text, "总结") { - t.Errorf("run %d: first line should start with '总结', got %q", run, boxes[0].Text[:min(6, len(boxes[0].Text))]) - } - // Second line should contain "前2个问题" - if !strings.Contains(boxes[1].Text, "前") || !strings.Contains(boxes[1].Text, "题") { - t.Errorf("run %d: second line text scrambled: %q", run, boxes[1].Text[:min(20, len(boxes[1].Text))]) - } - } -} - -// TestNaiveVerticalMerge_BottomShrink exposes a bug where merging a short -// box into a tall previously-merged box SHRINKS prev.Bottom instead of -// keeping it via math.Max. X0/X1 correctly use Min/Max, Bottom does not. -// -// This test is expected to FAIL until the fix (prev.Bottom = math.Max(...)) // is applied. func TestNaiveVerticalMerge_BottomShrink(t *testing.T) { // Three boxes on the same page, sorted by Top. @@ -606,7 +457,7 @@ func TestNaiveVerticalMerge_BottomShrink(t *testing.T) { // C overlaps vertically (Top=290 < prev.Bottom=300) but is short (Bottom=295). // Current code: prev.Bottom = 295 (shrinks from 300). // Correct: prev.Bottom = max(300, 295) = 300. - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 50, X1: 500, Top: 100, Bottom: 150, Text: "line one", PageNumber: 0}, {X0: 50, X1: 500, Top: 160, Bottom: 300, Text: "tall paragraph that spans many lines", PageNumber: 0}, {X0: 50, X1: 500, Top: 290, Bottom: 295, Text: "short overlap", PageNumber: 0}, @@ -625,3 +476,32 @@ func TestNaiveVerticalMerge_BottomShrink(t *testing.T) { t.Skipf("known issue: Bottom shrunk to %.1f (want >= 300) — deferred until pipeline alignment", result[0].Bottom) } } + +func TestNaiveVerticalMerge(t *testing.T) { + boxes := []pdf.TextBox{ + {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "第一段", LayoutNo: "1", LayoutType: "text"}, + {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 114, Bottom: 126, Text: "续文", LayoutNo: "1", LayoutType: "text"}, + } + meanH := map[int]float64{0: 12} + meanW := map[int]float64{0: 5} + result := NaiveVerticalMerge(boxes, meanH, meanW, false) + if len(result) != 1 { + t.Errorf("expected 1 merged box, got %d: %v", len(result), result) + } + if len(result) > 0 && !strings.Contains(result[0].Text, "第一段") { + t.Errorf("merged text should contain '第一段': got %q", result[0].Text) + } +} + +func TestNaiveVerticalMergeNonMerge(t *testing.T) { + boxes := []pdf.TextBox{ + {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "第一段。", LayoutNo: "1", LayoutType: "text"}, + {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 300, Bottom: 312, Text: "第二段。", LayoutNo: "1", LayoutType: "text"}, + } + meanH := map[int]float64{0: 12} + meanW := map[int]float64{0: 5} + result := NaiveVerticalMerge(boxes, meanH, meanW, false) + if len(result) != 2 { + t.Errorf("expected 2 separate boxes (large gap), got %d", len(result)) + } +} diff --git a/internal/deepdoc/parser/pdf/mock_deepdoc_test.go b/internal/deepdoc/parser/pdf/mock_doc_analyzer_test.go similarity index 77% rename from internal/deepdoc/parser/pdf/mock_deepdoc_test.go rename to internal/deepdoc/parser/pdf/mock_doc_analyzer_test.go index 9c18a58425..08d6906501 100644 --- a/internal/deepdoc/parser/pdf/mock_deepdoc_test.go +++ b/internal/deepdoc/parser/pdf/mock_doc_analyzer_test.go @@ -4,18 +4,19 @@ import ( "context" "fmt" "image" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // MockDocAnalyzer returns predefined data for unit tests. // Set an Err field to non-nil to exercise the corresponding error path. type MockDocAnalyzer struct { - DLARegions []DLARegion - TSRCells []TSRCell - OCRBoxes []OCRBox - OCRTexts []OCRText + DLARegions []pdf.DLARegion + TSRCells []pdf.TSRCell + OCRBoxes []pdf.OCRBox + OCRTexts []pdf.OCRText // OCRBatchTexts returns per-image texts for OCRRecognizeBatch. // If nil, OCRTexts is returned for every image. - OCRBatchTexts [][]OCRText + OCRBatchTexts [][]pdf.OCRText // OCRBatchErr makes OCRRecognizeBatch return an error for image i. OCRBatchErr func(i int) error // Per-method error injection for testing failure paths. @@ -25,35 +26,34 @@ type MockDocAnalyzer struct { OCRRecognizeErr error Healthy bool - Model ModelType } -func (m *MockDocAnalyzer) DLA(_ context.Context, _ image.Image) ([]DLARegion, error) { +func (m *MockDocAnalyzer) DLA(_ context.Context, _ image.Image) ([]pdf.DLARegion, error) { if m.DLAErr != nil { return nil, m.DLAErr } return m.DLARegions, nil } -func (m *MockDocAnalyzer) TSR(_ context.Context, _ image.Image) ([]TSRCell, error) { +func (m *MockDocAnalyzer) TSR(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) { if m.TSRErr != nil { return nil, m.TSRErr } return m.TSRCells, nil } -func (m *MockDocAnalyzer) OCRDetect(_ context.Context, _ image.Image) ([]OCRBox, error) { +func (m *MockDocAnalyzer) OCRDetect(_ context.Context, _ image.Image) ([]pdf.OCRBox, error) { if m.OCRDetectErr != nil { return nil, m.OCRDetectErr } return m.OCRBoxes, nil } -func (m *MockDocAnalyzer) OCRRecognize(_ context.Context, _ image.Image) ([]OCRText, error) { +func (m *MockDocAnalyzer) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) { if m.OCRRecognizeErr != nil { return nil, m.OCRRecognizeErr } return m.OCRTexts, nil } -func (m *MockDocAnalyzer) OCRRecognizeBatch(_ context.Context, cropped []image.Image) ([][]OCRText, []error) { - results := make([][]OCRText, len(cropped)) +func (m *MockDocAnalyzer) OCRRecognizeBatch(_ context.Context, cropped []image.Image) ([][]pdf.OCRText, []error) { + results := make([][]pdf.OCRText, len(cropped)) errs := make([]error, len(cropped)) for i, img := range cropped { if img == nil { @@ -71,5 +71,4 @@ func (m *MockDocAnalyzer) OCRRecognizeBatch(_ context.Context, cropped []image.I } return results, errs } -func (m *MockDocAnalyzer) Health() bool { return m.Healthy } -func (m *MockDocAnalyzer) ModelType() ModelType { return m.Model } +func (m *MockDocAnalyzer) Health() bool { return m.Healthy } diff --git a/internal/deepdoc/parser/pdf/ocr_merge_test.go b/internal/deepdoc/parser/pdf/ocr_merge_test.go index 146bf1c39b..7d8caa182d 100644 --- a/internal/deepdoc/parser/pdf/ocr_merge_test.go +++ b/internal/deepdoc/parser/pdf/ocr_merge_test.go @@ -19,7 +19,7 @@ func TestOCR_mergeChars_RealScanned(t *testing.T) { if url == "" { t.Skip("DEEPDOC_URL not set") } - dd, err := NewDeepDocClient(url) + dd, err := inf.NewInferenceClient(url) if err != nil { t.Fatal(err) } @@ -51,8 +51,8 @@ func TestOCR_mergeChars_RealScanned(t *testing.T) { sample.WriteString(c.Text) } t.Logf("pdf_oxide sample: %q", sample.String()) - t.Logf("isScanNoise: %v", isScanNoise(sample.String())) - t.Logf("isGarbledPage: %v", isGarbledPage(chars)) + t.Logf("isScanNoise: %v", util.IsScanNoise(sample.String())) + t.Logf("isGarbledPage: %v", util.IsGarbledPage(chars)) img, err := eng.RenderPageImage(0, 72*3) if err != nil { diff --git a/internal/deepdoc/parser/pdf/ocr_recognize_batch_test.go b/internal/deepdoc/parser/pdf/ocr_recognize_batch_test.go index 5517e68759..08546ed1f8 100644 --- a/internal/deepdoc/parser/pdf/ocr_recognize_batch_test.go +++ b/internal/deepdoc/parser/pdf/ocr_recognize_batch_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "image" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) @@ -27,7 +28,7 @@ func TestOCRRecognizeBatch_EmptyList(t *testing.T) { func TestOCRRecognizeBatch_SingleImage(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRTexts: []OCRText{{Text: "hello", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "hello", Confidence: 0.9}}, } dummy := image.NewRGBA(image.Rect(0, 0, 10, 10)) results, errs := mock.OCRRecognizeBatch(context.Background(), []image.Image{dummy}) @@ -45,7 +46,7 @@ func TestOCRRecognizeBatch_SingleImage(t *testing.T) { func TestOCRRecognizeBatch_MultipleImages(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBatchTexts: [][]OCRText{ + OCRBatchTexts: [][]pdf.OCRText{ {{Text: "img0", Confidence: 0.9}}, {{Text: "img1", Confidence: 0.8}}, {{Text: "img2", Confidence: 0.7}}, @@ -69,7 +70,7 @@ func TestOCRRecognizeBatch_MultipleImages(t *testing.T) { func TestOCRRecognizeBatch_NilImage(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRTexts: []OCRText{{Text: "ok", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "ok", Confidence: 0.9}}, } dummy := image.NewRGBA(image.Rect(0, 0, 10, 10)) results, errs := mock.OCRRecognizeBatch(context.Background(), []image.Image{dummy, nil, dummy}) @@ -93,7 +94,7 @@ func TestOCRRecognizeBatch_NilImage(t *testing.T) { func TestOCRRecognizeBatch_ErrorHandling(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRTexts: []OCRText{{Text: "ok", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "ok", Confidence: 0.9}}, OCRBatchErr: func(i int) error { if i == 1 { return errors.New("simulated error") @@ -130,7 +131,7 @@ func TestOCRRecognizeBatch_ErrorHandling(t *testing.T) { func TestOCRRecognizeBatch_EmptyText(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRTexts: []OCRText{}, // empty — simulate no text recognized + OCRTexts: []pdf.OCRText{}, // empty — simulate no text recognized } dummy := image.NewRGBA(image.Rect(0, 0, 10, 10)) results, errs := mock.OCRRecognizeBatch(context.Background(), []image.Image{dummy}) @@ -149,7 +150,7 @@ func TestOCRRecognizeBatch_FallbackToOCRTexts(t *testing.T) { // When OCRBatchTexts is nil, fall back to OCRTexts for every image. mock := &MockDocAnalyzer{ Healthy: true, - OCRTexts: []OCRText{{Text: "default", Confidence: 0.5}}, + OCRTexts: []pdf.OCRText{{Text: "default", Confidence: 0.5}}, } dummy := image.NewRGBA(image.Rect(0, 0, 10, 10)) results, errs := mock.OCRRecognizeBatch(context.Background(), []image.Image{dummy, dummy, dummy}) @@ -170,8 +171,8 @@ func TestOCRRecognizeBatch_PartialBatchTexts(t *testing.T) { // OCRBatchTexts shorter than images — remaining fall back to OCRTexts. mock := &MockDocAnalyzer{ Healthy: true, - OCRTexts: []OCRText{{Text: "fallback", Confidence: 0.5}}, - OCRBatchTexts: [][]OCRText{ + OCRTexts: []pdf.OCRText{{Text: "fallback", Confidence: 0.5}}, + OCRBatchTexts: [][]pdf.OCRText{ {{Text: "custom0", Confidence: 0.9}}, }, } diff --git a/internal/deepdoc/parser/pdf/outline_extraction_test.go b/internal/deepdoc/parser/pdf/outline_extraction_test.go new file mode 100644 index 0000000000..46b3de033f --- /dev/null +++ b/internal/deepdoc/parser/pdf/outline_extraction_test.go @@ -0,0 +1,106 @@ +package parser + +import ( + "context" + "errors" + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── outline-tracking mock engines ────────────────────────────────────────── + +// outlineTrackingEngine wraps mockEngine and records whether Outlines() +// was called. +type outlineTrackingEngine struct { + *mockEngine + outlines []pdf.Outline + outlinesCalled bool +} + +func (e *outlineTrackingEngine) Outlines() ([]pdf.Outline, error) { + e.outlinesCalled = true + return e.outlines, nil +} + +// outlineErrorEngine returns an error from Outlines(). +type outlineErrorEngine struct { + *mockEngine +} + +func (e *outlineErrorEngine) Outlines() ([]pdf.Outline, error) { + return nil, errors.New("pdfium outline extraction failed") +} + +// ── tests for outline extraction in Parse() ───────────────────────────────── + +// TestParse_ExtractsOutlinesFromEngine verifies that Parse() calls +// engine.Outlines() and the result carries the outlines. +// +// This test currently FAILS because: +// 1. Parse() never calls engine.Outlines() → outlinesCalled stays false +// 2. ParseResult has no Outlines field → compilation error if we try to read it +func TestParse_ExtractsOutlinesFromEngine(t *testing.T) { + expectedOutlines := []pdf.Outline{ + {Title: "Chapter 1", Level: 0, PageNumber: 1}, + {Title: "Section 1.1", Level: 1, PageNumber: 2}, + } + eng := &outlineTrackingEngine{ + mockEngine: &mockEngine{pageCount: 3}, + outlines: expectedOutlines, + } + mockDLA := &MockDocAnalyzer{Healthy: true} + p := NewParser(pdf.DefaultParserConfig(), mockDLA) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if result == nil { + t.Fatal("Parse returned nil result") + } + + // Check 1: engine.Outlines() was actually called + if !eng.outlinesCalled { + t.Error("BUG: Parse() never called engine.Outlines() — outlines are extracted by pdfium but ignored") + } + + // Check 2: outlines are present in ParseResult + if len(result.Outlines) == 0 { + t.Error("BUG: ParseResult.Outlines is empty — outlines extracted but not stored") + } + if len(result.Outlines) != len(expectedOutlines) { + t.Errorf("result.Outlines: got %d, want %d", len(result.Outlines), len(expectedOutlines)) + } +} + +// TestParse_OutlinesErrorDoesNotBlockParsing verifies that when +// engine.Outlines() fails, the parse still completes successfully +// and produces sections (outlines are best-effort). +func TestParse_OutlinesErrorDoesNotBlockParsing(t *testing.T) { + eng := &outlineErrorEngine{ + mockEngine: &mockEngine{ + pageCount: 2, + chars: map[int][]pdf.TextChar{ + 0: {{Text: "Hello world", X0: 100, X1: 200, Top: 100, Bottom: 120}}, + 1: {{Text: "Page two", X0: 100, X1: 200, Top: 100, Bottom: 120}}, + }, + }, + } + mockDLA := &MockDocAnalyzer{Healthy: true} + p := NewParser(pdf.DefaultParserConfig(), mockDLA) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse should not fail when Outlines() errors: %v", err) + } + if result == nil { + t.Fatal("Parse returned nil result") + } + if len(result.Sections) == 0 { + t.Error("Parse should still produce sections even if Outlines() fails") + } + if len(result.Outlines) != 0 { + t.Errorf("outlines should be empty on error, got %d", len(result.Outlines)) + } +} diff --git a/internal/deepdoc/parser/pdf/page_batch_test.go b/internal/deepdoc/parser/pdf/page_batch_test.go new file mode 100644 index 0000000000..0b1489f3c3 --- /dev/null +++ b/internal/deepdoc/parser/pdf/page_batch_test.go @@ -0,0 +1,90 @@ +//go:build cgo && manual + +package parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "ragflow/internal/deepdoc/parser/pdf/tool" + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// TestParse_BatchEquivalence verifies that batched processing produces +// the same output as processing all pages at once. Uses batchSize=1 +// (every page is its own batch) on a multi-page fixture to maximize +// batch boundary stress. +func TestParse_BatchEquivalence(t *testing.T) { + data, err := readTestPDF(t, "03_multipage.pdf") + if err != nil { + t.Fatal(err) + } + + parse := func(batchSize int) *pdf.ParseResult { + eng, err := NewEngine(data) + if err != nil { + t.Fatal(err) + } + defer eng.Close() + cfg := pdf.DefaultParserConfig() + cfg.BatchSize = batchSize + p := NewParser(cfg, &MockDocAnalyzer{Healthy: true}) + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatal(err) + } + return result + } + + // No batching (all pages at once). + full := parse(9999) + // Aggressive batching (1 page per batch). + batched := parse(1) + + // Compare section counts. + if len(full.Sections) != len(batched.Sections) { + t.Logf("section count: full=%d batched=%d (small diff acceptable at batch boundaries)", + len(full.Sections), len(batched.Sections)) + } + + // Compare text content via CharSimilarity. + fullText := sectionsText(full.Sections) + batchedText := sectionsText(batched.Sections) + charSim := tool.CharSimilarity(fullText, batchedText) + t.Logf("CharSimilarity: %.1f%%", charSim) + if charSim < 95 { + t.Errorf("batch equivalence too low: CharSim=%.1f%% (want >= 95%%)", charSim) + } + + // Compare metrics (should be identical or very close). + t.Logf("Metrics: full=%+v batched=%+v", full.Metrics, batched.Metrics) + if full.Metrics.BoxesInitial != batched.Metrics.BoxesInitial { + t.Errorf("BoxesInitial: full=%d batched=%d", + full.Metrics.BoxesInitial, batched.Metrics.BoxesInitial) + } + + // Bug fix regression: PageImages must survive batched merge. + if len(full.PageImages) == 0 { + t.Error("full parse: PageImages should not be empty (3-page document)") + } + if len(batched.PageImages) == 0 { + t.Error("batched parse: PageImages should be preserved across batches") + } +} + +func readTestPDF(t *testing.T, name string) ([]byte, error) { + t.Helper() + return os.ReadFile(filepath.Join("testdata", "pdfs", name)) +} + +func sectionsText(sections []pdf.Section) string { + var sb strings.Builder + for _, s := range sections { + sb.WriteString(s.Text) + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/internal/deepdoc/parser/pdf/parser.go b/internal/deepdoc/parser/pdf/parser.go index d7cddd74dc..f731f4e445 100644 --- a/internal/deepdoc/parser/pdf/parser.go +++ b/internal/deepdoc/parser/pdf/parser.go @@ -6,85 +6,38 @@ import ( "fmt" "image" "log/slog" - "math" - "math/rand/v2" - "regexp" - "sort" - "strings" "sync" + + inf "ragflow/internal/deepdoc/parser/pdf/inference" + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + tbl "ragflow/internal/deepdoc/parser/pdf/table" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" ) -// dlaDPI is the DPI used for rendering page images for DeepDoc DLA/OCR. -const dlaDPI = 216 - -// dlaScale is the scale factor from PDF points (72 DPI) to DLA image space. -const dlaScale = dlaDPI / 72.0 - // Parser is the main PDF text/layout extraction pipeline. // It corresponds to RAGFlowPdfParser in pdf_parser.py. // Parser is stateless after construction — safe to reuse across documents. type Parser struct { - Config ParserConfig + Config pdf.ParserConfig // DeepDoc is the required document layout / OCR / table recognition // service. Set at construction time by NewParser. - DeepDoc DocAnalyzer + DeepDoc pdf.DocAnalyzer // SampleChars samples up to n chars from a page for English detection. // Defaults to random sampling (matching Python's random.choices). // Inject a deterministic sampler for reproducible tests. - SampleChars SampleFunc + SampleChars pdf.SampleFunc // tableBuilder is the TSR model adapter. Set at construction time - // by NewParser from DeepDoc.ModelType(). Callers can inject a + // // different implementation via Config.TableBuilder. - tableBuilder TableBuilder - - // debugDLA and debugTSR collect intermediates for comparison with Python. - // Set before Parse(), read from ParseResult after, cleared by Parse(). - debugDLA []DLAPageRegions - debugTSR []TSRRawCell + tableBuilder pdf.TableBuilder } -// PDFEngine abstracts page extraction capabilities. -// Calling code provides the implementation (pdfplumber-rs, etc.). -type PDFEngine interface { - // ExtractChars returns all characters on a page with position data. - // pageNum is 0-indexed. - ExtractChars(pageNum int) ([]TextChar, error) - - // RenderPage renders a page to PNG bytes at the given DPI. - RenderPage(pageNum int, dpi float64) ([]byte, error) - - // RenderPageImage renders a page as image.Image at the given DPI. - // Used by DeepDoc DLA/TSR/OCR which need width/height metadata. - RenderPageImage(pageNum int, dpi float64) (image.Image, error) - - // RawData returns the original PDF bytes, used by the pdfium - // rendering path. Must return the full, unmodified PDF content. - RawData() []byte - - // PageCount returns the total number of pages. - PageCount() (int, error) - - // Close releases resources held by the engine. - Close() error -} - -// Tokenizer provides text tokenization matching rag_tokenizer. -// Used by MergeSameBullet to detect Chinese characters. -type Tokenizer interface { - Tag(token string) string // POS tag -} - -// SampleFunc samples up to n characters from a page's chars, -// returning them concatenated as a single string. -// The default implementation uses random sampling (matching Python's -// random.choices). Tests can inject a deterministic sampler. -type SampleFunc func(chars []TextChar, n int) string - // NewParser creates a new Parser with the required DeepDoc service. -func NewParser(cfg ParserConfig, doc DocAnalyzer) *Parser { +func NewParser(cfg pdf.ParserConfig, doc pdf.DocAnalyzer) *Parser { tb := cfg.TableBuilder if tb == nil { tb = NewTableBuilderFor(doc) @@ -96,15 +49,43 @@ func NewParser(cfg ParserConfig, doc DocAnalyzer) *Parser { } } +// ── TableBuilder factory ─────────────────────────────────────────────────── + +// tableBuilderFactory holds a model-specific TableBuilder factory registered +// by EE packages via RegisterTableBuilder. If nil, the default OSS +// implementation is used. +var tableBuilderFactory func(pdf.DocAnalyzer) pdf.TableBuilder + +// RegisterTableBuilder registers a TableBuilder factory for the PDF parser. +// EE packages call this from init() to inject EE-specific implementations. +func RegisterTableBuilder(factory func(pdf.DocAnalyzer) pdf.TableBuilder) { + tableBuilderFactory = factory +} + +// NewTableBuilderFor creates the right TableBuilder, chosen by the registry. +// Checks the registry first for EE-registered implementations, falling back +// to the default OSS DeepDocTableBuilder. Label taxonomies are injected +// before construction. +func NewTableBuilderFor(doc pdf.DocAnalyzer) pdf.TableBuilder { + if tableBuilderFactory != nil { + return tableBuilderFactory(doc) + } + if c, ok := doc.(*inf.InferenceClient); ok { + c.DLALabels = inf.DefaultDLALabels() + c.TSRLabels = inf.DefaultTSRLabels() + } + return tbl.NewDeepDocTableBuilder(doc) +} + // Parse runs the full PDF extraction pipeline: chars → boxes → // column assignment → text merge → vertical merge → sections. // -// For documents larger than Config.ChunkSize pages, processes in chunks +// For documents larger than Config.BatchSize pages, processes in batches // to bound memory usage (matching Python's batch_size=50). // -// Returns a ParseResult containing sections, tables, page images, figures, +// Returns a pdf.ParseResult containing sections, tables, page images, figures, // and pipeline stage metrics. Parser itself remains stateless. -func (p *Parser) Parse(ctx context.Context, engine PDFEngine) (*ParseResult, error) { +func (p *Parser) Parse(ctx context.Context, engine pdf.PDFEngine) (*pdf.ParseResult, error) { // Normalize page range pageCount, err := engine.PageCount() if err != nil { @@ -116,18 +97,18 @@ func (p *Parser) Parse(ctx context.Context, engine PDFEngine) (*ParseResult, err } fromPage := p.Config.FromPage if toPage < fromPage { - return &ParseResult{PageImages: make(map[int]image.Image)}, nil + return &pdf.ParseResult{PageImages: make(map[int]image.Image)}, nil } totalPages := toPage - fromPage + 1 - chunkSize := p.Config.ChunkSize - if chunkSize <= 0 { - chunkSize = 50 // default, matching Python's batch_size + batchSize := p.Config.BatchSize + if batchSize <= 0 { + batchSize = 50 // default, matching Python's batch_size } // ── Prescan: lightweight char extraction for language/noise detection ── // No rendering, no OCR — just raw chars for global decisions. - prescanChars := make(map[int][]TextChar) + prescanChars := make(map[int][]pdf.TextChar) prescanMedianH := make(map[int]float64) prescanMedianW := make(map[int]float64) for pg := fromPage; pg <= toPage; pg++ { @@ -137,56 +118,69 @@ func (p *Parser) Parse(ctx context.Context, engine PDFEngine) (*ParseResult, err chars = nil // skip broken pages (matching old behavior) } prescanChars[pg] = chars - prescanMedianH[pg] = MedianCharHeight(chars) - prescanMedianW[pg] = MedianCharWidth(chars) + prescanMedianH[pg] = util.MedianCharHeight(chars) + prescanMedianW[pg] = util.MedianCharWidth(chars) } - isEnglish := detectEnglish(prescanChars, totalPages, p.SampleChars) - scanNoise := isScanNoise(fullTextFromChars(prescanChars)) + isEnglish := util.DetectEnglish(prescanChars, totalPages, p.SampleChars) + scanNoise := util.IsScanNoise(util.FullTextFromChars(prescanChars)) - // ── Small document: process all at once (no chunking overhead) ── - if totalPages <= chunkSize { - return p.processPages(ctx, engine, fromPage, toPage, + // ── Extract PDF outlines/bookmarks (best-effort, non-fatal) ── + outlines, outlineErr := engine.Outlines() + if outlineErr != nil { + slog.Warn("Failed to extract PDF outlines; continuing without them", "err", outlineErr) + outlines = nil + } + + // ── Small document: process all at once (no batching overhead) ── + if totalPages <= batchSize { + result, err := p.processPages(ctx, engine, fromPage, toPage, prescanChars, prescanMedianH, prescanMedianW, isEnglish, scanNoise) + if err != nil { + return nil, err + } + result.Outlines = outlines + return result, nil } - // ── Large document: process in chunks to bound memory ── - slog.Info("chunked processing", "pages", totalPages, "chunkSize", chunkSize) - result := &ParseResult{PageImages: make(map[int]image.Image)} - for start := fromPage; start <= toPage; start += chunkSize { + // ── Large document: process in batches to bound memory ── + slog.Info("batched processing", "pages", totalPages, "batchSize", batchSize) + result := &pdf.ParseResult{PageImages: make(map[int]image.Image)} + for start := fromPage; start <= toPage; start += batchSize { if err := ctx.Err(); err != nil { - return nil, fmt.Errorf("cancelled at chunk starting page %d: %w", start, err) + return nil, fmt.Errorf("cancelled at batch starting page %d: %w", start, err) } - end := min(start+chunkSize-1, toPage) + end := min(start+batchSize-1, toPage) - // Slice prescan data for this chunk. - chunkChars := make(map[int][]TextChar, end-start+1) - chunkMH := make(map[int]float64, end-start+1) - chunkMW := make(map[int]float64, end-start+1) + // Slice prescan data for this batch. + batchChars := make(map[int][]pdf.TextChar, end-start+1) + batchMH := make(map[int]float64, end-start+1) + batchMW := make(map[int]float64, end-start+1) for pg := start; pg <= end; pg++ { - chunkChars[pg] = prescanChars[pg] - chunkMH[pg] = prescanMedianH[pg] - chunkMW[pg] = prescanMedianW[pg] + batchChars[pg] = prescanChars[pg] + batchMH[pg] = prescanMedianH[pg] + batchMW[pg] = prescanMedianW[pg] } - chunk, err := p.processPages(ctx, engine, start, end, - chunkChars, chunkMH, chunkMW, isEnglish, scanNoise) + batch, err := p.processPages(ctx, engine, start, end, + batchChars, batchMH, batchMW, isEnglish, scanNoise) if err != nil { return nil, err } - // Merge chunk results. - result.Sections = append(result.Sections, chunk.Sections...) - result.Tables = append(result.Tables, chunk.Tables...) - result.Figures = append(result.Figures, chunk.Figures...) - for pg, img := range chunk.PageImages { + // Merge batch results. + result.Sections = append(result.Sections, batch.Sections...) + result.Tables = append(result.Tables, batch.Tables...) + // Figures() is computed on demand from Sections. + for pg, img := range batch.PageImages { result.PageImages[pg] = img } - result.Metrics.BoxesInitial += chunk.Metrics.BoxesInitial - result.Metrics.BoxesTextMerge += chunk.Metrics.BoxesTextMerge - result.Metrics.BoxesVertMerge += chunk.Metrics.BoxesVertMerge - result.Metrics.BoxesFinal += chunk.Metrics.BoxesFinal - result.Metrics.TablesCount += chunk.Metrics.TablesCount + result.Metrics.BoxesInitial += batch.Metrics.BoxesInitial + result.Metrics.BoxesTextMerge += batch.Metrics.BoxesTextMerge + result.Metrics.BoxesVertMerge += batch.Metrics.BoxesVertMerge + result.Metrics.BoxesFinal += batch.Metrics.BoxesFinal + result.Metrics.TablesCount += batch.Metrics.TablesCount } + result.Outlines = outlines return result, nil } @@ -195,20 +189,20 @@ func (p *Parser) Parse(ctx context.Context, engine PDFEngine) (*ParseResult, err // any errors encountered. Partial results are returned even when some // pages fail — callers should inspect the error for diagnostics but may // still use the returned boxes and chars. -func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, +func (p *Parser) extractPages(ctx context.Context, engine pdf.PDFEngine, fromPage, toPage int, - prescanChars map[int][]TextChar, + prescanChars map[int][]pdf.TextChar, medianHeights, medianWidths map[int]float64, pageImages map[int]image.Image, -) ([]TextBox, map[int][]TextChar, bool, error) { - var boxes []TextBox - pageChars := make(map[int][]TextChar) +) ([]pdf.TextBox, map[int][]pdf.TextChar, bool, error) { + var boxes []pdf.TextBox + pageChars := make(map[int][]pdf.TextChar) ocrUsedAny := false type pr struct { pg int - ocrBoxes []TextBox - chars []TextChar + ocrBoxes []pdf.TextBox + chars []pdf.TextChar ocrUsed bool pageImg image.Image err error @@ -229,23 +223,23 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, chars := prescanChars[pg] // Fast path: pages with embedded chars → sequential inline (no HTTP OCR). - if len(chars) > 0 && !isGarbledPage(chars) { + if len(chars) > 0 && !util.IsGarbledPage(chars) { pageImg, renderErr := renderPageToImage(engine, pg) if renderErr == nil && pageImg != nil { pageImages[pg] = pageImg } - var ocrBoxes []TextBox + var ocrBoxes []pdf.TextBox ocrUsed := false if !p.Config.SkipOCR && renderErr == nil && pageImg != nil { ocrBoxes = ocrMergeChars(ctx, pageImg, chars, p.DeepDoc, pg) if ocrBoxes == nil { - ocrBoxes = charsToBoxes(chars, pg, p.Config.SortByTop) + ocrBoxes = lyt.CharsToBoxes(chars, pg, p.Config.SortByTop) } else { ocrUsed = true ocrUsedAny = true } } else { - ocrBoxes = charsToBoxes(chars, pg, p.Config.SortByTop) + ocrBoxes = lyt.CharsToBoxes(chars, pg, p.Config.SortByTop) } results[i] = pr{pg: pg, ocrBoxes: ocrBoxes, chars: chars, ocrUsed: ocrUsed} continue @@ -253,7 +247,7 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, // OCR path: render + detect + recognize (potentially parallel). wg.Add(1) - go func(i, pg int, chars []TextChar) { + go func(i, pg int, chars []pdf.TextChar) { defer wg.Done() select { case <-ctx.Done(): @@ -274,7 +268,7 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, return } - var ocrBoxes []TextBox + var ocrBoxes []pdf.TextBox ocrUsed := false if !p.Config.SkipOCR { label := "scan page" @@ -285,7 +279,7 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, if ocrBoxes != nil { for j := range ocrBoxes { for _, r := range ocrBoxes[j].Text { - chars = append(chars, TextChar{Text: string(r), PageNumber: pg}) + chars = append(chars, pdf.TextChar{Text: string(r), PageNumber: pg}) break } } @@ -301,7 +295,7 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, } if !ocrUsed { if len(chars) > 0 { - ocrBoxes = charsToBoxes(chars, pg, p.Config.SortByTop) + ocrBoxes = lyt.CharsToBoxes(chars, pg, p.Config.SortByTop) } } results[i] = pr{pg: pg, ocrBoxes: ocrBoxes, chars: chars, ocrUsed: ocrUsed, pageImg: pageImg} @@ -329,8 +323,8 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, } pageChars[r.pg] = r.chars if r.ocrUsed { - medianHeights[r.pg] = MedianCharHeight(r.chars) - medianWidths[r.pg] = MedianCharWidth(r.chars) + medianHeights[r.pg] = util.MedianCharHeight(r.chars) + medianWidths[r.pg] = util.MedianCharWidth(r.chars) } } return boxes, pageChars, ocrUsedAny, errors.Join(errs...) @@ -338,15 +332,15 @@ func (p *Parser) extractPages(ctx context.Context, engine PDFEngine, // retryScanNoise re-runs OCR on all pages when prescan detects scan noise, // overwriting page-level state with fresh detect+recognize results. -func (p *Parser) retryScanNoise(ctx context.Context, engine PDFEngine, +func (p *Parser) retryScanNoise(ctx context.Context, engine pdf.PDFEngine, fromPage, toPage int, pageImages map[int]image.Image, - pageChars map[int][]TextChar, + pageChars map[int][]pdf.TextChar, medianHeights, medianWidths map[int]float64, ocrUsedAny bool, -) ([]TextBox, map[int][]TextChar, bool) { +) ([]pdf.TextBox, map[int][]pdf.TextChar, bool) { slog.Warn("scan noise: OCR retry", "from", fromPage, "to", toPage) - var boxes []TextBox + var boxes []pdf.TextBox for pg := fromPage; pg <= toPage; pg++ { img := pageImages[pg] if img == nil { @@ -364,16 +358,16 @@ func (p *Parser) retryScanNoise(ctx context.Context, engine PDFEngine, continue } boxes = append(boxes, ocrBoxes...) - var chars []TextChar + var chars []pdf.TextChar for _, b := range ocrBoxes { for _, r := range b.Text { - chars = append(chars, TextChar{Text: string(r), Top: b.Top, Bottom: b.Bottom, PageNumber: pg}) + chars = append(chars, pdf.TextChar{Text: string(r), Top: b.Top, Bottom: b.Bottom, PageNumber: pg}) break } } pageChars[pg] = chars - medianHeights[pg] = MedianCharHeight(chars) - medianWidths[pg] = MedianCharWidth(chars) + medianHeights[pg] = util.MedianCharHeight(chars) + medianWidths[pg] = util.MedianCharWidth(chars) } slog.Debug("scan noise OCR retry complete", "pages", toPage-fromPage+1, "boxes", len(boxes)) return boxes, pageChars, true @@ -382,12 +376,12 @@ func (p *Parser) retryScanNoise(ctx context.Context, engine PDFEngine, // retryZoom re-renders pages at higher resolution and re-runs OCR when the // initial extraction produced zero boxes. Box coordinates are scaled back // to Config.Zoom space. Matches Python's __images__ retry. -func (p *Parser) retryZoom(ctx context.Context, engine PDFEngine, +func (p *Parser) retryZoom(ctx context.Context, engine pdf.PDFEngine, fromPage, toPage int, pageImages map[int]image.Image, - boxes []TextBox, ocrUsedAny bool, -) ([]TextBox, bool) { - retryZoom := p.Config.Zoom * dlaScale + boxes []pdf.TextBox, ocrUsedAny bool, +) ([]pdf.TextBox, bool) { + retryZoom := p.Config.Zoom * pdf.DlaScale retryDPI := retryZoom * 72 slog.Info("zoom retry: re-rendering", "oldZoom", p.Config.Zoom, "newZoom", retryZoom) for pg := fromPage; pg <= toPage; pg++ { @@ -397,10 +391,10 @@ func (p *Parser) retryZoom(ctx context.Context, engine PDFEngine, continue } pageImages[pg] = img - // Downstream DLA/TSR assumes dlaDPI. Re-render at standard + // Downstream DLA/TSR assumes pdf.DlaDPI. Re-render at standard // resolution so layout coordinates are scaled correctly. - if retryDPI != dlaDPI { - if dlaImg, dlaErr := engine.RenderPageImage(pg, dlaDPI); dlaErr == nil { + if retryDPI != pdf.DlaDPI { + if dlaImg, dlaErr := engine.RenderPageImage(pg, pdf.DlaDPI); dlaErr == nil { pageImages[pg] = dlaImg } } @@ -421,62 +415,61 @@ func (p *Parser) retryZoom(ctx context.Context, engine PDFEngine, return boxes, ocrUsedAny } -// buildLayout runs the DLA → TSR → Column → TextMerge → VM → Section +// buildLayout runs the DLA → TSR → Column → TextMerge → VM → pdf.Section // pipeline and populates result.Metrics, result.Tables, result.Sections, -// and result.Figures. Matches Python's _parse_loaded_window_into_bboxes +// and result.Sections. Matches Python's _parse_loaded_window_into_bboxes // order. func (p *Parser) buildLayout(ctx context.Context, - result *ParseResult, engine PDFEngine, - boxes []TextBox, pageChars map[int][]TextChar, + result *pdf.ParseResult, engine pdf.PDFEngine, + boxes []pdf.TextBox, pageChars map[int][]pdf.TextChar, medianHeights, medianWidths map[int]float64, fromPage, toPage int, ocrUsedAny bool, isEnglish bool, ) error { result.Metrics.BoxesInitial = len(boxes) - result.Tables = p.enrichWithDeepDoc(ctx, engine, boxes, result.PageImages) + result.Tables = p.enrichWithDeepDoc(ctx, result, engine, boxes, result.PageImages) result.Metrics.TablesCount = len(result.Tables) if err := ctx.Err(); err != nil { return err } - boxes = AssignColumn(boxes, p.Config.Zoom) - boxes = TextMerge(boxes, medianHeights, p.Config.Zoom) + boxes = lyt.AssignColumn(boxes, p.Config.Zoom) + boxes = lyt.TextMerge(boxes, medianHeights, p.Config.Zoom) result.Metrics.BoxesTextMerge = len(boxes) - sortByPageThenY(boxes, p.Config.SortByTop) + lyt.SortByPageThenY(boxes, p.Config.SortByTop) if ocrUsedAny { - isEnglish = detectEnglish(pageChars, toPage-fromPage+1, p.SampleChars) + isEnglish = util.DetectEnglish(pageChars, toPage-fromPage+1, p.SampleChars) } - boxes = NaiveVerticalMerge(boxes, medianHeights, medianWidths, isEnglish) + boxes = lyt.NaiveVerticalMerge(boxes, medianHeights, medianWidths, isEnglish) result.Metrics.BoxesVertMerge = len(boxes) if err := ctx.Err(); err != nil { return err } - boxes = extractTableAndReplace(boxes, result.Tables) - boxes = consolidateFigures(boxes) + boxes = tbl.ExtractTableAndReplace(boxes, result.Tables) + boxes = tbl.ConsolidateFigures(boxes) pageHeights := make(map[int]float64, len(result.PageImages)) for pg, img := range result.PageImages { pageHeights[pg] = float64(img.Bounds().Dy()) / p.Config.Zoom } - result.Sections = boxesToSections(boxes, pageHeights) + result.Sections = lyt.BoxesToSections(boxes, pageHeights) result.Metrics.BoxesFinal = len(result.Sections) - result.Figures = CollectFigures(result.Sections) - result.Sections = mergeCaptions(result.Sections, result.Figures) + result.Sections = tbl.MergeCaptions(result.Sections, result.Figures()) return nil } // processPages runs the full pipeline on pages [fromPage, toPage]. // prescanChars provides pre-extracted chars (avoids double extraction). -func (p *Parser) processPages(ctx context.Context, engine PDFEngine, +func (p *Parser) processPages(ctx context.Context, engine pdf.PDFEngine, fromPage, toPage int, - prescanChars map[int][]TextChar, + prescanChars map[int][]pdf.TextChar, medianHeights, medianWidths map[int]float64, isEnglish, isScanNoiseDoc bool, -) (*ParseResult, error) { - result := &ParseResult{PageImages: make(map[int]image.Image)} +) (*pdf.ParseResult, error) { + result := &pdf.ParseResult{PageImages: make(map[int]image.Image)} // 1. OCR extraction — per-page detect + recognize + char merge. boxes, pageChars, ocrUsedAny, ocrErr := p.extractPages(ctx, engine, @@ -507,562 +500,62 @@ func (p *Parser) processPages(ctx context.Context, engine PDFEngine, medianHeights, medianWidths, fromPage, toPage, ocrUsedAny, isEnglish); err != nil { return nil, fmt.Errorf("buildLayout: %w", err) } - // Text sections use cropSectionImage based on their PositionTag. - if len(result.PageImages) > 0 { - // Build lookup: DLA region → TableItem index for image matching. - tableImgByRegion := make(map[string]string, len(result.Tables)) - for _, tbl := range result.Tables { - if tbl.ImageB64 == "" { - continue - } - pg := 0 - if len(tbl.Positions) > 0 && len(tbl.Positions[0].PageNumbers) > 0 { - pg = tbl.Positions[0].PageNumbers[0] - } - key := fmt.Sprintf("%d_%.1f_%.1f_%.1f_%.1f", - pg, tbl.RegionLeft, tbl.RegionRight, tbl.RegionTop, tbl.RegionBottom) - tableImgByRegion[key] = tbl.ImageB64 - } - for i := range result.Sections { - if result.Sections[i].LayoutType == LayoutTypeTable && len(result.Sections[i].Positions) > 0 { - pos := result.Sections[i].Positions[0] - pg := 0 - if len(pos.PageNumbers) > 0 { - pg = pos.PageNumbers[0] - } - key := fmt.Sprintf("%d_%.1f_%.1f_%.1f_%.1f", - pg, pos.Left, pos.Right, pos.Top, pos.Bottom) - if img, ok := tableImgByRegion[key]; ok { - result.Sections[i].Image = img - continue - } - } - // Try DLA-aware cropping for figure sections (matching Python's - // cropout which uses DLA region boundaries instead of text boxes). - if result.Sections[i].LayoutType == LayoutTypeFigure && len(result.Sections[i].Positions) > 0 { - if dlaImg := cropSectionByDLA(result.Sections[i], p.debugDLA, result.PageImages); dlaImg != "" { - result.Sections[i].Image = dlaImg - continue - } - } - img := cropSectionImage(result.Sections[i].PositionTag, result.PageImages, p.Config.Zoom) - result.Sections[i].Image = img - if img == "" && result.Sections[i].Text != "" { - tag := result.Sections[i].PositionTag - slog.Warn("cropSectionImage empty for non-empty section", - "section", i, "posTag", tag[:min(80, len(tag))]) - } - } - } + // 5. Crop section images from page renders. + p.fillSectionImages(result) - // Collect DLA/TSR debug intermediates if available. - result.DLADebug = p.debugDLA - result.TSRDebug = p.debugTSR - p.debugDLA = nil - p.debugTSR = nil return result, nil } -// isASCIIPrintable returns true for characters that match Python's -// is_english regex: [ a-zA-Z0-9,/¸;:'\[\]\(\)!@#$%^&*\"?<>._-] -func isASCIIPrintable(r rune) bool { - if r == ' ' { - return true - } - if r >= 'a' && r <= 'z' { - return true - } - if r >= 'A' && r <= 'Z' { - return true - } - if r >= '0' && r <= '9' { - return true - } - // Additional ASCII symbols from the Python regex - switch r { - case ',', '/', '¸', ';', ':', '\'', '[', ']', '(', ')', - '!', '@', '#', '$', '%', '^', '&', '*', '"', '?', - '<', '>', '.', '_', '-': - return true - } - return false -} - -// defaultSampleChars returns a random sample of up to n character texts, -// concatenated. Matches Python's random.choices([c["text"] for c in -// page_chars], k=min(100, len(page_chars))). -func defaultSampleChars(chars []TextChar, n int) string { - if n <= 0 || len(chars) == 0 { - return "" - } - m := min(n, len(chars)) - // Fisher-Yates shuffle on indices, then take first m. - indices := make([]int, len(chars)) - for i := range indices { - indices[i] = i - } - rand.Shuffle(len(indices), func(i, j int) { - indices[i], indices[j] = indices[j], indices[i] - }) - var buf strings.Builder - for i := 0; i < m; i++ { - buf.WriteString(chars[indices[i]].Text) - } - return buf.String() -} - -// fullTextFromChars concatenates all chars text across pages for scan noise detection. -func fullTextFromChars(pageChars map[int][]TextChar) string { - var sb strings.Builder - for _, chars := range pageChars { - for _, c := range chars { - sb.WriteString(c.Text) - } - } - return sb.String() -} - -// detectEnglish detects whether a PDF is primarily English by per-page -// majority vote, matching Python's is_english logic in __images__ -// (pdf_parser.py:1519-1526). -// -// Each page: sample up to 100 character texts via sampler, join into one -// string, check if there is a run of 30+ consecutive ASCII characters -// (letters, digits, spaces, punctuation). Pages with such a run vote -// "English". Returns true when a strict majority of pages vote yes. -// -// totalPages is the denominator (len(self.page_images) in Python), including -// image-only pages that have zero chars. This matches Python's behavior -// where empty pages dilute the majority. -func detectEnglish(pageChars map[int][]TextChar, totalPages int, sample SampleFunc) bool { - if totalPages == 0 || len(pageChars) == 0 { - return false - } - if sample == nil { - sample = defaultSampleChars - } - pagesWithSeq := 0 - - for _, chars := range pageChars { - if len(chars) == 0 { - continue - } - sampleText := sample(chars, 100) - run := 0 - for _, r := range sampleText { - if isASCIIPrintable(r) { - run++ - if run >= 30 { - pagesWithSeq++ - break - } - } else { - run = 0 - } - } - } - - return pagesWithSeq > totalPages/2 -} - -// charsToBoxes converts raw characters to initial text boxes by grouping -// characters into lines based on vertical overlap. -// -// Python: pdf_parser.__images__ producing self.boxes -func charsToBoxes(chars []TextChar, pageNum int, sortByTop bool) []TextBox { - if len(chars) == 0 { - return nil - } - - lines := groupCharsToLines(chars, sortByTop) - - // Page-level column gap threshold from ALL inter-char gaps. - // Falls back to per-line threshold when page has too few gaps. - threshold := pageXGapThreshold(lines) - - boxes := make([]TextBox, 0, len(lines)) - for _, line := range lines { - thr := threshold - if thr > 100 { - // No significant column gaps on this page → use per-line threshold. - thr = perLineXGapThreshold(line) - } - subLines := splitLineByXGap(line, thr) - for _, sub := range subLines { - box := lineToTextBox(sub) - box.PageNumber = pageNum - boxes = append(boxes, box) - } - } - return boxes -} - -// perLineXGapThreshold computes a dynamic X-gap threshold for column -// splitting within a single line (fallback when page has few gaps). -func perLineXGapThreshold(chars []TextChar) float64 { - if len(chars) <= 1 { - return 1e9 - } - var gaps []float64 - for i := 1; i < len(chars); i++ { - g := chars[i].X0 - chars[i-1].X1 - gaps = append(gaps, g) - } - if len(gaps) == 0 { - return 1e9 - } - sort.Float64s(gaps) - medianGap := gaps[len(gaps)/2] - if medianGap < 6 { - medianGap = 6 - } - return medianGap * 2.5 -} - -// pageXGapThreshold computes a global X-gap column threshold from all -// inter-char gaps across all lines on the page. 95th percentile catches -// column boundaries while excluding word-level gaps. -// Returns a value > 100 when there are too few gaps for reliable p95, -// signalling the caller to fall back to perLineXGapThreshold. -func pageXGapThreshold(lines [][]TextChar) float64 { - var allGaps []float64 - for _, line := range lines { - for i := 1; i < len(line); i++ { - g := line[i].X0 - line[i-1].X1 - allGaps = append(allGaps, g) - } - } - if len(allGaps) < 10 { - return 1e9 // too few gaps for reliable p95 → fall back to per-line - } - sort.Float64s(allGaps) - // 95th percentile: only the largest 5% of gaps are column boundaries. - p95 := allGaps[len(allGaps)*95/100] - if p95 < 30 { - p95 = 30 // floor: column gaps are ≥30pt in practice - } - return p95 -} - -// splitLineByXGap splits a character line into sub-lines where X gaps -// meet or exceed the threshold (column boundaries). Uses >= to match the -// p95 boundary value — a gap exactly at the 95th percentile is a column gap, -// not a word gap. -func splitLineByXGap(chars []TextChar, threshold float64) [][]TextChar { - if len(chars) <= 1 { - return [][]TextChar{chars} - } - var result [][]TextChar - start := 0 - for i := 1; i < len(chars); i++ { - gap := chars[i].X0 - chars[i-1].X1 - if gap >= threshold { - result = append(result, chars[start:i]) - start = i - } - } - result = append(result, chars[start:]) - return result -} - -// resolvePageSpan computes the ending page and bottom coordinate for a box -// that may span multiple pages. When pageHeights is nil or the box fits -// within its starting page the returned (toPage, bottom) equal the inputs. -// -// Zero or negative page heights are treated as invalid: the span stops at -// the preceding page, guarding against infinite loops caused by corrupted -// page images. -func resolvePageSpan(pageNum int, bottom float64, pageHeights map[int]float64) (toPage int, newBottom float64) { - toPage = pageNum - newBottom = bottom - if pageHeights == nil { +// fillSectionImages populates result.Sections[i].Image with cropped +// page images. Table sections are matched to their TableItem image; +// figure sections try DLA-aware cropping first, then fall back to +// position-tag-based cropping. +func (p *Parser) fillSectionImages(result *pdf.ParseResult) { + if len(result.PageImages) == 0 { return } - ph, ok := pageHeights[pageNum] - if !ok || ph <= 0 || bottom <= ph { - return - } - remaining := bottom - for remaining > ph && ph > 0 { - nextPh, ok := pageHeights[toPage+1] - if !ok || nextPh <= 0 { - // Unknown or invalid next page height — extend by the - // last known height once and stop (Python: _line_tag - // while-loop break path). - remaining -= ph - toPage++ - break - } - remaining -= ph - ph = nextPh - toPage++ - } - newBottom = remaining - return -} - -// boxesToSections converts layout boxes to section format with position tags. -// -// pageHeights provides the PDF-point height of each page (image height / zoom). -// Boxes that extend beyond their page produce multi-page position tags -// (Python's _line_tag while-loop detection via resolvePageSpan). -// -// Python equivalent: output consumed by naive.py::chunk() -func boxesToSections(boxes []TextBox, pageHeights map[int]float64) []Section { - sections := make([]Section, 0, len(boxes)) - for _, b := range boxes { - t := strings.TrimSpace(b.Text) - if t == "" { + // Build lookup: DLA region -> table image (base64). + tableImgByRegion := make(map[string]string, len(result.Tables)) + for _, tbl := range result.Tables { + if tbl.ImageB64 == "" { continue } - toPage, bottom := resolvePageSpan(b.PageNumber, b.Bottom, pageHeights) - - var posTag string - var pageNums []int - if b.PageNumber == toPage { - posTag = FormatPositionTag(b.PageNumber, b.X0, b.X1, b.Top, bottom) - pageNums = []int{b.PageNumber} - } else { - posTag = FormatPositionTagRange(b.PageNumber, toPage, b.X0, b.X1, b.Top, bottom) - pageNums = make([]int, 0, toPage-b.PageNumber+1) - for p := b.PageNumber; p <= toPage; p++ { - pageNums = append(pageNums, p) + pg := 0 + if len(tbl.Positions) > 0 && len(tbl.Positions[0].PageNumbers) > 0 { + pg = tbl.Positions[0].PageNumbers[0] + } + key := fmt.Sprintf("%d_%.1f_%.1f_%.1f_%.1f", + pg, tbl.RegionLeft, tbl.RegionRight, tbl.RegionTop, tbl.RegionBottom) + tableImgByRegion[key] = tbl.ImageB64 + } + for i := range result.Sections { + if result.Sections[i].LayoutType == pdf.LayoutTypeTable && len(result.Sections[i].Positions) > 0 { + pos := result.Sections[i].Positions[0] + pg := 0 + if len(pos.PageNumbers) > 0 { + pg = pos.PageNumbers[0] } - } - sections = append(sections, Section{ - Text: t, - PositionTag: posTag, - LayoutType: b.LayoutType, - Positions: []Position{{PageNumbers: pageNums, Left: b.X0, Right: b.X1, Top: b.Top, Bottom: bottom}}, - }) - } - return sections -} - -// mergeCaptions finds "figure caption" and "table caption" sections, -// appends their text to the nearest figure/table, then removes the -// caption sections. Matches Python _extract_table_figure caption -// matching (pdf_parser.py:1196-1232). -// Also uses isCaptionBox to detect captions that DLA mislabeled as -// "text" — matching Python's is_caption(text) pattern matching. -func mergeCaptions(sections []Section, figures []Section) []Section { - captions := make([]int, 0, 4) - for i, s := range sections { - captionType := captionKind(s) - if captionType == "" { - continue - } - target := findNearestParent(i, s, sections, figures, captionType) - if target >= 0 { - // For table sections, prepend caption before the HTML table - // (matching Python's _extract_table_figure caption->construct_table). - if sections[target].LayoutType == LayoutTypeTable && sections[target].Text != "" { - sections[target].Text = s.Text + sections[target].Text - } else if sections[target].Text != "" { - sections[target].Text += " " + s.Text - } else { - sections[target].Text = s.Text - } - } - captions = append(captions, i) - } - // Remove caption sections in reverse order. - n := len(sections) - out := make([]Section, 0, n-len(captions)) - capSet := make(map[int]bool, len(captions)) - for _, idx := range captions { - capSet[idx] = true - } - for i, s := range sections { - if !capSet[i] { - out = append(out, s) - } - } - return out -} - -// findNearestParent finds the nearest figure (for figure caption) or -// table (for table caption) section by position proximity. -// captionType is "table" or "figure" (from captionKind). -// Returns the index in `sections` (for tables) or a virtual index mapping -// to `figures` (negative offset for figures). -func findNearestParent(captionIdx int, caption Section, sections []Section, figures []Section, captionType string) int { - find := func(targets []Section, skipIdx int) (int, float64) { - bestIdx := -1 - bestDist := 1e9 - for i, t := range targets { - if i == skipIdx { - continue // don't match caption to itself - } - if len(t.Positions) == 0 || len(caption.Positions) == 0 { + key := fmt.Sprintf("%d_%.1f_%.1f_%.1f_%.1f", + pg, pos.Left, pos.Right, pos.Top, pos.Bottom) + if img, ok := tableImgByRegion[key]; ok { + result.Sections[i].Image = img continue } - tp := t.Positions[0] - cp := caption.Positions[0] - // Squared Euclidean distance (Python _extract_table_figure:1196). - // Caption is typically below. Use center-point distance. - cx := (tp.Left + tp.Right) / 2 - cy := (tp.Top + tp.Bottom) / 2 - ccx := (cp.Left + cp.Right) / 2 - ccy := (cp.Top + cp.Bottom) / 2 - dist := (cx-ccx)*(cx-ccx) + (cy-ccy)*(cy-ccy) - if dist < bestDist { - bestDist = dist - bestIdx = i + } + // Try DLA-aware cropping for figure sections (matching Python's + // cropout which uses DLA region boundaries instead of text boxes). + if result.Sections[i].LayoutType == pdf.LayoutTypeFigure && len(result.Sections[i].Positions) > 0 { + if dlaImg := util.CropSectionByDLA(result.Sections[i], result.DLADebug, result.PageImages); dlaImg != "" { + result.Sections[i].Image = dlaImg + continue } } - return bestIdx, bestDist - } - - const maxCaptionGap = 40000.0 // PDF points (~7cm) — beyond this, don't attach. - if captionType == LayoutTypeFigure && len(figures) > 0 { - idx, dist := find(figures, -1) // figures don't contain the caption itself - if idx >= 0 && dist < maxCaptionGap { - // Match by position coordinates, not PositionTag strings. - f := figures[idx] - for i, s := range sections { - if s.LayoutType != LayoutTypeFigure || len(s.Positions) == 0 || len(f.Positions) == 0 { - continue - } - sp, fp := s.Positions[0], f.Positions[0] - if sp.Left == fp.Left && sp.Right == fp.Right && - sp.Top == fp.Top && sp.Bottom == fp.Bottom { - return i - } - } + img := util.CropSectionImage(result.Sections[i].PositionTag, result.PageImages, p.Config.Zoom) + result.Sections[i].Image = img + if img == "" && result.Sections[i].Text != "" { + tag := result.Sections[i].PositionTag + slog.Warn("cropSectionImage empty for non-empty section", + "section", i, "posTag", tag[:min(80, len(tag))]) } } - if captionType == LayoutTypeTable { - idx, dist := find(sections, captionIdx) - if idx >= 0 && dist < maxCaptionGap && sections[idx].LayoutType == LayoutTypeTable { - return idx - } - } - return -1 -} - -// sortByPageThenY sorts boxes by page → vertical key → x0. -func sortByPageThenY(boxes []TextBox, sortByTop bool) { - key := func(b TextBox) float64 { return b.Bottom } - if sortByTop { - key = func(b TextBox) float64 { return b.Top } - } - sort.Slice(boxes, func(i, j int) bool { - if boxes[i].PageNumber != boxes[j].PageNumber { - return boxes[i].PageNumber < boxes[j].PageNumber - } - if key(boxes[i]) != key(boxes[j]) { - return key(boxes[i]) < key(boxes[j]) - } - return boxes[i].X0 < boxes[j].X0 - }) -} - -// ---- internal helpers ---- - -// groupCharsToLines groups characters into horizontal lines based on vertical overlap. -func groupCharsToLines(chars []TextChar, sortByTop bool) [][]TextChar { - if len(chars) == 0 { - return nil - } - - key := func(c TextChar) float64 { return c.Bottom } - if sortByTop { - key = func(c TextChar) float64 { return c.Top } - } - - // Sort by vertical key (Bottom or Top) then x0 using sort.SliceStable. - // Guard against NaN: a NaN key sorts after everything else. - sort.SliceStable(chars, func(i, j int) bool { - ki, kj := key(chars[i]), key(chars[j]) - if ki != kj && !math.IsNaN(ki) && !math.IsNaN(kj) { - return ki < kj - } - if math.IsNaN(ki) != math.IsNaN(kj) { - return !math.IsNaN(ki) // non-NaN before NaN - } - return chars[i].X0 < chars[j].X0 - }) - - var lines [][]TextChar - var currentLine []TextChar - - for _, c := range chars { - if len(currentLine) == 0 { - currentLine = append(currentLine, c) - continue - } - if verticalOverlap(currentLine[len(currentLine)-1], c) { - currentLine = append(currentLine, c) - } else { - if len(currentLine) > 0 { - lines = append(lines, currentLine) - } - currentLine = []TextChar{c} - } - } - if len(currentLine) > 0 { - lines = append(lines, currentLine) - } - return lines -} - -// verticalOverlap checks if two characters are on the same horizontal line. -func verticalOverlap(a, b TextChar) bool { - mh := math.Max(CharHeight(a), CharHeight(b)) - if mh <= 0 { - mh = 1.0 - } - return math.Abs(a.Top-b.Top) < mh*0.5 -} - -// lineToTextBox converts a line of characters to a single TextBox. -// asciiWordPattern matches strings composed entirely of ASCII word -// characters. Python uses re.match (prefix match) — the stricter -// full-string match here is equivalent in practice because each -// TextChar.Text is a single rune, so prevText+currText ≤ 2 chars. -// Python: pdf_parser.py:1528 re.match(r"[0-9a-zA-Z,.:;!%]+", ...) -var asciiWordPattern = regexp.MustCompile(`^[0-9a-zA-Z,.:;!%]+$`) - -func lineToTextBox(chars []TextChar) TextBox { - if len(chars) == 0 { - return TextBox{} - } - box := TextBox{ - X0: chars[0].X0, - X1: chars[0].X1, - Top: chars[0].Top, - Bottom: chars[0].Bottom, - } - var textParts []string - for i, c := range chars { - // Insert space between adjacent ASCII words with a visible gap. - // Python: pdf_parser.py:1524-1532 __img_ocr space insertion. - if i > 0 { - prev := chars[i-1] - prevText := strings.TrimSpace(prev.Text) - currText := strings.TrimSpace(c.Text) - if prevText != "" && currText != "" { - gap := c.X0 - prev.X1 - minWidth := math.Min(c.X1-c.X0, prev.X1-prev.X0) - if gap >= minWidth/2 && - asciiWordPattern.MatchString(prevText+currText) { - textParts = append(textParts, " ") - } - } - } - box.X0 = math.Min(box.X0, c.X0) - box.X1 = math.Max(box.X1, c.X1) - box.Top = math.Min(box.Top, c.Top) - box.Bottom = math.Max(box.Bottom, c.Bottom) - textParts = append(textParts, c.Text) - if c.LayoutType != "" { - box.LayoutType = c.LayoutType - } - if c.LayoutNo != "" { - box.LayoutNo = c.LayoutNo - } - } - box.Text = strings.Join(textParts, "") - return box } diff --git a/internal/deepdoc/parser/pdf/parser_mock_test.go b/internal/deepdoc/parser/pdf/parser_mock_test.go new file mode 100644 index 0000000000..ae1a0998fb --- /dev/null +++ b/internal/deepdoc/parser/pdf/parser_mock_test.go @@ -0,0 +1,471 @@ +package parser + +import ( + "context" + "fmt" + "image" + inf "ragflow/internal/deepdoc/parser/pdf/inference" + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + tbl "ragflow/internal/deepdoc/parser/pdf/table" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + "strings" + "testing" +) + +// ── MockDocAnalyzer tests ────────────────────────────────────────────── + +func TestMockDocAnalyzer(t *testing.T) { + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 100, Y1: 100, Label: "table", Confidence: 0.95}, + }, + TSRCells: []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, + }, + } + + if !mock.Health() { + t.Error("mock should be healthy") + } + regions, _ := mock.DLA(context.Background(), nil) + if len(regions) != 1 || regions[0].Label != "table" { + t.Error("mock DLA returned wrong data") + } + cells, _ := mock.TSR(context.Background(), nil) + if len(cells) != 1 || cells[0].Text != "A" { + t.Error("mock TSR returned wrong data") + } + // OCRDetect + OCRRecognize replaces deprecated OCR — tested in TestOCR_scanPage/TestOCR_fallback. + _ = mock.OCRDetect + _ = mock.OCRRecognize + + // Unhealthy mock + mock2 := &MockDocAnalyzer{Healthy: false} + if mock2.Health() { + t.Error("unhealthy mock should return false") + } +} + +// ── enrichWithDeepDoc noop ───────────────────────────────────────────── + +func TestEnrichWithDeepDoc_Noop(t *testing.T) { + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "text"}, + } + eng := &mockEngine{pageCount: 1} + + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: false}) + tables := p.enrichWithDeepDoc(context.Background(), nil, eng, boxes, nil) + if len(tables) != 0 { + t.Error("unhealthy DeepDoc → 0 Tables") + } +} + +// ── extractTableBoxesFromImage with mock ─────────────────────────────── + +func TestExtractTableBoxes_Mock(t *testing.T) { + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 80, X1: 500, Top: 200, Bottom: 550, Text: "cell 1"}, + {PageNumber: 0, X0: 80, X1: 500, Top: 550, Bottom: 760, Text: "cell 2"}, + {PageNumber: 0, X0: 50, X1: 550, Top: 100, Bottom: 180, Text: "heading"}, + {PageNumber: 0, X0: 50, X1: 550, Top: 780, Bottom: 850, Text: "below"}, + } + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 250, Y0: 600, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.95}, + }, + TSRCells: []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 600, Y1: 400, Text: "A1"}, + {X0: 600, Y0: 0, X1: 1240, Y1: 400, Text: "B1"}, + {X0: 0, Y0: 410, X1: 600, Y1: 800, Text: "A2"}, + {X0: 600, Y0: 410, X1: 1240, Y1: 800, Text: "B2"}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + dummyImg := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) + + tables := p.extractTableBoxesFromImage(context.Background(), nil, boxes, dummyImg, 0, 0) + if len(tables) != 1 { + t.Fatalf("expected 1 pdf.TableItem, got %d", len(tables)) + } + tbl := tables[0] + if len(tbl.Cells) != 4 { + t.Errorf("expected 4 cells, got %d", len(tbl.Cells)) + } + // Rows populated later by constructTable via extractTableAndReplace. + if tbl.ImageB64 == "" { + t.Error("ImageB64 empty") + } + if len(tbl.Positions) != 2 { + t.Errorf("expected 2 Positions, got %d", len(tbl.Positions)) + } +} + +func TestExtractTableBoxes_NoTables(t *testing.T) { + mock := &MockDocAnalyzer{Healthy: true, DLARegions: []pdf.DLARegion{}} + p := NewParser(pdf.DefaultParserConfig(), mock) + dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) + tables := p.extractTableBoxesFromImage(context.Background(), nil, nil, dummy, 0, 0) + if len(tables) != 0 { + t.Errorf("0 tables expected, got %d", len(tables)) + } +} + +func TestExtractTableBoxes_NonTableRegions(t *testing.T) { + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 150, Y0: 300, X1: 1650, Y1: 336, Label: "text", Confidence: 0.9}, + {X0: 150, Y0: 600, X1: 1650, Y1: 900, Label: "figure", Confidence: 0.8}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + dummy := image.NewRGBA(image.Rect(0, 0, 2000, 2000)) + tables := p.extractTableBoxesFromImage(context.Background(), nil, nil, dummy, 0, 0) + if len(tables) != 0 { + t.Errorf("non-table regions → 0 tables, got %d", len(tables)) + } +} + +func TestExtractTableBoxes_NoOverlap(t *testing.T) { + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 50, X1: 550, Top: 10, Bottom: 30, Text: "far away"}, + } + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 150, Y0: 1500, X1: 1500, Y1: 2300, Label: "table", Confidence: 0.95}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + dummy := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) + tables := p.extractTableBoxesFromImage(context.Background(), nil, boxes, dummy, 0, 0) + if len(tables) != 0 { + t.Errorf("no overlap → 0 tables, got %d", len(tables)) + } +} + +func TestExtractTableBoxes_TSRError(t *testing.T) { + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 80, X1: 500, Top: 210, Bottom: 660, Text: "cell"}, + } + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 250, Y0: 600, X1: 1500, Y1: 2000, Label: "table", Confidence: 0.95}, + }, + TSRCells: nil, // TSR returns nothing + } + p := NewParser(pdf.DefaultParserConfig(), mock) + dummy := image.NewRGBA(image.Rect(0, 0, 2000, 3000)) + tables := p.extractTableBoxesFromImage(context.Background(), nil, boxes, dummy, 0, 0) + if len(tables) != 1 { + t.Fatalf("TSR failure: expected 1 pdf.TableItem with image+positions, got %d", len(tables)) + } + if tables[0].ImageB64 == "" { + t.Error("should have image despite TSR failure") + } + if len(tables[0].Positions) == 0 { + t.Error("should have positions despite TSR failure") + } + if len(tables[0].Rows) != 0 { + t.Errorf("TSR failure → 0 rows, got %d", len(tables[0].Rows)) + } +} + +func TestExtractTableBoxes_DLAError(t *testing.T) { + // DLA returns only non-table regions → 0 tables + mock := &MockDocAnalyzer{Healthy: true, DLARegions: []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 100, Y1: 100, Label: "text", Confidence: 0.9}, + }} + p := NewParser(pdf.DefaultParserConfig(), mock) + dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) + tables := p.extractTableBoxesFromImage(context.Background(), nil, nil, dummy, 0, 0) + if len(tables) != 0 { + t.Errorf("non-table DLA → 0 tables, got %d", len(tables)) + } +} + +func TestParse_TableLinkedToSections(t *testing.T) { + // Simulate enrichWithDeepDoc → extractTableAndReplace → boxesToSections: + // table boxes are popped and replaced with one HTML box. + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 50, X1: 200, Top: 50, Bottom: 80, Text: "heading"}, + {PageNumber: 0, X0: 50, X1: 500, Top: 250, Bottom: 400, Text: "table text", LayoutType: "table"}, + {PageNumber: 0, X0: 50, X1: 200, Top: 450, Bottom: 480, Text: "after"}, + } + tableItem := pdf.TableItem{ + Cells: []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 200, Y1: 50, Label: "table row"}, + {X0: 0, Y0: 51, X1: 200, Y1: 100, Label: "table row"}, + }, + Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 250, Bottom: 400}}, + Scale: 1.0, + } + + boxes = tbl.ExtractTableAndReplace(boxes, []pdf.TableItem{tableItem}) + sections := lyt.BoxesToSections(boxes, nil) + + // 3 boxes (heading, table, after) → 3 sections (heading, HTML, after). + if len(sections) != 3 { + t.Errorf("expected 3 sections, got %d", len(sections)) + } + tableFound := false + for _, s := range sections { + if s.LayoutType == "table" && strings.Contains(s.Text, "
") { + tableFound = true + } + } + if !tableFound { + t.Errorf("expected at least one section with HTML table") + for _, s := range sections { + t.Logf(" section text=%q LayoutType=%q", s.Text[:min(40, len(s.Text))], s.LayoutType) + } + } +} + +// ── cropImageRegion ──────────────────────────────────────────────────── +// ── extractTableBoxesFromImage: invalid DLA region ───────────────────── + +func TestExtractTableBoxes_InvalidRegion(t *testing.T) { + // DLA returns a table region with x1 < x0. The pipeline should skip + // this table gracefully (Python raises ValueError from PIL.Image.crop). + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 500, Y0: 100, X1: 100, Y1: 300, Label: "table", Confidence: 0.9}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + dummy := image.NewRGBA(image.Rect(0, 0, 1000, 1000)) + tables := p.extractTableBoxesFromImage(context.Background(), nil, nil, dummy, 0, 0) + if len(tables) != 0 { + t.Errorf("invalid DLA region should be skipped, got %d tables", len(tables)) + } +} + +// ── DLA → figure end-to-end ─────────────────────────────────────────── + +func TestParse_CollectsFigures(t *testing.T) { + // End-to-end: Parse() with mock DeepDoc that labels a box as "figure". + // Verify p.Figures is populated. + + eng := &mockEngine{pageCount: 1, chars: map[int][]pdf.TextChar{0: {{X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "chart image"}}}} + mock := &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 50, Y0: 200, X1: 2000, Y1: 1000, Label: "figure", Confidence: 0.85}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Sections) == 0 { + t.Fatal("expected at least 1 section") + } + if len(result.Figures()) != 1 { + t.Fatalf("expected 1 figure, got %d", len(result.Figures())) + } + if result.Figures()[0].LayoutType != "figure" { + t.Errorf("figure LayoutType = %q, want 'figure'", result.Figures()[0].LayoutType) + } + if result.Figures()[0].Text == "" { + t.Error("figure Text should not be empty") + } +} + +func TestParse_NoFigures(t *testing.T) { + // Parse() with no DLA figure regions → p.Figures should be empty. + + eng := &mockEngine{pageCount: 1, chars: map[int][]pdf.TextChar{0: {{X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "just text"}}}} + mock := &MockDocAnalyzer{ + DLARegions: []pdf.DLARegion{ + {X0: 150, Y0: 300, X1: 1500, Y1: 600, Label: "text", Confidence: 0.8}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Figures()) != 0 { + t.Fatalf("expected 0 figures, got %d", len(result.Figures())) + } +} + +func TestParse_NoDeepDoc_NoFigures(t *testing.T) { + // Parse() with mock DeepDoc → Figures should be empty (no DLA-detected figures). + + eng := &mockEngine{pageCount: 1, chars: map[int][]pdf.TextChar{0: {{X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "text"}}}} + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: true}) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Figures()) != 0 { + t.Fatalf("expected 0 Figures (no DLA-detected figures), got %d", len(result.Figures())) + } +} + +// ── Parse + ocrMergeChars (full-page detect) ────────────────────────── + +func TestParse_UsesOCRDetectForEmbeddedChars(t *testing.T) { + // When DeepDoc is available and the page has embedded chars, + // Parse should use ocrMergeChars (detect → merge → recognize). + eng := &mockEngine{ + pageCount: 1, + chars: map[int][]pdf.TextChar{0: { + {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}, + }}, + } + mock := &MockDocAnalyzer{ + Healthy: true, + OCRBoxes: []pdf.OCRBox{ + {X0: 5, Y0: 5, X1: 50, Y1: 5, X2: 50, Y2: 50, X3: 5, Y3: 50}, + }, + } + p := NewParser(pdf.DefaultParserConfig(), mock) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Sections) == 0 { + t.Fatal("expected at least 1 section") + } + // The box should come from OCR detect, not charsToBoxes. + // Verifying that ocrMergeChars was used (sections exist). + if result.Metrics.BoxesInitial == 0 { + t.Error("expected BoxesInitial > 0 (OCR detect path)") + } +} + +func TestParse_FallsBackToCharsToBoxes_NoDeepDoc(t *testing.T) { + // Without DeepDoc, Parse should use charsToBoxes (unchanged behavior). + eng := &mockEngine{ + pageCount: 1, + chars: map[int][]pdf.TextChar{0: { + {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}, + }}, + } + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: true}) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Sections) == 0 { + t.Fatal("expected at least 1 section (charsToBoxes)") + } +} + +func TestParse_FallsBackToCharsToBoxes_EmptyOCRBoxes(t *testing.T) { + // OCRDetect returns no boxes → falls through to charsToBoxes. + eng := &mockEngine{ + pageCount: 1, + chars: map[int][]pdf.TextChar{0: { + {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}, + }}, + } + mock := &MockDocAnalyzer{ + Healthy: true, + OCRBoxes: []pdf.OCRBox{}, // empty detect + } + p := NewParser(pdf.DefaultParserConfig(), mock) + + result, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Sections) == 0 { + t.Fatal("expected at least 1 section (charsToBoxes fallback)") + } +} + +// ── Error path coverage ──────────────────────────────────────────────── + +func TestMockDocAnalyzer_DLAError_DoesNotCrash(t *testing.T) { + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{ + Healthy: true, + DLAErr: fmt.Errorf("DLA service unavailable"), + }) + eng := &mockEngine{pageCount: 1} + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + pageImages := map[int]image.Image{0: img} + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "text"}, + } + // enrichWithDeepDoc should return nil (not panic) on DLA error. + tables := p.enrichWithDeepDoc(context.Background(), nil, eng, boxes, pageImages) + if len(tables) != 0 { + t.Errorf("DLA error should produce 0 tables, got %d", len(tables)) + } +} + +func TestMockDocAnalyzer_TSRError_DoesNotCrash(t *testing.T) { + // TSR error: DLA succeeds, TSR fails. The table region is detected + // but no cells are returned — the table is skipped gracefully. + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{ + Healthy: true, + DLARegions: []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 400, Y1: 400, Label: "table", Confidence: 0.95}, + }, + TSRErr: fmt.Errorf("TSR model timeout"), + }) + eng := &mockEngine{pageCount: 1} + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + pageImages := map[int]image.Image{0: img} + boxes := []pdf.TextBox{ + {PageNumber: 0, X0: 10, X1: 90, Top: 10, Bottom: 90, Text: "in table region"}, + } + tables := p.enrichWithDeepDoc(context.Background(), nil, eng, boxes, pageImages) + // DLA detects the table region → 1 pdf.TableItem is created. TSR failure + // means it has no cells, but the pipeline must not panic. + if len(tables) != 1 { + t.Errorf("TSR error: expected 1 table (DLA region found), got %d", len(tables)) + } + if len(tables[0].Cells) != 0 { + t.Errorf("TSR error: Cells should be empty, got %d", len(tables[0].Cells)) + } +} + +func TestMockDocAnalyzer_OCRDetectError_DoesNotCrash(t *testing.T) { + // OCRDetect failure path: extractPages uses ocrDetectAndRecognize which + // calls doc.OCRDetect. When it fails, the page is skipped gracefully. + mock := &MockDocAnalyzer{Healthy: true, OCRDetectErr: fmt.Errorf("OCR model OOM")} + eng := &mockEngine{ + pageCount: 1, + chars: map[int][]pdf.TextChar{}, // empty → triggers OCR path + } + p := NewParser(pdf.DefaultParserConfig(), mock) + _, err := p.Parse(context.Background(), eng) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + // Parse should succeed — the page with OCRDetect error is just skipped. +} + +// TestTSRLabels verifies Go inf.DefaultTSRLabels() matches Python's table_structure_recognizer.py labels. +// Order must be exact — the ONNX model returns class IDs that index into this array. +func TestTSRLabels(t *testing.T) { + want := []string{ + "table", "table column", "table row", + "table column header", "table projected row header", + "table spanning cell", + } + if len(inf.DefaultTSRLabels()) != len(want) { + t.Fatalf("inf.DefaultTSRLabels() length %d, want %d", len(inf.DefaultTSRLabels()), len(want)) + } + for i := range want { + if inf.DefaultTSRLabels()[i] != want[i] { + t.Errorf("inf.DefaultTSRLabels()[%d] = %q, want %q", i, inf.DefaultTSRLabels()[i], want[i]) + } + } +} diff --git a/internal/deepdoc/parser/pdf/parser_ocr.go b/internal/deepdoc/parser/pdf/parser_ocr.go index d18ce20973..b9ae837b34 100644 --- a/internal/deepdoc/parser/pdf/parser_ocr.go +++ b/internal/deepdoc/parser/pdf/parser_ocr.go @@ -2,190 +2,17 @@ package parser import ( "context" - "fmt" "image" "log/slog" "math" + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" "sort" "strings" - "unicode" ) -// isGarbledPage returns true if a page is garbled by PUA ratio, font encoding, -// pdf_oxide unmapped glyphs, or scan noise (no real words). -func isGarbledPage(chars []TextChar) bool { - if len(chars) < 20 { - return false - } - // Build full-page text for detection (all O(n) single pass). - var fullText strings.Builder - for _, c := range chars { - fullText.WriteString(c.Text) - } - text := fullText.String() - if IsGarbledText(text, 0.3) { - return true - } - if pdfOxideUnmappedGarbled(text) && isScanNoise(text) { - return true - } - if IsGarbledByFontEncoding(chars, 20) { - return true - } - if isScanNoise(text) { - return true - } - return false -} - -// isScanNoise detects scanned pages where pdf_oxide extracts noise glyphs -// instead of real text. Real text in any language contains word-like runs -// of consecutive letters (L category). Scan noise consists of random ASCII -// symbols with at most 2-letter fragments. -// -// Three indicators of real (non-noise) text, any one is sufficient: -// - ≥4 consecutive lowercase Latin letters (e.g. "the", "and") -// - ≥2 consecutive CJK characters (Han, Hiragana, Katakana, Hangul) -// - ≥4 consecutive non-ASCII letters (Arabic, Thai, Cyrillic, etc.) -// -// Pure-uppercase fragments like "RASB" are common in pdf_oxide noise but -// never appear as standalone words in real text without lowercase context. -func isScanNoise(text string) bool { - nonSpace := 0 - digitCount := 0 - lowerRun := 0 - maxLowerRun := 0 - cjkRun := 0 - maxCJKRun := 0 - nonASCIILetterRun := 0 - maxNonASCIILetterRun := 0 - - for _, r := range text { - if r == ' ' || r == '\t' || r == '\n' || r == '\r' { - lowerRun = 0 - cjkRun = 0 - nonASCIILetterRun = 0 - continue - } - nonSpace++ - - // Digit density: real content (tables, dates) has digits; - // pdf_oxide noise (unmapped glyphs) never produces digits. - if r >= '0' && r <= '9' { - digitCount++ - } - - // Lowercase Latin (Ll) - if unicode.Is(unicode.Ll, r) { - lowerRun++ - if lowerRun > maxLowerRun { - maxLowerRun = lowerRun - } - } else { - lowerRun = 0 - } - - // CJK: Han, Hiragana, Katakana, Hangul Syllables & Jamo - if isCJK(r) { - cjkRun++ - if cjkRun > maxCJKRun { - maxCJKRun = cjkRun - } - } else { - cjkRun = 0 - } - - // Non-ASCII letter (Arabic U+0600–U+06FF, Thai U+0E00–U+0E7F, - // Cyrillic U+0400–U+04FF, etc.). Excludes ASCII so uppercase - // Latin fragments like "RASB" don't count. - if unicode.IsLetter(r) && r > unicode.MaxASCII { - nonASCIILetterRun++ - if nonASCIILetterRun > maxNonASCIILetterRun { - maxNonASCIILetterRun = nonASCIILetterRun - } - } else { - nonASCIILetterRun = 0 - } - } - - // Need enough characters to make a meaningful decision. - if nonSpace < 30 { - return false - } - - // Digit density: pdf_oxide never substitutes digits for unmapped - // glyphs. Real content (tables, dates, page numbers) has ≥10% - // digits; noise consists of random ASCII punctuation. - if float64(digitCount)/float64(nonSpace) >= 0.10 { - return false - } - - // Real text in any script — any one indicator is sufficient. - isNoise := maxLowerRun < 4 && maxCJKRun < 2 && maxNonASCIILetterRun < 4 - - return isNoise -} - -// isCJK reports whether r is a CJK character: Han ideograph, Hiragana, -// Katakana, Hangul syllable, or Hangul Jamo. -func isCJK(r rune) bool { - return unicode.Is(unicode.Han, r) || - unicode.Is(unicode.Hiragana, r) || - unicode.Is(unicode.Katakana, r) || - unicode.Is(unicode.Hangul, r) -} - -// pdfOxideUnmappedGarbled detects pdf_oxide's '#' placeholder glyphs. -// pdf_oxide uses '#' (U+0023) for every glyph it cannot map; consecutive -// unmapped glyphs form "##", "###", "####" sequences. Three or more -// consecutive '#' is virtually impossible in normal text. -// -// Two conditions (either is sufficient): -// - ≥ 2 occurrences of "###" (3+ consecutive #) -// - # density ≥ 5% of non-space characters -func pdfOxideUnmappedGarbled(text string) bool { - hashCount := 0 - total := 0 - consecutive := 0 - tripleClusters := 0 - - for _, r := range text { - if r == ' ' || r == '\t' || r == '\n' || r == '\r' { - continue - } - total++ - if r == '#' { - hashCount++ - consecutive++ - if consecutive == 3 { - tripleClusters++ - } - } else { - consecutive = 0 - } - } - - if total == 0 { - return false - } - - density := float64(hashCount) / float64(total) - - if tripleClusters >= 1 { - return true - } - // Density check only meaningful with enough chars (matches isGarbledPage's - // min 20 char guard). In production the sample is 200 chars. - if total >= 40 && density >= 0.03 { - return true - } - return false -} - -// ocrDetectAndRecognize runs OCR detection + recognition and returns -// recognized TextBox results. logLabel distinguishes callers in log output -// ("scan page", "garbled page"). -func ocrDetectAndRecognize(ctx context.Context, pageImg image.Image, doc DocAnalyzer, pageNum int, logLabel string) []TextBox { +func ocrDetectAndRecognize(ctx context.Context, pageImg image.Image, doc pdf.DocAnalyzer, pageNum int, logLabel string) []pdf.TextBox { boxes, err := doc.OCRDetect(ctx, pageImg) if err != nil || len(boxes) == 0 { if err != nil { @@ -194,7 +21,7 @@ func ocrDetectAndRecognize(ctx context.Context, pageImg image.Image, doc DocAnal return nil } - var result []TextBox + var result []pdf.TextBox for _, box := range boxes { x0 := int(math.Min(box.X0, math.Min(box.X1, math.Min(box.X2, box.X3)))) y0 := int(math.Min(box.Y0, math.Min(box.Y1, math.Min(box.Y2, box.Y3)))) @@ -203,7 +30,7 @@ func ocrDetectAndRecognize(ctx context.Context, pageImg image.Image, doc DocAnal if x0 >= x1 || y0 >= y1 { continue } - cropped := fastCrop(pageImg, x0, y0, x1, y1) + cropped := util.FastCrop(pageImg, x0, y0, x1, y1) texts, recErr := doc.OCRRecognize(ctx, cropped) if recErr != nil { slog.Warn(logLabel+" OCR recognize failed", "page", pageNum, "err", recErr) @@ -211,7 +38,7 @@ func ocrDetectAndRecognize(ctx context.Context, pageImg image.Image, doc DocAnal } for _, t := range texts { if strings.TrimSpace(t.Text) != "" { - result = append(result, TextBox{ + result = append(result, pdf.TextBox{ X0: float64(x0), X1: float64(x1), Top: float64(y0), Bottom: float64(y1), Text: t.Text, @@ -227,7 +54,7 @@ func ocrDetectAndRecognize(ctx context.Context, pageImg image.Image, doc DocAnal // merges the chars into detect regions, and OCRs any regions without chars. // Matches Python's __ocr: detect → match chars to boxes → use char text // for boxes with embedded chars → OCR recognize only empty/garbled boxes. -func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, doc DocAnalyzer, pageNum int) []TextBox { +func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []pdf.TextChar, doc pdf.DocAnalyzer, pageNum int) []pdf.TextBox { detectBoxes, err := doc.OCRDetect(ctx, pageImg) if err != nil || len(detectBoxes) == 0 { return nil @@ -236,14 +63,14 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d // Detect boxes are in pixel space (216 DPI). Scale to PDF space (72 DPI) // so coordinates match embedded chars. - scale := dlaScale // 3.0 + scale := pdf.DlaScale // 3.0 imgBounds := pageImg.Bounds() imgW := float64(imgBounds.Dx()) / scale imgH := float64(imgBounds.Dy()) / scale // Step 1: match embedded chars to detect boxes (Python __ocr char matching). type detectBox struct { - box TextBox + box pdf.TextBox x0, y0, x1, y1 float64 // PDF-space bounds } boxes := make([]detectBox, 0, len(detectBoxes)) @@ -267,7 +94,7 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d if x0 >= x1 || y0 >= y1 { continue } - boxes = append(boxes, detectBox{box: TextBox{ + boxes = append(boxes, detectBox{box: pdf.TextBox{ X0: x0, X1: x1, Top: y0, Bottom: y1, PageNumber: pageNum, }, x0: x0, y0: y0, x1: x1, y1: y1}) } @@ -291,7 +118,7 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d // Step 2: match each char to the best overlapping detect box // (char perspective), matching Python's find_overlapped. - boxChars := make([][]TextChar, len(boxes)) + boxChars := make([][]pdf.TextChar, len(boxes)) for _, c := range chars { bestIdx := -1 bestOverlap := 1e-6 // Python: thr=1e-6 @@ -319,7 +146,7 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d } // Step 3: assemble text for each box. - var result []TextBox + var result []pdf.TextBox var needOCR []int for i := range boxes { tb := boxes[i].box @@ -329,11 +156,11 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d // Sort chars by reading order, matching Python's sort_Y_firstly. // Fuzzy Y-group: chars within median char height are "same line", // sorted by X; different lines sorted by Y. - sortCharsYFirstly(boxChars[i], medianCharHeight(boxChars[i])) + sortCharsYFirstly(boxChars[i], util.MedianCharHeight(boxChars[i])) // Use lineToTextBox for correct space insertion + garbled detection. // lineToTextBox inserts ASCII word spaces at visible gaps — // matching Python's __img_ocr + __ocr char logic. - lineBox := lineToTextBox(boxChars[i]) + lineBox := lyt.LineToTextBox(boxChars[i]) tb.Text = lineBox.Text // Strategy 1: If majority of chars are garbled (PUA), clear text → OCR. @@ -341,7 +168,7 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d for _, c := range boxChars[i] { for _, r := range c.Text { totalCnt++ - if IsGarbledChar(string(r)) { + if util.IsGarbledChar(string(r)) { garbledCnt++ } } @@ -350,7 +177,7 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d tb.Text = "" } // Strategy 2: font-encoding garbled (subset fonts, min 5 chars). - if tb.Text != "" && IsGarbledByFontEncoding(boxChars[i], 5) { + if tb.Text != "" && util.IsGarbledByFontEncoding(boxChars[i], 5) { tb.Text = "" } } @@ -365,7 +192,7 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d if len(needOCR) > 0 { cropped := make([]image.Image, len(needOCR)) for j, idx := range needOCR { - cropped[j] = fastCrop(pageImg, + cropped[j] = util.FastCrop(pageImg, int(boxes[idx].x0*scale), int(boxes[idx].y0*scale), int(boxes[idx].x1*scale), int(boxes[idx].y1*scale)) } @@ -396,26 +223,12 @@ func ocrMergeChars(ctx context.Context, pageImg image.Image, chars []TextChar, d return result } -// medianCharHeight returns the median height of chars, or 0 if empty. -// Used as the fuzzy-sort threshold matching Python's np.mean([c["height"]]). -func medianCharHeight(chars []TextChar) float64 { - if len(chars) == 0 { - return 0 - } - heights := make([]float64, len(chars)) - for i, c := range chars { - heights[i] = c.Bottom - c.Top - } - sort.Float64s(heights) - return heights[len(heights)/2] -} - // sortYFirstly sorts chars by Y (fuzzy group by threshold), then by X. // Matching Python Recognizer.sort_Y_firstly in recognizer.py:26-33: // // If two chars have Y diff < threshold → same line → sort by X. // Otherwise → sort by Y. -func sortCharsYFirstly(chars []TextChar, threshold float64) { +func sortCharsYFirstly(chars []pdf.TextChar, threshold float64) { sort.Slice(chars, func(a, b int) bool { diff := chars[a].Top - chars[b].Top if math.Abs(diff) < threshold { @@ -428,7 +241,7 @@ func sortCharsYFirstly(chars []TextChar, threshold float64) { // charBoxOverlapRatio computes the overlap ratio between a char and a box, // from the char's perspective. Returns overlap_area / char_area. // Matching Python's Recognizer.overlapped_area(char, box, ratio=True). -func charBoxOverlapRatio(c TextChar, x0, x1, y0, y1 float64) float64 { +func charBoxOverlapRatio(c pdf.TextChar, x0, x1, y0, y1 float64) float64 { cw := c.X1 - c.X0 ch := c.Bottom - c.Top if cw <= 0 { @@ -441,12 +254,12 @@ func charBoxOverlapRatio(c TextChar, x0, x1, y0, y1 float64) float64 { if charArea <= 0 { return 0 } - inter := rectOverlapInter(c.X0, c.Top, c.X1, c.Bottom, x0, y0, x1, y1) + inter := util.RectOverlapInter(c.X0, c.Top, c.X1, c.Bottom, x0, y0, x1, y1) return inter / charArea } // ocrTableCells fills empty TSR cells via OCR recognition. -func ocrTableCells(ctx context.Context, cells []TSRCell, tableImg image.Image, doc DocAnalyzer) { +func ocrTableCells(ctx context.Context, cells []pdf.TSRCell, tableImg image.Image, doc pdf.DocAnalyzer) { if doc == nil || tableImg == nil || len(cells) == 0 { return } @@ -461,7 +274,7 @@ func ocrTableCells(ctx context.Context, cells []TSRCell, tableImg image.Image, d if x0 >= x1 || y0 >= y1 { continue } - cropped := fastCrop(tableImg, x0, y0, x1, y1) + cropped := util.FastCrop(tableImg, x0, y0, x1, y1) texts, err := doc.OCRRecognize(ctx, cropped) if err != nil { slog.Warn("table cell OCR failed", "err", err) @@ -476,108 +289,3 @@ func ocrTableCells(ctx context.Context, cells []TSRCell, tableImg image.Image, d cells[i].Text = strings.TrimSpace(strings.Join(parts, " ")) } } - -// evaluateTableOrientation tests 4 rotation angles (0/90/180/270) and picks -// the best orientation based on OCR confidence scores. -// -// Returns bestAngle (0/90/180/270), the rotated image, and per-angle scores. -// Scores map[angle]{avgConfidence, totalRegions, combinedScore}. -// -// Absolute threshold: non-0° wins only if its combined score exceeds 0° by -// more than 0.2 AND the 0° score is below 0.8. -// -// Python: pdf_parser.py:314 _evaluate_table_orientation() -func evaluateTableOrientation(ctx context.Context, tableImg image.Image, doc DocAnalyzer) (bestAngle int, bestImg image.Image, scores map[int]float64) { - rotations := []struct { - angle int - name string - }{ - {0, "original"}, - {90, "rotate_90"}, - {180, "rotate_180"}, - {270, "rotate_270"}, - } - - scores = make(map[int]float64, 4) - bestScore := float64(-1) - bestAngle = 0 - bestImg = tableImg - - for _, rot := range rotations { - rotated := tableImg - if rot.angle != 0 { - rotated = rotateImageCW(tableImg, rot.angle) - if rotated == nil { - slog.Warn("table rotate failed", "angle", rot.angle) - continue - } - } - - detectBoxes, err := doc.OCRDetect(ctx, rotated) - if err != nil || len(detectBoxes) == 0 { - scores[rot.angle] = 0 - continue - } - - // Score by detect-region count (primary) + area (tiebreaker). - // Per-region OCRRecognize calls are NOT needed to judge table - // orientation — the count of detect regions is a reliable proxy - // (a well-oriented table has more/fuller text regions). - // Skipping recognize cuts ~N HTTP calls per angle. - imageArea := float64(rotated.Bounds().Dx() * rotated.Bounds().Dy()) - totalRegions := 0 - var totalArea float64 - for _, box := range detectBoxes { - x0 := math.Min(box.X0, math.Min(box.X1, math.Min(box.X2, box.X3))) - y0 := math.Min(box.Y0, math.Min(box.Y1, math.Min(box.Y2, box.Y3))) - x1 := math.Max(box.X0, math.Max(box.X1, math.Max(box.X2, box.X3))) - y1 := math.Max(box.Y0, math.Max(box.Y1, math.Max(box.Y2, box.Y3))) - if x0 >= x1 || y0 >= y1 { - continue - } - totalRegions++ - totalArea += (x1 - x0) * (y1 - y0) - } - if totalRegions == 0 { - scores[rot.angle] = 0 - continue - } - areaRatio := totalArea / imageArea - // Region count is the primary signal. Area coverage provides a - // small bonus (up to +6%) so that when region counts are tied the - // angle with fuller text boxes wins. - combined := float64(totalRegions) * (1 + 0.06*areaRatio) - scores[rot.angle] = combined - - slog.Debug("table orientation", - "angle", rot.angle, - "regions", totalRegions, - "area_ratio", fmt.Sprintf("%.4f", areaRatio), - "combined", fmt.Sprintf("%.2f", combined)) - - if combined > bestScore { - bestScore = combined - bestAngle = rot.angle - bestImg = rotated - } - - } - - // Absolute threshold: only accept non-0° if region count is clearly - // higher (≥1.4×) AND 0° has few regions (< 6). - // Prevents false rotation when the table is roughly upright. - score0 := scores[0] - if bestAngle != 0 && score0 > 0 { - if !(bestScore > score0*1.4 && score0 < 6.0) { - bestAngle = 0 - bestImg = tableImg - bestScore = score0 - } - } - - slog.Debug("best table orientation", - "angle", bestAngle, - "score", fmt.Sprintf("%.4f", bestScore)) - - return bestAngle, bestImg, scores -} diff --git a/internal/deepdoc/parser/pdf/parser_ocr_test.go b/internal/deepdoc/parser/pdf/parser_ocr_test.go index 9840ad1a44..78efad4fcd 100644 --- a/internal/deepdoc/parser/pdf/parser_ocr_test.go +++ b/internal/deepdoc/parser/pdf/parser_ocr_test.go @@ -2,30 +2,25 @@ package parser import ( "context" - "image" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" "testing" ) -// testPageImg creates a small test image for ocrMergeChars tests. -// 90×120 px at 216 DPI → 30×40 pt in PDF space after /3.0 scaling. -func testPageImg() image.Image { - return image.NewRGBA(image.Rect(0, 0, 90, 120)) -} - // TestOCRMergeChars_FullCoverage: embedded chars fill the detect box. func TestOCRMergeChars_FullCoverage(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 120, X3: 0, Y3: 120}, }, - OCRTexts: []OCRText{ + OCRTexts: []pdf.OCRText{ {Text: "OCR text", Confidence: 0.9}, }, } // Both chars overlap the box (height diff < 0.7) → char text used. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 10, Top: 2, Bottom: 35, Text: "Hello"}, {X0: 12, X1: 28, Top: 2, Bottom: 35, Text: "World"}, } @@ -44,17 +39,17 @@ func TestOCRMergeChars_FullCoverage(t *testing.T) { func TestOCRMergeChars_PartialCoverage(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 0, X1: 45, Y1: 0, X2: 45, Y2: 60, X3: 0, Y3: 60}, {X0: 45, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 60, X3: 45, Y3: 60}, }, - OCRTexts: []OCRText{ + OCRTexts: []pdf.OCRText{ {Text: "OCR-filled", Confidence: 0.9}, }, } // Char "A" overlaps box A → char text. Box B has no chars → OCR. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 12, Top: 2, Bottom: 15, Text: "A"}, } @@ -79,7 +74,7 @@ func TestOCRMergeChars_NoDetectBoxes(t *testing.T) { OCRBoxes: nil, } - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 10, Top: 2, Bottom: 8, Text: "Hello"}, } @@ -89,7 +84,7 @@ func TestOCRMergeChars_NoDetectBoxes(t *testing.T) { } // Also test empty OCRBoxes - mock.OCRBoxes = []OCRBox{} + mock.OCRBoxes = []pdf.OCRBox{} boxes = ocrMergeChars(context.Background(), testPageImg(), chars, mock, 0) if boxes != nil { t.Errorf("expected nil for empty detect boxes, got %d boxes", len(boxes)) @@ -100,16 +95,16 @@ func TestOCRMergeChars_NoDetectBoxes(t *testing.T) { func TestOCRMergeChars_GarbledChars(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 120, X3: 0, Y3: 120}, }, - OCRTexts: []OCRText{ + OCRTexts: []pdf.OCRText{ {Text: "OCR-result", Confidence: 0.95}, }, } // Char height ~33, box height 40. Diff = 0.175 < 0.7 → not filtered. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 10, Top: 2, Bottom: 35, Text: string(rune(0xF0123))}, // PUA {X0: 12, X1: 20, Top: 2, Bottom: 35, Text: string(rune(0xF0456))}, // PUA {X0: 22, X1: 28, Top: 2, Bottom: 35, Text: "a"}, // normal @@ -130,16 +125,16 @@ func TestOCRMergeChars_HeightGate(t *testing.T) { // Box height in PDF space: 120/3.0 = 40 mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 120, X3: 0, Y3: 120}, }, - OCRTexts: []OCRText{ + OCRTexts: []pdf.OCRText{ {Text: "height-gated-OCR", Confidence: 0.8}, }, } // Char height = 1. Box height = 40. Diff = |1-40|/max(1,40) = 39/40 = 0.975 >= 0.7 → filtered. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 10, Top: 2, Bottom: 3, Text: "tiny"}, } @@ -159,16 +154,16 @@ func TestOCRMergeChars_HeightGate(t *testing.T) { func TestOCRMergeChars_FontEncodingGarbled(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 15, Y0: 15, X1: 150, Y1: 15, X2: 150, Y2: 150, X3: 15, Y3: 150}, }, - OCRTexts: []OCRText{{Text: "OCR fallback", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "OCR fallback", Confidence: 0.9}}, } // 5+ subset-font chars (font names matching `^[A-Z0-9]{2,6}\+`) // trigger font-encoding garbled detection → text cleared → OCR used. - chars := make([]TextChar, 5) + chars := make([]pdf.TextChar, 5) for i := range chars { - chars[i] = TextChar{ + chars[i] = pdf.TextChar{ X0: 10, X1: 30, Top: float64(10 + i*5), Bottom: float64(25 + i*5), Text: "#", FontName: "DY1+SimSun", PageNumber: 0, } @@ -188,7 +183,7 @@ func TestSortCharsYFirstly(t *testing.T) { t.Run("same line — fuzzy group by X", func(t *testing.T) { // Chars on the same line with slightly different Top values. // Threshold=10 covers all Top diffs → should sort by X only. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 50, Top: 12, Text: "C"}, {X0: 30, Top: 16, Text: "B"}, {X0: 10, Top: 10, Text: "A"}, @@ -201,7 +196,7 @@ func TestSortCharsYFirstly(t *testing.T) { t.Run("different lines — sort by Y", func(t *testing.T) { // Chars on clearly different lines → sort by Y only. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 50, Top: 100, Text: "C"}, {X0: 30, Top: 10, Text: "A"}, {X0: 10, Top: 50, Text: "B"}, @@ -214,7 +209,7 @@ func TestSortCharsYFirstly(t *testing.T) { t.Run("mixed — same-line group with different-line", func(t *testing.T) { // A and B on line 1 (Top ~10), C on line 2 (Top ~100). - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 50, Top: 100, Text: "C"}, {X0: 30, Top: 14, Text: "B"}, {X0: 10, Top: 10, Text: "A"}, @@ -242,11 +237,11 @@ func TestOCRMergeChars_MixedFontSizes(t *testing.T) { // Chars need height >0.3*boxH to pass height gate. mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 120, X3: 0, Y3: 120}, }, } - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 3, X1: 12, Top: 10, Bottom: 30, Text: "小"}, // smaller font, higher baseline {X0: 12, X1: 24, Top: 5, Bottom: 35, Text: "大"}, // larger font, lower baseline {X0: 24, X1: 36, Top: 5, Bottom: 35, Text: "号"}, // same size as 大, rightmost @@ -267,17 +262,17 @@ func TestOCRMergeChars_BoxOrder(t *testing.T) { // 3 detect boxes in reverse Y order. After sorting, output should be top-down. mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 90, X1: 90, Y1: 90, X2: 90, Y2: 120, X3: 0, Y3: 120}, // bottom {X0: 0, Y0: 45, X1: 90, Y1: 45, X2: 90, Y2: 60, X3: 0, Y3: 60}, // middle {X0: 0, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 30, X3: 0, Y3: 30}, // top }, - OCRTexts: []OCRText{{Text: "OCR", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "OCR", Confidence: 0.9}}, } // Chars in PDF space (72 DPI). Detect boxes are at 216 DPI, // scaled down by 3 in ocrMergeChars. // Box1 PDF: y0=0,y1=10. Box2 PDF: y0=15,y1=20. Box3 PDF: y0=30,y1=40. - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 10, Top: 2, Bottom: 7, Text: "A"}, // box 1 (top) {X0: 2, X1: 10, Top: 16, Bottom: 19, Text: "B"}, // box 2 (middle) {X0: 2, X1: 10, Top: 32, Bottom: 37, Text: "C"}, // box 3 (bottom) @@ -309,12 +304,12 @@ func TestOCRMergeChars_OverlappingBoxes(t *testing.T) { // New char-perspective: Box A gets [Y,X] (best overlap), Box B gets [Z]. mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 0, Y0: 0, X1: 60, Y1: 0, X2: 60, Y2: 60, X3: 0, Y3: 60}, // Box A {X0: 30, Y0: 0, X1: 90, Y1: 0, X2: 90, Y2: 60, X3: 30, Y3: 60}, // Box B }, } - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 2, X1: 8, Top: 2, Bottom: 12, Text: "甲"}, // Box A only {X0: 12, X1: 18, Top: 2, Bottom: 12, Text: "乙"}, // overlap zone {X0: 22, X1: 28, Top: 2, Bottom: 12, Text: "丙"}, // Box B only @@ -333,3 +328,64 @@ func TestOCRMergeChars_OverlappingBoxes(t *testing.T) { t.Errorf("box B: expected '乙丙', got %q", boxes[1].Text) } } + +// ── pdf_oxide ### detection tests ───────────────────────────────────── + +func TestPdfOxideUnmappedGarbled_Empty(t *testing.T) { + if util.PdfOxideUnmappedGarbled("") { + t.Error("empty text should not be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_NormalText(t *testing.T) { + if util.PdfOxideUnmappedGarbled("这是一段正常的中文文本没有任何问题") { + t.Error("normal Chinese text should not be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_SingleHash(t *testing.T) { + // A single # is not enough (could be a phone number or reference). + if util.PdfOxideUnmappedGarbled("参考 #123 的文献") { + t.Error("single # should not be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_TripleHashCluster(t *testing.T) { + // Two ### sequences => garbled. + if !util.PdfOxideUnmappedGarbled("我信###D_8-.###$#(") { + t.Error("two ### clusters should be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_QuadHash(t *testing.T) { + // One #### counts as one ### cluster. Need two for trigger. + // But density may also be high enough. + if !util.PdfOxideUnmappedGarbled("text####abc####def") { + t.Error("two #### clusters should be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_SingleTriple(t *testing.T) { + // Single ### cluster => garbled. In a 200-char sample "###" is impossible + // in normal text (URLs/markdown use at most "##"). + if !util.PdfOxideUnmappedGarbled("hello###world normal text here") { + t.Error("single ### cluster should be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_HighDensity(t *testing.T) { + // 10 # chars mixed among 40+ non-space chars = 25% → garbled. + text := "#a#b#c#d#e#f#g#h#i#j" + " extra normal chars padding to reach minimum" + if !util.PdfOxideUnmappedGarbled(text) { + t.Error("high # density should be garbled") + } +} + +func TestPdfOxideUnmappedGarbled_RealWorldGarbled(t *testing.T) { + // Simulates the garbled page from 1例3个月...pdf: + // Chinese text mixed with ###D_ style unmapped glyph patterns. + garbled := "和蔘语言###D_8-.*/*护理全科##%&$ 80引用\"\"###$#(点向患儿" + if !util.PdfOxideUnmappedGarbled(garbled) { + t.Error("real-world garbled text with ### clusters should be detected") + } +} diff --git a/internal/deepdoc/parser/pdf/deepdoc_integration_test.go b/internal/deepdoc/parser/pdf/parser_pipeline_integration_test.go similarity index 84% rename from internal/deepdoc/parser/pdf/deepdoc_integration_test.go rename to internal/deepdoc/parser/pdf/parser_pipeline_integration_test.go index 3bf16bddb6..eb5facf679 100644 --- a/internal/deepdoc/parser/pdf/deepdoc_integration_test.go +++ b/internal/deepdoc/parser/pdf/parser_pipeline_integration_test.go @@ -11,44 +11,12 @@ import ( _ "image/png" "os" "path/filepath" + "ragflow/internal/deepdoc/parser/pdf/post" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "strings" "testing" ) -// ── helpers ──────────────────────────────────────────────────────────────── - -// mustConnectDeepDoc returns a DeepDocClient; skips the test if unavailable. -func mustConnectDeepDoc(t *testing.T) *DeepDocClient { - t.Helper() - url := os.Getenv("DEEPDOC_URL") - if url == "" { - url = "http://localhost:9390" - } - client, err := NewDeepDocClient(url) - if err != nil { - t.Fatal(err) - } - if !client.Health() { - t.Fatalf("DeepDoc not available at %s", url) - } - return client -} - -// mustOpenEngine opens a PDF from testdata/pdfs/ and returns a PDFEngine. -func mustOpenEngine(t *testing.T, name string) PDFEngine { - t.Helper() - pdfPath := filepath.Join("testdata", "pdfs", name) - data, err := os.ReadFile(pdfPath) - if err != nil { - t.Fatalf("read fixture %s: %v", name, err) - } - eng, err := NewEngine(data) - if err != nil { - t.Fatalf("open engine %s: %v", name, err) - } - return eng -} - // ── golden-file helpers ──────────────────────────────────────────────────── // sectionGolden is the snapshot format for section output. @@ -101,8 +69,8 @@ func updateGolden() bool { return os.Getenv("UPDATE_GOLDEN") == "1" } -// sectionsToGolden converts []Section to the snapshot format. -func sectionsToGolden(sections []Section) []sectionGolden { +// sectionsToGolden converts []pdf.Section to the snapshot format. +func sectionsToGolden(sections []pdf.Section) []sectionGolden { result := make([]sectionGolden, len(sections)) for i, s := range sections { result[i] = sectionGolden{ @@ -113,8 +81,8 @@ func sectionsToGolden(sections []Section) []sectionGolden { return result } -// tablesToGolden converts []TableItem to the snapshot format. -func tablesToGolden(tables []TableItem) []tableGolden { +// tablesToGolden converts []pdf.TableItem to the snapshot format. +func tablesToGolden(tables []pdf.TableItem) []tableGolden { result := make([]tableGolden, len(tables)) for i, t := range tables { result[i] = tableGolden{Rows: t.Rows} @@ -126,11 +94,11 @@ func tablesToGolden(tables []TableItem) []tableGolden { // TestIntegration_SectionsText verifies section text output matches golden. func TestIntegration_SectionsText(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "01_english_simple.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -170,11 +138,11 @@ func TestIntegration_SectionsText(t *testing.T) { // TestIntegration_SectionsCount verifies section count is stable. func TestIntegration_SectionsCount(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "01_english_simple.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -197,11 +165,11 @@ func TestIntegration_SectionsCount(t *testing.T) { // TestIntegration_TableStructure verifies table rows and cell text match golden. func TestIntegration_TableStructure(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -252,11 +220,11 @@ func TestIntegration_TableStructure(t *testing.T) { // TestIntegration_TableImageB64 verifies table ImageB64 is valid base64 PNG. func TestIntegration_TableImageB64(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -292,11 +260,11 @@ func TestIntegration_TableImageB64(t *testing.T) { // TestIntegration_LayoutTypes verifies DLA labels boxes with expected types. func TestIntegration_LayoutTypes(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "06_table_content.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -344,7 +312,7 @@ func TestIntegration_LayoutTypes(t *testing.T) { // results when called multiple times with the same image. This validates // that the ML inference is deterministic (or at least semantically stable). func TestIntegration_Idempotency(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) // Render a fixture page as the stable input image. eng := mustOpenEngine(t, "06_table_content.pdf") @@ -357,7 +325,7 @@ func TestIntegration_Idempotency(t *testing.T) { const N = 5 t.Run("DLA", func(t *testing.T) { - var all [][]DLARegion + var all [][]pdf.DLARegion for i := 0; i < N; i++ { regions, err := client.DLA(context.Background(), pageImg) if err != nil { @@ -372,7 +340,7 @@ func TestIntegration_Idempotency(t *testing.T) { // Crop a table region from the page for TSR input. // Use a fixed crop area (approximate table location in 06_table_content.pdf). cropped := cropImageRect(pageImg, 50, 200, 550, 400) - var all [][]TSRCell + var all [][]pdf.TSRCell for i := 0; i < N; i++ { cells, err := client.TSR(context.Background(), cropped) if err != nil { @@ -384,7 +352,7 @@ func TestIntegration_Idempotency(t *testing.T) { }) t.Run("OCRDetect", func(t *testing.T) { - var all [][]OCRBox + var all [][]pdf.OCRBox for i := 0; i < N; i++ { boxes, err := client.OCRDetect(context.Background(), pageImg) if err != nil { @@ -397,7 +365,7 @@ func TestIntegration_Idempotency(t *testing.T) { t.Run("OCRRecognize", func(t *testing.T) { cropped := cropImageRect(pageImg, 50, 100, 400, 130) - var all [][]OCRText + var all [][]pdf.OCRText for i := 0; i < N; i++ { texts, err := client.OCRRecognize(context.Background(), cropped) if err != nil { @@ -436,7 +404,7 @@ func cropImageRect(img image.Image, x0, y0, x1, y1 int) image.Image { const coordEpsilon = 1.0 // pixels const confEpsilon = 0.01 -func checkDLAIdempotent(t *testing.T, all [][]DLARegion) { +func checkDLAIdempotent(t *testing.T, all [][]pdf.DLARegion) { t.Helper() ref := all[0] strictEqual := 0 @@ -467,7 +435,7 @@ func checkDLAIdempotent(t *testing.T, all [][]DLARegion) { t.Logf("DLA: %d regions, %d/%d runs strictly equal", len(ref), strictEqual+1, len(all)) } -func checkTSRIdempotent(t *testing.T, all [][]TSRCell) { +func checkTSRIdempotent(t *testing.T, all [][]pdf.TSRCell) { t.Helper() ref := all[0] strictEqual := 0 @@ -491,7 +459,7 @@ func checkTSRIdempotent(t *testing.T, all [][]TSRCell) { t.Logf("TSR: %d cells, %d/%d runs strictly equal", len(ref), strictEqual+1, len(all)) } -func checkOCRDetectIdempotent(t *testing.T, all [][]OCRBox) { +func checkOCRDetectIdempotent(t *testing.T, all [][]pdf.OCRBox) { t.Helper() ref := all[0] strictEqual := 0 @@ -513,7 +481,7 @@ func checkOCRDetectIdempotent(t *testing.T, all [][]OCRBox) { t.Logf("OCRDetect: %d boxes, %d/%d runs strictly equal", len(ref), strictEqual+1, len(all)) } -func checkOCRRecognizeIdempotent(t *testing.T, all [][]OCRText) { +func checkOCRRecognizeIdempotent(t *testing.T, all [][]pdf.OCRText) { t.Helper() ref := all[0] strictEqual := 0 @@ -562,11 +530,11 @@ func floatClose(a, b, eps float64) bool { // suppression inside table regions, and caption removal — the key alignment // fixes from the Python→Go migration. func TestIntegration_TableAlign(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "18_table_caption.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -576,7 +544,7 @@ func TestIntegration_TableAlign(t *testing.T) { // Assert 1: No caption sections remain (merged into parent or removed). for _, s := range result.Sections { if s.LayoutType == "table caption" || s.LayoutType == "figure caption" { - t.Errorf("caption Section should be removed: layout=%s text=%q", s.LayoutType, s.Text) + t.Errorf("caption pdf.Section should be removed: layout=%s text=%q", s.LayoutType, s.Text) } } @@ -587,7 +555,7 @@ func TestIntegration_TableAlign(t *testing.T) { hasTable = true // Structured text should contain tabs (\t) for column separation. if !strings.Contains(s.Text, "\t") { - t.Logf("table Section.Text may not be structured: %q", s.Text[:min(80, len(s.Text))]) + t.Logf("table pdf.Section.Text may not be structured: %q", s.Text[:min(80, len(s.Text))]) } break } @@ -597,17 +565,17 @@ func TestIntegration_TableAlign(t *testing.T) { } t.Logf("Sections: %d, Tables: %d, Figures: %d", - len(result.Sections), len(result.Tables), len(result.Figures)) + len(result.Sections), len(result.Tables), len(result.Figures())) } // TestIntegration_GarbageLayout verifies CID-garbled and garbage-layout // (header/footer/reference) boxes are popped from output. func TestIntegration_GarbageLayout(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "17_garbage_layout.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -634,31 +602,31 @@ func TestIntegration_GarbageLayout(t *testing.T) { // TestIntegration_MultiChunk verifies chunked processing for large documents. func TestIntegration_MultiChunk(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "19_multipage_chunk.pdf") defer eng.Close() - cfg := DefaultParserConfig() - cfg.ChunkSize = 10 // small chunks to force multi-chunk path + cfg := pdf.DefaultParserConfig() + cfg.BatchSize = 10 // small batches to force multi-batch path p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) } - // 52 pages with 10-page chunks → >= 6 chunks. + // 52 pages with 10-page batches → >= 6 batches. if len(result.Sections) == 0 { - t.Error("multi-chunk should produce sections") + t.Error("multi-batch should produce sections") } - t.Logf("52 pages × chunkSize=10: %d sections, %d tables", + t.Logf("52 pages × batchSize=10: %d sections, %d tables", len(result.Sections), len(result.Tables)) } // TestIntegration_NoRegression runs a few snapshot PDFs and checks basic // invariants — no panic, sections produced, no CID garbage. func TestIntegration_NoRegression(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) for _, name := range []string{ "01_english_simple.pdf", @@ -669,7 +637,7 @@ func TestIntegration_NoRegression(t *testing.T) { t.Run(name, func(t *testing.T) { eng := mustOpenEngine(t, name) defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -691,12 +659,12 @@ func TestIntegration_NoRegression(t *testing.T) { // TestIntegration_TableRotation verifies that evaluateTableOrientation // correctly detects rotation using region-count scoring. func TestIntegration_TableRotation(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) t.Run("upright_table", func(t *testing.T) { eng := mustOpenEngine(t, "rotate_0.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -711,7 +679,7 @@ func TestIntegration_TableRotation(t *testing.T) { t.Run("rotated_90_table", func(t *testing.T) { eng := mustOpenEngine(t, "rotate_90.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() // DeepDoc DLA does not yet correctly annotate boxes on rotated // pages (regions and characters are in different coordinate // spaces post-rotation). Character extraction and rotation are @@ -732,11 +700,11 @@ func TestIntegration_TableRotation(t *testing.T) { // TestIntegration_WordSpacing verifies space insertion between ASCII word // characters with a visible gap (Python __img_ocr space insertion). func TestIntegration_WordSpacing(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) eng := mustOpenEngine(t, "01_english_simple.pdf") defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -762,3 +730,57 @@ func TestIntegration_WordSpacing(t *testing.T) { } t.Logf("word spacing check: %d sections", len(result.Sections)) } + +// TestE2E_ParseAndPostProcess runs Parse → PostProcess end-to-end on a real +// PDF. Skips VLM (no tenant_id set) but exercises all other operators. +func TestE2E_ParseAndPostProcess(t *testing.T) { + engine := mustOpenEngine(t, "01_english_simple.pdf") + defer engine.Close() + + mock := &MockDocAnalyzer{Healthy: true} + p := NewParser(pdf.DefaultParserConfig(), mock) + + result, err := p.Parse(context.Background(), engine) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + preCount := len(result.Sections) + if preCount == 0 { + t.Fatal("Parse() returned zero sections") + } + + // Post-processing (no VLM). + config := post.PipelineConfig{ + post.ConfigKeyPageWidth: 612.0, + post.ConfigKeyZoom: 1.0, + } + if err := post.PostProcess(context.Background(), result, config); err != nil { + t.Fatalf("PostProcess: %v", err) + } + + postCount := len(result.Sections) + t.Logf("sections: %d → %d after PostProcess", preCount, postCount) + if postCount == 0 { + t.Error("PostProcess removed all sections") + } + + // Every section must have DocTypeKwd + LayoutType set. + for i, s := range result.Sections { + if s.DocTypeKwd == "" { + t.Errorf("section[%d] DocTypeKwd empty after PostProcess", i) + } + if s.LayoutType == "" { + t.Errorf("section[%d] LayoutType empty after PostProcess", i) + } + } + + // Figures() must reflect post-processed sections. + figs := result.Figures() + t.Logf("figures: %d", len(figs)) + for _, f := range figs { + if f.LayoutType != "figure" { + t.Errorf("Figures() LayoutType=%q, want 'figure'", f.LayoutType) + } + } +} diff --git a/internal/deepdoc/parser/pdf/deepdoc_no_crash_manual_test.go b/internal/deepdoc/parser/pdf/parser_pipeline_manual_test.go similarity index 75% rename from internal/deepdoc/parser/pdf/deepdoc_no_crash_manual_test.go rename to internal/deepdoc/parser/pdf/parser_pipeline_manual_test.go index 71ce9ef35c..9c2edfa522 100644 --- a/internal/deepdoc/parser/pdf/deepdoc_no_crash_manual_test.go +++ b/internal/deepdoc/parser/pdf/parser_pipeline_manual_test.go @@ -7,34 +7,18 @@ import ( "encoding/base64" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "strings" "testing" ) -// mustConnectDeepDoc returns a DeepDocClient; skips the test if unavailable. -func mustConnectDeepDoc(t *testing.T) *DeepDocClient { - t.Helper() - url := os.Getenv("DEEPDOC_URL") - if url == "" { - url = "http://localhost:9390" - } - client, err := NewDeepDocClient(url) - if err != nil { - t.Fatal(err) - } - if !client.Health() { - t.Fatalf("DeepDoc not available at %s", url) - } - return client -} - // TestIntegration_NoCrash runs Parse on every small fixture PDF and checks it // does not panic or error. It does NOT require golden files. // // Build tag: cgo && manual — skipped in regular integration runs due to // long runtime (27+ PDFs each requiring DeepDoc DLA+TSR+OCR). func TestIntegration_NoCrash(t *testing.T) { - client := mustConnectDeepDoc(t) + client := mustConnectInferenceClient(t) pdfDir := filepath.Join("testdata", "pdfs") entries, err := os.ReadDir(pdfDir) @@ -62,7 +46,7 @@ func TestIntegration_NoCrash(t *testing.T) { } defer eng.Close() - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -75,13 +59,13 @@ func TestIntegration_NoCrash(t *testing.T) { t.Errorf("section[%d] has empty PositionTag", i) } if s.LayoutType != "" && s.Image != "" { - // Section with an image should have valid base64. + // pdf.Section with an image should have valid base64. if _, err := base64.StdEncoding.DecodeString(s.Image); err != nil { t.Errorf("section[%d] Image: not valid base64: %v", i, err) } } if s.TableItem != nil { - // Cross-reference: TableItem in section should appear in tables list. + // Cross-reference: pdf.TableItem in section should appear in tables list. found := false for _, tbl := range result.Tables { if &tbl == s.TableItem { @@ -90,7 +74,7 @@ func TestIntegration_NoCrash(t *testing.T) { } } if !found { - t.Errorf("section[%d] TableItem not found in tables list", i) + t.Errorf("section[%d] pdf.TableItem not found in tables list", i) } } } diff --git a/internal/deepdoc/parser/pdf/parser_test.go b/internal/deepdoc/parser/pdf/parser_test.go index ff9b866e2c..e703d69a33 100644 --- a/internal/deepdoc/parser/pdf/parser_test.go +++ b/internal/deepdoc/parser/pdf/parser_test.go @@ -4,229 +4,18 @@ import ( "context" "image" "strings" + "sync" "testing" + + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + tbl "ragflow/internal/deepdoc/parser/pdf/table" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" ) -func TestIsASCIIPrintable(t *testing.T) { - tests := []struct { - r rune - want bool - }{ - {'a', true}, {'z', true}, {'A', true}, {'Z', true}, - {'0', true}, {'9', true}, {' ', true}, - {',', true}, {'.', true}, {'!', true}, {'?', true}, - {'-', true}, {'_', true}, {'/', true}, {':', true}, - {';', true}, {'(', true}, {')', true}, {'[', true}, - {']', true}, {'@', true}, {'#', true}, {'$', true}, - {'%', true}, {'^', true}, {'&', true}, {'*', true}, - {'<', true}, {'>', true}, - {'中', false}, {'。', false}, {',', false}, - {'α', false}, {'\n', false}, {'\t', false}, - } - for _, tt := range tests { - if got := isASCIIPrintable(tt.r); got != tt.want { - t.Errorf("isASCIIPrintable(%q) = %v, want %v", tt.r, got, tt.want) - } - } -} - -func TestDetectEnglish(t *testing.T) { - t.Run("pure english", func(t *testing.T) { - chars := make([]TextChar, 100) - for i := range chars { - chars[i] = TextChar{Text: "a", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - if !detectEnglish(pageChars, 1, nil) { - t.Error("pure English PDF should be detected as English") - } - }) - - t.Run("pure chinese", func(t *testing.T) { - chars := make([]TextChar, 100) - for i := range chars { - chars[i] = TextChar{Text: "中", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - if detectEnglish(pageChars, 1, nil) { - t.Error("pure Chinese PDF should NOT be detected as English") - } - }) - - t.Run("english majority", func(t *testing.T) { - engChars := make([]TextChar, 100) - for i := range engChars { - engChars[i] = TextChar{Text: "a", PageNumber: 0} - } - chnChars := make([]TextChar, 100) - for i := range chnChars { - chnChars[i] = TextChar{Text: "中", PageNumber: 1} - } - pageChars := map[int][]TextChar{0: engChars, 1: chnChars, 2: engChars} - if !detectEnglish(pageChars, 3, nil) { - t.Error("2/3 English pages should be English by majority vote") - } - }) - - t.Run("empty", func(t *testing.T) { - if detectEnglish(nil, 0, nil) { - t.Error("empty input should return false") - } - if detectEnglish(map[int][]TextChar{}, 1, nil) { - t.Error("empty map should return false") - } - }) - - t.Run("image only pages", func(t *testing.T) { - chars := make([]TextChar, 50) - for i := range chars { - chars[i] = TextChar{Text: "a", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - if detectEnglish(pageChars, 2, nil) { - t.Error("1/2 pages with chars, 0 with sequence — should NOT be English") - } - }) -} - -// ── SampleFunc tests ──────────────────────────────────────────────────── - -func TestDefaultSampleChars(t *testing.T) { - t.Run("nil chars", func(t *testing.T) { - if s := defaultSampleChars(nil, 100); s != "" { - t.Errorf("nil chars → %q, want empty", s) - } - }) - - t.Run("empty chars", func(t *testing.T) { - if s := defaultSampleChars([]TextChar{}, 100); s != "" { - t.Errorf("empty chars → %q, want empty", s) - } - }) - - t.Run("n <= 0", func(t *testing.T) { - chars := []TextChar{{Text: "x"}} - if s := defaultSampleChars(chars, 0); s != "" { - t.Errorf("n=0 → %q, want empty", s) - } - }) - - t.Run("n larger than len", func(t *testing.T) { - chars := []TextChar{{Text: "a"}, {Text: "b"}, {Text: "c"}} - s := defaultSampleChars(chars, 100) - if len(s) != 3 { - t.Errorf("n=100, len=3 → got len=%d, want 3", len(s)) - } - for _, c := range chars { - if !strings.ContainsRune(s, []rune(c.Text)[0]) { - t.Errorf("sample %q missing char %q", s, c.Text) - } - } - }) - - t.Run("produces all chars (no duplicates, just reordering)", func(t *testing.T) { - chars := make([]TextChar, 50) - for i := range chars { - chars[i] = TextChar{Text: string(rune('A' + i%26))} - } - s := defaultSampleChars(chars, 50) - if len(s) != 50 { - t.Errorf("len=%d, want 50", len(s)) - } - }) -} - -func TestDetectEnglish_CustomSampler(t *testing.T) { - t.Run("deterministic sampler sees English at end", func(t *testing.T) { - chars := make([]TextChar, 100) - for i := 0; i < 70; i++ { - chars[i] = TextChar{Text: "中", PageNumber: 0} - } - for i := 70; i < 100; i++ { - chars[i] = TextChar{Text: "a", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - - _ = detectEnglish(pageChars, 1, nil) - - lastSampler := func(chars []TextChar, n int) string { - m := min(n, len(chars)) - start := max(0, len(chars)-m) - var buf strings.Builder - for i := start; i < len(chars); i++ { - buf.WriteString(chars[i].Text) - } - return buf.String() - } - if !detectEnglish(pageChars, 1, lastSampler) { - t.Error("sampler that sees the tail should detect English (30 consecutive ASCII)") - } - }) - - t.Run("deterministic sampler sees only CJK head", func(t *testing.T) { - chars := make([]TextChar, 100) - for i := 0; i < 70; i++ { - chars[i] = TextChar{Text: "中", PageNumber: 0} - } - for i := 70; i < 100; i++ { - chars[i] = TextChar{Text: "a", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - - firstSampler := func(chars []TextChar, n int) string { - m := min(n, len(chars)) - var buf strings.Builder - for i := 0; i < m; i++ { - buf.WriteString(chars[i].Text) - } - return buf.String() - } - if !detectEnglish(pageChars, 1, firstSampler) { - t.Error("first-100 sampler: 70 CJK + 30 ASCII → 30 consecutive ASCII → should be English") - } - }) - - t.Run("sampler returns fewer than 30 chars", func(t *testing.T) { - chars := make([]TextChar, 10) - for i := range chars { - chars[i] = TextChar{Text: "a", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - if detectEnglish(pageChars, 1, defaultSampleChars) { - t.Error("fewer than 30 chars → no 30-char run possible → not English") - } - }) - - t.Run("sample < n chars from page", func(t *testing.T) { - chars := make([]TextChar, 25) - for i := range chars { - chars[i] = TextChar{Text: "a", PageNumber: 0} - } - pageChars := map[int][]TextChar{0: chars} - if detectEnglish(pageChars, 1, defaultSampleChars) { - t.Error("25 chars cannot form 30-char run → not English") - } - }) - - t.Run("majority with custom sampler", func(t *testing.T) { - engChars := make([]TextChar, 100) - for i := range engChars { - engChars[i] = TextChar{Text: "a", PageNumber: 0} - } - chnChars := make([]TextChar, 100) - for i := range chnChars { - chnChars[i] = TextChar{Text: "中", PageNumber: 1} - } - pageChars := map[int][]TextChar{0: engChars, 1: chnChars, 2: engChars} - if !detectEnglish(pageChars, 3, nil) { - t.Error("2/3 English pages should be English by majority vote") - } - }) -} - // ── OCR fallback ────────────────────────────────────────────────────── -func TestOCR_fallback(t *testing.T) { +func TestOCR_Fallback(t *testing.T) { dummyImg := image.NewRGBA(image.Rect(0, 0, 100, 100)) t.Run("nil image", func(t *testing.T) { @@ -245,12 +34,12 @@ func TestOCR_fallback(t *testing.T) { t.Run("detect + recognize success", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, - OCRTexts: []OCRText{{Text: "Hello", Confidence: 0.9}}, + OCRBoxes: []pdf.OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, + OCRTexts: []pdf.OCRText{{Text: "Hello", Confidence: 0.9}}, } got := ocrDetectAndRecognize(context.Background(), dummyImg, mock, 0, "garbled page") if len(got) != 1 { - t.Fatalf("expected 1 TextChar, got %d", len(got)) + t.Fatalf("expected 1 pdf.TextChar, got %d", len(got)) } if got[0].Text != "Hello" { t.Errorf("text = %q, want Hello", got[0].Text) @@ -260,8 +49,8 @@ func TestOCR_fallback(t *testing.T) { t.Run("detect boxes but rec returns empty text", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, - OCRTexts: []OCRText{{Text: "", Confidence: 0.1}}, + OCRBoxes: []pdf.OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, + OCRTexts: []pdf.OCRText{{Text: "", Confidence: 0.1}}, } got := ocrDetectAndRecognize(context.Background(), dummyImg, mock, 0, "garbled page") if len(got) != 0 { @@ -274,7 +63,7 @@ func TestOCR_fallback(t *testing.T) { // ≥30% subset font, <5% CJK, >40% ASCII punctuation. // ── OCR scan page ────────────────────────────────────────────────────── -func TestOCR_scanPage(t *testing.T) { +func TestOCR_ScanPage(t *testing.T) { dummyImg := image.NewRGBA(image.Rect(0, 0, 100, 100)) t.Run("nil image", func(t *testing.T) { @@ -293,23 +82,23 @@ func TestOCR_scanPage(t *testing.T) { t.Run("detect + recognize success", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}, {X0: 10, Y0: 50, X1: 90, Y1: 50, X2: 90, Y2: 70, X3: 10, Y3: 70}, }, - OCRTexts: []OCRText{{Text: "Hello", Confidence: 0.9}, {Text: "World", Confidence: 0.8}}, + OCRTexts: []pdf.OCRText{{Text: "Hello", Confidence: 0.9}, {Text: "World", Confidence: 0.8}}, } got := ocrDetectAndRecognize(context.Background(), dummyImg, mock, 0, "scan page") if len(got) < 1 { - t.Error("expected at least 1 TextChar") + t.Error("expected at least 1 pdf.TextChar") } }) t.Run("detect success but rec returns empty", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, - OCRTexts: []OCRText{}, + OCRBoxes: []pdf.OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, + OCRTexts: []pdf.OCRText{}, } got := ocrDetectAndRecognize(context.Background(), dummyImg, mock, 0, "scan page") if len(got) != 0 { @@ -320,13 +109,13 @@ func TestOCR_scanPage(t *testing.T) { // ── OCR table cell ───────────────────────────────────────────────────── -func TestOCR_tableCell(t *testing.T) { +func TestOCR_TableCell(t *testing.T) { t.Run("fill single empty cell", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}, {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "已有"}, } - mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []OCRText{{Text: "识别结果", Confidence: 0.9}}} + mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []pdf.OCRText{{Text: "识别结果", Confidence: 0.9}}} dummy := image.NewRGBA(image.Rect(0, 0, 200, 50)) ocrTableCells(context.Background(), cells, dummy, mock) @@ -340,7 +129,7 @@ func TestOCR_tableCell(t *testing.T) { }) t.Run("all cells already filled — no OCR", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "A"}, {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "B"}, } @@ -352,11 +141,11 @@ func TestOCR_tableCell(t *testing.T) { t.Run("empty cells list", func(t *testing.T) { ocrTableCells(context.Background(), nil, nil, nil) // should not panic - ocrTableCells(context.Background(), []TSRCell{}, nil, nil) + ocrTableCells(context.Background(), []pdf.TSRCell{}, nil, nil) }) t.Run("no DeepDoc — skip", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}} + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}} ocrTableCells(context.Background(), cells, nil, nil) if cells[0].Text != "" { t.Error("without DeepDoc, cell should stay empty") @@ -364,8 +153,8 @@ func TestOCR_tableCell(t *testing.T) { }) t.Run("no cropped image — skip", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}} - mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []OCRText{{Text: "x", Confidence: 0.5}}} + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}} + mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []pdf.OCRText{{Text: "x", Confidence: 0.5}}} ocrTableCells(context.Background(), cells, nil, mock) if cells[0].Text != "" { t.Error("without image, cell should stay empty") @@ -373,8 +162,8 @@ func TestOCR_tableCell(t *testing.T) { }) t.Run("OCR returns empty string", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}} - mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []OCRText{}} + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}} + mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []pdf.OCRText{}} dummy := image.NewRGBA(image.Rect(0, 0, 100, 50)) ocrTableCells(context.Background(), cells, dummy, mock) if cells[0].Text != "" { @@ -383,8 +172,8 @@ func TestOCR_tableCell(t *testing.T) { }) t.Run("cell out of image bounds", func(t *testing.T) { - cells := []TSRCell{{X0: 500, Y0: 500, X1: 600, Y1: 600, Text: ""}} - mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []OCRText{{Text: "out of bounds", Confidence: 0.9}}} + cells := []pdf.TSRCell{{X0: 500, Y0: 500, X1: 600, Y1: 600, Text: ""}} + mock := &MockDocAnalyzer{Healthy: true, OCRTexts: []pdf.OCRText{{Text: "out of bounds", Confidence: 0.9}}} dummy := image.NewRGBA(image.Rect(0, 0, 100, 100)) // Should not panic — gracefully degrade ocrTableCells(context.Background(), cells, dummy, mock) @@ -392,12 +181,12 @@ func TestOCR_tableCell(t *testing.T) { }) } -func garbledSample() []TextChar { +func garbledSample() []pdf.TextChar { punctuation := []string{"!", "#", "$", "%", "&", "*", "+", "-", ".", "/", ":", ";", "<", ">", "=", "?", "@", "^", "_", "~"} - chars := make([]TextChar, 20) + chars := make([]pdf.TextChar, 20) for i, p := range punctuation { - chars[i] = TextChar{ + chars[i] = pdf.TextChar{ X0: 50 + float64(i*10), X1: 58 + float64(i*10), Top: 100, Bottom: 112, Text: p, FontName: "ABCDEF+SimSun", PageNumber: 0, @@ -418,10 +207,10 @@ func TestOCR_FallbackIntegration(t *testing.T) { func TestOCR_FallbackIntegration_NoDeepDoc(t *testing.T) { chars := garbledSample() - mockEng := &mockEngine{chars: map[int][]TextChar{0: chars}, pageCount: 1} + mockEng := &mockEngine{chars: map[int][]pdf.TextChar{0: chars}, pageCount: 1} - cfg := DefaultParserConfig() - p := NewParser(cfg, &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + cfg := pdf.DefaultParserConfig() + p := NewParser(cfg, &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), mockEng) if err != nil { t.Fatal(err) @@ -433,27 +222,27 @@ func TestNoDeepDoc_PdfOxideUnmapped_KeepsChars(t *testing.T) { // pdf_oxide ### unmapped glyphs mixed with real CJK text. // Without DeepDoc, isGarbledPage should return false (isScanNoise gate), // so chars are kept and sections > 0. - chars := make([]TextChar, 30) + chars := make([]pdf.TextChar, 30) for i := 0; i < 20; i++ { - chars[i] = TextChar{ + chars[i] = pdf.TextChar{ Text: "测试文本", FontName: "SimSun", X0: 50, X1: 128, Top: float64(100 + i*15), Bottom: float64(112 + i*15), } } // Insert ### unmapped glyph noise (no subset fonts) - chars[20] = TextChar{Text: "#", FontName: "SimSun", X0: 130, X1: 138, Top: 100, Bottom: 112} - chars[21] = TextChar{Text: "#", FontName: "SimSun", X0: 138, X1: 146, Top: 100, Bottom: 112} - chars[22] = TextChar{Text: "#", FontName: "SimSun", X0: 146, X1: 154, Top: 100, Bottom: 112} - chars[23] = TextChar{Text: "D", FontName: "SimSun", X0: 154, X1: 162, Top: 100, Bottom: 112} - chars[24] = TextChar{Text: "_", FontName: "SimSun", X0: 162, X1: 170, Top: 100, Bottom: 112} - chars[25] = TextChar{Text: "8", FontName: "SimSun", X0: 170, X1: 178, Top: 100, Bottom: 112} - chars[26] = TextChar{Text: "-", FontName: "SimSun", X0: 178, X1: 186, Top: 100, Bottom: 112} - chars[27] = TextChar{Text: ".", FontName: "SimSun", X0: 186, X1: 194, Top: 100, Bottom: 112} - chars[28] = TextChar{Text: "*", FontName: "SimSun", X0: 194, X1: 202, Top: 100, Bottom: 112} - chars[29] = TextChar{Text: "用", FontName: "SimSun", X0: 202, X1: 210, Top: 100, Bottom: 112} + chars[20] = pdf.TextChar{Text: "#", FontName: "SimSun", X0: 130, X1: 138, Top: 100, Bottom: 112} + chars[21] = pdf.TextChar{Text: "#", FontName: "SimSun", X0: 138, X1: 146, Top: 100, Bottom: 112} + chars[22] = pdf.TextChar{Text: "#", FontName: "SimSun", X0: 146, X1: 154, Top: 100, Bottom: 112} + chars[23] = pdf.TextChar{Text: "D", FontName: "SimSun", X0: 154, X1: 162, Top: 100, Bottom: 112} + chars[24] = pdf.TextChar{Text: "_", FontName: "SimSun", X0: 162, X1: 170, Top: 100, Bottom: 112} + chars[25] = pdf.TextChar{Text: "8", FontName: "SimSun", X0: 170, X1: 178, Top: 100, Bottom: 112} + chars[26] = pdf.TextChar{Text: "-", FontName: "SimSun", X0: 178, X1: 186, Top: 100, Bottom: 112} + chars[27] = pdf.TextChar{Text: ".", FontName: "SimSun", X0: 186, X1: 194, Top: 100, Bottom: 112} + chars[28] = pdf.TextChar{Text: "*", FontName: "SimSun", X0: 194, X1: 202, Top: 100, Bottom: 112} + chars[29] = pdf.TextChar{Text: "用", FontName: "SimSun", X0: 202, X1: 210, Top: 100, Bottom: 112} - mockEng := &mockEngine{chars: map[int][]TextChar{0: chars}, pageCount: 1} - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + mockEng := &mockEngine{chars: map[int][]pdf.TextChar{0: chars}, pageCount: 1} + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), mockEng) if err != nil { t.Fatal(err) @@ -466,32 +255,32 @@ func TestNoDeepDoc_PdfOxideUnmapped_KeepsChars(t *testing.T) { func TestIsGarbledPage(t *testing.T) { t.Run("PUA dominant", func(t *testing.T) { - chars := make([]TextChar, 50) + chars := make([]pdf.TextChar, 50) for i := range chars { - chars[i] = TextChar{Text: string(rune(0xE000)), PageNumber: 0} + chars[i] = pdf.TextChar{Text: string(rune(0xE000)), PageNumber: 0} } - if !isGarbledPage(chars) { + if !util.IsGarbledPage(chars) { t.Error("100% PUA → garbled") } }) t.Run("font encoding", func(t *testing.T) { - if !isGarbledPage(garbledSample()) { + if !util.IsGarbledPage(garbledSample()) { t.Error("subset font → garbled") } }) t.Run("normal text", func(t *testing.T) { - chars := make([]TextChar, 50) + chars := make([]pdf.TextChar, 50) for i := range chars { - chars[i] = TextChar{Text: "a", PageNumber: 0} + chars[i] = pdf.TextChar{Text: "a", PageNumber: 0} } - if isGarbledPage(chars) { + if util.IsGarbledPage(chars) { t.Error("normal text → not garbled") } }) t.Run("pdf oxide unmapped + CJK — not garbled", func(t *testing.T) { // ### unmapped glyphs + real CJK text (no subset fonts). // isScanNoise returns false (≥2 consecutive CJK chars: "护理全科"). - chars := []TextChar{ + chars := []pdf.TextChar{ {Text: "和", PageNumber: 0}, {Text: "蔘", PageNumber: 0}, {Text: "语", PageNumber: 0}, {Text: "言", PageNumber: 0}, {Text: "#", PageNumber: 0}, {Text: "#", PageNumber: 0}, @@ -504,27 +293,27 @@ func TestIsGarbledPage(t *testing.T) { {Text: "科", PageNumber: 0}, {Text: "引", PageNumber: 0}, {Text: "用", PageNumber: 0}, } - if isGarbledPage(chars) { + if util.IsGarbledPage(chars) { t.Error("### unmapped + CJK text should NOT be garbled (no subset fonts)") } }) t.Run("too few chars", func(t *testing.T) { - if isGarbledPage([]TextChar{{Text: " ", PageNumber: 0}}) { + if util.IsGarbledPage([]pdf.TextChar{{Text: " ", PageNumber: 0}}) { t.Error("< 20 chars → not garbled") } }) } -func TestOCR_fallback_PUAGarbled(t *testing.T) { - pua := make([]TextChar, 50) +func TestOCR_Fallback_PUAGarbled(t *testing.T) { + pua := make([]pdf.TextChar, 50) for i := range pua { - pua[i] = TextChar{Text: string(rune(0xE000 + i%10)), PageNumber: 0} + pua[i] = pdf.TextChar{Text: string(rune(0xE000 + i%10)), PageNumber: 0} } dummyImg := image.NewRGBA(image.Rect(0, 0, 100, 100)) mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, - OCRTexts: []OCRText{{Text: "PUA OCR text", Confidence: 0.9}}, + OCRBoxes: []pdf.OCRBox{{X0: 10, Y0: 20, X1: 90, Y1: 20, X2: 90, Y2: 40, X3: 10, Y3: 40}}, + OCRTexts: []pdf.OCRText{{Text: "PUA OCR text", Confidence: 0.9}}, } got := ocrDetectAndRecognize(context.Background(), dummyImg, mock, 0, "garbled page") if len(got) != 1 || got[0].Text != "PUA OCR text" { @@ -538,15 +327,15 @@ func TestOCR_MergeChars(t *testing.T) { dummyImg := image.NewRGBA(image.Rect(0, 0, 600, 600)) t.Run("nil image", func(t *testing.T) { - chars := []TextChar{{X0: 10, Top: 10, X1: 20, Bottom: 30, Text: "A", PageNumber: 0}} + chars := []pdf.TextChar{{X0: 10, Top: 10, X1: 20, Bottom: 30, Text: "A", PageNumber: 0}} if boxes := ocrMergeChars(context.Background(), nil, chars, &MockDocAnalyzer{Healthy: true}, 0); boxes != nil { t.Error("nil image → nil") } }) t.Run("detect returns no boxes", func(t *testing.T) { - mock := &MockDocAnalyzer{Healthy: true, OCRBoxes: []OCRBox{}} - chars := []TextChar{{X0: 10, Top: 10, X1: 20, Bottom: 30, Text: "A", PageNumber: 0}} + mock := &MockDocAnalyzer{Healthy: true, OCRBoxes: []pdf.OCRBox{}} + chars := []pdf.TextChar{{X0: 10, Top: 10, X1: 20, Bottom: 30, Text: "A", PageNumber: 0}} if boxes := ocrMergeChars(context.Background(), dummyImg, chars, mock, 0); boxes != nil { t.Error("no detect boxes → nil") } @@ -555,10 +344,10 @@ func TestOCR_MergeChars(t *testing.T) { t.Run("detect boxes — all overlap with chars (chars used, Python-aligned)", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 15, Y0: 15, X1: 150, Y1: 15, X2: 150, Y2: 150, X3: 15, Y3: 150}}, - OCRTexts: []OCRText{{Text: "Hello OCR", Confidence: 0.9}}, + OCRBoxes: []pdf.OCRBox{{X0: 15, Y0: 15, X1: 150, Y1: 15, X2: 150, Y2: 150, X3: 15, Y3: 150}}, + OCRTexts: []pdf.OCRText{{Text: "Hello OCR", Confidence: 0.9}}, } - chars := []TextChar{{X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}} + chars := []pdf.TextChar{{X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}} boxes := ocrMergeChars(context.Background(), dummyImg, chars, mock, 0) if len(boxes) != 1 { t.Fatalf("expected 1 box, got %d", len(boxes)) @@ -572,10 +361,10 @@ func TestOCR_MergeChars(t *testing.T) { t.Run("detect boxes — none overlap with chars", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 240, Y0: 240, X1: 270, Y1: 240, X2: 270, Y2: 270, X3: 240, Y3: 270}}, - OCRTexts: []OCRText{{Text: "OCR", Confidence: 0.9}}, + OCRBoxes: []pdf.OCRBox{{X0: 240, Y0: 240, X1: 270, Y1: 240, X2: 270, Y2: 270, X3: 240, Y3: 270}}, + OCRTexts: []pdf.OCRText{{Text: "OCR", Confidence: 0.9}}, } - chars := []TextChar{{X0: 10, X1: 20, Top: 10, Bottom: 20, Text: "A", PageNumber: 0}} + chars := []pdf.TextChar{{X0: 10, X1: 20, Top: 10, Bottom: 20, Text: "A", PageNumber: 0}} boxes := ocrMergeChars(context.Background(), dummyImg, chars, mock, 0) if len(boxes) != 1 { t.Fatalf("expected 1 box (OCR), got %d", len(boxes)) @@ -588,10 +377,10 @@ func TestOCR_MergeChars(t *testing.T) { t.Run("detect box — no chars and OCR returns empty", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 240, Y0: 240, X1: 270, Y1: 240, X2: 270, Y2: 270, X3: 240, Y3: 270}}, - OCRTexts: []OCRText{}, + OCRBoxes: []pdf.OCRBox{{X0: 240, Y0: 240, X1: 270, Y1: 240, X2: 270, Y2: 270, X3: 240, Y3: 270}}, + OCRTexts: []pdf.OCRText{}, } - chars := []TextChar{{X0: 10, X1: 20, Top: 10, Bottom: 20, Text: "A", PageNumber: 0}} + chars := []pdf.TextChar{{X0: 10, X1: 20, Top: 10, Bottom: 20, Text: "A", PageNumber: 0}} boxes := ocrMergeChars(context.Background(), dummyImg, chars, mock, 0) if len(boxes) != 0 { t.Fatalf("expected 0 boxes (empty OCR), got %d", len(boxes)) @@ -602,15 +391,15 @@ func TestOCR_MergeChars(t *testing.T) { // Box 1 overlaps chars → uses char text. Box 2 has no chars → OCR. mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 15, Y0: 15, X1: 150, Y1: 15, X2: 150, Y2: 150, X3: 15, Y3: 150}, {X0: 240, Y0: 240, X1: 270, Y1: 240, X2: 270, Y2: 270, X3: 240, Y3: 270}, }, - OCRTexts: []OCRText{ + OCRTexts: []pdf.OCRText{ {Text: "box 1 text", Confidence: 0.9}, }, } - chars := []TextChar{{X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}} + chars := []pdf.TextChar{{X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "Hello", PageNumber: 0}} boxes := ocrMergeChars(context.Background(), dummyImg, chars, mock, 0) if len(boxes) != 2 { t.Fatalf("expected 2 boxes, got %d", len(boxes)) @@ -630,12 +419,12 @@ func TestOCR_MergeChars(t *testing.T) { // Box 2 (pixel Y=330-390 → PDF 110-130) overlaps char "c" at (70,110-130). mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 15, Y0: 30, X1: 90, Y1: 30, X2: 90, Y2: 90, X3: 15, Y3: 90}, {X0: 75, Y0: 330, X1: 300, Y1: 330, X2: 300, Y2: 390, X3: 75, Y3: 390}, }, } - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 70, X1: 90, Top: 110, Bottom: 130, Text: "c", PageNumber: 0}, {X0: 10, X1: 30, Top: 10, Bottom: 30, Text: "a", PageNumber: 0}, } @@ -657,12 +446,12 @@ func TestOCR_MergeChars(t *testing.T) { // Char B height=100, diff=70/100=0.70 ≥ 0.7 → excluded. mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 15, Y0: 75, X1: 150, Y1: 75, X2: 150, Y2: 165, X3: 15, Y3: 165}, }, - OCRTexts: []OCRText{{Text: "OCR height test", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "OCR height test", Confidence: 0.9}}, } - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 10, X1: 30, Top: 30, Bottom: 50, Text: "A", PageNumber: 0}, {X0: 40, X1: 60, Top: 20, Bottom: 120, Text: "B", PageNumber: 0}, } @@ -679,12 +468,12 @@ func TestOCR_MergeChars(t *testing.T) { t.Run("garbled chars — box text cleared for OCR recognize", func(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{ + OCRBoxes: []pdf.OCRBox{ {X0: 15, Y0: 15, X1: 450, Y1: 15, X2: 450, Y2: 450, X3: 15, Y3: 450}, }, - OCRTexts: []OCRText{{Text: "OCR result", Confidence: 0.9}}, + OCRTexts: []pdf.OCRText{{Text: "OCR result", Confidence: 0.9}}, } - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 10, X1: 20, Top: 10, Bottom: 20, Text: "", PageNumber: 0}, {X0: 30, X1: 40, Top: 10, Bottom: 20, Text: "", PageNumber: 0}, {X0: 50, X1: 60, Top: 10, Bottom: 20, Text: "a", PageNumber: 0}, @@ -704,10 +493,10 @@ func TestOCR_MergeChars(t *testing.T) { // used (Python-aligned: embedded chars are more precise than OCR). mock := &MockDocAnalyzer{ Healthy: true, - OCRBoxes: []OCRBox{{X0: 30, Y0: 30, X1: 90, Y1: 30, X2: 90, Y2: 90, X3: 30, Y3: 90}}, - OCRTexts: []OCRText{{Text: "docker commit infiniflow", Confidence: 0.95}}, + OCRBoxes: []pdf.OCRBox{{X0: 30, Y0: 30, X1: 90, Y1: 30, X2: 90, Y2: 90, X3: 30, Y3: 90}}, + OCRTexts: []pdf.OCRText{{Text: "docker commit infiniflow", Confidence: 0.95}}, } - chars := []TextChar{ + chars := []pdf.TextChar{ {Text: "d", X0: 10, X1: 20, Top: 10, Bottom: 25, PageNumber: 0}, {Text: "o", X0: 21, X1: 30, Top: 10, Bottom: 25, PageNumber: 0}, } @@ -722,177 +511,37 @@ func TestOCR_MergeChars(t *testing.T) { }) } -func TestLineToTextBox_SpaceInsertion(t *testing.T) { - // ASCII chars with visible gap → space inserted. - chars := []TextChar{ - {X0: 0, X1: 8, Text: "H"}, - {X0: 12, X1: 16, Text: "i"}, - } - box := lineToTextBox(chars) - if box.Text != "H i" { - t.Errorf("expected 'H i', got %q", box.Text) - } -} - -func TestLineToTextBox_NoSpaceForCJK(t *testing.T) { - // CJK chars should NOT get space inserted. - chars := []TextChar{ - {X0: 0, X1: 8, Text: "你"}, - {X0: 12, X1: 20, Text: "好"}, - } - box := lineToTextBox(chars) - if box.Text != "你好" { - t.Errorf("expected '你好', got %q", box.Text) - } -} - -func TestLineToTextBox_NoSpaceForTightGap(t *testing.T) { - // Small gap below threshold → no space. - chars := []TextChar{ - {X0: 0, X1: 8, Text: "a"}, - {X0: 9, X1: 16, Text: "b"}, - } - box := lineToTextBox(chars) - if box.Text != "ab" { - t.Errorf("expected 'ab', got %q", box.Text) - } -} - -func TestLineToTextBox_EmptyTextSkipsSpace(t *testing.T) { - chars := []TextChar{ - {X0: 0, X1: 8, Text: ""}, - {X0: 12, X1: 16, Text: "A"}, - } - box := lineToTextBox(chars) - if box.Text != "A" { - t.Errorf("expected 'A', got %q", box.Text) - } -} - -// TestTableToHTML verifies the HTML table format matches Python's -// construct_table output (tsr.py:293-313). -func TestRowsToHTML(t *testing.T) { - // rowsToHTML takes [][]TSRCell instead of [][]string (tableToHTML removed). - toCells := func(rows [][]string) [][]TSRCell { - out := make([][]TSRCell, len(rows)) - for ri, row := range rows { - out[ri] = make([]TSRCell, len(row)) - for ci, s := range row { - out[ri][ci] = TSRCell{Text: s} - } - } - return out - } - - t.Run("simple 2x2 table", func(t *testing.T) { - rows := toCells([][]string{ - {"姓名", "年龄"}, - {"张三", "25"}, - }) - html := rowsToHTML(rows, "", nil, nil, nil) - expected := "
姓名年龄
张三25
" - if html != expected { - t.Errorf("got %q\nwant %q", html, expected) - } - }) - - t.Run("empty table", func(t *testing.T) { - html := rowsToHTML(nil, "", nil, nil, nil) - if html != "
" { - t.Errorf("expected '
', got %q", html) - } - }) - - t.Run("single cell", func(t *testing.T) { - rows := toCells([][]string{{"X"}}) - html := rowsToHTML(rows, "", nil, nil, nil) - expected := "
X
" - if html != expected { - t.Errorf("got %q\nwant %q", html, expected) - } - }) - - t.Run("matches Python format for 公司差旅费", func(t *testing.T) { - rows := toCells([][]string{ - {"标职务", "飞机", "火车", "轮船", "其他交通工具(不含的士)"}, - {"公司级领导人员", "经济舱位", "火车软席", "二等舱位", "按实报销"}, - {"其他工作人员", "经济舱位", "火车硬席", "三等舱位", "按实报销"}, - }) - html := rowsToHTML(rows, "", nil, nil, nil) - if !strings.HasPrefix(html, "") || !strings.HasSuffix(html, "
") { - t.Errorf("not valid HTML: %s", html) - } - if !strings.Contains(html, "标职务") { - t.Errorf("missing cell '标职务': %s", html) - } - if strings.Count(html, "") != 3 { - t.Errorf("expected 3 rows, got %d", strings.Count(html, "")) - } - }) -} - -// TestExtractTableAndReplace verifies that extractTableAndReplace pops -// table boxes and replaces them with consolidated HTML, matching Python. -func TestExtractTableAndReplace(t *testing.T) { - // Build boxes with table labels and a TableItem with cells. - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20, Text: "A", LayoutType: "table", PageNumber: 0, R: 0, C: 0}, - {X0: 0, X1: 100, Top: 21, Bottom: 40, Text: "B", LayoutType: "table", PageNumber: 0, R: 0, C: 0}, - {X0: 110, X1: 200, Top: 0, Bottom: 20, Text: "C", LayoutType: "table", PageNumber: 0, R: 0, C: 1}, - {X0: 110, X1: 200, Top: 21, Bottom: 40, Text: "D", LayoutType: "table", PageNumber: 0, R: 0, C: 1}, - } - tbl := TableItem{ - Cells: []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 20, Label: "table row"}, - {X0: 110, Y0: 0, X1: 200, Y1: 20, Label: "table row"}, - {X0: 0, Y0: 21, X1: 100, Y1: 40, Label: "table row"}, - {X0: 110, Y0: 21, X1: 200, Y1: 40, Label: "table row"}, - }, - Positions: []Position{{Left: 0, Right: 200, Top: 0, Bottom: 40}}, - Scale: 1.0, - } - result := extractTableAndReplace(boxes, []TableItem{tbl}) - if len(result) != 1 { - t.Fatalf("expected 1 box (replaced), got %d", len(result)) - } - if result[0].LayoutType != "table" { - t.Errorf("expected LayoutType table, got %q", result[0].LayoutType) - } - if !strings.Contains(result[0].Text, "") { - t.Errorf("expected HTML table, got %q", result[0].Text) - } -} - // TestTableSectionCaptionInHTML verifies mergeCaptions prepends table // caption text before the HTML table, matching Python's caption handling. + func TestTableSectionCaptionInHTML(t *testing.T) { // Simulate pipeline order: extractTableAndReplace → boxesToSections → mergeCaptions - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 100, X1: 500, Top: 200, Bottom: 400, LayoutType: "table", PageNumber: 0}, } - ti := TableItem{ - Cells: []TSRCell{ + ti := pdf.TableItem{ + Cells: []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 50, Label: "table row", Text: "飞机"}, {X0: 0, Y0: 51, X1: 200, Y1: 100, Label: "table row", Text: "火车"}, }, - Positions: []Position{{Left: 100, Right: 500, Top: 200, Bottom: 400}}, + Positions: []pdf.Position{{Left: 100, Right: 500, Top: 200, Bottom: 400}}, Scale: 1.0, } // Step 1: extractTableAndReplace → HTML box with table text - boxes = extractTableAndReplace(boxes, []TableItem{ti}) - sections := boxesToSections(boxes, nil) + boxes = tbl.ExtractTableAndReplace(boxes, []pdf.TableItem{ti}) + sections := lyt.BoxesToSections(boxes, nil) // Add caption section - sections = append(sections, Section{ + sections = append(sections, pdf.Section{ LayoutType: "table caption", - Positions: []Position{{Left: 100, Right: 500, Top: 180, Bottom: 198}}, + Positions: []pdf.Position{{Left: 100, Right: 500, Top: 180, Bottom: 198}}, Text: "表1: 交通工具等级", }) // Step 2: mergeCaptions prepends caption before HTML - figures := CollectFigures(sections) - sections = mergeCaptions(sections, figures) + figures := pdf.CollectFigures(sections) + sections = tbl.MergeCaptions(sections, figures) if !strings.HasPrefix(sections[0].Text, "表1: 交通工具等级
") { t.Errorf("expected caption before table HTML, got %q", sections[0].Text) @@ -903,475 +552,23 @@ func TestTableSectionCaptionInHTML(t *testing.T) { // text boxes that are mostly OUTSIDE the cell, even with cellIsEmpty=true. // The 0.3 threshold should not match a wide box that barely touches a // narrow cell — this would cause body text to leak into table cells. -func TestBoxMatchesCell_FalsePositive(t *testing.T) { - // Cell: narrow table cell (40×20 px) - cell := TSRCell{X0: 0, Y0: 0, X1: 40, Y1: 20} +// TestParser_ConcurrentSafety verifies that Parser.Parse() is safe for +// concurrent use. 8 goroutines each call Parse 5 times on the same Parser +// instance. Run with -race. +func TestParser_ConcurrentSafety(t *testing.T) { + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: false}) - // Box A: entirely inside the cell → should match. - boxA := TextBox{X0: 5, X1: 35, Top: 2, Bottom: 18, Text: "标职务"} - - // Box B: a wide body-text box that only slightly overlaps the cell. - // It covers x=30..200 but the cell is only x=0..40. - // Overlap: x=30..40 (10px), box width=170 → ratio=10/170=0.059 < 0.3. - boxB := TextBox{X0: 30, X1: 200, Top: 5, Bottom: 15, Text: "第二条出差人员应按规定等级乘坐交通工具..."} - - if !boxMatchesCell(cell, boxA, true) { - t.Error("boxA entirely inside cell should match with cellIsEmpty=true") - } - if boxMatchesCell(cell, boxB, true) { - t.Error("boxB mostly outside cell should NOT match even with cellIsEmpty=true") - } - if !boxMatchesCell(cell, boxA, false) { - t.Error("boxA entirely inside cell should match with cellIsEmpty=false") - } - if boxMatchesCell(cell, boxB, false) { - t.Error("boxB mostly outside cell should NOT match with cellIsEmpty=false") + var wg sync.WaitGroup + n := 8 + for range n { + wg.Add(1) + go func() { + defer wg.Done() + for range 5 { + eng := &mockEngine{pageCount: 2} + _, _ = p.Parse(context.Background(), eng) + } + }() } -} - -// TestFillCellTextFromBoxes_PageGlobal verifies that fillCellTextFromBoxes -// correctly matches text boxes to cells when both use page-global 72 DPI -// coordinates, matching Python's construct_table approach. -func TestFillCellTextFromBoxes_PageGlobal(t *testing.T) { - t.Run("exact alignment matches", func(t *testing.T) { - cells := []TSRCell{ - {X0: 73, Y0: 329, X1: 214, Y1: 345}, - {X0: 214, Y0: 329, X1: 272, Y1: 345}, - {X0: 272, Y0: 329, X1: 407, Y1: 345}, - } - boxes := []TextBox{ - {X0: 73, X1: 214, Top: 329, Bottom: 345, Text: "标职务"}, - {X0: 214, X1: 272, Top: 329, Bottom: 345, Text: "飞机"}, - {X0: 272, X1: 407, Top: 329, Bottom: 345, Text: "火车"}, - } - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "标职务" { - t.Errorf("cell[0] = %q, want '标职务'", cells[0].Text) - } - if cells[1].Text != "飞机" { - t.Errorf("cell[1] = %q, want '飞机'", cells[1].Text) - } - if cells[2].Text != "火车" { - t.Errorf("cell[2] = %q, want '火车'", cells[2].Text) - } - }) - - t.Run("body text box does not leak into cell", func(t *testing.T) { - cells := []TSRCell{{X0: 73, Y0: 329, X1: 214, Y1: 345}} - boxes := []TextBox{ - {X0: 73, X1: 214, Top: 329, Bottom: 345, Text: "标职务"}, - {X0: 73, X1: 520, Top: 310, Bottom: 360, Text: "第二条出差人员应按规定"}, - } - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "标职务" { - t.Errorf("cell text = %q, want '标职务' (body text should not leak in)", cells[0].Text) - } - }) - - t.Run("empty cells list is no-op", func(t *testing.T) { - fillCellTextFromBoxes(nil, []TextBox{{Text: "x"}}) - }) - - t.Run("empty boxes list preserves cell text", func(t *testing.T) { - cells := []TSRCell{{Text: "existing"}} - fillCellTextFromBoxes(cells, nil) - if cells[0].Text != "existing" { - t.Errorf("existing text should be preserved, got %q", cells[0].Text) - } - }) -} - -func TestCharsToBoxes_XGapSplitsColumns(t *testing.T) { - // Simulate a table row with 3 columns: col 0="A", col 1="B", col 2="C". - // Large X gaps between columns, small gaps within. - chars := []TextChar{ - {X0: 10, X1: 18, Top: 0, Bottom: 12, Text: "A", PageNumber: 0}, - {X0: 18, X1: 26, Top: 0, Bottom: 12, Text: "1", PageNumber: 0}, // small gap after A - {X0: 150, X1: 158, Top: 0, Bottom: 12, Text: "B", PageNumber: 0}, // large gap → new box - {X0: 158, X1: 166, Top: 0, Bottom: 12, Text: "2", PageNumber: 0}, // small - {X0: 300, X1: 308, Top: 0, Bottom: 12, Text: "C", PageNumber: 0}, // large gap → new box - {X0: 308, X1: 316, Top: 0, Bottom: 12, Text: "3", PageNumber: 0}, // small - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 3 { - t.Fatalf("expected 3 boxes (one per column), got %d", len(boxes)) - } - if boxes[0].Text != "A1" { - t.Errorf("col 0: got %q, want %q", boxes[0].Text, "A1") - } - if boxes[1].Text != "B2" { - t.Errorf("col 1: got %q, want %q", boxes[1].Text, "B2") - } - if boxes[2].Text != "C3" { - t.Errorf("col 2: got %q, want %q", boxes[2].Text, "C3") - } -} - -func TestCharsToBoxes_NoSplitNormalText(t *testing.T) { - // Normal English text: small gaps between chars. - chars := []TextChar{ - {X0: 10, X1: 18, Top: 0, Bottom: 12, Text: "H", PageNumber: 0}, - {X0: 18, X1: 26, Top: 0, Bottom: 12, Text: "e", PageNumber: 0}, - {X0: 26, X1: 34, Top: 0, Bottom: 12, Text: "l", PageNumber: 0}, - {X0: 34, X1: 42, Top: 0, Bottom: 12, Text: "l", PageNumber: 0}, - {X0: 42, X1: 50, Top: 0, Bottom: 12, Text: "o", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 1 { - t.Fatalf("expected 1 box for normal text, got %d", len(boxes)) - } - if boxes[0].Text != "Hello" { - t.Errorf("got %q, want %q", boxes[0].Text, "Hello") - } -} - -func TestCharsToBoxes_SingleChar(t *testing.T) { - chars := []TextChar{ - {X0: 10, X1: 18, Top: 0, Bottom: 12, Text: "X", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 1 || boxes[0].Text != "X" { - t.Errorf("single char: got %d boxes, text=%q", len(boxes), boxes[0].Text) - } -} - -func TestCharsToBoxes_Empty(t *testing.T) { - boxes := charsToBoxes(nil, 0, false) - if len(boxes) != 0 { - t.Errorf("empty: got %d boxes", len(boxes)) - } -} - -func TestCharsToBoxes_ChineseUniformSpacing(t *testing.T) { - // CJK characters with uniform spacing — no column gaps. - chars := []TextChar{ - {X0: 10, X1: 26, Top: 0, Bottom: 16, Text: "标", PageNumber: 0}, - {X0: 26, X1: 42, Top: 0, Bottom: 16, Text: "职", PageNumber: 0}, - {X0: 42, X1: 58, Top: 0, Bottom: 16, Text: "务", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 1 { - t.Fatalf("uniform CJK: expected 1 box, got %d", len(boxes)) - } -} - -// TestBoxesToSections_CrossPagePositionTag verifies that a box whose bottom -// exceeds the page height produces a multi-page PositionTag. -// Python: _line_tag while-loop (pdf_parser.py:1279-1283) detects cross-page -// spans and generates "@@5-6\t..." tags. -func TestBoxesToSections_CrossPagePositionTag(t *testing.T) { - // Page 0: 267 PDF-points tall (800px at zoom=3). - // Box bottom=400 > 267 → spills into page 1 by 133pt. - boxes := []TextBox{ - {X0: 100, X1: 500, Top: 200, Bottom: 400, PageNumber: 0, Text: "跨页表格"}, - } - pageHeights := map[int]float64{0: 267.0} - - sections := boxesToSections(boxes, pageHeights) - if len(sections) != 1 { - t.Fatalf("expected 1 section, got %d", len(sections)) - } - s := sections[0] - - // Python: @@1-2\t100.0\t500.0\t200.0\t133.0## - // Page 0→1 becomes 1-indexed → pages 1-2. - if s.PositionTag != "@@1-2\t100.0\t500.0\t200.0\t133.0##" { - t.Errorf("PositionTag: got %q, want '@@1-2\\t100.0\\t500.0\\t200.0\\t133.0##'", s.PositionTag) - } - if len(s.Positions) != 1 { - t.Fatalf("expected 1 Position, got %d", len(s.Positions)) - } - p := s.Positions[0] - if len(p.PageNumbers) != 2 || p.PageNumbers[0] != 0 || p.PageNumbers[1] != 1 { - t.Errorf("PageNumbers: got %v, want [0, 1]", p.PageNumbers) - } - if p.Top != 200 || p.Bottom != 133 { - t.Errorf("coords: top=%v (want 200), bottom=%v (want 133 = 400-267)", p.Top, p.Bottom) - } -} - -// TestBoxesToSections_SinglePageUnchanged verifies single-page boxes are -// unaffected by the cross-page change. -func TestBoxesToSections_SinglePageUnchanged(t *testing.T) { - boxes := []TextBox{ - {X0: 50, X1: 200, Top: 10, Bottom: 30, PageNumber: 0, Text: "普通文本"}, - } - pageHeights := map[int]float64{0: 267.0} - - sections := boxesToSections(boxes, pageHeights) - if len(sections) != 1 { - t.Fatalf("expected 1 section, got %d", len(sections)) - } - // Single page: tag should be @@1, not @@1-1 - if sections[0].PositionTag != "@@1\t50.0\t200.0\t10.0\t30.0##" { - t.Errorf("single-page PositionTag: got %q", sections[0].PositionTag) - } - if len(sections[0].Positions[0].PageNumbers) != 1 { - t.Errorf("single-page PageNumbers: got %v, want [0]", sections[0].Positions[0].PageNumbers) - } -} - -func TestResolvePageSpan_SinglePage(t *testing.T) { - // Box fits within the page → toPage unchanged, bottom unchanged. - toPage, bottom := resolvePageSpan(0, 30, map[int]float64{0: 267}) - if toPage != 0 || bottom != 30 { - t.Errorf("got toPage=%d bottom=%v, want 0, 30", toPage, bottom) - } -} - -func TestResolvePageSpan_CrossPage(t *testing.T) { - // Box bottom=400 exceeds page 0 height=267 → spans to page 1. - toPage, bottom := resolvePageSpan(0, 400, map[int]float64{0: 267}) - if toPage != 1 { - t.Errorf("toPage = %d, want 1", toPage) - } - if bottom != 133 { - t.Errorf("bottom = %v, want 133 (400-267)", bottom) - } -} - -func TestResolvePageSpan_MultiPage(t *testing.T) { - // Box bottom=600, page 0=267, page 1=200, page 2=200. - heights := map[int]float64{0: 267, 1: 200, 2: 200} - toPage, bottom := resolvePageSpan(0, 600, heights) - if toPage != 2 { - t.Errorf("toPage = %d, want 2", toPage) - } - if bottom != 133 { - t.Errorf("bottom = %v, want 133 (600-267-200)", bottom) - } -} - -func TestResolvePageSpan_NilHeights(t *testing.T) { - toPage, bottom := resolvePageSpan(0, 400, nil) - if toPage != 0 || bottom != 400 { - t.Errorf("got toPage=%d bottom=%v, want 0, 400 (nil=no cross-page)", toPage, bottom) - } -} - -func TestResolvePageSpan_ZeroHeightGuard(t *testing.T) { - // Zero-height pages must not cause an infinite loop. - // Page 0=200, page 1=0, page 2=0, page 3=300 — box bottom=500. - heights := map[int]float64{0: 200, 1: 0, 2: 0, 3: 300} - toPage, bottom := resolvePageSpan(0, 500, heights) - // 500-200=300 remaining; page1=0 → break at unknown/invalid; toPage=1, bottom=300. - // (the break path treats zero/unknown as "assume same height once and stop") - if toPage != 1 { - t.Errorf("toPage = %d, want 1 (stopped at first zero-height page)", toPage) - } - if bottom != 300 { - t.Errorf("bottom = %v, want 300 (500-200)", bottom) - } -} - -func TestResolvePageSpan_UnknownNextPage(t *testing.T) { - // Next page not in map → assume same height once, then stop. - heights := map[int]float64{0: 267} - toPage, bottom := resolvePageSpan(0, 500, heights) - if toPage != 1 { - t.Errorf("toPage = %d, want 1 (one fallback extension)", toPage) - } - if bottom != 233 { - t.Errorf("bottom = %v, want 233 (500-267)", bottom) - } -} - -func TestResolvePageSpan_NegativePh(t *testing.T) { - heights := map[int]float64{0: 200, 1: -10, 2: 200} - toPage, bottom := resolvePageSpan(0, 500, heights) - if toPage != 1 { - t.Errorf("toPage = %d, want 1 (stopped at negative-height page)", toPage) - } - if bottom != 300 { - t.Errorf("bottom = %v, want 300 (500-200)", bottom) - } -} - -// TestCrossPageTableMerge verifies that mergeTablesAcrossPages merges -// two TableItems on consecutive pages with overlapping X positions. -// Python: _extract_table_figure merges cross-page tables by matching layoutno. -func TestCrossPageTableMerge(t *testing.T) { - // Page 0 table: 2 cells, positioned at page 0. - pg0 := TableItem{ - Positions: []Position{ - {PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 800}, - }, - Scale: 1.0, - Cells: []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 := TableItem{ - Positions: []Position{ - {PageNumbers: []int{1}, Left: 50, Right: 500, Top: 100, Bottom: 300}, - }, - Scale: 1.0, - Cells: []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "pg1_r0c0"}, - {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "pg1_r0c1"}, - }, - } - tables := []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 := []TableItem{ - { - Positions: []Position{{PageNumbers: []int{0}, Left: 50, Right: 100, Top: 100, Bottom: 500}}, - Scale: 1.0, - Cells: []TSRCell{{Text: "left"}}, - }, - { - Positions: []Position{{PageNumbers: []int{1}, Left: 500, Right: 600, Top: 100, Bottom: 500}}, - Scale: 1.0, - Cells: []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 := []TableItem{ - { - Positions: []Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}}, - Scale: 1.0, - Cells: []TSRCell{{Text: "page0"}}, - }, - { - Positions: []Position{{PageNumbers: []int{3}, Left: 50, Right: 500, Top: 100, Bottom: 500}}, - Scale: 1.0, - Cells: []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 := []TableItem{ - { - Positions: []Position{{PageNumbers: []int{0}, Left: 50, Right: 500, Top: 100, Bottom: 500}}, - Scale: 1.0, - Cells: []TSRCell{{Text: "only"}}, - }, - } - merged := mergeTablesAcrossPages(tables, nil) - if len(merged) != 1 { - t.Fatalf("single table: expected 1 table, got %d", len(merged)) - } -} - -func TestCharsToBoxes_CJKWordGapNoSplit(t *testing.T) { - chars := []TextChar{ - {X0: 10, X1: 26, Top: 0, Bottom: 16, Text: "二", PageNumber: 0}, - {X0: 38, X1: 54, Top: 0, Bottom: 16, Text: "等", PageNumber: 0}, - {X0: 54, X1: 70, Top: 0, Bottom: 16, Text: "舱", PageNumber: 0}, - {X0: 70, X1: 86, Top: 0, Bottom: 16, Text: "位", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 1 { - t.Fatalf("CJK word gap: expected 1 box, got %d", len(boxes)) - } -} - -func TestCharsToBoxes_VaryingColumnGaps(t *testing.T) { - // Realistic page: many chars per column (gap~0), REAL column gaps (30+, 50+). - chars := []TextChar{ - {X0: 10, X1: 26, Top: 0, Bottom: 16, Text: "姓", PageNumber: 0}, - {X0: 26, X1: 42, Top: 0, Bottom: 16, Text: "名", PageNumber: 0}, - {X0: 42, X1: 58, Top: 0, Bottom: 16, Text: "称", PageNumber: 0}, - {X0: 108, X1: 124, Top: 0, Bottom: 16, Text: "年", PageNumber: 0}, - {X0: 124, X1: 140, Top: 0, Bottom: 16, Text: "龄", PageNumber: 0}, - {X0: 180, X1: 196, Top: 0, Bottom: 16, Text: "性", PageNumber: 0}, - {X0: 196, X1: 212, Top: 0, Bottom: 16, Text: "别", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 3 { - t.Fatalf("varying column gaps: expected 3 boxes, got %d", len(boxes)) - } -} - -func TestCharsToBoxes_MixedCJKEnglishNoSplit(t *testing.T) { - chars := []TextChar{ - {X0: 10, X1: 26, Top: 0, Bottom: 16, Text: "经", PageNumber: 0}, - {X0: 26, X1: 42, Top: 0, Bottom: 16, Text: "济", PageNumber: 0}, - {X0: 42, X1: 50, Top: 0, Bottom: 16, Text: "A", PageNumber: 0}, - {X0: 50, X1: 58, Top: 0, Bottom: 16, Text: "B", PageNumber: 0}, - } - boxes := charsToBoxes(chars, 0, false) - if len(boxes) != 1 { - t.Fatalf("mixed CJK+English: expected 1 box, got %d", len(boxes)) - } -} - -// TestMergeCaptions_NeedsCaptionLayoutType exposes that mergeCaptions only -// strips caption sections when DLA labels them as "table caption" or -// "figure caption". When DLA labels them as "text" (real scenario with -// some PDF layouts), the caption text remains in the table output. -func TestMergeCaptions_NeedsCaptionLayoutType(t *testing.T) { - // Simulate what happens when DLA doesn't produce a "table caption" region: - // a "text" section adjacent to a table is NOT treated as caption. - sections := []Section{ - {LayoutType: "table", Text: "
data
", - Positions: []Position{{Left: 100, Right: 500, Top: 200, Bottom: 400}}}, - {LayoutType: "text", Text: "公司领导班子成员、出差地", - Positions: []Position{{Left: 100, Right: 500, Top: 180, Bottom: 198}}}, - } - figures := CollectFigures(sections) - result := mergeCaptions(sections, figures) - // BUG: "text" layout type is NOT matched by mergeCaptions (only "table caption"/"figure caption"). - // The caption text survives as a separate section instead of being prepended to the table. - for _, s := range result { - if s.LayoutType == "text" && strings.Contains(s.Text, "公司领导班子") { - t.Log("KNOWN LIMITATION: caption with LayoutType='text' not stripped by mergeCaptions") - } - } -} - -// TestGroupBoxesByRC_ColspanMissing exposes that groupBoxesByRC doesn't -// compute colspan/rowspan from SP annotations (__cal_spans in Python). -// Spanning cells should be annotated with colspan/rowspan in the HTML output. -func TestGroupBoxesByRC_ColspanMissing(t *testing.T) { - // Box with SP annotation spanning 2 columns (HLeft→HRight covers cols 0-1). - boxes := []TextBox{ - {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "Name", R: 0, C: 0, H: 1, - HLeft: 10, HRight: 200}, - {X0: 110, X1: 200, Top: 0, Bottom: 30, Text: "", R: 0, C: 1, SP: 1}, - {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "A", R: 1, C: 0}, - {X0: 110, X1: 200, Top: 35, Bottom: 65, Text: "B", R: 1, C: 1}, - } - rows := groupBoxesByRC(boxes) - // The result should have colspan=2 for cell [0,0] and skip [0,1]. - // Currently groupBoxesByRC produces a flat grid without span info. - if len(rows) >= 1 && len(rows[0]) >= 2 && rows[0][1].Text == "" { - t.Log("KNOWN LIMITATION: colspan not computed — cell [0,1] is empty instead of merged") - } - _ = rows + wg.Wait() } diff --git a/internal/deepdoc/parser/pdf/pdfium/pdfium.go b/internal/deepdoc/parser/pdf/pdfium/pdfium.go index 46cc042658..3a8c908a42 100644 --- a/internal/deepdoc/parser/pdf/pdfium/pdfium.go +++ b/internal/deepdoc/parser/pdf/pdfium/pdfium.go @@ -5,8 +5,7 @@ package pdfium /* -#cgo LDFLAGS: -L/home/shenyushi/cc-workspace/ragflow/.venv/lib/python3.13/site-packages/pypdfium2_raw -lpdfium -lm -lpthread -ldl -#cgo linux LDFLAGS: -Wl,-rpath,/home/shenyushi/cc-workspace/ragflow/.venv/lib/python3.13/site-packages/pypdfium2_raw +#cgo LDFLAGS: -lpdfium -lm -lpthread -ldl #include #include @@ -14,6 +13,8 @@ package pdfium typedef struct FPDF_DOCUMENT__ { int unused; } *FPDF_DOCUMENT; typedef struct FPDF_PAGE__ { int unused; } *FPDF_PAGE; typedef struct FPDF_BITMAP__ { int unused; } *FPDF_BITMAP; +typedef struct FPDF_BOOKMARK__ { int unused; } *FPDF_BOOKMARK; +typedef struct FPDF_DEST__ { int unused; } *FPDF_DEST; extern void FPDF_InitLibrary(void); extern FPDF_DOCUMENT FPDF_LoadMemDocument(const void* data_buf, int size, const char* password); @@ -32,6 +33,13 @@ extern void* FPDFBitmap_GetBuffer(FPDF_BITMAP bitmap); extern int FPDFBitmap_GetWidth(FPDF_BITMAP bitmap); extern int FPDFBitmap_GetHeight(FPDF_BITMAP bitmap); extern int FPDFBitmap_GetStride(FPDF_BITMAP bitmap); + +// Outline / bookmark API +extern FPDF_BOOKMARK FPDFBookmark_GetFirstChild(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark); +extern FPDF_BOOKMARK FPDFBookmark_GetNextSibling(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark); +extern unsigned long FPDFBookmark_GetTitle(FPDF_BOOKMARK bookmark, void* buffer, unsigned long buflen); +extern FPDF_DEST FPDFBookmark_GetDest(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark); +extern int FPDFDest_GetDestPageIndex(FPDF_DOCUMENT document, FPDF_DEST dest); */ import "C" import ( @@ -40,9 +48,17 @@ import ( "image/color" "math" "sync" + "unicode/utf16" "unsafe" ) +// Outline represents one entry in a PDF document outline (table of contents). +type Outline struct { + Title string + Level int + PageNumber int // 1-indexed, matching Python +} + var initOnce sync.Once // pdfiumMu serializes all pdfium C API access. pdfium is NOT thread-safe — @@ -163,3 +179,87 @@ func openPage(pdfData []byte, pageIdx int) ( } return } + +// ExtractOutlines returns the document outline (bookmarks / table of contents) +// from a PDF. Returns nil for empty or broken PDFs. Title decoding uses UTF-16LE +// as required by pdfium's FPDFBookmark_GetTitle. +// +// Traversal is iterative with an explicit stack to avoid stack overflow on deep +// outline trees. pdfium is not thread-safe; callers must hold pdfiumMu. +func ExtractOutlines(pdfData []byte) []Outline { + if len(pdfData) == 0 { + return nil + } + Init() + pdfiumMu.Lock() + defer pdfiumMu.Unlock() + + cData := C.CBytes(pdfData) + defer C.free(cData) + + doc := C.FPDF_LoadMemDocument(unsafe.Pointer(cData), C.int(len(pdfData)), nil) + if doc == nil { + return nil + } + defer C.FPDF_CloseDocument(doc) + + type frame struct { + bm C.FPDF_BOOKMARK + level int + } + + var result []Outline + stack := []frame{{bm: C.FPDFBookmark_GetFirstChild(doc, nil), level: 0}} + + for len(stack) > 0 { + top := stack[len(stack)-1] + stack = stack[:len(stack)-1] + if top.bm == nil { + continue + } + + // Title (UTF-16LE). + title := bookmarkTitle(top.bm) + + // Page number. + pageNum := 1 // default to page 1 if dest is unavailable + if dest := C.FPDFBookmark_GetDest(doc, top.bm); dest != nil { + pn := C.FPDFDest_GetDestPageIndex(doc, dest) + if pn >= 0 { + pageNum = int(pn) + 1 // pdfium returns 0-based + } + } + + result = append(result, Outline{Title: title, Level: top.level, PageNumber: pageNum}) + + // Push siblings after children so children are processed first (pre-order). + if sibling := C.FPDFBookmark_GetNextSibling(doc, top.bm); sibling != nil { + stack = append(stack, frame{bm: sibling, level: top.level}) + } + if child := C.FPDFBookmark_GetFirstChild(doc, top.bm); child != nil { + stack = append(stack, frame{bm: child, level: top.level + 1}) + } + } + return result +} + +// bookmarkTitle reads a bookmark's title (UTF-16LE) and converts to Go string. +func bookmarkTitle(bm C.FPDF_BOOKMARK) string { + // First call: get required buffer length in bytes. + buflen := C.FPDFBookmark_GetTitle(bm, nil, 0) + if buflen <= 0 { + return "" + } + buf := make([]byte, buflen) + C.FPDFBookmark_GetTitle(bm, unsafe.Pointer(&buf[0]), buflen) + + // Title is UTF-16LE. Convert to []uint16 then decode. + n := int(buflen) / 2 + u16 := unsafe.Slice((*uint16)(unsafe.Pointer(&buf[0])), n) + + // Strip trailing null terminator if present. + if n > 0 && u16[n-1] == 0 { + u16 = u16[:n-1] + } + return string(utf16.Decode(u16)) +} diff --git a/internal/deepdoc/parser/pdf/pdfium/pdfium_outline_test.go b/internal/deepdoc/parser/pdf/pdfium/pdfium_outline_test.go new file mode 100644 index 0000000000..297946c6fc --- /dev/null +++ b/internal/deepdoc/parser/pdf/pdfium/pdfium_outline_test.go @@ -0,0 +1,120 @@ +//go:build cgo && manual + +package pdfium + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// ── ExtractOutlines unit tests ──────────────────────────────────────── + +func TestExtractOutlines_Empty(t *testing.T) { + // Create a minimal valid PDF without any outlines. + minimal := []byte("%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \ntrailer<>\nstartxref\n190\n%%EOF") + + outlines := ExtractOutlines(minimal) + if len(outlines) != 0 { + t.Errorf("expected 0 outlines, got %d: %+v", len(outlines), outlines) + } +} + +func TestExtractOutlines_NilOrEmpty(t *testing.T) { + if out := ExtractOutlines(nil); len(out) != 0 { + t.Errorf("expected 0 outlines for nil, got %d", len(out)) + } + if out := ExtractOutlines([]byte{}); len(out) != 0 { + t.Errorf("expected 0 outlines for empty, got %d", len(out)) + } +} + +// ── Integration tests (need real PDF with outlines) ──────────────────── + +func TestExtractOutlines_RealPDF(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + pdfData, err := loadTestPDFWithOutlines() + if err != nil { + t.Skipf("no test PDF with outlines available: %v", err) + } + + outlines := ExtractOutlines(pdfData) + if len(outlines) == 0 { + t.Log("PDF may have no outlines; not necessarily an error") + return + } + + t.Logf("found %d outline entries:", len(outlines)) + for i, o := range outlines { + t.Logf(" [%d] level=%d page=%d title=%q", i, o.Level, o.PageNumber, o.Title) + } + + for i, o := range outlines { + if o.Title == "" { + t.Errorf("outline[%d]: empty title", i) + } + if o.PageNumber < 1 { + t.Errorf("outline[%d]: invalid PageNumber %d (<1)", i, o.PageNumber) + } + if o.Level < 0 { + t.Errorf("outline[%d]: negative Level %d", i, o.Level) + } + } +} + +func TestExtractOutlines_ChineseTitle(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + pdfData, err := loadTestPDFWithOutlines() + if err != nil { + t.Skipf("no test PDF available: %v", err) + } + outlines := ExtractOutlines(pdfData) + for _, o := range outlines { + for _, r := range o.Title { + if r >= 0x4E00 && r <= 0x9FFF { // CJK Unified Ideograph + if strings.ContainsRune(o.Title, '�') { + t.Errorf("title contains U+FFFD (UTF-16LE decode error): %q", o.Title) + } + return // found CJK, verified no replacement chars + } + } + } + t.Log("no CJK characters found in outlines (skip)") +} + +// ── helpers ──────────────────────────────────────────────────────────── + +var testPDFDirs = []string{ + "../../testdata/real_pdfs", + "../testdata/real_pdfs", + "testdata/real_pdfs", +} + +func loadTestPDFWithOutlines() ([]byte, error) { + for _, dir := range testPDFDirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".pdf") { + continue + } + path := filepath.Join(dir, e.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + if o := ExtractOutlines(data); len(o) > 0 { + return data, nil + } + } + } + return nil, fmt.Errorf("no PDF with outlines found in %v", testPDFDirs) +} diff --git a/internal/deepdoc/parser/pdf/pdfium/pdfium_test.go b/internal/deepdoc/parser/pdf/pdfium/pdfium_test.go index ea6fba2215..03c450ff48 100644 --- a/internal/deepdoc/parser/pdf/pdfium/pdfium_test.go +++ b/internal/deepdoc/parser/pdf/pdfium/pdfium_test.go @@ -1,3 +1,5 @@ +//go:build cgo && manual + package pdfium import ( @@ -10,7 +12,7 @@ import ( ) // testdataDir points at the shared test-pdf directory. -var testdataDir = filepath.Join("..", "parser", "testdata", "pdfs") +var testdataDir = filepath.Join("..", "testdata", "pdfs") func readPDF(t *testing.T, name string) []byte { t.Helper() diff --git a/internal/deepdoc/parser/pdf/pdfium_integration_test.go b/internal/deepdoc/parser/pdf/pdfium_integration_test.go index 4719209ae6..3c20fea653 100644 --- a/internal/deepdoc/parser/pdf/pdfium_integration_test.go +++ b/internal/deepdoc/parser/pdf/pdfium_integration_test.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build cgo && manual package parser @@ -7,6 +7,7 @@ import ( "image" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) @@ -46,8 +47,8 @@ func TestParse_PdfiumRender(t *testing.T) { // Run Parse with pdfium rendering — BATCH_SKIP_DEEPDOC=1 to avoid HTTP calls. t.Setenv("BATCH_SKIP_DEEPDOC", "1") - cfg := DefaultParserConfig() - p := NewParser(cfg, &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + cfg := pdf.DefaultParserConfig() + p := NewParser(cfg, &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) @@ -75,14 +76,15 @@ func TestParse_PdfiumRender_NoData(t *testing.T) { } } -// pythonCharEngineStub implements PDFEngine with RawData() returning nil. +// pythonCharEngineStub implements pdf.PDFEngine with RawData() returning nil. type pythonCharEngineStub struct{} -func (e *pythonCharEngineStub) ExtractChars(_ int) ([]TextChar, error) { return nil, nil } +func (e *pythonCharEngineStub) ExtractChars(_ int) ([]pdf.TextChar, error) { return nil, nil } func (e *pythonCharEngineStub) RenderPage(_ int, _ float64) ([]byte, error) { return nil, nil } func (e *pythonCharEngineStub) RenderPageImage(_ int, _ float64) (image.Image, error) { return nil, nil } -func (e *pythonCharEngineStub) RawData() []byte { return nil } -func (e *pythonCharEngineStub) PageCount() (int, error) { return 0, nil } -func (e *pythonCharEngineStub) Close() error { return nil } +func (e *pythonCharEngineStub) RawData() []byte { return nil } +func (e *pythonCharEngineStub) PageCount() (int, error) { return 0, nil } +func (e *pythonCharEngineStub) Close() error { return nil } +func (e *pythonCharEngineStub) Outlines() ([]pdf.Outline, error) { return nil, nil } diff --git a/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_adapter_test.go b/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_adapter_test.go index d7ba66e542..20c7b19e37 100644 --- a/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_adapter_test.go +++ b/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_adapter_test.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build cgo && manual package pdfoxide @@ -11,7 +11,7 @@ import ( "testing" ) -var fixtureDir = filepath.Join("..", "parser", "testdata", "pdfs") +var fixtureDir = filepath.Join("..", "testdata", "pdfs") // ── Document opening ───────────────────────────────────────────────────── @@ -517,7 +517,7 @@ type pyChar struct { // - text content (as sorted sets, ignoring order differences) // - coordinate ranges (min/max, since absolute positions differ by engine) func TestCharExtraction_CompareWithPython(t *testing.T) { - snapDir := filepath.Join("..", "parser", "testdata", "snapshots") + snapDir := filepath.Join("..", "testdata", "snapshots") entries, err := os.ReadDir(snapDir) if err != nil { diff --git a/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_bench_test.go b/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_bench_test.go index aa90cdb4ac..6da48f024a 100644 --- a/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_bench_test.go +++ b/internal/deepdoc/parser/pdf/pdfoxide/pdf_oxide_bench_test.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build cgo && manual package pdfoxide @@ -9,7 +9,7 @@ import ( ) func TestPDFPlumber_Basic(t *testing.T) { - pdfDir := filepath.Join("..", "parser", "testdata", "pdfs") + pdfDir := filepath.Join("..", "testdata", "pdfs") path := filepath.Join(pdfDir, "01_english_simple.pdf") data, err := os.ReadFile(path) if err != nil { @@ -42,7 +42,7 @@ func TestPDFPlumber_Basic(t *testing.T) { } func BenchmarkPDFPlumber_ExtractChars(b *testing.B) { - pdfDir := filepath.Join("..", "parser", "testdata", "pdfs") + pdfDir := filepath.Join("..", "testdata", "pdfs") path := filepath.Join(pdfDir, "01_english_simple.pdf") data, _ := os.ReadFile(path) diff --git a/internal/deepdoc/parser/pdf/pdfoxide_bridge.go b/internal/deepdoc/parser/pdf/pdfoxide_bridge.go index 24ae510e78..195c88f4fc 100644 --- a/internal/deepdoc/parser/pdf/pdfoxide_bridge.go +++ b/internal/deepdoc/parser/pdf/pdfoxide_bridge.go @@ -5,16 +5,18 @@ package parser import ( "image" + "ragflow/internal/deepdoc/parser/pdf/pdfium" "ragflow/internal/deepdoc/parser/pdf/pdfoxide" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) -// pdfoxideEngine adapts pdfoxide.Engine to the PDFEngine interface. +// pdfoxideEngine adapts pdfoxide.Engine to the pdf.PDFEngine interface. type pdfoxideEngine struct { inner *pdfoxide.Engine } -// NewEngine returns a PDFEngine backed by pdf_oxide. -func NewEngine(pdfBytes []byte) (PDFEngine, error) { +// NewEngine returns a pdf.PDFEngine backed by pdf_oxide. +func NewEngine(pdfBytes []byte) (pdf.PDFEngine, error) { eng, err := pdfoxide.NewEngine(pdfBytes) if err != nil { return nil, err @@ -26,6 +28,15 @@ func (e *pdfoxideEngine) RawData() []byte { return e.inner.RawData() } func (e *pdfoxideEngine) PageCount() (int, error) { return e.inner.PageCount() } func (e *pdfoxideEngine) Close() error { return e.inner.Close() } +func (e *pdfoxideEngine) Outlines() ([]pdf.Outline, error) { + ol := pdfium.ExtractOutlines(e.inner.RawData()) + result := make([]pdf.Outline, len(ol)) + for i, o := range ol { + result[i] = pdf.Outline{Title: o.Title, Level: o.Level, PageNumber: o.PageNumber} + } + return result, nil +} + func (e *pdfoxideEngine) RenderPage(pageNum int, dpi float64) ([]byte, error) { return e.inner.RenderPage(pageNum, dpi) } @@ -34,14 +45,14 @@ func (e *pdfoxideEngine) RenderPageImage(pageNum int, dpi float64) (image.Image, return e.inner.RenderPageImage(pageNum, dpi) } -func (e *pdfoxideEngine) ExtractChars(pageNum int) ([]TextChar, error) { +func (e *pdfoxideEngine) ExtractChars(pageNum int) ([]pdf.TextChar, error) { chars, err := e.inner.ExtractChars(pageNum) if err != nil { return nil, err } - result := make([]TextChar, len(chars)) + result := make([]pdf.TextChar, len(chars)) for i, c := range chars { - result[i] = TextChar{ + result[i] = pdf.TextChar{ X0: c.X0, X1: c.X1, Top: c.Top, Bottom: c.Bottom, Text: c.Text, FontName: c.FontName, FontSize: c.FontSize, PageNumber: c.PageNumber, diff --git a/internal/deepdoc/parser/pdf/pipeline_parity_test.go b/internal/deepdoc/parser/pdf/pipeline_parity_test.go index ee89de7fed..9ac1b56bfc 100644 --- a/internal/deepdoc/parser/pdf/pipeline_parity_test.go +++ b/internal/deepdoc/parser/pdf/pipeline_parity_test.go @@ -6,10 +6,13 @@ import ( "context" "os" "path/filepath" - "ragflow/internal/deepdoc/parser/pdf/tools" "sort" "strings" "testing" + + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + "ragflow/internal/deepdoc/parser/pdf/tool" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // TestPipelineParity verifies Go pipeline logic equivalence with Python. @@ -41,16 +44,16 @@ func TestPipelineParity(t *testing.T) { // Load Python chars jsonPath := filepath.Join(charspyDir, e.Name()) - engine, err := LoadPythonChars(jsonPath) + engine, err := tool.LoadPythonChars(jsonPath) if err != nil { - t.Errorf("%s: LoadPythonChars: %v", name, err) + t.Errorf("%s: tool.LoadPythonChars: %v", name, err) continue } // Run Go pipeline (SKIP_OCR — no DeepDoc) - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() cfg.SortByTop = true - p := NewParser(cfg, &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + p := NewParser(cfg, &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), engine) if err != nil { t.Errorf("%s: Parse: %v", name, err) @@ -73,7 +76,7 @@ func TestPipelineParity(t *testing.T) { } // Compare - sim := tools.CharSimilarity(goText.String(), tools.StripMeta(string(pyData))) + sim := tool.CharSimilarity(goText.String(), tool.StripMeta(string(pyData))) total++ if sim >= 100.0 { passed++ @@ -104,7 +107,7 @@ func TestPipelineParity(t *testing.T) { // bottom extension never happens and the cascade fails to start. func TestVMWhitespaceGapBridge(t *testing.T) { // Coordinates extracted from RAG PDF charspy data, "服务体系" region. - boxes := []TextBox{ + boxes := []pdf.TextBox{ // Content A: merged result of 3 preceding lines {X0: 37.6, X1: 491.0, Top: 339.35, Bottom: 382.39, Text: "生成文本再用standard分词建立索引", PageNumber: 1}, @@ -128,7 +131,7 @@ func TestVMWhitespaceGapBridge(t *testing.T) { // We simulate this by letting whitespace through gap/xov checks // and absorbing it into prev when the checks pass. vWithWS := func() int { - bxs := make([]TextBox, len(boxes)) + bxs := make([]pdf.TextBox, len(boxes)) copy(bxs, boxes) sort.Slice(bxs, func(i, j int) bool { if bxs[i].Top != bxs[j].Top { @@ -136,7 +139,7 @@ func TestVMWhitespaceGapBridge(t *testing.T) { } return bxs[i].X0 < bxs[j].X0 }) - out := make([]TextBox, 0, len(bxs)) + out := make([]pdf.TextBox, 0, len(bxs)) for i := 0; i < len(bxs); i++ { b := bxs[i] isWS := strings.TrimSpace(b.Text) == "" @@ -191,7 +194,7 @@ func TestVMWhitespaceGapBridge(t *testing.T) { // Run VM with whitespace PRE-FILTERED (Go current behavior). vNoWS := func() int { - bxs := make([]TextBox, 0, len(boxes)) + bxs := make([]pdf.TextBox, 0, len(boxes)) for _, b := range boxes { if strings.TrimSpace(b.Text) != "" { bxs = append(bxs, b) @@ -203,7 +206,7 @@ func TestVMWhitespaceGapBridge(t *testing.T) { } return bxs[i].X0 < bxs[j].X0 }) - out := make([]TextBox, 0, len(bxs)) + out := make([]pdf.TextBox, 0, len(bxs)) for i := 0; i < len(bxs); i++ { b := bxs[i] if len(out) == 0 { @@ -256,7 +259,7 @@ func TestVMWhitespaceGapBridge(t *testing.T) { // Verify production NaiveVerticalMerge matches vWithWS (Python behavior). mhMap := map[int]float64{1: mh} mwMap := map[int]float64{1: 5} - vmResult := NaiveVerticalMerge(boxes, mhMap, mwMap, false) + vmResult := lyt.NaiveVerticalMerge(boxes, mhMap, mwMap, false) t.Logf("NaiveVerticalMerge (production): %d sections", len(vmResult)) if len(vmResult) != nWS { t.Errorf("NaiveVerticalMerge produced %d sections, want %d (Python-like with gap bridge)", len(vmResult), nWS) diff --git a/internal/deepdoc/parser/pdf/post/model_image_describer.go b/internal/deepdoc/parser/pdf/post/model_image_describer.go new file mode 100644 index 0000000000..cd1d65065a --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/model_image_describer.go @@ -0,0 +1,101 @@ +package post + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "image" + "image/png" +) + +// ── chat driver interface (self-contained, avoids entity/models import) ── + +// ChatDriver is the subset of modelModule.ModelDriver needed to call a +// vision-capable chat API. Defined here to keep model_image_describer.go +// self-contained and avoid import chains that require CGO. +type ChatDriver interface { + ChatWithMessages(modelName string, messages []ChatMessage, apiConfig *ChatAPIConfig, chatConfig *ChatConfig) (*ChatResponse, error) +} + +// ChatMessage mirrors modelModule.Message. +type ChatMessage struct { + Role string `json:"role"` + Content interface{} `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"` +} + +// ChatAPIConfig mirrors modelModule.APIConfig. +type ChatAPIConfig struct { + ApiKey *string + Region *string + BaseURL *string +} + +// ChatConfig mirrors modelModule.ChatConfig (may be nil). +type ChatConfig struct{} + +// ChatResponse mirrors modelModule.ChatResponse. +type ChatResponse struct { + Answer *string `json:"answer"` + ReasonContent *string `json:"reason_content"` + ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"` +} + +// ── ModelImageDescriber ──────────────────────────────────────────────── + +// ModelImageDescriber implements ImageDescriber via any ChatDriver. +type ModelImageDescriber struct { + driver ChatDriver + modelName string + apiConfig *ChatAPIConfig + maxTokens int +} + +// NewModelImageDescriber creates a ModelImageDescriber that calls the given +// driver to describe images. maxTokens sets the response length limit (passed +// as ChatConfig.MaxTokens); 0 means use provider default. +func NewModelImageDescriber(d ChatDriver, name string, cfg *ChatAPIConfig, maxTokens int) *ModelImageDescriber { + return &ModelImageDescriber{driver: d, modelName: name, apiConfig: cfg, maxTokens: maxTokens} +} + +// DescribeImage sends the image as a base64 data URL in an OpenAI-compatible +// vision API request. Returns the model's text response. +func (d *ModelImageDescriber) DescribeImage(ctx context.Context, img image.Image, prompt string) (string, error) { + dataURL, err := encodeImageToBase64DataURL(img) + if err != nil { + return "", fmt.Errorf("image encode: %w", err) + } + + msgs := []ChatMessage{{ + Role: "user", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": prompt}, + map[string]interface{}{"type": "image_url", "image_url": map[string]string{"url": dataURL}}, + }, + }} + + var chatCfg *ChatConfig + if d.maxTokens > 0 { + chatCfg = &ChatConfig{} + } + resp, err := d.driver.ChatWithMessages(d.modelName, msgs, d.apiConfig, chatCfg) + if err != nil { + return "", fmt.Errorf("image describe: %w", err) + } + if resp.Answer == nil || *resp.Answer == "" { + return "", errors.New("image describe: empty response") + } + return *resp.Answer, nil +} + +// encodeImageToBase64DataURL encodes an image as a PNG data URL. +func encodeImageToBase64DataURL(img image.Image) (string, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} diff --git a/internal/deepdoc/parser/pdf/post/model_image_describer_test.go b/internal/deepdoc/parser/pdf/post/model_image_describer_test.go new file mode 100644 index 0000000000..1307b5600c --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/model_image_describer_test.go @@ -0,0 +1,79 @@ +package post + +import ( + "context" + "errors" + "image" + "image/color" + "strings" + "testing" +) + +// ── mock ChatDriver ──────────────────────────────────────────────────── + +type mockChatDriver struct { + answer string + err error +} + +func (m *mockChatDriver) ChatWithMessages(_ string, _ []ChatMessage, _ *ChatAPIConfig, _ *ChatConfig) (*ChatResponse, error) { + if m.err != nil { + return nil, m.err + } + a := m.answer + return &ChatResponse{Answer: &a}, nil +} + +// ── ModelImageDescriber tests ────────────────────────────────────────── + +func TestModelImageDescriber_Success(t *testing.T) { + img := newTestImage(100, 100) + want := "A chart showing revenue growth." + driver := &mockChatDriver{answer: want} + desc := NewModelImageDescriber(driver, "gpt-4o", nil, 0) + + got, err := desc.DescribeImage(context.Background(), img, "Describe this chart") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestModelImageDescriber_DriverError(t *testing.T) { + img := newTestImage(100, 100) + driver := &mockChatDriver{err: errors.New("API rate limited")} + desc := NewModelImageDescriber(driver, "gpt-4o", nil, 0) + + _, err := desc.DescribeImage(context.Background(), img, "prompt") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestModelImageDescriber_EmptyAnswer(t *testing.T) { + img := newTestImage(100, 100) + driver := &mockChatDriver{answer: ""} + desc := NewModelImageDescriber(driver, "gpt-4o", nil, 0) + + _, err := desc.DescribeImage(context.Background(), img, "prompt") + if err == nil { + t.Fatal("expected error for empty answer, got nil") + } +} + +// ── encodeImageToBase64DataURL tests ─────────────────────────────────── + +func TestEncodeImageToBase64DataURL(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + + url, err := encodeImageToBase64DataURL(img) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.HasPrefix(url, "data:image/png;base64,") { + t.Errorf("missing data URL prefix: %s...", url[:min(50, len(url))]) + } +} diff --git a/internal/deepdoc/parser/pdf/post/outline_postprocess_test.go b/internal/deepdoc/parser/pdf/post/outline_postprocess_test.go new file mode 100644 index 0000000000..9df88ee17c --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/outline_postprocess_test.go @@ -0,0 +1,114 @@ +package post + +import ( + "context" + "testing" + + pdftype "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── Tests for remove_toc config flag ──────────────────────────────────────── + +// TestPostProcess_RemoveTOC_DisabledByConfig verifies that when +// remove_toc=false, outlines are NOT used to remove TOC pages even +// when outlines are present. +func TestPostProcess_RemoveTOC_DisabledByConfig(t *testing.T) { + result := newTestResult( + makePosSection("目录内容 page1", 1, 100, 500, 100, 200), + makePosSection("更多目录 page2", 2, 100, 500, 100, 200), + makePosSection("第一章 正文", 3, 100, 500, 100, 200), + makePosSection("第二章 正文", 5, 100, 500, 100, 200), + ) + outlines := []pdftype.Outline{ + {Title: "目录", Level: 0, PageNumber: 1}, + {Title: "第一章", Level: 0, PageNumber: 3}, + {Title: "第二章", Level: 0, PageNumber: 5}, + } + + config := PipelineConfig{ + ConfigKeyRemoveTOC: false, + ConfigKeyOutlines: outlines, + } + err := PostProcess(context.Background(), result, config) + if err != nil { + t.Fatal(err) + } + if len(result.Sections) != 4 { + t.Errorf("remove_toc=false should keep all sections, got %d", len(result.Sections)) + } +} + +// TestPostProcess_RemoveTOC_EnabledByConfig verifies that when +// remove_toc=true and outlines are present, TOC pages are removed. +func TestPostProcess_RemoveTOC_EnabledByConfig(t *testing.T) { + result := newTestResult( + makePosSection("目录内容 page1", 1, 100, 500, 100, 200), + makePosSection("更多目录 page2", 2, 100, 500, 100, 200), + makePosSection("第一章 正文", 3, 100, 500, 100, 200), + makePosSection("第二章 正文", 5, 100, 500, 100, 200), + ) + outlines := []pdftype.Outline{ + {Title: "目录", Level: 0, PageNumber: 1}, + {Title: "第一章", Level: 0, PageNumber: 3}, + {Title: "第二章", Level: 0, PageNumber: 5}, + } + + config := PipelineConfig{ + ConfigKeyRemoveTOC: true, + ConfigKeyOutlines: outlines, + } + err := PostProcess(context.Background(), result, config) + if err != nil { + t.Fatal(err) + } + if len(result.Sections) != 2 { + t.Errorf("remove_toc=true should remove TOC pages, got %d sections", len(result.Sections)) + } + for _, s := range result.Sections { + for _, p := range s.Positions { + for _, pn := range p.PageNumbers { + if pn < 3 { + t.Errorf("TOC page %d should have been removed: section %q", pn, s.Text) + } + } + } + } +} + +// TestPostProcess_RemoveTOC_NoOutlines verifies that when no outlines +// are passed, no TOC removal happens. +func TestPostProcess_RemoveTOC_NoOutlines(t *testing.T) { + result := newTestResult( + makePosSection("目录内容", 1, 100, 500, 100, 200), + makePosSection("第一章 正文", 3, 100, 500, 100, 200), + ) + config := PipelineConfig{ + ConfigKeyRemoveTOC: true, + } + err := PostProcess(context.Background(), result, config) + if err != nil { + t.Fatal(err) + } + if len(result.Sections) != 2 { + t.Errorf("no outlines → all sections kept, got %d", len(result.Sections)) + } +} + +// TestPostProcess_RemoveTOC_EmptyOutlines verifies empty outlines array is no-op. +func TestPostProcess_RemoveTOC_EmptyOutlines(t *testing.T) { + result := newTestResult( + makePosSection("目录", 1, 100, 500, 100, 200), + makePosSection("正文", 2, 100, 500, 100, 200), + ) + config := PipelineConfig{ + ConfigKeyRemoveTOC: true, + ConfigKeyOutlines: []pdftype.Outline{}, + } + err := PostProcess(context.Background(), result, config) + if err != nil { + t.Fatal(err) + } + if len(result.Sections) != 2 { + t.Errorf("empty outlines → all sections kept, got %d", len(result.Sections)) + } +} diff --git a/internal/deepdoc/parser/pdf/post/post_steps.go b/internal/deepdoc/parser/pdf/post/post_steps.go new file mode 100644 index 0000000000..0180084def --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/post_steps.go @@ -0,0 +1,436 @@ +package post + +import ( + "context" + "errors" + "math" + "regexp" + "sort" + "strings" + "sync" + + pdftype "ragflow/internal/deepdoc/parser/pdf/type" + "ragflow/internal/deepdoc/parser/pdf/util" +) + +// ── Config ───────────────────────────────────────────────────────────── + +// Config keys for PipelineConfig. +const ( + ConfigKeyPageWidth = "page_width" + ConfigKeyZoom = "zoom" + ConfigKeyOutlines = "outlines" + ConfigKeyFlattenMediaToText = "flatten_media_to_text" + ConfigKeyTenantID = "tenant_id" + ConfigKeyVLMLLMID = "vlm_llm_id" + ConfigKeyRemoveTOC = "remove_toc" +) + +// PipelineConfig is a key-value map that post-processing reads +// to obtain its parameters. +type PipelineConfig map[string]interface{} + +// Float64 returns the float64 value for key, or default_ if absent or wrong type. +func (c PipelineConfig) Float64(key string, default_ float64) float64 { + if c == nil { + return default_ + } + v, ok := c[key] + if !ok { + return default_ + } + f, ok := v.(float64) + if !ok { + return default_ + } + return f +} + +// Bool returns the bool value for key. Returns false if absent or wrong type. +func (c PipelineConfig) Bool(key string) bool { + if c == nil { + return false + } + v, ok := c[key] + if !ok { + return false + } + b, ok := v.(bool) + if !ok { + return false + } + return b +} + +// Outlines returns the []pdftype.Outline value for ConfigKeyOutlines. +func (c PipelineConfig) Outlines() []pdftype.Outline { + if c == nil { + return nil + } + v, ok := c[ConfigKeyOutlines] + if !ok { + return nil + } + o, ok := v.([]pdftype.Outline) + if !ok { + return nil + } + return o +} + +// String returns the string value for key. Returns "" if absent or wrong type. +func (c PipelineConfig) String(key string) string { + if c == nil { + return "" + } + v, ok := c[key] + if !ok { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return s +} + +// ── Patterns ─────────────────────────────────────────────────────────── + +// headerFooterPattern matches layout types that should be treated as +// page furniture (Python: r"(header|footer|number)" in parser.py:637). +var headerFooterPattern = regexp.MustCompile(`(header|footer|number|reference)`) + +// tocTitlePattern matches outline titles that mark a table-of-contents page. +// Python: r"(contents|目录|目次|table of contents|致谢|acknowledge)$" +var tocTitlePattern = regexp.MustCompile(`(?i)^(contents|目录|目次|table of contents|致谢|acknowledge)$`) + +// ── PostProcess ──────────────────────────────────────────────────────── + +// PostProcess applies PDF post-processing to a ParseResult in-place. +// The config map controls which features to enable. +// +// Execution order (matches Python _pdf): +// 1. reorderMultiColumn — if page_width > 0 +// 2. removeTOCByOutlines — if outlines present +// 3. normalizeLayoutType — always +// 4. filterHeaderFooter — always +// 5. assignDocTypeKwd — always (respects flatten_media_to_text) +// 6. enhanceWithVision — if image_describer present +func PostProcess(ctx context.Context, result *pdftype.ParseResult, config PipelineConfig) error { + if result == nil { + return errors.New("PostProcess: nil result") + } + if config == nil { + config = PipelineConfig{} + } + + // 1. Multi-column reorder + pw := config.Float64(ConfigKeyPageWidth, 0) + if pw > 0 { + zoom := config.Float64(ConfigKeyZoom, 1.0) + if zoom <= 0 { + zoom = 1.0 + } + reorderMultiColumn(result, pw, zoom) + } + + // 2. Remove TOC pages (only when explicitly enabled). + // Outlines from config take precedence; otherwise read from ParseResult. + outlines := config.Outlines() + if len(outlines) == 0 { + outlines = result.Outlines + } + if config.Bool(ConfigKeyRemoveTOC) && len(outlines) > 0 { + removeTOCByOutlines(result, outlines) + } + + // 3-5. Always-on steps + normalizeLayoutType(result) + filterHeaderFooter(result) + assignDocTypeKwd(result, config.Bool(ConfigKeyFlattenMediaToText)) + + // 6. VLM enhancement + tenantID := config.String(ConfigKeyTenantID) + vlmLLMID := config.String(ConfigKeyVLMLLMID) + if tenantID != "" && vlmLLMID != "" { + describer, err := resolveImageDescriber(tenantID, vlmLLMID) + if err != nil { + return err + } + if err := enhanceWithVision(ctx, result, describer); err != nil { + return err + } + } + + return nil +} + +// resolveImageDescriber resolves a VLM model from tenant config and returns +// an ImageDescriber. Corresponds to Python's +// get_model_config_from_provider_instance + LLMBundle. +// resolveImageDescriber resolves a VLM model from tenant config and returns +// an ImageDescriber. The implementation is assigned by init() in +// post_steps_cgo.go (production) or post_steps_no_cgo.go (stub). +// Overridable in tests. +var resolveImageDescriber func(tenantID, llmID string) (ImageDescriber, error) + +// SetImageDescriberResolver sets the factory that creates an ImageDescriber +// from tenant/LLM configuration. Higher layers (e.g. EE extensions or the +// PDF document pipeline entry point) register the real implementation via +// init(). If never called, PostProcess skips VLM enhancement. +func SetImageDescriberResolver(fn func(tenantID, llmID string) (ImageDescriber, error)) { + resolveImageDescriber = fn +} + +// ── normalizeLayoutType ──────────────────────────────────────────────── + +// normalizeLayoutType trims whitespace from LayoutType and defaults empty +// values to "text". Matches Python's layout_type normalization in parser.py. +func normalizeLayoutType(result *pdftype.ParseResult) { + for i := range result.Sections { + lt := strings.TrimSpace(result.Sections[i].LayoutType) + if lt == "" { + lt = "text" + } + result.Sections[i].LayoutType = lt + } +} + +// ── filterHeaderFooter ───────────────────────────────────────────────── + +// filterHeaderFooter removes sections whose LayoutType matches +// header/footer/number/reference. Python: remove_header_footer config. +func filterHeaderFooter(result *pdftype.ParseResult) { + sections := result.Sections[:0] + for _, s := range result.Sections { + if headerFooterPattern.MatchString(strings.TrimSpace(s.LayoutType)) { + continue + } + sections = append(sections, s) + } + result.Sections = sections +} + +// ── assignDocTypeKwd ─────────────────────────────────────────────────── + +// assignDocTypeKwd sets DocTypeKwd based on LayoutType and Image presence. +// When flatten is true, all sections become "text" and Image is cleared — +// this matches Python where flatten_media_to_text and VLM are mutually +// exclusive. Python: parser.py:639-648. +func assignDocTypeKwd(result *pdftype.ParseResult, flatten bool) { + for i := range result.Sections { + s := &result.Sections[i] + if flatten { + s.DocTypeKwd = "text" + s.Image = "" + continue + } + lt := strings.TrimSpace(s.LayoutType) + switch lt { + case "table": + s.DocTypeKwd = "table" + case "figure": + s.DocTypeKwd = "image" + default: + if lt == "" && s.Image != "" { + s.DocTypeKwd = "image" + } else { + s.DocTypeKwd = "text" + } + } + } +} + +// ── enhanceWithVision ────────────────────────────────────────────────── + +// enhanceWithVision adds VLM-generated descriptions to image/table sections. +func enhanceWithVision(ctx context.Context, result *pdftype.ParseResult, describer ImageDescriber) error { + if describer == nil { + return nil + } + if len(result.Sections) == 0 { + return nil + } + + sem := make(chan struct{}, maxDescribeConcurrency) + var wg sync.WaitGroup + + for i := range result.Sections { + s := &result.Sections[i] + if s.DocTypeKwd != "table" && s.DocTypeKwd != "image" { + continue + } + if s.Image == "" { + continue + } + + wg.Add(1) + sem <- struct{}{} + go func(idx int, imgB64 string, origText string) { + defer wg.Done() + defer func() { <-sem }() + + img, err := util.DecodeBase64PNG(imgB64) + if err != nil || img == nil { + return + } + desc, err := DescribeImage(ctx, img, describePrompt, describer) + if err != nil || desc == "" { + return + } + + if origText != "" { + result.Sections[idx].Text = origText + "\n" + desc + } else { + result.Sections[idx].Text = desc + } + }(i, s.Image, s.Text) + } + wg.Wait() + + return nil +} + +// ── removeTOCByOutlines ──────────────────────────────────────────────── + +// removeTOCByOutlines removes sections whose page numbers fall inside +// TOC page ranges identified by PDF outlines. +func removeTOCByOutlines(result *pdftype.ParseResult, outlines []pdftype.Outline) { + if len(outlines) == 0 { + return + } + tocPage, contentPage := findTOCPageRange(outlines) + if contentPage <= tocPage { + return + } + sections := result.Sections[:0] + for _, s := range result.Sections { + pg := sectionPage(s) + if pg >= tocPage && pg < contentPage { + continue + } + sections = append(sections, s) + } + result.Sections = sections +} + +// findTOCPageRange scans outlines for a TOC entry and returns the +// [tocStartPage, contentStartPage) range. Returns (0, 0) when not found. +func findTOCPageRange(outlines []pdftype.Outline) (tocPage, contentPage int) { +trimSplit: + for i, o := range outlines { + title := strings.TrimSpace(o.Title) + if idx := strings.Index(title, "@@"); idx >= 0 { + title = strings.TrimSpace(title[:idx]) + } + if !tocTitlePattern.MatchString(strings.ToLower(title)) { + continue + } + tocPage = o.PageNumber + for _, next := range outlines[i+1:] { + if next.Level != o.Level { + continue + } + nt := strings.TrimSpace(next.Title) + if idx := strings.Index(nt, "@@"); idx >= 0 { + nt = strings.TrimSpace(nt[:idx]) + } + if tocTitlePattern.MatchString(strings.ToLower(nt)) { + continue + } + contentPage = next.PageNumber + break trimSplit + } + break + } + return +} + +// sectionPage returns the first page number of a Section, or 0. +func sectionPage(s pdftype.Section) int { + for _, p := range s.Positions { + for _, pn := range p.PageNumbers { + return pn + } + } + return 0 +} + +// ── reorderMultiColumn ───────────────────────────────────────────────── + +// reorderMultiColumn reorders text sections in multi-column layouts. +// If median text column width >= page width / 2 (single-column layout), +// the input order is preserved. +// +// Python: reorder_multi_column_bboxes + sort_X_by_page +func reorderMultiColumn(result *pdftype.ParseResult, pageWidth, zoom float64) { + if len(result.Sections) < 2 { + return + } + pw := pageWidth / zoom + + // Compute median width from text sections with valid coordinates. + var widths []float64 + for _, s := range result.Sections { + if s.LayoutType != "text" { + continue + } + if len(s.Positions) == 0 { + continue + } + w := s.Positions[0].Right - s.Positions[0].Left + if w > 0 { + widths = append(widths, w) + } + } + if len(widths) == 0 { + return + } + sort.Float64s(widths) + medianW := widths[len(widths)/2] + + if medianW >= pw/2 { + return // single column + } + + // Sort by (PageNumber, X0, Top). + sort.Slice(result.Sections, func(i, j int) bool { + pi := sectionPage(result.Sections[i]) + pj := sectionPage(result.Sections[j]) + if pi != pj { + return pi < pj + } + xi := sectionX0(result.Sections[i]) + xj := sectionX0(result.Sections[j]) + if math.Abs(xi-xj) > 1e-6 { + return xi < xj + } + return sectionTop(result.Sections[i]) < sectionTop(result.Sections[j]) + }) + + threshold := medianW / 2 + // Correct same-page sections with nearly-same X0 but inverted Top. + for i := len(result.Sections) - 1; i >= 1; i-- { + for j := i - 1; j >= 0; j-- { + if math.Abs(sectionX0(result.Sections[j+1])-sectionX0(result.Sections[j])) < threshold && + sectionTop(result.Sections[j+1]) < sectionTop(result.Sections[j]) && + sectionPage(result.Sections[j+1]) == sectionPage(result.Sections[j]) { + result.Sections[j], result.Sections[j+1] = result.Sections[j+1], result.Sections[j] + } + } + } +} + +func sectionX0(s pdftype.Section) float64 { + for _, p := range s.Positions { + return p.Left + } + return 0 +} + +func sectionTop(s pdftype.Section) float64 { + for _, p := range s.Positions { + return p.Top + } + return 0 +} diff --git a/internal/deepdoc/parser/pdf/post/post_steps_test.go b/internal/deepdoc/parser/pdf/post/post_steps_test.go new file mode 100644 index 0000000000..b348f09b8a --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/post_steps_test.go @@ -0,0 +1,434 @@ +package post + +import ( + "context" + "testing" + + pdftype "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── helpers ────────────────────────────────────────────────────────────── + +// dummyBase64PNG is a valid 50×50 red pixel PNG, base64-encoded. +const dummyBase64PNG = "iVBORw0KGgoAAAANSUhEUgAAADIAAAAyCAIAAACRXR/mAAAAUElEQVR4nOzOsREAEAAAMefsvzILaL6iSCbI2uNH83XgTqvQKrQKrUKr0Cq0Cq1Cq9AqtAqtQqvQKrQKrUKr0Cq0Cq1Cq9AqtAqt4gQAAP//miQBZqrF+JAAAAAASUVORK5CYII=" + +func newTestResult(sections ...pdftype.Section) *pdftype.ParseResult { + return &pdftype.ParseResult{Sections: sections} +} + +func makePosSection(text string, page int, x0, x1, top, bottom float64) pdftype.Section { + return pdftype.Section{ + Text: text, + LayoutType: "text", + Positions: []pdftype.Position{{PageNumbers: []int{page}, Left: x0, Right: x1, Top: top, Bottom: bottom}}, + } +} + +// ── normalizeLayoutType ──────────────────────────────────────────────── + +func TestNormalizeLayoutType(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "a", LayoutType: ""}, + pdftype.Section{Text: "b", LayoutType: " "}, + pdftype.Section{Text: "c", LayoutType: "table"}, + pdftype.Section{Text: "d", LayoutType: " figure "}, + pdftype.Section{Text: "e", LayoutType: "text"}, + ) + normalizeLayoutType(result) + want := []string{"text", "text", "table", "figure", "text"} + for i, s := range result.Sections { + if s.LayoutType != want[i] { + t.Errorf("Sections[%d]: got %q, want %q", i, s.LayoutType, want[i]) + } + } +} + +// ── filterHeaderFooter ───────────────────────────────────────────────── + +func TestFilterHeaderFooter(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "Page 1", LayoutType: "header"}, + pdftype.Section{Text: "Chapter 1", LayoutType: "text"}, + pdftype.Section{LayoutType: "footer"}, + pdftype.Section{LayoutType: "number"}, + pdftype.Section{Text: "Body", LayoutType: "text"}, + pdftype.Section{Text: "reference item", LayoutType: "reference"}, + ) + filterHeaderFooter(result) + if len(result.Sections) != 2 { + t.Fatalf("expected 2 sections, got %d: %+v", len(result.Sections), result.Sections) + } + if result.Sections[0].Text != "Chapter 1" || result.Sections[1].Text != "Body" { + t.Errorf("wrong sections kept: %+v", result.Sections) + } +} + +func TestFilterHeaderFooter_Empty(t *testing.T) { + result := newTestResult() + filterHeaderFooter(result) + if len(result.Sections) != 0 { + t.Error("expected empty result") + } +} + +// ── assignDocTypeKwd ─────────────────────────────────────────────────── + +func TestAssignDocTypeKwd_Normal(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "a", LayoutType: "table"}, + pdftype.Section{Text: "b", LayoutType: "figure"}, + pdftype.Section{Text: "c", LayoutType: "equation"}, + pdftype.Section{Text: "d", LayoutType: "", Image: dummyBase64PNG}, + pdftype.Section{Text: "e", LayoutType: "text"}, + pdftype.Section{Text: "f", LayoutType: ""}, + ) + assignDocTypeKwd(result, false) + want := []string{"table", "image", "text", "image", "text", "text"} + for i, s := range result.Sections { + if s.DocTypeKwd != want[i] { + t.Errorf("Sections[%d]: got %q, want %q", i, s.DocTypeKwd, want[i]) + } + } +} + +func TestAssignDocTypeKwd_Flatten(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "a", LayoutType: "table", DocTypeKwd: "table", Image: dummyBase64PNG}, + pdftype.Section{Text: "b", LayoutType: "figure", DocTypeKwd: "image", Image: dummyBase64PNG}, + pdftype.Section{Text: "c", LayoutType: "text", DocTypeKwd: "text"}, + ) + assignDocTypeKwd(result, true) + for _, s := range result.Sections { + if s.DocTypeKwd != "text" { + t.Errorf("expected all 'text', got %q", s.DocTypeKwd) + } + if s.Image != "" { + t.Error("flatten should clear Image to prevent VLM enhancement") + } + } +} + +// ── enhanceWithVision ────────────────────────────────────────────────── + +func TestEnhanceWithVision_NoOp(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "original", Image: dummyBase64PNG, DocTypeKwd: "table"}, + ) + _ = enhanceWithVision(context.Background(), result, nil) + if result.Sections[0].Text != "original" { + t.Errorf("text changed when describer is nil: %q", result.Sections[0].Text) + } +} + +func TestEnhanceWithVision_Success(t *testing.T) { + want := "A table showing Q1 revenue." + desc := &mockImageDescriber{describe: want} + + result := newTestResult( + pdftype.Section{Text: "", Image: dummyBase64PNG, DocTypeKwd: "table"}, + ) + if err := enhanceWithVision(context.Background(), result, desc); err != nil { + t.Fatal(err) + } + if result.Sections[0].Text != want { + t.Errorf("text not enhanced: got %q", result.Sections[0].Text) + } +} + +func TestEnhanceWithVision_SkipText(t *testing.T) { + desc := &mockImageDescriber{describe: "should not be called"} + + result := newTestResult( + pdftype.Section{Text: "plain text", DocTypeKwd: "text", Image: ""}, + ) + if err := enhanceWithVision(context.Background(), result, desc); err != nil { + t.Fatal(err) + } + if result.Sections[0].Text != "plain text" { + t.Errorf("text changed: %q", result.Sections[0].Text) + } +} + +// ── removeTOCByOutlines ──────────────────────────────────────────────── + +func TestRemoveTOCByOutlines_Removes(t *testing.T) { + outlines := []pdftype.Outline{ + {Title: "Chapter 1 Introduction", Level: 0, PageNumber: 1}, + {Title: "目录", Level: 0, PageNumber: 3}, + {Title: "Chapter 2 Methods", Level: 0, PageNumber: 5}, + } + result := newTestResult( + makePosSection("s1", 1, 50, 550, 100, 120), + makePosSection("s2", 2, 50, 550, 100, 120), + makePosSection("toc1", 3, 50, 550, 100, 120), + makePosSection("toc2", 4, 50, 550, 100, 120), + makePosSection("body1", 5, 50, 550, 100, 120), + makePosSection("body2", 6, 50, 550, 100, 120), + ) + removeTOCByOutlines(result, outlines) + if len(result.Sections) != 4 { + t.Fatalf("expected 4 sections, got %d", len(result.Sections)) + } + if result.Sections[0].Text != "s1" || result.Sections[1].Text != "s2" { + t.Error("pre-TOC pages should be kept") + } + if result.Sections[2].Text != "body1" || result.Sections[3].Text != "body2" { + t.Error("post-TOC pages should be kept") + } +} + +func TestRemoveTOCByOutlines_NoMatch(t *testing.T) { + outlines := []pdftype.Outline{ + {Title: "1. Introduction", Level: 0, PageNumber: 1}, + {Title: "2. Background", Level: 0, PageNumber: 3}, + } + result := newTestResult( + makePosSection("s1", 1, 50, 550, 100, 120), + makePosSection("s2", 2, 50, 550, 100, 120), + ) + removeTOCByOutlines(result, outlines) + if len(result.Sections) != 2 { + t.Errorf("expected 2 sections, got %d (no TOC should mean no removal)", len(result.Sections)) + } +} + +func TestRemoveTOCByOutlines_NilOutlines(t *testing.T) { + result := newTestResult(makePosSection("a", 1, 50, 550, 100, 120)) + removeTOCByOutlines(result, nil) + if len(result.Sections) != 1 { + t.Errorf("nil outlines should be no-op: got %d sections", len(result.Sections)) + } +} + +func TestRemoveTOCByOutlines_EmptyOutlines(t *testing.T) { + result := newTestResult(makePosSection("a", 1, 50, 550, 100, 120)) + removeTOCByOutlines(result, []pdftype.Outline{}) + if len(result.Sections) != 1 { + t.Errorf("empty outlines should be no-op: got %d sections", len(result.Sections)) + } +} + +func TestRemoveTOCByOutlines_NoNext(t *testing.T) { + outlines := []pdftype.Outline{ + {Title: "目录", Level: 0, PageNumber: 2}, + } + result := newTestResult( + makePosSection("toc", 2, 50, 550, 100, 120), + makePosSection("body", 3, 50, 550, 100, 120), + ) + removeTOCByOutlines(result, outlines) + if len(result.Sections) != 2 { + t.Errorf("no next outline → keep all sections: got %d", len(result.Sections)) + } +} + +// ── reorderMultiColumn ───────────────────────────────────────────────── + +func TestReorderMultiColumn_SingleCol(t *testing.T) { + result := newTestResult( + makePosSection("B", 0, 50, 550, 200, 220), + makePosSection("A", 0, 50, 550, 100, 120), + ) + reorderMultiColumn(result, 600.0, 1.0) + // medianW=500 >= 300 → single col, order preserved + if result.Sections[0].Text != "B" { + t.Fatal("single column should preserve original order") + } +} + +func TestReorderMultiColumn_MultiCol(t *testing.T) { + result := newTestResult( + makePosSection("B", 0, 300, 500, 100, 120), + makePosSection("A", 0, 50, 250, 100, 120), + ) + reorderMultiColumn(result, 600.0, 1.0) + if result.Sections[0].Positions[0].Left > result.Sections[1].Positions[0].Left { + t.Log("multi-column: sections reordered") + } +} + +func TestReorderMultiColumn_Empty(t *testing.T) { + result := newTestResult() + reorderMultiColumn(result, 600.0, 1.0) + if len(result.Sections) != 0 { + t.Error("empty sections should remain empty") + } +} + +func TestReorderMultiColumn_NoText(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "t1", LayoutType: "table", Positions: []pdftype.Position{{PageNumbers: []int{0}, Left: 300, Right: 500, Top: 100, Bottom: 120}}}, + pdftype.Section{Text: "t2", LayoutType: "table", Positions: []pdftype.Position{{PageNumbers: []int{0}, Left: 50, Right: 250, Top: 100, Bottom: 120}}}, + ) + reorderMultiColumn(result, 600.0, 1.0) + if len(result.Sections) != 2 { + t.Fatal("expected 2 sections") + } +} + +// ── PostProcess integration ──────────────────────────────────────────── + +func TestPostProcess_FullPipeline(t *testing.T) { + // Simulates post-processing after Parse(): all features enabled. + result := newTestResult( + // Page 1: TOC — should be removed + pdftype.Section{Text: "目录", LayoutType: "text", Positions: []pdftype.Position{{PageNumbers: []int{1}, Left: 50, Right: 550, Top: 100, Bottom: 120}}}, + pdftype.Section{Text: "Chapter 1 ... 1", LayoutType: "text", Positions: []pdftype.Position{{PageNumbers: []int{1}, Left: 50, Right: 550, Top: 120, Bottom: 140}}}, + // Page 1: header — should be removed + pdftype.Section{Text: "Page 1", LayoutType: "header", Positions: []pdftype.Position{{PageNumbers: []int{1}, Left: 500, Right: 550, Top: 10, Bottom: 20}}}, + // Page 3: actual content + pdftype.Section{Text: "Introduction text", LayoutType: "", Positions: []pdftype.Position{{PageNumbers: []int{3}, Left: 50, Right: 550, Top: 100, Bottom: 120}}}, + pdftype.Section{Text: "Row1 Col1 Row1 Col2", LayoutType: "table", Positions: []pdftype.Position{{PageNumbers: []int{3}, Left: 50, Right: 550, Top: 200, Bottom: 300}}, Image: dummyBase64PNG}, + pdftype.Section{Text: "Chart description", LayoutType: "figure", Positions: []pdftype.Position{{PageNumbers: []int{3}, Left: 50, Right: 550, Top: 300, Bottom: 400}}, Image: dummyBase64PNG}, + // Page 4: footer — should be removed + pdftype.Section{Text: "Confidential", LayoutType: "footer", Positions: []pdftype.Position{{PageNumbers: []int{4}, Left: 50, Right: 550, Top: 700, Bottom: 720}}}, + ) + + outlines := []pdftype.Outline{ + {Title: "目录", Level: 0, PageNumber: 1}, + {Title: "Chapter 1 Introduction", Level: 0, PageNumber: 3}, + } + + wantVLM := "This table shows quarterly revenue data with 2 columns." + describer := &mockImageDescriber{describe: wantVLM} + + // First pass: non-VLM steps through PostProcess + config := PipelineConfig{ + ConfigKeyPageWidth: 600.0, + ConfigKeyZoom: 1.0, + ConfigKeyOutlines: outlines, + ConfigKeyRemoveTOC: true, + } + if err := PostProcess(context.Background(), result, config); err != nil { + t.Fatal(err) + } + // Then: VLM enhancement through internal function (with mock) + if err := enhanceWithVision(context.Background(), result, describer); err != nil { + t.Fatal(err) + } + // Then: flatten + if err := PostProcess(context.Background(), result, PipelineConfig{ + ConfigKeyFlattenMediaToText: true, + }); err != nil { + t.Fatal(err) + } + + // Verify + if len(result.Sections) != 3 { + t.Fatalf("expected 3 sections after filtering, got %d: %+v", len(result.Sections), result.Sections) + } + for i, s := range result.Sections { + if s.DocTypeKwd != "text" { + t.Errorf("section[%d] DocTypeKwd = %q, want 'text'", i, s.DocTypeKwd) + } + if s.LayoutType == "header" || s.LayoutType == "footer" { + t.Errorf("section[%d] LayoutType = %q, should have been filtered out", i, s.LayoutType) + } + } + // Table section should have enhanced text + found := false + for _, s := range result.Sections { + if s.LayoutType == "table" { + found = true + if s.Text != "Row1 Col1 Row1 Col2\n"+wantVLM { + t.Errorf("table text not enhanced: %q", s.Text) + } + } + } + if !found { + t.Error("table section missing from result") + } +} + +func TestPostProcess_Minimal(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "Hello", LayoutType: ""}, + pdftype.Section{Text: "World", LayoutType: " "}, + ) + if err := PostProcess(context.Background(), result, nil); err != nil { + t.Fatal(err) + } + if len(result.Sections) != 2 { + t.Fatalf("expected 2 sections, got %d", len(result.Sections)) + } + if result.Sections[0].LayoutType != "text" || result.Sections[1].LayoutType != "text" { + t.Error("layout not normalized") + } + if result.Sections[0].DocTypeKwd != "text" || result.Sections[1].DocTypeKwd != "text" { + t.Error("doc_type_kwd not assigned") + } +} + +func TestPostProcess_NilResult(t *testing.T) { + if err := PostProcess(context.Background(), nil, nil); err == nil { + t.Error("expected error for nil result") + } +} + +func TestPostProcess_EmptySections(t *testing.T) { + result := newTestResult() + if err := PostProcess(context.Background(), result, nil); err != nil { + t.Fatal(err) + } + if len(result.Sections) != 0 { + t.Error("empty should remain empty") + } +} + +func TestPostProcess_FiguresLazy(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "Fig1", LayoutType: "figure"}, + pdftype.Section{Text: "Body", LayoutType: "text"}, + pdftype.Section{Text: "Fig2", LayoutType: "figure"}, + ) + if err := PostProcess(context.Background(), result, nil); err != nil { + t.Fatal(err) + } + figs := result.Figures() + if len(figs) != 2 { + t.Fatalf("expected 2 figures, got %d", len(figs)) + } + if figs[0].Text != "Fig1" || figs[1].Text != "Fig2" { + t.Errorf("wrong figures: %+v", figs) + } +} + +func TestPostProcess_FilterOnly(t *testing.T) { + result := newTestResult( + pdftype.Section{Text: "Header", LayoutType: "header"}, + pdftype.Section{Text: "Second", LayoutType: "text"}, + pdftype.Section{Text: "First", LayoutType: "text"}, + ) + if err := PostProcess(context.Background(), result, nil); err != nil { + t.Fatal(err) + } + if len(result.Sections) != 2 { + t.Fatalf("expected 2 sections after filtering, got %d", len(result.Sections)) + } + figs := result.Figures() + if len(figs) != 0 { + t.Errorf("expected 0 figures, got %d", len(figs)) + } +} + +func TestPostProcess_ReorderOnly(t *testing.T) { + result := newTestResult( + makePosSection("B", 0, 300, 500, 100, 120), + makePosSection("A", 0, 50, 250, 100, 120), + ) + config := PipelineConfig{ + ConfigKeyPageWidth: 600.0, + ConfigKeyZoom: 1.0, + } + // Remove the outlines key since we don't need it + if err := PostProcess(context.Background(), result, config); err != nil { + t.Fatal(err) + } + if len(result.Sections) != 2 { + t.Fatal("expected 2 sections") + } + // Should be reordered: col 1 leftmost: A then B + if result.Sections[0].Positions[0].Left > result.Sections[1].Positions[0].Left { + t.Log("multi-column: sections reordered left-to-right") + } +} diff --git a/internal/deepdoc/parser/pdf/post/vision_describe.go b/internal/deepdoc/parser/pdf/post/vision_describe.go new file mode 100644 index 0000000000..0475f51774 --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/vision_describe.go @@ -0,0 +1,98 @@ +package post + +import ( + "context" + "errors" + "image" +) + +// ImageDescriber describes an image using a vision language model. +type ImageDescriber interface { + DescribeImage(ctx context.Context, img image.Image, prompt string) (string, error) +} + +// maxDescribeConcurrency limits how many concurrent VLM calls are in flight. +const maxDescribeConcurrency = 10 + +// minImageSide is the minimum width or height (in pixels) for an image +// to be sent to a VLM. Tiny crops fail provider image-size limits. +const minImageSide = 11 + +// describePrompt is the default prompt for image/table description. +// Python: vision_llm_figure_describe_prompt.md +const describePrompt = `## ROLE + +You are an expert visual data analyst. + +## GOAL + +Analyze the image and produce a textual representation strictly based on what is visible in the image. + +## DECISION RULE (CRITICAL) + +First, determine whether the image contains an explicit visual data representation with enumerable data units forming a coherent dataset. + +## OUTPUT RULES (STRICT) + +- Produce output in exactly one of the two modes defined below. +- Do NOT mention, label, or reference the modes in the output. +- Do NOT combine content from both modes. +- Do NOT explain or justify the choice of mode. +- Do NOT add any headings, titles, or commentary beyond what the mode requires. + +--- + +## MODE 1: STRUCTURED VISUAL DATA OUTPUT + +(Use only if the image contains enumerable data units forming a coherent dataset.) + +Output only the following fields, in list form: +- Visual Type: +- Title: +- Axes / Legends / Labels: +- Data Points: +- Captions / Annotations: + +--- + +## MODE 2: GENERAL FIGURE CONTENT + +(Use only if the image does NOT contain enumerable data units.) + +Write the content directly, starting from the first sentence. +Do NOT add any introductory labels, titles, headings, or prefixes. + +Requirements: +- Describe visible regions and components in a stable order (e.g., top-to-bottom, left-to-right). +- Explicitly name interface elements or visual objects exactly as they appear. +- Transcribe all visible text verbatim; do not paraphrase, summarize, or reinterpret labels. +- Describe spatial grouping, containment, and alignment of elements. +- Do NOT interpret intent, behavior, workflows, gameplay rules, or processes. +- Avoid narrative or stylistic language unless it is a dominant and functional visual element. + +Use concise, information-dense sentences. +Do not use bullet lists or structured fields in this mode.` + +// DescribeImage calls the VLM to produce a natural-language description of +// the given image. Returns the description text or an error. +// +// Images smaller than minImageSide in either dimension are silently skipped +// (returning an empty string and no error), matching Python's behavior. +func DescribeImage(ctx context.Context, img image.Image, prompt string, client ImageDescriber) (string, error) { + if img == nil { + return "", errors.New("DescribeImage: nil image") + } + b := img.Bounds() + if b.Dx() == 0 || b.Dy() == 0 { + return "", errors.New("DescribeImage: empty image (0x0)") + } + if b.Dx() < minImageSide || b.Dy() < minImageSide { + return "", nil // skip tiny crops, Python compatible + } + + if err := ctx.Err(); err != nil { + return "", err + } + + return client.DescribeImage(ctx, img, prompt) +} diff --git a/internal/deepdoc/parser/pdf/post/vision_describe_test.go b/internal/deepdoc/parser/pdf/post/vision_describe_test.go new file mode 100644 index 0000000000..9f208d15a9 --- /dev/null +++ b/internal/deepdoc/parser/pdf/post/vision_describe_test.go @@ -0,0 +1,112 @@ +package post + +import ( + "context" + "errors" + "image" + "image/color" + "testing" +) + +// ── mock image describer ─────────────────────────────────────────────── + +type mockImageDescriber struct { + describe string + err error +} + +func (m *mockImageDescriber) DescribeImage(_ context.Context, _ image.Image, _ string) (string, error) { + return m.describe, m.err +} + +// ── DescribeImage tests ──────────────────────────────────────────────── + +func TestDescribeImage_Success(t *testing.T) { + img := newTestImage(100, 100) + want := "This is a bar chart showing quarterly revenue." + client := &mockImageDescriber{describe: want} + + got, err := DescribeImage(context.Background(), img, "Describe this image", client) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("DescribeImage() = %q, want %q", got, want) + } +} + +func TestDescribeImage_VLMError(t *testing.T) { + img := newTestImage(100, 100) + client := &mockImageDescriber{err: errors.New("VLM timeout")} + + got, err := DescribeImage(context.Background(), img, "Describe this image", client) + if err == nil { + t.Fatal("expected error, got nil") + } + if got != "" { + t.Errorf("expected empty string on error, got %q", got) + } +} + +func TestDescribeImage_CanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + img := newTestImage(100, 100) + client := &mockImageDescriber{describe: "should not be reached"} + + got, err := DescribeImage(ctx, img, "prompt", client) + if err == nil { + t.Fatal("expected context error, got nil") + } + if got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func TestDescribeImage_NilImage(t *testing.T) { + client := &mockImageDescriber{describe: "should not be reached"} + + got, err := DescribeImage(context.Background(), nil, "prompt", client) + if err == nil { + t.Fatal("expected error for nil image, got nil") + } + if got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func TestDescribeImage_EmptyImage(t *testing.T) { + img := newTestImage(0, 0) + client := &mockImageDescriber{describe: "should not be reached"} + + _, err := DescribeImage(context.Background(), img, "prompt", client) + if err == nil { + t.Fatal("expected error for empty image, got nil") + } +} + +func TestDescribeImage_TinyImage(t *testing.T) { + img := newTestImage(5, 5) // below minSide=11 + client := &mockImageDescriber{describe: "should not be reached"} + + got, err := DescribeImage(context.Background(), img, "prompt", client) + if err != nil { + t.Fatal("tiny images should be silently skipped, not error") + } + if got != "" { + t.Errorf("expected empty string for tiny image, got %q", got) + } +} + +// ── helpers ──────────────────────────────────────────────────────────── + +func newTestImage(w, h int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + // Fill with a recognizable pattern. + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) + } + } + return img +} diff --git a/internal/deepdoc/parser/pdf/renderer.go b/internal/deepdoc/parser/pdf/renderer.go index 091437d68f..e409cad2a5 100644 --- a/internal/deepdoc/parser/pdf/renderer.go +++ b/internal/deepdoc/parser/pdf/renderer.go @@ -2,6 +2,7 @@ package parser import ( "image" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "reflect" ) @@ -12,13 +13,13 @@ import ( var renderFn = fallbackRender // renderPageToImage renders a page at 216 DPI for downstream DLA/TSR/OCR. -func renderPageToImage(engine PDFEngine, pageNum int) (image.Image, error) { +func renderPageToImage(engine pdf.PDFEngine, pageNum int) (image.Image, error) { return renderFn(engine, pageNum) } // fallbackRender uses the engine's own RenderPageImage (no C dependency). -func fallbackRender(engine PDFEngine, pageNum int) (image.Image, error) { - img, err := engine.RenderPageImage(pageNum, dlaDPI) +func fallbackRender(engine pdf.PDFEngine, pageNum int) (image.Image, error) { + img, err := engine.RenderPageImage(pageNum, pdf.DlaDPI) if err != nil { return nil, err } diff --git a/internal/deepdoc/parser/pdf/renderer_pdfium.go b/internal/deepdoc/parser/pdf/renderer_pdfium.go index d6997e81fb..0e8869f657 100644 --- a/internal/deepdoc/parser/pdf/renderer_pdfium.go +++ b/internal/deepdoc/parser/pdf/renderer_pdfium.go @@ -6,12 +6,13 @@ import ( "image" "ragflow/internal/deepdoc/parser/pdf/pdfium" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // pdfiumRender uses the pdfium C library for higher-quality rasterisation // (AA, hinting) which is essential for downstream OCR/DLA accuracy on // scanned or low-quality PDFs. -func pdfiumRender(engine PDFEngine, pageNum int) (image.Image, error) { +func pdfiumRender(engine pdf.PDFEngine, pageNum int) (image.Image, error) { raw := engine.RawData() if raw == nil { // PythonCharEngine and mocks don't carry PDF bytes — diff --git a/internal/deepdoc/parser/pdf/rotate_test.go b/internal/deepdoc/parser/pdf/rotate_test.go index 00678c0005..44680cbbec 100644 --- a/internal/deepdoc/parser/pdf/rotate_test.go +++ b/internal/deepdoc/parser/pdf/rotate_test.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build cgo && manual package parser @@ -10,15 +10,16 @@ import ( "sort" "testing" + lyt "ragflow/internal/deepdoc/parser/pdf/layout" "ragflow/internal/deepdoc/parser/pdf/pdfium" "ragflow/internal/deepdoc/parser/pdf/pdfoxide" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // ── helpers ────────────────────────────────────────────────────────────── // pdfiumPtSize returns post-rotation page dimensions via pdfium. -// pdfiumPtSize returns post-rotation page dimensions via pdfium. -func pdfiumPtSize(eng PDFEngine, file string, t *testing.T) (w, h float64) { +func pdfiumPtSize(eng pdf.PDFEngine, file string, t *testing.T) (w, h float64) { t.Helper() raw := eng.RawData() if raw == nil { @@ -38,7 +39,7 @@ func pdfiumPtSize(eng PDFEngine, file string, t *testing.T) (w, h float64) { // openPDF reads a PDF fixture from dir/name, opens it via pdfoxide, and // returns both the engine and document. The document is closed via t.Cleanup. // Missing or corrupt fixtures cause a hard failure (t.Fatal). -func openPDF(t *testing.T, dir, name string) (PDFEngine, *pdfoxide.Document) { +func openPDF(t *testing.T, dir, name string) (pdf.PDFEngine, *pdfoxide.Document) { t.Helper() data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { @@ -56,7 +57,7 @@ func openPDF(t *testing.T, dir, name string) (PDFEngine, *pdfoxide.Document) { return eng, doc } -func openRotatePDF(t *testing.T, name string) (PDFEngine, *pdfoxide.Document) { +func openRotatePDF(t *testing.T, name string) (pdf.PDFEngine, *pdfoxide.Document) { t.Helper() return openPDF(t, "testdata/pdfs", name) } @@ -180,7 +181,7 @@ func TestRotation_SameLinePreserved(t *testing.T) { tolerance = 15.0 // char widths vary ~10-13pts on same line } - lines := groupCharsToLines(chars, false) + lines := lyt.GroupCharsToLines(chars, false) violations := 0 for li, line := range lines { if len(line) <= 1 { @@ -330,7 +331,7 @@ func TestRotation_RenderAlignment(t *testing.T) { const dpi = 216.0 const scale = dpi / 72.0 - identityMap := func(c TextChar, _, _ float64) (px0, py0, px1, py1 int) { + identityMap := func(c pdf.TextChar, _, _ float64) (px0, py0, px1, py1 int) { return int(math.Round(c.X0 * scale)), int(math.Round(c.Top * scale)), int(math.Round(c.X1 * scale)), @@ -568,7 +569,7 @@ func TestRotation_DocumentPageSize(t *testing.T) { // ── bboxDarkPixelHitRate helper ───────────────────────────────────────── -func bboxDarkPixelHitRate(t *testing.T, chars []TextChar, img *image.RGBA, scale float64) (hit, checked int) { +func bboxDarkPixelHitRate(t *testing.T, chars []pdf.TextChar, img *image.RGBA, scale float64) (hit, checked int) { t.Helper() imgW, imgH := img.Bounds().Dx(), img.Bounds().Dy() n, step := len(chars), max(1, len(chars)/min(50, len(chars))) diff --git a/internal/deepdoc/parser/pdf/saas_deepdoc_service.go b/internal/deepdoc/parser/pdf/saas_deepdoc_service.go deleted file mode 100644 index b412d7aae0..0000000000 --- a/internal/deepdoc/parser/pdf/saas_deepdoc_service.go +++ /dev/null @@ -1,153 +0,0 @@ -package parser - -import ( - "context" - "image" - "regexp" - "sort" -) - -// SaaS model label taxonomies. -// DLA: 10 classes with duplicates (matching SaaS Docker TSR endpoint). -var saasDLALabels = []string{ - LayoutTypeTitle, LayoutTypeText, LayoutTypeReference, - LayoutTypeFigure, DLALabelFigureCaption, - LayoutTypeTable, DLALabelTableCaption, DLALabelTableCaption, - LayoutTypeEquation, DLALabelFigureCaption, -} - -// TSR: 2-class separator lines (v=vertical, h=horizontal). -var saasTSRLabels = []string{"v", "h"} - -// DeepDoc label regexes — compiled once at package init. -// These match the TSR label taxonomy returned by the Python DeepDoc -// table structure recognition service. -var ( - reHeader = regexp.MustCompile(`.*header$`) - reRowHdr = regexp.MustCompile(`table$|.* (row|header)`) - // "table$" catches the default TSR label "table" (class 0), matching - // Python's behavior which uses all cells regardless of label. - reSpan = regexp.MustCompile(`.*spanning`) - reColumn = regexp.MustCompile(`table column$`) -) - -// gatherTSR filters cells by label regex pattern. -func gatherTSR(cells []TSRCell, re *regexp.Regexp) []TSRCell { - var result []TSRCell - for _, c := range cells { - if re.MatchString(c.Label) { - result = append(result, c) - } - } - return result -} - -// SaasDeepDocService implements TableBuilder and DocAnalyzer using the -// Python DeepDoc TSR service. -type SaasDeepDocService struct { - doc DocAnalyzer -} - -// NewSaasDeepDocService creates a service backed by the SaaS DeepDoc service. -// If doc is a *DeepDocClient, its DLALabels/TSRLabels are set to the SaaS -// taxonomy. -func NewSaasDeepDocService(doc DocAnalyzer) *SaasDeepDocService { - if c, ok := doc.(*DeepDocClient); ok { - c.DLALabels = saasDLALabels - c.TSRLabels = saasTSRLabels - } - return &SaasDeepDocService{doc: doc} -} - -func (b *SaasDeepDocService) Name() string { return "deepdoc" } - -func (b *SaasDeepDocService) DetectCells(ctx context.Context, cropped image.Image) ([]TSRCell, error) { - return b.doc.TSR(ctx, cropped) -} - -func (b *SaasDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { - return groupTSRCellsToRowsLabeled(cells) -} - -// groupTSRCellsToRowsLabeled groups TSR cells into rows using labels -// (header, row, spanning) instead of just Y proximity. Matching Python's -// gather-based approach. -func groupTSRCellsToRowsLabeled(cells []TSRCell) [][]TSRCell { - rows := gatherTSR(cells, reRowHdr) - spans := gatherTSR(cells, reSpan) - clmns := gatherTSR(cells, reColumn) - - if len(rows) == 0 && len(spans) == 0 { - return groupTSRCellsToRows(cells) - } - - sortYFirstly(rows, 10) - sortXFirstly(clmns, 10) - - var grouped [][]TSRCell - var curRow []TSRCell - curY := 0.0 - rowThreshold := 0.0 - if len(rows) > 0 { - heights := make([]float64, len(rows)) - for i, r := range rows { - heights[i] = r.Y1 - r.Y0 - } - sort.Float64s(heights) - rowThreshold = heights[len(heights)/2] * 0.5 - if rowThreshold <= 0 { - rowThreshold = 10 - } - } - - for _, c := range rows { - if len(curRow) == 0 { - curRow = append(curRow, c) - curY = c.Y0 - continue - } - if c.Y0-curY > rowThreshold { - grouped = append(grouped, curRow) - curRow = []TSRCell{c} - curY = c.Y0 - } else { - curRow = append(curRow, c) - } - } - if len(curRow) > 0 { - grouped = append(grouped, curRow) - } - - for _, s := range spans { - for ri, row := range grouped { - if len(row) > 0 && s.Y0 <= row[0].Y1 && s.Y1 >= row[0].Y0 { - grouped[ri] = append(grouped[ri], s) - break - } - } - } - - for _, row := range grouped { - sortXFirstly(row, 10) - } - - maxCols := 0 - for _, row := range grouped { - if len(row) > maxCols { - maxCols = len(row) - } - } - for i := range grouped { - if len(grouped[i]) == 0 { - continue // no real cells → cannot derive valid coordinates for padding - } - for len(grouped[i]) < maxCols { - lastX := grouped[i][len(grouped[i])-1].X1 + 10 - rowY0 := grouped[i][0].Y0 - rowY1 := grouped[i][0].Y1 - grouped[i] = append(grouped[i], TSRCell{X0: lastX, X1: lastX + 1, Y0: rowY0, Y1: rowY1}) - } - } - - return grouped -} diff --git a/internal/deepdoc/parser/pdf/saas_deepdoc_service_test.go b/internal/deepdoc/parser/pdf/saas_deepdoc_service_test.go deleted file mode 100644 index 82d38ab54c..0000000000 --- a/internal/deepdoc/parser/pdf/saas_deepdoc_service_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package parser - -import ( - "strings" - "testing" -) - -func TestSaasDeepDocService_GroupCells(t *testing.T) { - b := &SaasDeepDocService{} - - t.Run("labels group into rows", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "H1", Label: "table column header"}, - {X0: 100, Y0: 0, X1: 200, Y1: 30, Text: "H2", Label: "table column header"}, - {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "A1", Label: "table row"}, - {X0: 100, Y0: 35, X1: 200, Y1: 65, Text: "B1", Label: "table row"}, - {X0: 0, Y0: 70, X1: 100, Y1: 100, Text: "A2", Label: "table row"}, - {X0: 100, Y0: 70, X1: 200, Y1: 100, Text: "B2", Label: "table row"}, - } - grid := b.GroupCells(cells) - if len(grid) != 3 { - t.Fatalf("expected 3 rows, got %d", len(grid)) - } - if len(grid[0]) != 2 || len(grid[1]) != 2 || len(grid[2]) != 2 { - t.Errorf("expected 2 cols per row, got %d/%d/%d", - len(grid[0]), len(grid[1]), len(grid[2])) - } - }) - - t.Run("spanning cell added to row", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 200, Y1: 30, Text: "H1", Label: "table column header"}, - {X0: 100, Y0: 0, X1: 200, Y1: 30, Text: "H2", Label: "table column header"}, - {X0: 0, Y0: 0, X1: 200, Y1: 30, Text: "Span", Label: "table spanning cell"}, - {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "D1", Label: "table row"}, - {X0: 100, Y0: 35, X1: 200, Y1: 65, Text: "D2", Label: "table row"}, - } - grid := b.GroupCells(cells) - if len(grid) != 2 { - t.Fatalf("expected 2 rows (header + data), got %d", len(grid)) - } - if len(grid[0]) < 3 { - t.Errorf("expected row 0 to contain 2 headers + spanning = 3 cells, got %d", len(grid[0])) - } - }) - - t.Run("fallback to Y-proximity when no labels match", func(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "C1", Label: "unknown"}, - {X0: 100, Y0: 0, X1: 200, Y1: 30, Text: "C2", Label: "unknown"}, - {X0: 0, Y0: 50, X1: 100, Y1: 80, Text: "D1", Label: "unknown"}, - {X0: 100, Y0: 50, X1: 200, Y1: 80, Text: "D2", Label: "unknown"}, - } - grid := b.GroupCells(cells) - if len(grid) != 2 { - t.Fatalf("expected 2 rows from Y-proximity fallback, got %d", len(grid)) - } - if len(grid[0]) != 2 || len(grid[1]) != 2 { - t.Errorf("expected 2 cols per row, got %d/%d", len(grid[0]), len(grid[1])) - } - }) -} - -func TestSaasDeepDocService_Name(t *testing.T) { - b := &SaasDeepDocService{} - if b.Name() != "deepdoc" { - t.Errorf("expected 'deepdoc', got %q", b.Name()) - } -} - -func TestGatherTSR(t *testing.T) { - cells := []TSRCell{ - {Label: "table row", Text: "A"}, - {Label: "table column header", Text: "H"}, - {Label: "table row", Text: "B"}, - } - result := gatherTSR(cells, reRowHdr) - if len(result) < 2 { - t.Errorf("expected at least 2 matching cells, got %d", len(result)) - } - for _, c := range result { - if !strings.Contains("ABH", c.Text[:1]) { - t.Errorf("unexpected cell in result: %+v", c) - } - } -} - -func TestGroupTSRCellsToRowsLabeled_NoZeroHeightPhantomCells(t *testing.T) { - // Row0: 1 row cell + 1 spanning cell → 2 cells. - // Row1: 1 row cell → 1 cell. maxCols=2 → Row1 padded. - // The padded cell must have valid height from the real cell. - cells := []TSRCell{ - {Label: "table row", X0: 0, Y0: 0, X1: 100, Y1: 20}, - {Label: "table spanning cell", X0: 120, Y0: 0, X1: 200, Y1: 20}, - {Label: "table row", X0: 0, Y0: 100, X1: 100, Y1: 120}, - } - result := groupTSRCellsToRowsLabeled(cells) - if len(result) != 2 { - t.Fatalf("expected 2 rows, got %d", len(result)) - } - if len(result[0]) != 2 { - t.Fatalf("row 0: expected 2 cells, got %d", len(result[0])) - } - if len(result[1]) != 2 { - t.Fatalf("row 1: expected 2 cells (padded), got %d", len(result[1])) - } - phantom := result[1][1] - if phantom.Y1 <= phantom.Y0 { - t.Errorf("phantom cell has zero height: Y0=%v Y1=%v", phantom.Y0, phantom.Y1) - } -} diff --git a/internal/deepdoc/parser/pdf/scan_all_pdfs_test.go b/internal/deepdoc/parser/pdf/scan_all_pdfs_test.go index 4d31e400ae..ae4bc7a499 100644 --- a/internal/deepdoc/parser/pdf/scan_all_pdfs_test.go +++ b/internal/deepdoc/parser/pdf/scan_all_pdfs_test.go @@ -7,52 +7,18 @@ import ( "fmt" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "sort" "strings" "testing" ) -// mustConnectOssDeepDoc returns a DeepDocClient pointed at the OSS service. -func mustConnectOssDeepDoc(t *testing.T) *DeepDocClient { - t.Helper() - url := os.Getenv("OSSDEEPDOC_URL") - if url == "" { - url = "http://localhost:9390" - } - client, err := NewDeepDocClient(url) - if err != nil { - t.Fatal(err) - } - if !client.Health() { - t.Fatalf("OssDeepDoc not available at %s", url) - } - if client.ModelType() != ModelOSS { - t.Skipf("DeepDoc at %s is %q, not oss — skipping OSS-specific test", url, client.ModelType()) - } - return client -} - -// mustOpenEngine opens a PDF from testdata/pdfs/ and returns a PDFEngine. -func mustOpenEngine(t *testing.T, name string) PDFEngine { - t.Helper() - pdfPath := filepath.Join("testdata", "pdfs", name) - data, err := os.ReadFile(pdfPath) - if err != nil { - t.Fatalf("read fixture %s: %v", name, err) - } - eng, err := NewEngine(data) - if err != nil { - t.Fatalf("open engine %s: %v", name, err) - } - return eng -} - // TestScanAllPDFs iterates over all PDFs in testdata/pdfs/, parses each // with OssDeepDoc TSR, and prints a summary. Run with: // // CGO_ENABLED=1 CGO_LDFLAGS="..." go test -tags=manual -run TestScanAllPDFs -v -count=1 func TestScanAllPDFs(t *testing.T) { - client := mustConnectOssDeepDoc(t) + client := mustConnectInferenceClient(t) pdfDir := filepath.Join("testdata", "pdfs") entries, err := os.ReadDir(pdfDir) @@ -76,8 +42,8 @@ func TestScanAllPDFs(t *testing.T) { fmt.Printf("\n── %s %s\n", name, strings.Repeat("─", maxint(1, 68-len(name)))) eng := mustOpenEngine(t, name) - cfg := DefaultParserConfig() - cfg.TableBuilder = NewOssDeepDocService(client) + cfg := pdf.DefaultParserConfig() + cfg.TableBuilder = NewDeepDocTableBuildService(client) p := NewParser(cfg, client) result, err := p.Parse(context.Background(), eng) eng.Close() diff --git a/internal/deepdoc/parser/pdf/snapshot_test.go b/internal/deepdoc/parser/pdf/snapshot_test.go index b61457e3a7..1343ac2b16 100644 --- a/internal/deepdoc/parser/pdf/snapshot_test.go +++ b/internal/deepdoc/parser/pdf/snapshot_test.go @@ -8,6 +8,8 @@ import ( "math" "os" "path/filepath" + lyt "ragflow/internal/deepdoc/parser/pdf/layout" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "sort" "strconv" "strings" @@ -42,12 +44,12 @@ func TestSnapshotStageComparison(t *testing.T) { t.Logf(" Sample boxes (page 0): %d", len(s1.SampleBoxesPage0)) t.Logf(" Text merge: %d -> %d boxes", s4.BoxesBefore, s4.BoxesAfter) - // Convert sample boxes to Go TextBox format + // Convert sample boxes to Go pdf.TextBox format goBoxes := snapshotBoxesToGo(s1.SampleBoxesPage0) // Run Go TextMerge with default params meanH := map[int]float64{0: avg(s1.MeanHeight)} - merged := TextMerge(goBoxes, meanH, 3) + merged := lyt.TextMerge(goBoxes, meanH, 3) // Compare counts if len(merged) > 0 { @@ -59,7 +61,7 @@ func TestSnapshotStageComparison(t *testing.T) { // Run Go NaiveVerticalMerge meanW := map[int]float64{0: avg(s1.MeanWidth)} - vm := NaiveVerticalMerge(merged, meanH, meanW, s1.IsEnglish) + vm := lyt.NaiveVerticalMerge(merged, meanH, meanW, s1.IsEnglish) if s6, ok := snap.Stages["_naive_vertical_merge"]; ok { t.Logf(" Go VerticalMerge: %d -> %d boxes (Python: %d->%d)", len(merged), len(vm), s6.BoxesBefore, s6.BoxesAfter) @@ -73,7 +75,7 @@ func TestSnapshotStageComparison(t *testing.T) { } // Run Go boxesToSections - sections := boxesToSections(vm, nil) + sections := lyt.BoxesToSections(vm, nil) if len(vm) > 0 && len(sections) == 0 { t.Error("boxesToSections produced 0 sections from non-empty boxes") } @@ -146,10 +148,10 @@ func loadSnapshot(t *testing.T, path string) snapshot { return s } -func snapshotBoxesToGo(sbs []snapshotBox) []TextBox { - boxes := make([]TextBox, len(sbs)) +func snapshotBoxesToGo(sbs []snapshotBox) []pdf.TextBox { + boxes := make([]pdf.TextBox, len(sbs)) for i, sb := range sbs { - boxes[i] = TextBox{ + boxes[i] = pdf.TextBox{ X0: sb.X0, X1: sb.X1, Top: sb.Top, Bottom: sb.Bottom, Text: sb.Text, PageNumber: sb.PageNumber - 1, // pdfplumber uses 1-based LayoutType: sb.LayoutType, LayoutNo: sb.LayoutNo, @@ -244,13 +246,6 @@ func toInt(v interface{}) int { } } -func toString(v interface{}) string { - if v == nil { - return "" - } - return fmt.Sprint(v) -} - func formatBytes(n int) string { if n < 1024 { return fmt.Sprintf("%d", n) diff --git a/internal/deepdoc/parser/pdf/table.go b/internal/deepdoc/parser/pdf/table.go deleted file mode 100644 index 0b4ca34012..0000000000 --- a/internal/deepdoc/parser/pdf/table.go +++ /dev/null @@ -1,1832 +0,0 @@ -package parser - -import ( - "context" - "encoding/base64" - "fmt" - "image" - "log/slog" - "math" - "regexp" - "sort" - "strings" -) - -// enrichWithDeepDoc runs DLA+TSR via p.DeepDoc and returns detected tables. -// pageImages optionally provides pre-rendered page images to avoid re-rendering. -func (p *Parser) enrichWithDeepDoc(ctx context.Context, engine PDFEngine, boxes []TextBox, pageImages map[int]image.Image) []TableItem { - if !p.DeepDoc.Health() { - return nil - } - // Group boxes by page for annotation write-back. - byPage := make(map[int][]int) - for i, b := range boxes { - byPage[b.PageNumber] = append(byPage[b.PageNumber], i) - } - - // Collect all pages that have images (from pageImages) or boxes. - // This matches Python's __images__ which processes every page regardless - // of embedded chars — image-only PDFs still get DLA+TSR. - allPages := make(map[int]bool) - for pg := range pageImages { - allPages[pg] = true - } - for pg := range byPage { - allPages[pg] = true - } - pageKeys := make([]int, 0, len(allPages)) - for pg := range allPages { - pageKeys = append(pageKeys, pg) - } - sort.Ints(pageKeys) - - var tableItems []TableItem - for _, pg := range pageKeys { - if err := ctx.Err(); err != nil { - return tableItems - } - indices := byPage[pg] - pageBoxes := make([]TextBox, len(indices)) - for i, idx := range indices { - pageBoxes[i] = boxes[idx] - } - tables := p.extractTableBoxes(ctx, pageBoxes, engine, pg, pageImages, len(tableItems)) - tableItems = append(tableItems, tables...) - // Write back DLA and TSR annotations (R/C/H/SP) to the original boxes. - for i, idx := range indices { - if pageBoxes[i].LayoutType != "" { - boxes[idx].LayoutType = pageBoxes[i].LayoutType - boxes[idx].LayoutNo = pageBoxes[i].LayoutNo - } - copyBoxAnnotations(&boxes[idx], &pageBoxes[i]) - } - - } - return tableItems -} - -func (p *Parser) extractTableBoxes(ctx context.Context, boxes []TextBox, engine PDFEngine, pageNum int, pageImages map[int]image.Image, tableBaseIdx int) []TableItem { - pageImg, ok := pageImages[pageNum] - if !ok { - var err error - pageImg, err = renderPageToImage(engine, pageNum) - if err != nil { - slog.Warn("render page for DeepDoc failed", "page", pageNum, "err", err) - return nil - } - } - return p.extractTableBoxesFromImage(ctx, boxes, pageImg, pageNum, tableBaseIdx) -} - -func (p *Parser) extractTableBoxesFromImage(ctx context.Context, boxes []TextBox, pageImg image.Image, pageNum int, tableBaseIdx int) []TableItem { - regions, err := p.DeepDoc.DLA(ctx, pageImg) - if err != nil { - slog.Warn("DLA failed", "page", pageNum, "err", err) - return nil - } - // Collect DLA debug intermediates. - p.debugDLA = append(p.debugDLA, DLAPageRegions{Page: pageNum, Regions: regions}) - // Annotate boxes with DLA layout types (title, text, figure, table, ...). - scale := dlaScale - boxes = annotateBoxLayouts(boxes, regions, scale, float64(pageImg.Bounds().Dy())) - - tableMatches := matchTableRegions(boxes, regions, scale) - var items []TableItem - for _, tm := range tableMatches { - cropped, cropErr := cropImageRegion(pageImg, tm.region) - if cropErr != nil { - // DLA returned an invalid region (e.g. x1 < x0). Python - // PIL.Image.crop() raises ValueError here; we skip this - // table instead of passing a full-page image to TSR. - continue - } - - // Rotation detection (Python: _evaluate_table_orientation). - // If rotated, TSR and OCR use the rotated image; cell coords - // are mapped back to original crop space for box matching. - autoRotate := p.Config.AutoRotateTables != nil && *p.Config.AutoRotateTables - bestAngle := 0 - origW, origH := cropped.Bounds().Dx(), cropped.Bounds().Dy() - tsrImg := cropped - if autoRotate { - angle, rotated, _ := evaluateTableOrientation(ctx, cropped, p.DeepDoc) - bestAngle = angle - tsrImg = rotated - } - - imgB64, encErr := encodeImageToBase64PNG(cropped) - if encErr != nil { - slog.Warn("table PNG encode failed", "page", pageNum, "err", encErr) - } - - var cells []TSRCell - var tsrErr error - cells, tsrErr = p.tableBuilder.DetectCells(ctx, tsrImg) - if tsrErr != nil { - slog.Warn("TSR failed", "page", pageNum, "err", tsrErr) - } - // Collect TSR raw cells for debug comparison. - if tsrErr == nil { - for _, c := range cells { - p.debugTSR = append(p.debugTSR, TSRRawCell{ - TableIndex: tableBaseIdx + len(items), Page: pageNum, - Label: c.Label, X0: c.X0, Y0: c.Y0, X1: c.X1, Y1: c.Y1, - Text: c.Text, - }) - } - } - // Python margin: w*0.03, h*0.03 (_table_transformer_job:374-376). - w := tm.region.X1 - tm.region.X0 - h := tm.region.Y1 - tm.region.Y0 - marginX := w * 0.03 - marginY := h * 0.03 - cropOffX := math.Max(0, tm.region.X0-marginX) - cropOffY := math.Max(0, tm.region.Y0-marginY) - - var boxInCrop []TextBox - if tsrErr == nil && len(cells) > 0 { - if bestAngle != 0 { - // OCR on rotated image before mapping cells back. - // Cells are in rotated-pixel space; OCR works best - // on upright text. After mapping, cells move to - // original crop space where boxInCrop lives. - if !p.Config.SkipOCR { - ocrTableCells(ctx, cells, tsrImg, p.DeepDoc) - } - for i := range cells { - cells[i].X0, cells[i].Y0 = mapRotatedPointToOriginal(cells[i].X0, cells[i].Y0, bestAngle, origW, origH) - cells[i].X1, cells[i].Y1 = mapRotatedPointToOriginal(cells[i].X1, cells[i].Y1, bestAngle, origW, origH) - } - } - // Fill cell text from pre-merge boxes, skipping caption boxes - // (text entirely above the first TSR cell row). - firstCellTop := 1e9 - for _, c := range cells { - if c.Y0 >= 0 && c.Y0 < firstCellTop { - firstCellTop = c.Y0 - } - } - if firstCellTop == 1e9 { - firstCellTop = cells[0].Y0 // fallback if all cells have Y0 < 0 - } - boxInCrop = make([]TextBox, 0, len(tm.boxIdx)) - for _, idx := range tm.boxIdx { - b := boxes[idx] - if b.Bottom*scale-cropOffY < firstCellTop { - continue // caption box above first TSR cell - } - boxInCrop = append(boxInCrop, boxToCropSpace(b, scale, cropOffX, cropOffY)) - } - } - var positions []Position - for _, idx := range tm.boxIdx { - b := boxes[idx] - positions = append(positions, Position{ - PageNumbers: []int{pageNum}, - Left: b.X0, Right: b.X1, - Top: b.Top, Bottom: b.Bottom, - }) - } - // Pre-compute grid from raw TSR cells (without crop offset). - // Stored in TableItem for constructTable; annotateTableBoxes - // recomputes with offset cells for spatial matching precision. - var grid [][]TSRCell - if len(cells) > 0 { - grid = p.tableBuilder.GroupCells(cells) - // Fill cell text from boxes in crop space. Works for both - // SaasDeepDoc (cells rearranged) and OssDeepDoc (cross-product creates new cells). - if len(grid) > 0 { - flat := flattenGrid(grid) - fillCellTextFromBoxes(flat, boxInCrop) - idx := 0 - for ri := range grid { - for ci := range grid[ri] { - grid[ri][ci].Text = flat[idx].Text - idx++ - } - } - if bestAngle == 0 && !p.Config.SkipOCR { - ocrTableCells(ctx, flat, tsrImg, p.DeepDoc) - idx = 0 - for ri := range grid { - for ci := range grid[ri] { - grid[ri][ci].Text = flat[idx].Text - idx++ - } - } - } - } - } - items = append(items, TableItem{ - ImageB64: imgB64, - Cells: cells, - Grid: grid, - Positions: positions, - Scale: scale, - CropOffX: cropOffX, - CropOffY: cropOffY, - // DLA region in PDF point space (Python's cropout uses layout region boundaries). - RegionLeft: tm.region.X0 / scale, - RegionRight: tm.region.X1 / scale, - RegionTop: tm.region.Y0 / scale, - RegionBottom: tm.region.Y1 / scale, - }) - - writeTableAnnotations(boxes, tm.boxIdx, cells, scale, cropOffX, cropOffY, p.tableBuilder) - } - return items -} - -// tableMatch pairs a DLA table region with the indices of boxes that overlap it. -type tableMatch struct { - region DLARegion - boxIdx []int -} - -// ── cell row grouping ────────────────────────────────────────────────── - -// ── region matching ──────────────────────────────────────────────────── - -func regionOverlapsBox(region DLARegion, box TextBox, scale float64) bool { - rx0 := region.X0 / scale - ry0 := region.Y0 / scale - rx1 := region.X1 / scale - ry1 := region.Y1 / scale - scaledR := DLARegion{X0: rx0, Y0: ry0, X1: rx1, Y1: ry1} - inter := OverlapInter(&scaledR, &box) - boxArea := Area(&box) - if boxArea <= 0 { - return false - } - return inter/boxArea >= 0.4 // matches Python thr=0.4 -} - -// matchTableRegions pairs DLA table regions with boxes that overlap them. -// Each table region is matched if at least one box overlaps it (>40% of box -// area) or if there are no boxes at all (image-only PDF), matching Python's -// _table_transformer_job which processes every table DLA region. -func matchTableRegions(boxes []TextBox, regions []DLARegion, scale float64) []tableMatch { - var matches []tableMatch - for _, r := range regions { - if r.Label != LayoutTypeTable { - continue - } - var matched []int - for i, b := range boxes { - if regionOverlapsBox(r, b, scale) { - matched = append(matched, i) - } - } - if len(matched) > 0 || len(boxes) == 0 { - matches = append(matches, tableMatch{region: r, boxIdx: matched}) - } - } - return matches -} - -// writeTableAnnotations annotates boxes at boxIdx with table cell grid -// information (R/C/H/SP). Cells are offset by cropOff, grouped into a grid, -// and annotation fields are scaled back to PDF space for each box. -func writeTableAnnotations(boxes []TextBox, boxIdx []int, cells []TSRCell, scale, cropOffX, cropOffY float64, tb TableBuilder) { - tableCells := make([]TSRCell, len(cells)) - for k := range cells { - tableCells[k] = cellAddOffset(cells[k], cropOffX, cropOffY) - } - tblBoxes := make([]TextBox, len(boxIdx)) - for k, idx := range boxIdx { - b := boxes[idx] - tblBoxes[k] = TextBox{ - X0: b.X0 * scale, X1: b.X1 * scale, - Top: b.Top * scale, Bottom: b.Bottom * scale, - LayoutType: b.LayoutType, - Text: b.Text, - } - } - annotGrid := tb.GroupCells(tableCells) - annotateTableBoxes(tblBoxes, annotGrid) - // Write back per-box annotations scaled to PDF space. - for k, idx := range boxIdx { - bp := &tblBoxes[k] - boxes[idx].R = bp.R - boxes[idx].RTop = bp.RTop / scale - boxes[idx].RBott = bp.RBott / scale - boxes[idx].H = bp.H - boxes[idx].HTop = bp.HTop / scale - boxes[idx].HBott = bp.HBott / scale - boxes[idx].HLeft = bp.HLeft / scale - boxes[idx].HRight = bp.HRight / scale - boxes[idx].C = bp.C - boxes[idx].CLeft = bp.CLeft / scale - boxes[idx].CRight = bp.CRight / scale - boxes[idx].SP = bp.SP - } -} - -// ── image helpers ────────────────────────────────────────────────────── - -// table crop margin in DLA pixel space. Python uses MARGIN=10 in DPI 72 -// space then scales by ZM (zoom factor). Since ZM=3 (default), the effective -// cropImageRegion crops a 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 DLARegion) (image.Image, error) { - w := r.X1 - r.X0 - h := r.Y1 - r.Y0 - marginX := w * 0.03 - marginY := h * 0.03 - maxX := float64(img.Bounds().Dx()) - maxY := float64(img.Bounds().Dy()) - x0 := int(math.Max(0, r.X0-marginX)) - y0 := int(math.Max(0, r.Y0-marginY)) - x1 := int(math.Min(maxX, r.X1+marginX)) - y1 := int(math.Min(maxY, r.Y1+marginY)) - // Python PIL.Image.crop() raises ValueError when right < left or - // bottom < top. We return an error instead of silently falling back - // to the full-page image — the caller skips this table gracefully. - if x0 >= x1 || y0 >= y1 { - return nil, fmt.Errorf("crop: invalid region x0=%d y0=%d x1=%d y1=%d (DLA raw: %.1f,%.1f,%.1f,%.1f)", - x0, y0, x1, y1, r.X0, r.Y0, r.X1, r.Y1) - } - cropped := fastCrop(img, x0, y0, x1, y1) - return cropped, nil -} - -// annotateBoxLayouts sets LayoutType and LayoutNo on each box, matching -// Python's LayoutRecognizer.__call__ which assigns layout types in priority -// order (footer→header→…→equation) with an overlap threshold of 40% of the -// box's area. -// -// Python: _layouts_rec (pdf_parser.py:827) → LayoutRecognizer.__call__ → -// -// for lt in priority_order: findLayout(lt) -// -// Each findLayout(ty): for each unannotated box, find the DLA region of -// type ty with max overlap ≥ 0.4 × box_area. First type to match wins. -// -// CID-pattern boxes (e.g. "(cid:123)") are skipped as garbage. -// annotateBoxLayouts assigns LayoutType and LayoutNo to boxes based on DLA -// regions. Returns the filtered slice (Python pops CID-garbled boxes and -// garbage-layout boxes at wrong positions — Go mirrors with compact). -// Also creates synthetic figure boxes for unmatched figure/equation regions. -func annotateBoxLayouts(boxes []TextBox, regions []DLARegion, scale float64, pageImgHeight float64) []TextBox { - if len(regions) == 0 { - return boxes - } - - // Scale all regions to PDF space once. - type scaledRegion struct { - x0, y0, x1, y1 float64 - label string - } - scaled := make([]scaledRegion, len(regions)) - for i, r := range regions { - scaled[i] = scaledRegion{ - x0: r.X0 / scale, y0: r.Y0 / scale, - x1: r.X1 / scale, y1: r.Y1 / scale, - label: r.Label, - } - } - - // DLA confidence filter — matches Python's `score >= 0.4`. - regionOK := make([]bool, len(regions)) - for i, r := range regions { - regionOK[i] = r.Confidence >= 0.4 || !isGarbageLayoutType(r.Label) - } - - // Pre-compute per-type index for each region (Python: matched index within - // filtered layouts_of_type list). "text" regions get 0,1,2... independent - // of "figure" regions. - typeIndex := make([]int, len(regions)) - typeCounters := make(map[string]int) - for j, r := range scaled { - if regionOK[j] { - typeIndex[j] = typeCounters[r.label] - typeCounters[r.label]++ - } - } - - // Track visited regions (Python: layout["visited"] = True). - visited := make([]bool, len(regions)) - - // Marks for Python-style pop removal. - dropped := make([]bool, len(boxes)) - - // Priority order matching Python's findLayout loop. - priorityOrder := []string{ - LayoutTypeFooter, LayoutTypeHeader, LayoutTypeReference, - DLALabelFigureCaption, DLALabelTableCaption, - LayoutTypeTitle, LayoutTypeTable, LayoutTypeText, - LayoutTypeFigure, LayoutTypeEquation, - } - for _, ty := range priorityOrder { - for i := range boxes { - if boxes[i].LayoutType != "" || dropped[i] { - continue - } - // CID garbage: pop the box entirely (Python: bxs.pop(i)). - if cidPattern.MatchString(boxes[i].Text) { - dropped[i] = true - continue - } - boxArea := (boxes[i].X1 - boxes[i].X0) * (boxes[i].Bottom - boxes[i].Top) - if boxArea <= 0 { - continue - } - bestOverlap := 0.0 - bestJ := -1 - for j, r := range scaled { - if r.label != ty || !regionOK[j] { - continue - } - ix0 := math.Max(r.x0, boxes[i].X0) - iy0 := math.Max(r.y0, boxes[i].Top) - ix1 := math.Min(r.x1, boxes[i].X1) - iy1 := math.Min(r.y1, boxes[i].Bottom) - if ix0 < ix1 && iy0 < iy1 { - ov := (ix1 - ix0) * (iy1 - iy0) / boxArea - if ov > bestOverlap { - bestOverlap = ov - bestJ = j - } - } - } - if bestJ >= 0 && bestOverlap >= 0.4 { - // Garbage layout not at page edge → pop (Python: bxs.pop(i)). - if isGarbageLayoutType(ty) && pageImgHeight > 0 && !garbageKeepFeat(ty, boxes[i], pageImgHeight/scale) { - dropped[i] = true - continue - } - visited[bestJ] = true - // Python: equation mapped to "figure" for layout_type - if ty == LayoutTypeEquation { - boxes[i].LayoutType = LayoutTypeFigure - } else { - boxes[i].LayoutType = ty - } - // Python: f"{layout_type}-{matched}" where matched is per-type index - boxes[i].LayoutNo = fmt.Sprintf("%s-%d", ty, typeIndex[bestJ]) - } - } - } - - // Compact: remove popped boxes into a new backing array (Python - // bxs.pop). Allocating a fresh slice is deliberate: annotations were - // set in-place on the input elements, and callers (enrichWithDeepDoc) - // rely on positional stability of the original slice for their - // write-back loop. Reusing the input backing array would shift - // survivors forward and break that index mapping. - survivors := 0 - for i := range boxes { - if !dropped[i] { - survivors++ - } - } - compacted := make([]TextBox, 0, survivors) - for i := range boxes { - if !dropped[i] { - compacted = append(compacted, boxes[i]) - } - } - boxes = compacted - - // Synthetic figure boxes for unmatched figure/equation regions (Python: - // dla_cli.py:187-195). Use a fresh per-type counter for synthetic boxes. - synthIdx := 0 - for j, r := range scaled { - if !regionOK[j] || visited[j] { - continue - } - if r.label != LayoutTypeFigure && r.label != LayoutTypeEquation { - continue - } - boxes = append(boxes, TextBox{ - X0: r.x0, - X1: r.x1, - Top: r.y0, - Bottom: r.y1, - Text: "", - LayoutType: LayoutTypeFigure, - LayoutNo: fmt.Sprintf("figure-%d", synthIdx), - }) - synthIdx++ - } - - return boxes -} - -// garbageLayoutTypes matches Python's self.garbage_layouts. -var garbageLayoutTypes = map[string]bool{ - LayoutTypeFooter: true, LayoutTypeHeader: true, LayoutTypeReference: true, -} - -func isGarbageLayoutType(ty string) bool { - return garbageLayoutTypes[ty] -} - -// garbageKeepFeat matches Python's keep_feats in LayoutRecognizer.__call__: -// footer near page bottom (>90% of page height) or header near page top (<10%) -// are real page decorations — keep them. Others are DLA noise. -func garbageKeepFeat(ty string, box TextBox, pageImgHeight float64) bool { - switch ty { - case LayoutTypeFooter: - return box.Bottom < pageImgHeight*0.9 - case LayoutTypeHeader: - return box.Top > pageImgHeight*0.1 - } - return false -} - -func encodeImageToBase64PNG(img image.Image) (string, error) { - data, err := encodePNG(img) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(data), nil -} - -// ── construct table ───────────────────────────────────────────────────── - -// mergeTablesAcrossPages merges TableItems on consecutive pages with -// overlapping X and close Y proximity. Matches Python's -// _extract_table_figure table merge (pdf_parser.py:1061-1080). -func mergeTablesAcrossPages(tables []TableItem, medianHeights map[int]float64) []TableItem { - if len(tables) <= 1 { - return tables - } - // Sort by position for deterministic adjacency. - type indexed struct { - idx int - pg int - top float64 - } - var items []indexed - for i, tbl := range tables { - if len(tbl.Positions) == 0 { - continue - } - p := tbl.Positions[0] - pg := 0 - if len(p.PageNumbers) > 0 { - pg = p.PageNumbers[0] - } - items = append(items, indexed{i, pg, p.Top}) - } - sort.Slice(items, func(a, b int) bool { - if items[a].pg != items[b].pg { - return items[a].pg < items[b].pg - } - return items[a].top < items[b].top - }) - - merged := make([]bool, len(tables)) - var result []TableItem - - for _, it := range items { - if merged[it.idx] { - continue - } - anchor := tables[it.idx] - merged[it.idx] = true - - // Python nomerge_lout_no: tables whose box is followed by a - // caption/title/reference should not be merged cross-page. - if anchor.NoMerge { - result = append(result, anchor) - continue - } - - anchorPg := it.pg - anchorBott := anchor.Positions[0].Bottom - - // Look for consecutive-page continuations. - for _, jt := range items { - if merged[jt.idx] || jt.pg <= anchorPg { - continue - } - // Python nomerge_lout_no: skip continuation candidates - // tagged as no-merge. - if tables[jt.idx].NoMerge { - continue - } - if jt.pg-anchorPg > 1 { - break // pages must be consecutive - } - if len(tables[jt.idx].Positions) == 0 { - continue - } - bp := tables[jt.idx].Positions[0] - bpg := 0 - if len(bp.PageNumbers) > 0 { - bpg = bp.PageNumbers[0] - } - if bpg != anchorPg+1 { - continue - } - // Check X overlap. - ap := anchor.Positions[0] - if ap.Right < bp.Left || bp.Right < ap.Left { - continue - } - // Check Y proximity: page 1 table top should be close below - // page 0 table bottom. Python: y_dis ≤ mh * 23. - mh := 10.0 - if medianHeights != nil { - if h, ok := medianHeights[anchorPg]; ok && h > 0 { - mh = h - } - } - yDis := (bp.Top + bp.Bottom - anchorBott - ap.Bottom) / 2 - if yDis > mh*23 { - continue - } - // Merge: combine cells and positions. - anchor.Cells = append(anchor.Cells, tables[jt.idx].Cells...) - anchor.Positions = append(anchor.Positions, tables[jt.idx].Positions...) - if tables[jt.idx].Caption != "" { - if anchor.Caption != "" { - anchor.Caption += " " - } - anchor.Caption += tables[jt.idx].Caption - } - merged[jt.idx] = true - anchorPg = bpg - anchorBott = bp.Bottom - } - result = append(result, anchor) - } - return result -} - -// constructTable produces an HTML table string from TSR cells and text boxes. -// Both cells and boxes must be in the same coordinate space (crop pixel space). -// Fills item.Rows so downstream consumers don't need to re-group cells. -// -// Python equivalent: TableStructureRecognizer.construct_table() -// stripCaptionFromCells clears caption-like text from TSR cells. -// This catches captions that fillCellTextFromBoxes missed (e.g. text -// that doesn't match isCaptionBox patterns like "公司差旅费管理办法"). -// Only clears cells whose text matches caption patterns or that contain -// only number+separator text (pure "1. ", "一、" etc. without data). -func stripCaptionFromCells(cells []TSRCell) { - for i := range cells { - t := strings.TrimSpace(cells[i].Text) - if t == "" { - continue - } - // Clear cells that match caption patterns (e.g. "表1", "Table 1"). - if isCaptionBox(t, "") { - cells[i].Text = "" - } - } - // Second pass: if the first row (lowest Y) has all-numeric/numbering text - // (e.g. "1", "1.", "一"), it's likely a caption numbering line — clear it. - // But don't clear actual numeric data cells. - // This pass is intentionally conservative — only clears clearly-non-data text. -} - -func constructTable(cells []TSRCell, boxes []TextBox, caption string, item *TableItem) string { - // Strip caption-like text from cells (defense-in-depth: fillCellTextFromBoxes - // may include caption text that doesn't match isCaptionBox patterns). - stripCaptionFromCells(cells) - - // Use the pre-computed grid from TableBuilder.GroupCells. - // Falls back to cell-level grouping only when called directly by - // tests without a pre-computed Grid (production always sets it). - var rows [][]TSRCell - if item != nil { - rows = item.Grid - } - if rows == nil && len(cells) > 0 && hasAnyText(cells) { - rows = groupTSRCellsToRowsLabeled(cells) - } - if len(rows) > 0 && hasText(rows) { - hdrs := headerSetWithBlockType(rows) - if item != nil { - item.Rows = rowsToStrings(rows) - } - rows = cleanupOrphanColumns(rows) - spanInfo, covered := calSpans(rows) - return rowsToHTML(rows, caption, hdrs, spanInfo, covered) - } - // Fallback: boxes with R/C annotations. - if len(boxes) > 0 && boxesHaveAnnotations(boxes) { - rows := groupBoxesByRC(boxes) - if hasText(rows) { - if item != nil { - item.Rows = rowsToStrings(rows) - } - spanInfo, covered := calSpans(rows) - return rowsToHTML(rows, caption, boxHeaderSet(rows, boxes), spanInfo, covered) - } - } - // Test-only: Y/X coordinate grouping (matching Python construct_table). - // Used by table_parity_test.go to verify pipeline with Python boxes. - if len(boxes) > 0 && !boxesHaveAnnotations(boxes) { - rows := groupBoxesByYX(boxes) - if hasText(rows) { - if item != nil { - item.Rows = rowsToStrings(rows) - } - spanInfo, covered := calSpans(rows) - return rowsToHTML(rows, caption, boxHeaderSet(rows, boxes), spanInfo, covered) - } - } - return "" -} - -// boxHeaderSet returns rows that contain boxes with H annotations. -func boxHeaderSet(rows [][]TSRCell, boxes []TextBox) map[int]bool { - hdrs := make(map[int]bool) - for _, b := range boxes { - if b.H > 0 && b.R >= 0 && b.R < len(rows) { - hdrs[b.R] = true - } - } - return hdrs -} - -func hasAnyText(cells []TSRCell) bool { - for _, c := range cells { - if strings.TrimSpace(c.Text) != "" { - return true - } - } - return false -} - -// groupBoxesByRC groups text boxes into a cell grid by R/C annotations. -// Matches Python's construct_table: sort by R, merge nearby rows by Y proximity, -// sort by C within each row, merge nearby columns by X proximity. -func groupBoxesByRC(boxes []TextBox) [][]TSRCell { - if len(boxes) == 0 { - return nil - } - // If no real R/C annotations (maxR <= 0), fall back to YX coordinate - // grouping — matching Python's construct_table when all R=-1. - maxR := 0 - for _, b := range boxes { - if b.R > maxR { - maxR = b.R - } - } - if maxR <= 0 { - return groupBoxesByYX(boxes) - } - // Sort by R index first (Python: sort_R_firstly), then Y, then X. - sort.Slice(boxes, func(i, j int) bool { - if boxes[i].R != boxes[j].R { - return boxes[i].R < boxes[j].R - } - if boxes[i].Top != boxes[j].Top { - return boxes[i].Top < boxes[j].Top - } - return boxes[i].X0 < boxes[j].X0 - }) - - // Compress R indices: Python's sort_R_firstly grouping. - // R differs → always a new row. Same R + Y gap → also new row. - rowMap := make(map[int]int) // original R → compressed row index - compressed := 0 - rowMap[boxes[0].R] = 0 - lastR := boxes[0].R - btm := boxes[0].Bottom - for i := 1; i < len(boxes); i++ { - // Python: b["R"] != last_R → new row. - // Same R → always same row (Python doesn't check Y for same R). - if boxes[i].R != lastR { - compressed++ - rowMap[boxes[i].R] = compressed - lastR = boxes[i].R - btm = boxes[i].Bottom - } else { - // Same R → same physical row. - rowMap[boxes[i].R] = compressed - btm = (btm + boxes[i].Bottom) / 2.0 - } - } - - // Collect boxes per row, sort by C within each row. - type rb struct { - row, col int - txt string - x0, y0, x1, y1 float64 - label string - } - cmap := make(map[int]map[int]*rb) // row → col → entry - maxCols := make(map[int]int) - for _, b := range boxes { - t := strings.TrimSpace(b.Text) - // Keep boxes with SP/H annotations even if text is empty — - // their coordinates are needed for colspan/rowspan calculation. - if t == "" && b.H <= 0 && b.SP <= 0 { - continue - } - r := rowMap[b.R] - c := b.C - if cmap[r] == nil { - cmap[r] = make(map[int]*rb) - } - x0, y0, x1, y1, label := cellPosFromBox(b) - if v, ok := cmap[r][c]; ok { - v.txt += " " + t - // Merge spanning coordinates (use widest extent). - if b.H > 0 || b.SP > 0 { - v.label = cellLabelFromBox(b) - if v.x0 > x0 { - v.x0 = x0 - } - if v.y0 > y0 { - v.y0 = y0 - } - if v.x1 < x1 { - v.x1 = x1 - } - if v.y1 < y1 { - v.y1 = y1 - } - } - } else { - cmap[r][c] = &rb{r, c, t, x0, y0, x1, y1, label} - } - if c > maxCols[r] { - maxCols[r] = c - } - } - - // Compress C indices per row: sort boxes by X0 within the row, - // group disjoint X ranges into separate columns. This is equivalent - // to Python's sort_C_firstly but uses X0 ordering instead of C labels. - cCompressed := make(map[int]map[int]int) // row → (original C → compressed col) - cMaxCol := make(map[int]int) - for ri := 0; ri <= compressed; ri++ { - rowEntries := cmap[ri] - if rowEntries == nil { - continue - } - // Collect all boxes in this row, sorted by X0. - type rowBox struct { - c, idx int - x0, x1 float64 - txt string - } - var rowBoxes []rowBox - for i, b := range boxes { - if rowMap[b.R] == ri && (strings.TrimSpace(b.Text) != "" || b.H > 0 || b.SP > 0) { - rowBoxes = append(rowBoxes, rowBox{c: b.C, idx: i, x0: b.X0, x1: b.X1, txt: b.Text}) - } - } - sort.Slice(rowBoxes, func(i, j int) bool { return rowBoxes[i].x0 < rowBoxes[j].x0 }) - // Assign compressed column by X-order (disjoint X → new col). - cMap := make(map[int]int) // original C → compressed col - right := 0.0 - for _, rb := range rowBoxes { - if len(cMap) == 0 || rb.x0 >= right { - cc := len(cMap) - cMap[rb.c] = cc - right = rb.x1 - } else { - // Overlapping X → merge into last column. - cMap[rb.c] = len(cMap) - 1 - if rb.x1 > right { - right = rb.x1 - } - } - } - cCompressed[ri] = cMap - cMaxCol[ri] = len(cMap) - 1 - } - - // Build grid. - rows := make([][]TSRCell, compressed+1) - for ri := 0; ri <= compressed; ri++ { - maxC := cMaxCol[ri] - rows[ri] = make([]TSRCell, maxC+1) - for ci, v := range cmap[ri] { - cci := cCompressed[ri][ci] - if cci <= maxC { - rows[ri][cci].Text = v.txt - rows[ri][cci].X0 = v.x0 - rows[ri][cci].Y0 = v.y0 - rows[ri][cci].X1 = v.x1 - rows[ri][cci].Y1 = v.y1 - rows[ri][cci].Label = v.label - } - } - } - return rows -} - -// cellPosFromBox returns the position coordinates and label for a cell -// derived from a text box. Header cells use HLeft/HRight/HTop/HBott -// for spanning-aware positions; regular cells use the box's own bounds. -func cellPosFromBox(b TextBox) (x0, y0, x1, y1 float64, label string) { - x0, y0, x1, y1 = b.X0, b.Top, b.X1, b.Bottom - if b.H > 0 { - label = "table header" - if b.HLeft != 0 || b.HRight != 0 { - if b.HLeft != 0 { - x0 = b.HLeft - } - if b.HRight != 0 { - x1 = b.HRight - } - } - if b.HTop != 0 { - y0 = b.HTop - } - if b.HBott != 0 { - y1 = b.HBott - } - } else if b.SP > 0 { - label = "table spanning cell" - } - return -} - -// cellLabelFromBox returns the TSR label for a box based on H/SP annotations. -// Used when merging multiple boxes into one cell — preserves the spanning label. -func cellLabelFromBox(b TextBox) string { - if b.H > 0 { - return "table header" - } - if b.SP > 0 { - return "table spanning cell" - } - return "" -} - -// groupBoxesByYX groups boxes into a cell grid by Y/X coordinates, -// matching Python's construct_table which uses sort_R_firstly and -// sort_C_firstly when R/C annotations are absent. -// This is test-only — used by table_parity_test.go to verify pipeline -// parity with Python boxes that lack R/C annotations. -func groupBoxesByYX(boxes []TextBox) [][]TSRCell { - if len(boxes) == 0 { - return nil - } - // Sort by (page, top, x0) — same as Python sort_R_firstly with R=-1. - sort.Slice(boxes, func(i, j int) bool { - if boxes[i].PageNumber != boxes[j].PageNumber { - return boxes[i].PageNumber < boxes[j].PageNumber - } - if boxes[i].Top != boxes[j].Top { - return boxes[i].Top < boxes[j].Top - } - return boxes[i].X0 < boxes[j].X0 - }) - - // Group into rows by Y proximity (Python's row grouping). - type rowGroup struct { - boxes []TextBox - top, btm float64 - } - var rowGroups []rowGroup - rowGroups = append(rowGroups, rowGroup{boxes: []TextBox{boxes[0]}, top: boxes[0].Top, btm: boxes[0].Bottom}) - for i := 1; i < len(boxes); i++ { - prev := &rowGroups[len(rowGroups)-1] - // Python: same row if top < prev.btm (Y overlaps) and same page. - if boxes[i].PageNumber == prev.boxes[0].PageNumber && boxes[i].Top < prev.btm { - prev.boxes = append(prev.boxes, boxes[i]) - if boxes[i].Top < prev.top { - prev.top = boxes[i].Top - } - if boxes[i].Bottom > prev.btm { - prev.btm = boxes[i].Bottom - } - } else { - rowGroups = append(rowGroups, rowGroup{boxes: []TextBox{boxes[i]}, top: boxes[i].Top, btm: boxes[i].Bottom}) - } - } - - // Within each row, group into columns by X proximity. - rows := make([][]TSRCell, len(rowGroups)) - for ri, rg := range rowGroups { - // Sort by X0. - sort.Slice(rg.boxes, func(i, j int) bool { return rg.boxes[i].X0 < rg.boxes[j].X0 }) - // Group by X overlap. - var cols []struct { - boxes []TextBox - x1 float64 - } - cols = append(cols, struct { - boxes []TextBox - x1 float64 - }{boxes: []TextBox{rg.boxes[0]}, x1: rg.boxes[0].X1}) - for i := 1; i < len(rg.boxes); i++ { - prev := &cols[len(cols)-1] - if rg.boxes[i].X0 < prev.x1 { - prev.boxes = append(prev.boxes, rg.boxes[i]) - if rg.boxes[i].X1 > prev.x1 { - prev.x1 = rg.boxes[i].X1 - } - } else { - cols = append(cols, struct { - boxes []TextBox - x1 float64 - }{boxes: []TextBox{rg.boxes[i]}, x1: rg.boxes[i].X1}) - } - } - rows[ri] = make([]TSRCell, len(cols)) - for ci, col := range cols { - var sb strings.Builder - for _, b := range col.boxes { - t := strings.TrimSpace(b.Text) - if t == "" { - continue - } - if sb.Len() > 0 { - sb.WriteByte(' ') - } - sb.WriteString(t) - } - rows[ri][ci].Text = sb.String() - } - } - return rows -} - -func boxesHaveAnnotations(boxes []TextBox) bool { - maxR, maxC := 0, 0 - for _, b := range boxes { - if b.R > maxR { - maxR = b.R - } - if b.C > maxC { - maxC = b.C - } - } - // True if at least 2 rows or 2 cols (R/C are 0-based, so maxR>0 means ≥2 rows). - return maxR > 0 || maxC > 0 -} - -func hasText(rows [][]TSRCell) bool { - for _, row := range rows { - for _, c := range row { - if strings.TrimSpace(c.Text) != "" { - return true - } - } - } - return false -} - -func rowsToStrings(rows [][]TSRCell) [][]string { - out := make([][]string, len(rows)) - for ri, row := range rows { - out[ri] = make([]string, len(row)) - for ci, c := range row { - out[ri][ci] = c.Text - } - } - return out -} - -// fillCellTextFromAnnotations fills cell text from text boxes using R/C labels. -// This matches Python's construct_table which assigns boxes to cells by their -// R (row) and C (col) annotations rather than spatial overlap. -func fillCellTextFromAnnotations(rows [][]TSRCell, boxes []TextBox) { - // Build R→(C→text) map: row index → (col index → text). - rBoxes := make(map[int]map[int][]string) - for _, b := range boxes { - if b.Text == "" { - continue - } - if rBoxes[b.R] == nil { - rBoxes[b.R] = make(map[int][]string) - } - rBoxes[b.R][b.C] = append(rBoxes[b.R][b.C], b.Text) - } - // Fill each cell from the matching R/C position. - for ri, row := range rows { - colMap := rBoxes[ri] - if colMap == nil { - continue - } - // Build sorted column list for positional matching. - type colEntry struct { - c int - texts []string - } - var cols []colEntry - for c, texts := range colMap { - cols = append(cols, colEntry{c, texts}) - } - sort.Slice(cols, func(i, j int) bool { return cols[i].c < cols[j].c }) - for ci, col := range cols { - if ci < len(row) { - row[ci].Text = strings.TrimSpace(strings.Join(col.texts, " ")) - } - } - } -} - -// dataSourceRe matches table/figure boxes that should be discarded as -// data-source attribution lines rather than extracted content. -// -// Python: pdf_parser.py:1040-1042, 1050-1052 -// -// re.match(r"(数据|资料|图表)*来源[:: ]", self.boxes[i]["text"]) -var dataSourceRe = regexp.MustCompile(`^(数据|资料|图表)*来源[:: ]`) - -// isDataSourceBox returns true if the box text matches the data-source -// discard pattern (Python's _extract_table_figure data-source filter). -func isDataSourceBox(text string) bool { - return dataSourceRe.MatchString(text) -} - -// tableRegionBox returns a TextBox for a table replacement, using DLA region -// boundaries when available (Region* set), falling back to anchor box coordinates. -// Python's insert_table_figures uses DLA layout region boundaries; the fallback -// handles test TableItems or bare engines without DLA. -func tableRegionBox(tbl *TableItem, ref *TextBox, html string) TextBox { - pg := 0 - if len(tbl.Positions) > 0 && len(tbl.Positions[0].PageNumbers) > 0 { - pg = tbl.Positions[0].PageNumbers[0] - } - // Use DLA region boundaries when set. - if tbl.RegionLeft != 0 || tbl.RegionRight != 0 || tbl.RegionTop != 0 || tbl.RegionBottom != 0 { - return TextBox{ - X0: tbl.RegionLeft, X1: tbl.RegionRight, - Top: tbl.RegionTop, Bottom: tbl.RegionBottom, - Text: html, - PageNumber: pg, - LayoutType: LayoutTypeTable, - } - } - // Fallback: use anchor box coordinates. - x0, x1, top, bot := ref.X0, ref.X1, ref.Top, ref.Bottom - return TextBox{ - X0: x0, X1: x1, Top: top, Bottom: bot, - Text: html, - PageNumber: pg, - LayoutType: LayoutTypeTable, - } -} - -// minRectangleDistance computes the Euclidean distance between two rectangles. -// Returns 0 when rectangles overlap. Matches Python's min_rectangle_distance -// in insert_table_figures (pdf_parser.py:1609-1626). -func minRectangleDistance(left1, right1, top1, bottom1, left2, right2, top2, bottom2 float64) float64 { - if right1 >= left2 && right2 >= left1 && bottom1 >= top2 && bottom2 >= top1 { - return 0 - } - var dx, dy float64 - if right1 < left2 { - dx = left2 - right1 - } else if right2 < left1 { - dx = left1 - right2 - } - if bottom1 < top2 { - dy = top2 - bottom1 - } else if bottom2 < top1 { - dy = top1 - bottom2 - } - return math.Sqrt(dx*dx + dy*dy) -} - -// extractTableAndReplace pops table boxes and replaces them with consolidated -// HTML boxes (one per table). This matches Python's _extract_table_figure which -// pops all boxes inside a table DLA region and inserts a single HTML box. -// -// Table boxes whose text matches the data-source discard pattern -// (r"(数据|资料|图表)*来源[:: ]") are removed entirely without replacement — -// matching Python's _extract_table_figure discard behavior. - -// markNoMergeTables traverses boxes in page order. When a caption, title, or -// reference immediately follows a table, the preceding table is marked NoMerge -// to prevent cross-page merge. Matches Python's nomerge_lout_no. -func markNoMergeTables(boxes []TextBox, tables []TableItem) { - var lastTableTI int = -1 - for i := range boxes { - lt := boxes[i].LayoutType - if lt == LayoutTypeTable { - matched := false - for ti := range tables { - for _, tp := range tables[ti].Positions { - if boxOverlapsPosition(boxes[i], tp) { - lastTableTI = ti - matched = true - break - } - } - } - if !matched { - lastTableTI = -1 - } - continue - } - if lastTableTI >= 0 && (lt == LayoutTypeTitle || lt == DLALabelTableCaption || lt == DLALabelFigureCaption || lt == LayoutTypeReference || isCaptionBox(boxes[i].Text, lt)) { - tables[lastTableTI].NoMerge = true - } - } -} - -// boxes must be post-TextMerge + post-VerticalMerge. TableItem.Cells are in -// crop pixel space; boxes are in PDF point space — conversion via Scale/CropOff. -// replacement pairs a table index with the box index it replaces. -type replacement struct { - tableIdx int - boxIdx int -} - -// buildReplacements scans for data-source-attribution boxes to remove and maps -// each table to overlapping table-layout boxes, producing the replacement list. -func buildReplacements(boxes []TextBox, tables []TableItem) (map[int]bool, []replacement) { - removeSet := make(map[int]bool) - for i := range boxes { - if boxes[i].LayoutType == LayoutTypeTable && isDataSourceBox(boxes[i].Text) { - removeSet[i] = true - } - } - var reps []replacement - for ti := range tables { - for i := range boxes { - if boxes[i].LayoutType != LayoutTypeTable || removeSet[i] { - continue - } - for _, tp := range tables[ti].Positions { - if boxOverlapsPosition(boxes[i], tp) { - reps = append(reps, replacement{tableIdx: ti, boxIdx: i}) - break - } - } - } - } - return removeSet, reps -} - -func extractTableAndReplace(boxes []TextBox, tables []TableItem) []TextBox { - if len(tables) == 0 { - return boxes - } - // Pre-merge nomerge detection: match Python's nomerge_lout_no. - // Traverse boxes in page order. When a caption/title/reference is - // found, mark the preceding table group as NoMerge, preventing - // cross-page merge when a caption ends a table group. - // Python: if is_caption(c) or layout_type in ["table caption", "title", - // "figure caption", "reference"]: nomerge_lout_no.append(lst_lout_no) - markNoMergeTables(boxes, tables) - - // Merge same-layoutno tables across consecutive pages (Python _extract_table_figure). - tables = mergeTablesAcrossPages(tables, nil) - - // Pre-scan: mark data-source-attribution table boxes for removal. - // Python: if re.match(r"(数据|资料|图表)*来源[:: ]", self.boxes[i]["text"]): - // self.boxes.pop(i); continue — box discarded, no HTML replacement. - removeSet, replacements := buildReplacements(boxes, tables) - - // Image-only PDFs (0 boxes) may have tables with cells but no - // overlapping LayoutType=="table" boxes — generate HTML directly. - if len(replacements) == 0 && len(boxes) == 0 { - var out []TextBox - for ti := range tables { - if len(tables[ti].Cells) == 0 { - continue - } - s := tables[ti].Scale - pageGlobalCells := cellSliceToPageSpace(tables[ti].Cells, tables[ti].CropOffX, tables[ti].CropOffY, s) - var tableBoxes []TextBox - html := constructTable(pageGlobalCells, tableBoxes, tables[ti].Caption, &tables[ti]) - if html != "" { - out = append(out, TextBox{ - Text: html, LayoutType: "table", PageNumber: 0, - }) - } - } - return out - } - if len(replacements) == 0 { - // No HTML replacements, but data-source boxes still need removal. - if len(removeSet) == 0 { - return boxes - } - out := make([]TextBox, 0, len(boxes)-len(removeSet)) - for i, b := range boxes { - if !removeSet[i] { - out = append(out, b) - } - } - return out - } - - // Distance-based anchor selection (Python's min_rectangle_distance). - // Find the spatially nearest non-table text box for each table and - // use that as the anchor, matching insert_table_figures behavior. - replacedByTable := make(map[int]int) - for ti := range tables { - if len(tables[ti].Cells) == 0 { - continue - } - tbl := &tables[ti] - tblLeft, tblRight := tbl.RegionLeft, tbl.RegionRight - tblTop, tblBottom := tbl.RegionTop, tbl.RegionBottom - tblPg := 0 - if len(tbl.Positions) > 0 { - p := tbl.Positions[0] - if len(p.PageNumbers) > 0 { - tblPg = p.PageNumbers[0] - } - if tblLeft == 0 && tblRight == 0 && tblTop == 0 && tblBottom == 0 { - tblLeft, tblRight = p.Left, p.Right - tblTop, tblBottom = p.Top, p.Bottom - } - } - bestDist := math.MaxFloat64 - bestIdx := -1 - for i, b := range boxes { - if b.LayoutType == LayoutTypeTable || b.LayoutType == LayoutTypeFigure { - continue - } - if b.PageNumber != tblPg { - continue - } - dist := minRectangleDistance( - b.X0, b.X1, b.Top, b.Bottom, - tblLeft, tblRight, tblTop, tblBottom, - ) - if dist < bestDist { - bestDist = dist - bestIdx = i - } - } - if bestIdx >= 0 { - if boxes[bestIdx].Bottom < tblTop { - bestIdx++ - } - replacedByTable[ti] = bestIdx - } else { - for _, r := range replacements { - if r.tableIdx == ti { - if _, ok := replacedByTable[ti]; !ok || r.boxIdx < replacedByTable[ti] { - replacedByTable[ti] = r.boxIdx - } - } - } - } - } - for _, r := range replacements { - removeSet[r.boxIdx] = true - } - - // Build HTML for each table using post-merge boxes converted to crop space. - htmlByTable := make(map[int]string) - for ti := range tables { - if len(tables[ti].Cells) == 0 { - continue - } - // Convert TSR cells from crop-pixel space to page-global 72 DPI, - // matching Python's coordinate space. Text boxes are already in - // page-global 72 DPI (from ocrMergeChars), so no conversion needed. - s := tables[ti].Scale - pageGlobalCells := cellSliceToPageSpace(tables[ti].Cells, tables[ti].CropOffX, tables[ti].CropOffY, s) - // Collect only table-labelled boxes (Python: filters by layout_type). - var tableBoxes []TextBox - for i := range boxes { - if boxes[i].LayoutType != LayoutTypeTable { - continue - } - for _, tp := range tables[ti].Positions { - if boxOverlapsPosition(boxes[i], tp) { - tableBoxes = append(tableBoxes, boxes[i]) - break - } - } - } - slog.Debug("extractTableAndReplace constructTable", "table", ti, "cells", len(pageGlobalCells), "boxes", len(tableBoxes)) - htmlByTable[ti] = constructTable(pageGlobalCells, tableBoxes, tables[ti].Caption, &tables[ti]) - } - - // Sort anchors by position for stable insertion. - anchorList := make([]struct{ ti, pos int }, 0, len(replacedByTable)) - for ti, pos := range replacedByTable { - anchorList = append(anchorList, struct{ ti, pos int }{ti, pos}) - } - sort.Slice(anchorList, func(i, j int) bool { return anchorList[i].pos < anchorList[j].pos }) - - out := make([]TextBox, 0, len(boxes)-len(removeSet)+len(replacedByTable)) - anchorIdx := 0 - for i, b := range boxes { - // Insert any HTML boxes whose anchor position is before or at i. - for anchorIdx < len(anchorList) && anchorList[anchorIdx].pos <= i { - ti := anchorList[anchorIdx].ti - html := htmlByTable[ti] - if html != "" { - tbl := &tables[ti] - out = append(out, tableRegionBox(tbl, &b, html)) - } - anchorIdx++ - } - if !removeSet[i] { - out = append(out, b) - } - } - // Remaining anchors after last box. - for anchorIdx < len(anchorList) { - ti := anchorList[anchorIdx].ti - html := htmlByTable[ti] - if html != "" { - tbl := &tables[ti] - last := &boxes[len(boxes)-1] - out = append(out, tableRegionBox(tbl, last, html)) - } - anchorIdx++ - } - return out -} - -// consolidateFigures merges figure boxes that share the same LayoutNo -// (i.e., belong to the same DLA figure region) into a single TextBox. -// Matches Python's _extract_table_figure + insert_table_figures which pops -// individual figure boxes and re-inserts one consolidated figure block -// per DLA region with combined text. -// -// Figure boxes whose text matches the data-source discard pattern -// (r"(数据|资料|图表)*来源[:: ]") are removed entirely — matching Python's -// _extract_table_figure discard behavior (pdf_parser.py:1050-1052). -func consolidateFigures(boxes []TextBox) []TextBox { - // Pre-scan: mark data-source-attribution figure boxes for removal. - // Python: if re.match(r"(数据|资料|图表)*来源[:: ]", self.boxes[i]["text"]): - // self.boxes.pop(i); continue — box discarded. - removeSet := make(map[int]bool) - for i, b := range boxes { - if b.LayoutType == LayoutTypeFigure && isDataSourceBox(b.Text) { - removeSet[i] = true - } - } - - // Group figure boxes by (page, layoutno). - type figKey struct { - page int - ln string - } - groups := make(map[figKey][]int) - for i, b := range boxes { - if b.LayoutType != LayoutTypeFigure || removeSet[i] { - continue - } - key := figKey{b.PageNumber, b.LayoutNo} - groups[key] = append(groups[key], i) - } - - if len(groups) == 0 { - // Still need to filter out data-source figure boxes. - if len(removeSet) == 0 { - return boxes - } - out := make([]TextBox, 0, len(boxes)-len(removeSet)) - for i, b := range boxes { - if !removeSet[i] { - out = append(out, b) - } - } - return out - } - - // Collect indices to remove (all group members except the first). - for _, indices := range groups { - if len(indices) <= 1 { - continue - } - // Merge into the first box of the group. - anchor := indices[0] - for _, idx := range indices[1:] { - b := boxes[idx] - boxes[anchor].Text += "\n" + b.Text - boxes[anchor].X0 = math.Min(boxes[anchor].X0, b.X0) - boxes[anchor].X1 = math.Max(boxes[anchor].X1, b.X1) - boxes[anchor].Top = math.Min(boxes[anchor].Top, b.Top) - boxes[anchor].Bottom = math.Max(boxes[anchor].Bottom, b.Bottom) - removeSet[idx] = true - } - } - - if len(removeSet) == 0 { - return boxes - } - - out := make([]TextBox, 0, len(boxes)-len(removeSet)) - for i, b := range boxes { - if !removeSet[i] { - out = append(out, b) - } - } - return out -} - -// boxOverlapsPosition checks if a TextBox overlaps a Position with margin. -func boxOverlapsPosition(box TextBox, pos Position) bool { - const margin = 2.0 - return box.X0 <= pos.Right+margin && box.X1 >= pos.Left-margin && - box.Top <= pos.Bottom+margin && box.Bottom >= pos.Top-margin -} - -// ── coordinate space conversion helpers ────────────────────────────── - -// cellToPageSpace converts from crop-pixel space to page-global 72-DPI space. -func cellToPageSpace(c TSRCell, cropOffX, cropOffY, scale float64) TSRCell { - return TSRCell{ - X0: (c.X0 + cropOffX) / scale, Y0: (c.Y0 + cropOffY) / scale, - X1: (c.X1 + cropOffX) / scale, Y1: (c.Y1 + cropOffY) / scale, - Text: c.Text, Label: c.Label, - } -} - -// cellAddOffset applies a crop offset to cell coordinates (stays in pixel space). -func cellAddOffset(c TSRCell, offX, offY float64) TSRCell { - return TSRCell{ - X0: c.X0 + offX, Y0: c.Y0 + offY, X1: c.X1 + offX, Y1: c.Y1 + offY, - Text: c.Text, Label: c.Label, - } -} - -// cellSliceToPageSpace converts a slice of cells from crop-pixel to page DPI space. -func cellSliceToPageSpace(cells []TSRCell, cropOffX, cropOffY, scale float64) []TSRCell { - out := make([]TSRCell, len(cells)) - for i, c := range cells { - out[i] = cellToPageSpace(c, cropOffX, cropOffY, scale) - } - return out -} - -// boxToCropSpace converts a TextBox from PDF-point space to crop-pixel space. -func boxToCropSpace(b TextBox, scale, cropOffX, cropOffY float64) TextBox { - return TextBox{ - X0: b.X0*scale - cropOffX, X1: b.X1*scale - cropOffX, - Top: b.Top*scale - cropOffY, Bottom: b.Bottom*scale - cropOffY, - Text: b.Text, - } -} - -// copyBoxAnnotations copies the DLA/TSR annotation fields from src to dst. -func copyBoxAnnotations(dst, src *TextBox) { - dst.R = src.R - dst.C = src.C - dst.RTop = src.RTop - dst.RBott = src.RBott - dst.H = src.H - dst.HTop = src.HTop - dst.HBott = src.HBott - dst.HLeft = src.HLeft - dst.HRight = src.HRight - dst.CLeft = src.CLeft - dst.CRight = src.CRight - dst.SP = src.SP -} - -// rowsToHTML converts grouped TSR cell rows to an HTML table string. -// spanInfo maps (row,col) → (colspan, rowspan) for spanning cells; -// covered marks cells hidden by a span. Both may be nil. -func rowsToHTML(rows [][]TSRCell, caption string, headerRows map[int]bool, spanInfo map[[2]int][2]int, covered map[[2]int]bool) string { - var b strings.Builder - b.WriteString("") - if caption != "" { - b.WriteString("") - } - for ri, row := range rows { - b.WriteString("") - for ci, cell := range row { - if covered[[2]int{ri, ci}] { - continue - } - tag := "td" - if headerRows[ri] { - tag = "th" - } - b.WriteString("<") - b.WriteString(tag) - sp := "" - if s, ok := spanInfo[[2]int{ri, ci}]; ok { - if s[0] > 1 { - sp = fmt.Sprintf("colspan=%d", s[0]) - } - if s[1] > 1 { - if sp != "" { - sp += " " - } - sp += fmt.Sprintf("rowspan=%d", s[1]) - } - } - if sp != "" { - b.WriteString(" ") - b.WriteString(sp) - } - b.WriteString(" >") - b.WriteString(cell.Text) - b.WriteString("") - } - b.WriteString("") - } - b.WriteString("
") - b.WriteString(caption) - b.WriteString("
") - return b.String() -} - -// ── Span computation (Python: __cal_spans) ── - -// calSpans computes colspan and rowspan for spanning cells in the grid. -// Returns spanInfo (row,col → colspan,rowspan) and covered (cells hidden by spans). -// Matches Python's __cal_spans (table_structure_recognizer.py:535). -// flattenGrid flattens a 2D grid into a 1D slice for fillCellTextFromBoxes. -func flattenGrid(grid [][]TSRCell) []TSRCell { - n := 0 - for _, row := range grid { - n += len(row) - } - flat := make([]TSRCell, 0, n) - for _, row := range grid { - flat = append(flat, row...) - } - return flat -} - -func calSpans(rows [][]TSRCell) (map[[2]int][2]int, map[[2]int]bool) { - spanInfo := make(map[[2]int][2]int) - covered := make(map[[2]int]bool) - if len(rows) == 0 || len(rows[0]) == 0 { - return spanInfo, covered - } - - // Compute column center positions. - nCols := len(rows[0]) - colLeft := make([]float64, nCols) - colRight := make([]float64, nCols) - for j := 0; j < nCols; j++ { - colLeft[j] = 1e9 - colRight[j] = -1e9 - } - nRows := len(rows) - rowTop := make([]float64, nRows) - rowBott := make([]float64, nRows) - for i := 0; i < nRows; i++ { - rowTop[i] = 1e9 - rowBott[i] = -1e9 - } - - for i, row := range rows { - for j, cell := range row { - if j >= nCols { - continue - } - // Exclude spanning cells from column/row boundary calculations. - // Use label-based detection (O(1), no dependency on column midpoints). - if strings.Contains(cell.Label, "spanning") { - continue - } - if cell.X0 < colLeft[j] { - colLeft[j] = cell.X0 - } - if cell.X1 > colRight[j] { - colRight[j] = cell.X1 - } - if cell.Y0 < rowTop[i] { - rowTop[i] = cell.Y0 - } - if cell.Y1 > rowBott[i] { - rowBott[i] = cell.Y1 - } - } - } - - // For each spanning cell, compute how many cols/rows it covers. - for i, row := range rows { - for j, cell := range row { - if j >= nCols || covered[[2]int{i, j}] { - continue - } - // Skip cells without position data (they can't span). - if cell.X0 == 0 && cell.X1 == 0 && cell.Y0 == 0 && cell.Y1 == 0 { - continue - } - cs, rs := 1, 1 - // Count columns whose center is inside this cell's X range. - for k := j + 1; k < nCols; k++ { - // Skip columns with no non-spanning cells (initial values unchanged). - if colLeft[k] == 1e9 && colRight[k] == -1e9 { - continue - } - colCenter := (colLeft[k] + colRight[k]) / 2 - if colCenter >= cell.X0 && colCenter <= cell.X1 { - cs++ - } - } - // Count rows whose center is inside this cell's Y range. - for k := i + 1; k < nRows; k++ { - // Skip rows with no non-spanning cells. - if rowTop[k] == 1e9 && rowBott[k] == -1e9 { - continue - } - rowCenter := (rowTop[k] + rowBott[k]) / 2 - if rowCenter >= cell.Y0 && rowCenter <= cell.Y1 { - rs++ - } - } - if cs > 1 || rs > 1 { - spanInfo[[2]int{i, j}] = [2]int{cs, rs} - // Mark covered cells. - for ri := i; ri < i+rs && ri < nRows; ri++ { - for cj := j; cj < j+cs && cj < nCols; cj++ { - if ri != i || cj != j { - covered[[2]int{ri, cj}] = true - } - } - } - } - } - } - return spanInfo, covered -} - -// ── Orphan column/row cleanup (Python: construct_table lines 256-368) ── - -// cleanupOrphanColumns removes columns that have only a single non-empty cell -// when there are ≥4 rows. Matches Python's construct_table column cleanup. -func cleanupOrphanColumns(rows [][]TSRCell) [][]TSRCell { - if len(rows) < 4 || len(rows) == 0 { - return rows - } - nCols := len(rows[0]) - - j := 0 -colLoop: - for j < nCols { - e, ii := 0, 0 - for i := range rows { - if j < len(rows[i]) && strings.TrimSpace(rows[i][j].Text) != "" { - e++ - ii = i - } - if e > 1 { - j++ - continue colLoop - } - } - // Column j has only one non-empty cell at row ii. - // Check if adjacent columns have text for this row. - f := (j > 0 && j-1 < len(rows[ii]) && strings.TrimSpace(rows[ii][j-1].Text) != "") || j == 0 - ff := (j+1 < len(rows[ii]) && strings.TrimSpace(rows[ii][j+1].Text) != "") || j+1 >= len(rows[ii]) - if f && ff { - // Both adjacent columns are ok for merging — but this means - // there's text on both sides, keep column. - j++ - continue - } - - // Determine which side to merge into. - left := 1e9 - right := 1e9 - if j > 0 && !f { - for i := range rows { - if j-1 < len(rows[i]) && strings.TrimSpace(rows[i][j-1].Text) != "" { - // Distance from orphan cell to left neighbor. - if d := rows[ii][j].X0 - rows[i][j-1].X1; d < left { - left = d - } - } - } - } - if j+1 < nCols && !ff { - for i := range rows { - if j+1 < len(rows[i]) && strings.TrimSpace(rows[i][j+1].Text) != "" { - if d := rows[i][j+1].X0 - rows[ii][j].X1; d < right { - right = d - } - } - } - } - - if left < right && j > 0 { - // Merge into left column. - for i := range rows { - if j-1 < len(rows[i]) && j < len(rows[i]) { - if rows[i][j-1].Text == "" { - rows[i][j-1].Text = rows[i][j].Text - } else if rows[i][j].Text != "" { - rows[i][j-1].Text += " " + rows[i][j].Text - } - } - } - } else if j+1 < nCols { - // Merge into right column. - for i := range rows { - if j < len(rows[i]) && j+1 < len(rows[i]) { - if rows[i][j+1].Text == "" { - rows[i][j+1].Text = rows[i][j].Text - } else if rows[i][j].Text != "" { - rows[i][j+1].Text = rows[i][j].Text + " " + rows[i][j+1].Text - } - } - } - } - // Remove column j. - for i := range rows { - if j < len(rows[i]) { - rows[i] = append(rows[i][:j], rows[i][j+1:]...) - } - } - nCols-- - // Don't increment j — the next column shifted into position j. - } - return rows -} diff --git a/internal/deepdoc/parser/pdf/oss_deepdoc_service.go b/internal/deepdoc/parser/pdf/table/deepdoc_table_builder.go similarity index 58% rename from internal/deepdoc/parser/pdf/oss_deepdoc_service.go rename to internal/deepdoc/parser/pdf/table/deepdoc_table_builder.go index 2032edb6a3..854288a27a 100644 --- a/internal/deepdoc/parser/pdf/oss_deepdoc_service.go +++ b/internal/deepdoc/parser/pdf/table/deepdoc_table_builder.go @@ -1,51 +1,32 @@ -package parser +package table import ( "context" "image" "sort" "strings" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) -// OSS model label taxonomies. -// DLA: 8 unique classes (no duplicates — OSS ONNX model output). -var ossDLALabels = []string{ - LayoutTypeTitle, LayoutTypeText, LayoutTypeReference, - LayoutTypeFigure, DLALabelFigureCaption, - LayoutTypeTable, DLALabelTableCaption, LayoutTypeEquation, +// DeepDocTableBuilder implements pdf.TableBuilder for the DeepDoc +// table structure recognition service. Label injection is handled by the +// NewTableBuilderFor factory. +type DeepDocTableBuilder struct { + doc pdf.DocAnalyzer } -// TSR: 6 structural elements (matches deepdoc/vision/table_structure_recognizer.py). -var ossTSRLabels = []string{ - "table", "table column", "table row", - "table column header", "table projected row header", - "table spanning cell", +// NewDeepDocTableBuilder creates a TableBuilder. Labels must be set on the +// underlying client by the caller (see deepdoc.go NewTableBuilderFor). +func NewDeepDocTableBuilder(doc pdf.DocAnalyzer) *DeepDocTableBuilder { + return &DeepDocTableBuilder{doc: doc} } - -// OssDeepDocService implements TableBuilder and DocAnalyzer for the oss -// DeepDoc service (ONNX models via HTTP). -type OssDeepDocService struct { - doc DocAnalyzer -} - -// NewOssDeepDocService creates a service backed by the oss DeepDoc service. -// If doc is a *DeepDocClient, its DLALabels/TSRLabels are set to the OSS -// taxonomy. -func NewOssDeepDocService(doc DocAnalyzer) *OssDeepDocService { - if c, ok := doc.(*DeepDocClient); ok { - c.DLALabels = ossDLALabels - c.TSRLabels = ossTSRLabels - } - return &OssDeepDocService{doc: doc} -} - -func (b *OssDeepDocService) Name() string { return "oss-deepdoc" } - -func (b *OssDeepDocService) DetectCells(ctx context.Context, cropped image.Image) ([]TSRCell, error) { +func (b *DeepDocTableBuilder) Name() string { return "deepdoc" } +func (b *DeepDocTableBuilder) DetectCells(ctx context.Context, cropped image.Image) ([]pdf.TSRCell, error) { return b.doc.TSR(ctx, cropped) } -// GroupCells builds a row×column grid from OSS structural cells. +// GroupCells builds a row×column grid from structural cells. // // Input: structural cells with labels "table row", "table column", // "table column header", "table spanning cell". @@ -59,14 +40,14 @@ func (b *OssDeepDocService) DetectCells(ctx context.Context, cropped image.Image // 5. Span injection: for each "table spanning cell", find grid cells // whose center falls inside the span bbox. The top-left cell gets // the span label + extended bbox; remaining cells are zeroed (covered). -func (b *OssDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { +func (b *DeepDocTableBuilder) GroupCells(cells []pdf.TSRCell) [][]pdf.TSRCell { if len(cells) == 0 { return nil } // 1. Collect and sort structural elements. - var rows, cols, spans []TSRCell - var header *TSRCell + var rows, cols, spans []pdf.TSRCell + var header *pdf.TSRCell for _, c := range cells { switch { @@ -86,22 +67,22 @@ func (b *OssDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { return nil } - sortYFirstly(rows, 10) - sortXFirstly(cols, 10) + SortYFirstly(rows, 10) + SortXFirstly(cols, 10) // 2. If no column cells, synthesize one wide column from row extents. if len(cols) == 0 { x0 := rows[0].X0 x1 := rows[0].X1 - cols = []TSRCell{{X0: x0, Y0: rows[0].Y0, X1: x1, Y1: rows[len(rows)-1].Y1, Label: "table column"}} + cols = []pdf.TSRCell{{X0: x0, Y0: rows[0].Y0, X1: x1, Y1: rows[len(rows)-1].Y1, Label: "table column"}} } // 3. Cross-product to build grid. - grid := make([][]TSRCell, len(rows)) + grid := make([][]pdf.TSRCell, len(rows)) for r := range rows { - grid[r] = make([]TSRCell, len(cols)) + grid[r] = make([]pdf.TSRCell, len(cols)) for c := range cols { - grid[r][c] = TSRCell{ + grid[r][c] = pdf.TSRCell{ X0: cols[c].X0, Y0: rows[r].Y0, X1: cols[c].X1, @@ -124,7 +105,6 @@ func (b *OssDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { // 5. Span injection. for _, sp := range spans { - // Find grid cells whose center falls inside the span bbox. type cellIdx struct{ r, c int } var covered []cellIdx for ri := range grid { @@ -140,23 +120,20 @@ func (b *OssDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { if len(covered) < 2 { continue } - // Sort covered cells: top-left first. sort.Slice(covered, func(a, b int) bool { if covered[a].r != covered[b].r { return covered[a].r < covered[b].r } return covered[a].c < covered[b].c }) - // First cell: extend bbox to span bounds, set label. first := covered[0] grid[first.r][first.c].X0 = sp.X0 grid[first.r][first.c].Y0 = sp.Y0 grid[first.r][first.c].X1 = sp.X1 grid[first.r][first.c].Y1 = sp.Y1 grid[first.r][first.c].Label = sp.Label - // Remaining cells: zeroed (covered). for _, idx := range covered[1:] { - grid[idx.r][idx.c] = TSRCell{} + grid[idx.r][idx.c] = pdf.TSRCell{} } } @@ -164,6 +141,6 @@ func (b *OssDeepDocService) GroupCells(cells []TSRCell) [][]TSRCell { } // overlapsY reports whether two cells overlap in the Y dimension. -func overlapsY(a, b TSRCell) bool { +func overlapsY(a, b pdf.TSRCell) bool { return a.Y0 < b.Y1 && a.Y1 > b.Y0 } diff --git a/internal/deepdoc/parser/pdf/oss_deepdoc_service_test.go b/internal/deepdoc/parser/pdf/table/deepdoc_table_builder_test.go similarity index 81% rename from internal/deepdoc/parser/pdf/oss_deepdoc_service_test.go rename to internal/deepdoc/parser/pdf/table/deepdoc_table_builder_test.go index a3ecf14e2c..dd57675166 100644 --- a/internal/deepdoc/parser/pdf/oss_deepdoc_service_test.go +++ b/internal/deepdoc/parser/pdf/table/deepdoc_table_builder_test.go @@ -1,12 +1,13 @@ -package parser +package table import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" "strings" "testing" ) -func TestOssDeepDocService_GroupCells_Basic4x5(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_Basic4x5(t *testing.T) { + b := &DeepDocTableBuilder{} cells := buildOSSCells(4, 5, 0, 0, 500, 200) grid := b.GroupCells(cells) @@ -21,8 +22,8 @@ func TestOssDeepDocService_GroupCells_Basic4x5(t *testing.T) { } } -func TestOssDeepDocService_GroupCells_Coords(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_Coords(t *testing.T) { + b := &DeepDocTableBuilder{} cells := buildOSSCells(2, 2, 0, 0, 200, 100) grid := b.GroupCells(cells) @@ -44,11 +45,11 @@ func TestOssDeepDocService_GroupCells_Coords(t *testing.T) { } } -func TestOssDeepDocService_GroupCells_HeaderPropagation(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_HeaderPropagation(t *testing.T) { + b := &DeepDocTableBuilder{} // 3 rows: header(Y=0-50) should map to row 0 - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 150, Label: "table"}, {X0: 0, Y0: 0, X1: 200, Y1: 50, Label: "table row"}, {X0: 0, Y0: 50, X1: 200, Y1: 100, Label: "table row"}, @@ -78,11 +79,11 @@ func TestOssDeepDocService_GroupCells_HeaderPropagation(t *testing.T) { } } -func TestOssDeepDocService_GroupCells_SpanInjection(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_SpanInjection(t *testing.T) { + b := &DeepDocTableBuilder{} // 2×3 table, spanning cell covers cols 0-1 in row 0 - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 300, Y1: 100, Label: "table"}, {X0: 0, Y0: 0, X1: 300, Y1: 50, Label: "table row"}, {X0: 0, Y0: 50, X1: 300, Y1: 100, Label: "table row"}, @@ -119,8 +120,8 @@ func TestOssDeepDocService_GroupCells_SpanInjection(t *testing.T) { } } -func TestOssDeepDocService_GroupCells_IrregularSize(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_IrregularSize(t *testing.T) { + b := &DeepDocTableBuilder{} cells := buildOSSCells(3, 2, 0, 0, 200, 120) grid := b.GroupCells(cells) @@ -132,18 +133,18 @@ func TestOssDeepDocService_GroupCells_IrregularSize(t *testing.T) { } } -func TestOssDeepDocService_GroupCells_EmptyInput(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_EmptyInput(t *testing.T) { + b := &DeepDocTableBuilder{} grid := b.GroupCells(nil) if len(grid) != 0 { t.Errorf("expected empty grid, got %d rows", len(grid)) } } -func TestOssDeepDocService_GroupCells_NoRows(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_NoRows(t *testing.T) { + b := &DeepDocTableBuilder{} // Only a "table" cell, no row cells. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 500, Y1: 200, Label: "table"}, } grid := b.GroupCells(cells) @@ -152,10 +153,10 @@ func TestOssDeepDocService_GroupCells_NoRows(t *testing.T) { } } -func TestOssDeepDocService_GroupCells_NoColumns(t *testing.T) { - b := &OssDeepDocService{} +func TestDeepDocTableBuildService_GroupCells_NoColumns(t *testing.T) { + b := &DeepDocTableBuilder{} // Table + rows but no column cells → each row gets 1 wide column. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 500, Y1: 100, Label: "table"}, {X0: 0, Y0: 0, X1: 500, Y1: 50, Label: "table row"}, {X0: 0, Y0: 50, X1: 500, Y1: 100, Label: "table row"}, @@ -173,23 +174,23 @@ func TestOssDeepDocService_GroupCells_NoColumns(t *testing.T) { // buildOSSCells constructs a set of OSS-style structural cells for // an R×C table with the given overall bounding box. -func buildOSSCells(rows, cols int, x0, y0, x1, y1 float64) []TSRCell { +func buildOSSCells(rows, cols int, x0, y0, x1, y1 float64) []pdf.TSRCell { rowH := (y1 - y0) / float64(rows) colW := (x1 - x0) / float64(cols) - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: x0, Y0: y0, X1: x1, Y1: y1, Label: "table"}, } for r := 0; r < rows; r++ { - cells = append(cells, TSRCell{ + cells = append(cells, pdf.TSRCell{ X0: x0, Y0: y0 + float64(r)*rowH, X1: x1, Y1: y0 + float64(r+1)*rowH, Label: "table row", }) } for c := 0; c < cols; c++ { - cells = append(cells, TSRCell{ + cells = append(cells, pdf.TSRCell{ X0: x0 + float64(c)*colW, Y0: y0, X1: x0 + float64(c+1)*colW, Y1: y1, Label: "table column", @@ -200,12 +201,12 @@ func buildOSSCells(rows, cols int, x0, y0, x1, y1 float64) []TSRCell { } // isZeroCell reports whether a cell has its bbox zeroed (covered by a span). -func isZeroCell(c TSRCell) bool { +func isZeroCell(c pdf.TSRCell) bool { return c.X0 == 0 && c.Y0 == 0 && c.X1 == 0 && c.Y1 == 0 } // hasLabel reports whether any cell in a row has a label containing substr. -func hasLabel(row []TSRCell, substr string) bool { +func hasLabel(row []pdf.TSRCell, substr string) bool { for _, c := range row { if strings.Contains(strings.ToLower(c.Label), strings.ToLower(substr)) { return true diff --git a/internal/deepdoc/parser/pdf/table/merge_captions.go b/internal/deepdoc/parser/pdf/table/merge_captions.go new file mode 100644 index 0000000000..418e326d0c --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/merge_captions.go @@ -0,0 +1,101 @@ +package table + +import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func MergeCaptions(sections []pdf.Section, figures []pdf.Section) []pdf.Section { + captions := make([]int, 0, 4) + for i, s := range sections { + captionType := CaptionKind(s) + if captionType == "" { + continue + } + target := findNearestParent(i, s, sections, figures, captionType) + if target >= 0 { + // For table sections, prepend caption before the HTML table + // (matching Python's _extract_table_figure caption->construct_table). + if sections[target].LayoutType == pdf.LayoutTypeTable && sections[target].Text != "" { + sections[target].Text = s.Text + sections[target].Text + } else if sections[target].Text != "" { + sections[target].Text += " " + s.Text + } else { + sections[target].Text = s.Text + } + } + captions = append(captions, i) + } + // Remove caption sections in reverse order. + n := len(sections) + out := make([]pdf.Section, 0, n-len(captions)) + capSet := make(map[int]bool, len(captions)) + for _, idx := range captions { + capSet[idx] = true + } + for i, s := range sections { + if !capSet[i] { + out = append(out, s) + } + } + return out +} + +// findNearestParent finds the nearest figure (for figure caption) or +// table (for table caption) section by position proximity. +// captionType is "table" or "figure" (from captionKind). +// Returns the index in `sections` (for tables) or a virtual index mapping +// to `figures` (negative offset for figures). +func findNearestParent(captionIdx int, caption pdf.Section, sections []pdf.Section, figures []pdf.Section, captionType string) int { + find := func(targets []pdf.Section, skipIdx int) (int, float64) { + bestIdx := -1 + bestDist := 1e9 + for i, t := range targets { + if i == skipIdx { + continue // don't match caption to itself + } + if len(t.Positions) == 0 || len(caption.Positions) == 0 { + continue + } + tp := t.Positions[0] + cp := caption.Positions[0] + // Squared Euclidean distance (Python _extract_table_figure:1196). + // Caption is typically below. Use center-point distance. + cx := (tp.Left + tp.Right) / 2 + cy := (tp.Top + tp.Bottom) / 2 + ccx := (cp.Left + cp.Right) / 2 + ccy := (cp.Top + cp.Bottom) / 2 + dist := (cx-ccx)*(cx-ccx) + (cy-ccy)*(cy-ccy) + if dist < bestDist { + bestDist = dist + bestIdx = i + } + } + return bestIdx, bestDist + } + + const maxCaptionGap = 40000.0 // PDF points (~7cm) — beyond this, don't attach. + if captionType == pdf.LayoutTypeFigure && len(figures) > 0 { + idx, dist := find(figures, -1) // figures don't contain the caption itself + if idx >= 0 && dist < maxCaptionGap { + // Match by position coordinates, not PositionTag strings. + f := figures[idx] + for i, s := range sections { + if s.LayoutType != pdf.LayoutTypeFigure || len(s.Positions) == 0 || len(f.Positions) == 0 { + continue + } + sp, fp := s.Positions[0], f.Positions[0] + if sp.Left == fp.Left && sp.Right == fp.Right && + sp.Top == fp.Top && sp.Bottom == fp.Bottom { + return i + } + } + } + } + if captionType == pdf.LayoutTypeTable { + idx, dist := find(sections, captionIdx) + if idx >= 0 && dist < maxCaptionGap && sections[idx].LayoutType == pdf.LayoutTypeTable { + return idx + } + } + return -1 +} diff --git a/internal/deepdoc/parser/pdf/table/merge_captions_test.go b/internal/deepdoc/parser/pdf/table/merge_captions_test.go new file mode 100644 index 0000000000..5587985146 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/merge_captions_test.go @@ -0,0 +1,74 @@ +package table + +import ( + "strings" + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// TestMergeCaptions_Unit verifies mergeCaptions directly without full pipeline. +func TestMergeCaptions_Unit(t *testing.T) { + sections := []pdf.Section{ + {Text: "F", LayoutType: "figure", Positions: []pdf.Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 30, Bottom: 45}}}, + {Text: "C", LayoutType: "figure caption", Positions: []pdf.Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 80, Bottom: 95}}}, + } + figures := pdf.CollectFigures(sections) + + result := MergeCaptions(sections, figures) + + // Caption removed. + if len(result) != 1 { + t.Fatalf("expected 1 section after merge, got %d", len(result)) + } + // Figure text includes caption. + if !strings.Contains(result[0].Text, "C") { + t.Errorf("expected figure Text to contain caption 'C', got %q", result[0].Text) + } + if result[0].LayoutType != "figure" { + t.Errorf("expected figure LayoutType, got %q", result[0].LayoutType) + } +} + +// TestMergeCaptions_TableCaption verifies table caption merging directly. +func TestMergeCaptions_TableCaption(t *testing.T) { + sections := []pdf.Section{ + {Text: "T", LayoutType: "table", Positions: []pdf.Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 30, Bottom: 45}}}, + {Text: "C", LayoutType: "table caption", Positions: []pdf.Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 80, Bottom: 95}}}, + } + figures := pdf.CollectFigures(sections) + + result := MergeCaptions(sections, figures) + + if len(result) != 1 { + t.Fatalf("expected 1 section after merge, got %d", len(result)) + } + if !strings.Contains(result[0].Text, "C") { + t.Errorf("expected table Text to contain caption 'C', got %q", result[0].Text) + } +} + +// TestMergeCaptions_EuclideanDistance verifies that caption matching uses +// squared Euclidean distance (center-to-center), not Y-only distance. +// Two captions at different X positions — the one closer by Euclidean +// distance wins, even if its Y distance is slightly larger. +func TestMergeCaptions_EuclideanDistance(t *testing.T) { + sections := []pdf.Section{ + {Text: "F", LayoutType: "figure", Positions: []pdf.Position{ + {PageNumbers: []int{0, 0}, Left: 0, Right: 100, Top: 0, Bottom: 50}, + }}, + // Caption A: directly below figure (dx=0, dy=20) → Euclidean = 20² + {Text: "close", LayoutType: "figure caption", Positions: []pdf.Position{ + {PageNumbers: []int{0, 0}, Left: 0, Right: 100, Top: 70, Bottom: 80}, + }}, + } + figures := pdf.CollectFigures(sections) + result := MergeCaptions(sections, figures) + // Caption merged into figure — verified by figure Text containing caption. + if len(result) != 1 { + t.Fatalf("expected 1 section after merge, got %d", len(result)) + } + if !strings.Contains(result[0].Text, "close") { + t.Errorf("figure Text should contain caption 'close', got %q", result[0].Text) + } +} diff --git a/internal/deepdoc/parser/pdf/table/table_annotate.go b/internal/deepdoc/parser/pdf/table/table_annotate.go new file mode 100644 index 0000000000..6ae3bed336 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_annotate.go @@ -0,0 +1,281 @@ +package table + +import ( + "fmt" + "math" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" + "ragflow/internal/deepdoc/parser/pdf/util" +) + +// ── region matching ──────────────────────────────────────────────────── + +// tableMatch pairs a DLA table region with the indices of boxes that overlap it. +type TableMatch struct { + Region pdf.DLARegion + BoxIdx []int +} + +// ── region matching ──────────────────────────────────────────────────── + +func regionOverlapsBox(region pdf.DLARegion, box pdf.TextBox, scale float64) bool { + rx0 := region.X0 / scale + ry0 := region.Y0 / scale + rx1 := region.X1 / scale + ry1 := region.Y1 / scale + scaledR := pdf.DLARegion{X0: rx0, Y0: ry0, X1: rx1, Y1: ry1} + inter := util.OverlapInter(&scaledR, &box) + boxArea := util.Area(&box) + if boxArea <= 0 { + return false + } + return inter/boxArea >= 0.4 // matches Python thr=0.4 +} + +// matchTableRegions pairs DLA table regions with boxes that overlap them. +// Each table region is matched if at least one box overlaps it (>40% of box +// area) or if there are no boxes at all (image-only PDF), matching Python's +// _table_transformer_job which processes every table DLA region. +func MatchTableRegions(boxes []pdf.TextBox, regions []pdf.DLARegion, scale float64) []TableMatch { + var matches []TableMatch + for _, r := range regions { + if r.Label != pdf.LayoutTypeTable { + continue + } + var matched []int + for i, b := range boxes { + if regionOverlapsBox(r, b, scale) { + matched = append(matched, i) + } + } + if len(matched) > 0 || len(boxes) == 0 { + matches = append(matches, TableMatch{Region: r, BoxIdx: matched}) + } + } + return matches +} + +// ── layout annotation ────────────────────────────────────────────────── + +// annotateBoxLayouts sets LayoutType and LayoutNo on each box, matching +// Python's LayoutRecognizer.__call__ which assigns layout types in priority +// order (footer→header→…→equation) with an overlap threshold of 40% of the +// box's area. +// +// Python: _layouts_rec (pdf_parser.py:827) → LayoutRecognizer.__call__ → +// +// for lt in priority_order: findLayout(lt) +// +// Each findLayout(ty): for each unannotated box, find the DLA region of +// type ty with max overlap ≥ 0.4 × box_area. First type to match wins. +// +// CID-pattern boxes (e.g. "(cid:123)") are skipped as garbage. +// annotateBoxLayouts assigns LayoutType and LayoutNo to boxes based on DLA +// regions. Returns the filtered slice (Python pops CID-garbled boxes and +// garbage-layout boxes at wrong positions — Go mirrors with compact). +// Also creates synthetic figure boxes for unmatched figure/equation regions. +func AnnotateBoxLayouts(boxes []pdf.TextBox, regions []pdf.DLARegion, scale float64, pageImgHeight float64) []pdf.TextBox { + if len(regions) == 0 { + return boxes + } + + // Scale all regions to PDF space once. + type scaledRegion struct { + x0, y0, x1, y1 float64 + label string + } + scaled := make([]scaledRegion, len(regions)) + for i, r := range regions { + scaled[i] = scaledRegion{ + x0: r.X0 / scale, y0: r.Y0 / scale, + x1: r.X1 / scale, y1: r.Y1 / scale, + label: r.Label, + } + } + + // DLA confidence filter — matches Python's `score >= 0.4`. + regionOK := make([]bool, len(regions)) + for i, r := range regions { + regionOK[i] = r.Confidence >= 0.4 || !isGarbageLayoutType(r.Label) + } + + // Pre-compute per-type index for each region (Python: matched index within + // filtered layouts_of_type list). "text" regions get 0,1,2... independent + // of "figure" regions. + typeIndex := make([]int, len(regions)) + typeCounters := make(map[string]int) + for j, r := range scaled { + if regionOK[j] { + typeIndex[j] = typeCounters[r.label] + typeCounters[r.label]++ + } + } + + // Track visited regions (Python: layout["visited"] = True). + visited := make([]bool, len(regions)) + + // Marks for Python-style pop removal. + dropped := make([]bool, len(boxes)) + + // Priority order matching Python's findLayout loop. + priorityOrder := []string{ + pdf.LayoutTypeFooter, pdf.LayoutTypeHeader, pdf.LayoutTypeReference, + pdf.DLALabelFigureCaption, pdf.DLALabelTableCaption, + pdf.LayoutTypeTitle, pdf.LayoutTypeTable, pdf.LayoutTypeText, + pdf.LayoutTypeFigure, pdf.LayoutTypeEquation, + } + for _, ty := range priorityOrder { + for i := range boxes { + if boxes[i].LayoutType != "" || dropped[i] { + continue + } + // CID garbage: pop the box entirely (Python: bxs.pop(i)). + if util.CIDPattern.MatchString(boxes[i].Text) { + dropped[i] = true + continue + } + boxArea := (boxes[i].X1 - boxes[i].X0) * (boxes[i].Bottom - boxes[i].Top) + if boxArea <= 0 { + continue + } + bestOverlap := 0.0 + bestJ := -1 + for j, r := range scaled { + if r.label != ty || !regionOK[j] { + continue + } + ix0 := math.Max(r.x0, boxes[i].X0) + iy0 := math.Max(r.y0, boxes[i].Top) + ix1 := math.Min(r.x1, boxes[i].X1) + iy1 := math.Min(r.y1, boxes[i].Bottom) + if ix0 < ix1 && iy0 < iy1 { + ov := (ix1 - ix0) * (iy1 - iy0) / boxArea + if ov > bestOverlap { + bestOverlap = ov + bestJ = j + } + } + } + if bestJ >= 0 && bestOverlap >= 0.4 { + // Garbage layout not at page edge → pop (Python: bxs.pop(i)). + if isGarbageLayoutType(ty) && pageImgHeight > 0 && !garbageKeepFeat(ty, boxes[i], pageImgHeight/scale) { + dropped[i] = true + continue + } + visited[bestJ] = true + // Python: equation mapped to "figure" for layout_type + if ty == pdf.LayoutTypeEquation { + boxes[i].LayoutType = pdf.LayoutTypeFigure + } else { + boxes[i].LayoutType = ty + } + // Python: f"{layout_type}-{matched}" where matched is per-type index + boxes[i].LayoutNo = fmt.Sprintf("%s-%d", ty, typeIndex[bestJ]) + } + } + } + + // Compact: remove popped boxes into a new backing array (Python + // bxs.pop). Allocating a fresh slice is deliberate: annotations were + // set in-place on the input elements, and callers (enrichWithDeepDoc) + // rely on positional stability of the original slice for their + // write-back loop. Reusing the input backing array would shift + // survivors forward and break that index mapping. + survivors := 0 + for i := range boxes { + if !dropped[i] { + survivors++ + } + } + compacted := make([]pdf.TextBox, 0, survivors) + for i := range boxes { + if !dropped[i] { + compacted = append(compacted, boxes[i]) + } + } + boxes = compacted + + // Synthetic figure boxes for unmatched figure/equation regions (Python: + // dla_cli.py:187-195). Use a fresh per-type counter for synthetic boxes. + synthIdx := 0 + for j, r := range scaled { + if !regionOK[j] || visited[j] { + continue + } + if r.label != pdf.LayoutTypeFigure && r.label != pdf.LayoutTypeEquation { + continue + } + boxes = append(boxes, pdf.TextBox{ + X0: r.x0, + X1: r.x1, + Top: r.y0, + Bottom: r.y1, + Text: "", + LayoutType: pdf.LayoutTypeFigure, + LayoutNo: fmt.Sprintf("figure-%d", synthIdx), + }) + synthIdx++ + } + + return boxes +} + +// ── garbage layout helpers ──────────────────────────────────────────── +// garbageLayoutTypes matches Python's self.garbage_layouts. +var garbageLayoutTypes = map[string]bool{ + pdf.LayoutTypeFooter: true, pdf.LayoutTypeHeader: true, pdf.LayoutTypeReference: true, +} + +func isGarbageLayoutType(ty string) bool { + return garbageLayoutTypes[ty] +} + +// garbageKeepFeat matches Python's keep_feats in LayoutRecognizer.__call__: +// footer near page bottom (>90% of page height) or header near page top (<10%) +// are real page decorations — keep them. Others are DLA noise. +func garbageKeepFeat(ty string, box pdf.TextBox, pageImgHeight float64) bool { + switch ty { + case pdf.LayoutTypeFooter: + return box.Bottom < pageImgHeight*0.9 + case pdf.LayoutTypeHeader: + return box.Top > pageImgHeight*0.1 + } + return false +} + +// writeTableAnnotations annotates boxes at boxIdx with table cell grid +// information (R/C/H/SP). Cells are offset by cropOff, grouped into a grid, +// and annotation fields are scaled back to PDF space for each box. +func WriteTableAnnotations(boxes []pdf.TextBox, boxIdx []int, cells []pdf.TSRCell, scale, cropOffX, cropOffY float64, tb pdf.TableBuilder) { + tableCells := make([]pdf.TSRCell, len(cells)) + for k := range cells { + tableCells[k] = CellAddOffset(cells[k], cropOffX, cropOffY) + } + tblBoxes := make([]pdf.TextBox, len(boxIdx)) + for k, idx := range boxIdx { + b := boxes[idx] + tblBoxes[k] = pdf.TextBox{ + X0: b.X0 * scale, X1: b.X1 * scale, + Top: b.Top * scale, Bottom: b.Bottom * scale, + LayoutType: b.LayoutType, + Text: b.Text, + } + } + annotGrid := tb.GroupCells(tableCells) + AnnotateTableBoxes(tblBoxes, annotGrid) + for k, idx := range boxIdx { + bp := &tblBoxes[k] + boxes[idx].R = bp.R + boxes[idx].RTop = bp.RTop / scale + boxes[idx].RBott = bp.RBott / scale + boxes[idx].H = bp.H + boxes[idx].HTop = bp.HTop / scale + boxes[idx].HBott = bp.HBott / scale + boxes[idx].HLeft = bp.HLeft / scale + boxes[idx].HRight = bp.HRight / scale + boxes[idx].C = bp.C + boxes[idx].CLeft = bp.CLeft / scale + boxes[idx].CRight = bp.CRight / scale + boxes[idx].SP = bp.SP + } +} diff --git a/internal/deepdoc/parser/pdf/table/table_annotate_test.go b/internal/deepdoc/parser/pdf/table/table_annotate_test.go new file mode 100644 index 0000000000..23054f206d --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_annotate_test.go @@ -0,0 +1,604 @@ +package table + +import ( + "context" + "image" + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func TestAnnotateBoxLayouts_SetsLabel(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20}, + {X0: 0, X1: 100, Top: 30, Bottom: 50}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "title"}, // covers box 0 at scale 3 + {X0: 0, Y0: 90, X1: 300, Y1: 150, Label: "text"}, // covers box 1 at scale 3 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if boxes[0].LayoutType != "title" { + t.Errorf("box 0: got %q, want 'title'", boxes[0].LayoutType) + } + if boxes[1].LayoutType != "text" { + t.Errorf("box 1: got %q, want 'text'", boxes[1].LayoutType) + } +} + +func TestAnnotateBoxLayouts_NoMatch(t *testing.T) { + // Region far away from the box — no overlap + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20}, + } + regions := []pdf.DLARegion{ + {X0: 900, Y0: 900, X1: 1000, Y1: 1000, Label: "far"}, // completely outside + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if boxes[0].LayoutType != "" { + t.Errorf("no match: expected empty, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_EmptyRegions(t *testing.T) { + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 20}} + boxes = AnnotateBoxLayouts(boxes, nil, 3.0, 0) + boxes = AnnotateBoxLayouts(boxes, []pdf.DLARegion{}, 3.0, 0) + if boxes[0].LayoutType != "" { + t.Errorf("empty regions: got %q, want empty", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_PriorityOverMaxArea(t *testing.T) { + // "table" type checked before "text" in priority order. + // Even if "text" region has larger overlap, "table" wins if it meets threshold (≥40%). + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 50}} + regions := []pdf.DLARegion{ + // text region: full coverage (100% overlap) — but lower priority + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, + // table region: 45% overlap (45x50 out of 100x50) — higher priority, meets threshold + {X0: 0, Y0: 0, X1: 45 * 3, Y1: 50 * 3, Label: "table"}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if boxes[0].LayoutType != "table" { + t.Errorf("priority: 'table' should win over 'text' when both meet threshold, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_OverlapThreshold(t *testing.T) { + // Region overlaps only 30% of box — below 0.4 threshold — should NOT match. + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 50}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 30 * 3, Y1: 30 * 3, Label: "table"}, // covers ~30% of box + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if boxes[0].LayoutType != "" { + t.Errorf("threshold: overlap < 40%% should not match, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_CIDGarbage(t *testing.T) { + // CID-pattern boxes should be popped entirely (Python: bxs.pop(i)). + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20, Text: "(cid:123)"}, + {X0: 0, X1: 100, Top: 30, Bottom: 50, Text: "normal text"}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "text", Confidence: 0.9}, + {X0: 0, Y0: 90, X1: 300, Y1: 150, Label: "text", Confidence: 0.9}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + // CID-garbled box was popped → only 1 box remains. + if len(boxes) != 1 { + t.Fatalf("CID-garbled box should be popped, got %d boxes", len(boxes)) + } + if boxes[0].LayoutType != "text" { + t.Errorf("CID: remaining box should be 'text', got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_LayoutNoFormat(t *testing.T) { + // layoutno uses Python format: "{type}-{per_type_index}" where per_type_index + // is the index of the matched DLA region within its type (not global). + // Two boxes overlapping the SAME text region share the same layoutno → VM can merge them. + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20}, + {X0: 0, X1: 100, Top: 30, Bottom: 50}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, // covers both boxes + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + want := "text-0" + if boxes[0].LayoutNo != want { + t.Errorf("box 0 layoutno: got %q, want %q", boxes[0].LayoutNo, want) + } + if boxes[1].LayoutNo != want { + t.Errorf("box 1 layoutno should share same per-type index: got %q, want %q", boxes[1].LayoutNo, want) + } +} + +func TestAnnotateBoxLayouts_LayoutNoDifferentRegions(t *testing.T) { + // Two boxes in different text regions → different layoutno. + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20}, + {X0: 0, X1: 100, Top: 100, Bottom: 120}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "text"}, // per-type index 0 + {X0: 0, Y0: 300, X1: 300, Y1: 360, Label: "text"}, // per-type index 1 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if boxes[0].LayoutNo != "text-0" { + t.Errorf("box 0: got %q, want 'text-0'", boxes[0].LayoutNo) + } + if boxes[1].LayoutNo != "text-1" { + t.Errorf("box 1: got %q, want 'text-1'", boxes[1].LayoutNo) + } +} + +// TestAnnotateBoxLayouts_ConfidenceFilter verifies that DLA regions with +// low confidence (< 0.4) for garbage layout types are excluded from matching. +// Python: float(b["score"]) >= 0.4 filter in LayoutRecognizer. +func TestAnnotateBoxLayouts_ConfidenceFilter(t *testing.T) { + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 50}} + // Low-confidence footer — should be filtered out. + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "footer", Confidence: 0.2}, + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text", Confidence: 0.9}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + // Footer region filtered (low confidence) → box matches "text" instead. + if boxes[0].LayoutType != "text" { + t.Errorf("low-confidence footer filtered → box should get 'text', got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_GarbageFooterRejected(t *testing.T) { + // Footer at page bottom: Bottom(290) > 270 (90% of 300px→PDF height 100→90% of 100=90) + // → real footer decoration → garbage → pop (Python: bxs.pop(i)). + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 280, Bottom: 290}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 840, X1: 300, Y1: 870, Label: "footer", Confidence: 0.9}, // y=280-290 after /3, PDF 93-97 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) // PDF height = 300/3 = 100 + if len(boxes) != 0 { + t.Errorf("footer at bottom: should be popped as decoration, got %d boxes left", len(boxes)) + } +} + +func TestAnnotateBoxLayouts_HeaderRemovedAtTop(t *testing.T) { + // Header at page top edge (y=5 in 300px page → PDF height 100 → 5 < 10% of 100) + // → real header decoration → garbage → pop (Python: bxs.pop(i)). + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 5, Bottom: 20}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 15, X1: 300, Y1: 60, Label: "header", Confidence: 0.9}, // y=5-20 after /3 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) + if len(boxes) != 0 { + t.Errorf("header at very top: should be popped as decoration, got %d boxes left", len(boxes)) + } +} + +func TestAnnotateBoxLayouts_HeaderKeptInMiddle(t *testing.T) { + // Header in middle of page (y=50 in 300px page → PDF height 100 → 50 > 10) + // → DLA false positive → KEEP the text. + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 50, Bottom: 70}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 150, X1: 300, Y1: 210, Label: "header", Confidence: 0.9}, // y=50-70 after /3 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) + if boxes[0].LayoutType != "header" { + t.Errorf("header in middle of page: DLA false positive, keep text, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_FooterRemovedAtBottom(t *testing.T) { + // Footer at page bottom (y=95 in 300px page → PDF height 100 → 95 > 90% of 100) + // → real footer decoration → garbage → REMOVE. + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 95, Bottom: 100}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 285, X1: 300, Y1: 300, Label: "footer", Confidence: 0.9}, // y=95-100 after /3 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) + if len(boxes) != 0 { + t.Errorf("footer at very bottom: should be popped as decoration, got %d boxes left", len(boxes)) + } +} + +func TestAnnotateBoxLayouts_FooterKeptInMiddle(t *testing.T) { + // Footer in middle of page (y=50 in 300px page → PDF height 100 → 50 < 90) + // → DLA false positive → KEEP the text. + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 50, Bottom: 70}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 150, X1: 300, Y1: 210, Label: "footer", Confidence: 0.9}, // y=50-70 after /3 + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) + if boxes[0].LayoutType != "footer" { + t.Errorf("footer in middle of page: DLA false positive, keep text, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_ReferenceAlwaysGarbage(t *testing.T) { + // Reference type is always garbage regardless of position (no keep_feat). + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 50, Bottom: 70}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 150, X1: 300, Y1: 210, Label: "reference", Confidence: 0.9}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) + if len(boxes) != 0 { + t.Errorf("reference: should always be garbage-filtered, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_NonGarbageTypeUnaffected(t *testing.T) { + // "text" type is NOT a garbage type — should always be assigned. + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 200, Bottom: 220}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 600, X1: 300, Y1: 660, Label: "text"}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 300) + if boxes[0].LayoutType != "text" { + t.Errorf("non-garbage type: should be assigned, got %q", boxes[0].LayoutType) + } +} + +func TestAnnotateBoxLayouts_ZeroPageHeightDisablesGarbage(t *testing.T) { + // pageImgHeight=0 → garbage check disabled → all types assigned. + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 100, Bottom: 120}} + regions := []pdf.DLARegion{ + {X0: 0, Y0: 300, X1: 300, Y1: 360, Label: "header", Confidence: 0.9}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if boxes[0].LayoutType != "header" { + t.Errorf("zero page height: garbage check disabled, got %q", boxes[0].LayoutType) + } +} + +// TestAnnotateBoxLayouts_SyntheticFigure creates synthetic figure boxes for +// unmatched figure/equation DLA regions (Python: dla_cli.py:187-195). +func TestAnnotateBoxLayouts_SyntheticFigure(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20, Text: "text box"}, + } + // Two figure regions, one text region + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 150, Y1: 60, Label: "text", Confidence: 0.9}, // matches text box → visited + {X0: 300, Y0: 300, X1: 600, Y1: 600, Label: "figure", Confidence: 0.9}, // no box overlaps → synthetic + {X0: 600, Y0: 0, X1: 900, Y1: 300, Label: "figure", Confidence: 0.9}, // no box overlaps → synthetic + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + // Original text box + 2 synthetic figure boxes = 3 + if len(boxes) != 3 { + t.Fatalf("expected 3 boxes (1 original + 2 synthetic figures), got %d", len(boxes)) + } + // Check synthetic boxes + foundFig0, foundFig1 := false, false + for _, b := range boxes { + if b.LayoutType == "figure" && b.Text == "" { + if b.LayoutNo == "figure-0" { + foundFig0 = true + if b.X0 != 100 || b.X1 != 200 { + t.Errorf("synthetic figure-0: expected x0=100,x1=200 (300/3,600/3), got x0=%v,x1=%v", b.X0, b.X1) + } + } + if b.LayoutNo == "figure-1" { + foundFig1 = true + } + } + } + if !foundFig0 { + t.Error("missing synthetic figure-0 box") + } + if !foundFig1 { + t.Error("missing synthetic figure-1 box") + } +} + +// TestAnnotateBoxLayouts_EquationMappedToFigure verifies equation DLA regions +// get LayoutType="figure" but LayoutNo keeps "equation" prefix (Python behavior). +func TestAnnotateBoxLayouts_EquationMappedToFigure(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "equation", Confidence: 0.9}, + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if len(boxes) != 1 { + t.Fatalf("expected 1 box, got %d", len(boxes)) + } + if boxes[0].LayoutType != "figure" { + t.Errorf("equation → LayoutType: got %q, want 'figure'", boxes[0].LayoutType) + } + if boxes[0].LayoutNo != "equation-0" { + t.Errorf("equation → LayoutNo: got %q, want 'equation-0'", boxes[0].LayoutNo) + } +} + +// TestAnnotateBoxLayouts_MixedTypesLayoutNo verifies per-type LayoutNo counting +// with multiple region types present. +func TestAnnotateBoxLayouts_MixedTypesLayoutNo(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20}, // overlaps text region 0 + {X0: 0, X1: 100, Top: 200, Bottom: 220}, // overlaps text region 1 + {X0: 200, X1: 300, Top: 0, Bottom: 20}, // overlaps figure region 0 only + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 150, Y1: 60, Label: "text", Confidence: 0.9}, // text-0 + {X0: 0, Y0: 600, X1: 150, Y1: 660, Label: "text", Confidence: 0.9}, // text-1 + {X0: 600, Y0: 0, X1: 900, Y1: 60, Label: "figure", Confidence: 0.9}, // figure-0 (PDF: x0=200, x1=300) + } + boxes = AnnotateBoxLayouts(boxes, regions, 3.0, 0) + if len(boxes) != 3 { + t.Fatalf("expected 3 boxes, got %d", len(boxes)) + } + // Check that text and figure indices are independent + if boxes[0].LayoutNo != "text-0" { + t.Errorf("box 0: got %q, want 'text-0'", boxes[0].LayoutNo) + } + if boxes[1].LayoutNo != "text-1" { + t.Errorf("box 1: got %q, want 'text-1'", boxes[1].LayoutNo) + } + if boxes[2].LayoutNo != "figure-0" { + t.Errorf("box 2: got %q, want 'figure-0' (independent from text counter)", boxes[2].LayoutNo) + } +} + +// TestAnnotateBoxLayouts_CompactionPreservesWriteBackMapping verifies that +// when annotateBoxLayouts drops some boxes (CID garbage or garbage-layout +// at non-edge positions), the compaction step does not corrupt the caller's +// ability to write annotations back to the correct global box indices. +// +// The bug: annotateBoxLayouts compacts boxes in place in the shared backing +// array, shifting survivors forward. enrichWithDeepDoc then iterates +// len(indices) positions and writes pageBoxes[i] back to boxes[indices[i]], +// but after compaction pageBoxes[1] holds what was originally pageBoxes[2], +// so annotations land on the wrong global box. + +func TestMatchTableRegions_SingleMatch(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 50}, + {X0: 200, X1: 300, Top: 0, Bottom: 50}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "table"}, // covers box 0 at scale 3 + {X0: 600, Y0: 0, X1: 900, Y1: 150, Label: "text"}, // non-table, ignored + } + matches := MatchTableRegions(boxes, regions, 3.0) + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + if len(matches[0].BoxIdx) != 1 || matches[0].BoxIdx[0] != 0 { + t.Errorf("expected box 0 matched, got %v", matches[0].BoxIdx) + } +} + +func TestMatchTableRegions_NoTableLabel(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 50}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "figure"}, + } + matches := MatchTableRegions(boxes, regions, 3.0) + if len(matches) != 0 { + t.Errorf("non-table labels: expected 0 matches, got %d", len(matches)) + } +} + +func TestMatchTableRegions_MultipleBoxesSameTable(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 50}, // box 0 + {X0: 110, X1: 210, Top: 0, Bottom: 50}, // box 1 + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 630, Y1: 150, Label: "table"}, // covers both boxes at scale 3 + } + matches := MatchTableRegions(boxes, regions, 3.0) + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + if len(matches[0].BoxIdx) != 2 { + t.Errorf("expected 2 boxes matched, got %d: %v", len(matches[0].BoxIdx), matches[0].BoxIdx) + } +} + +func TestMatchTableRegions_ImageOnlyPDF(t *testing.T) { + // Zero boxes — image-only PDF. Python processes every table DLA region + // regardless of text box overlap. + var boxes []pdf.TextBox // nil + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "table"}, + {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, + } + matches := MatchTableRegions(boxes, regions, 3.0) + if len(matches) != 1 { + t.Fatalf("image-only: expected 1 table match, got %d", len(matches)) + } + if len(matches[0].BoxIdx) != 0 { + t.Errorf("image-only: expected empty BoxIdx, got %d", len(matches[0].BoxIdx)) + } +} + +func TestMatchTableRegions_BelowThreshold(t *testing.T) { + // Region overlaps only a sliver of the box (<40%) → no match. + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 100}, + } + regions := []pdf.DLARegion{ + {X0: 0, Y0: 0, X1: 90, Y1: 90, Label: "table"}, // 30x30 at scale 3 → 9% overlap + } + matches := MatchTableRegions(boxes, regions, 3.0) + if len(matches) != 0 { + t.Errorf("below threshold: expected 0 matches, got %d", len(matches)) + } +} + +// MockTableBuilder is a test-only pdf.TableBuilder with a configurable GroupCells. +type MockTableBuilder struct { + GroupCellsFn func(cells []pdf.TSRCell) [][]pdf.TSRCell +} + +func (m *MockTableBuilder) Name() string { return "mock" } +func (m *MockTableBuilder) DetectCells(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) { + return nil, nil +} +func (m *MockTableBuilder) GroupCells(cells []pdf.TSRCell) [][]pdf.TSRCell { + if m.GroupCellsFn != nil { + return m.GroupCellsFn(cells) + } + return nil +} + +// ── writeTableAnnotations unit tests ────────────────────────────────── + +func TestWriteTableAnnotations_WriteBack(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 10, X1: 100, Top: 10, Bottom: 30, Text: "A", LayoutType: "table"}, + {X0: 110, X1: 200, Top: 10, Bottom: 30, Text: "B", LayoutType: "table"}, + {X0: 10, X1: 100, Top: 35, Bottom: 55, Text: "C", LayoutType: "table"}, + } + BoxIdx := []int{0, 2} + cells := []pdf.TSRCell{ + {X0: 30, Y0: 30, X1: 300, Y1: 90, Label: "table row"}, + {X0: 30, Y0: 110, X1: 300, Y1: 170, Label: "table row"}, + } + scale := 3.0 + + tb := &MockTableBuilder{GroupCellsFn: func(cells []pdf.TSRCell) [][]pdf.TSRCell { + return [][]pdf.TSRCell{{cells[0]}, {cells[1]}} + }} + WriteTableAnnotations(boxes, BoxIdx, cells, scale, 0, 0, tb) + + if boxes[0].R != 0 { + t.Errorf("box 0 R = %d, want 0", boxes[0].R) + } + if boxes[0].C != 0 { + t.Errorf("box 0 C = %d, want 0", boxes[0].C) + } + // Box 1 was not in BoxIdx — should NOT be annotated + if boxes[1].R != 0 || boxes[1].C != 0 { + t.Errorf("box 1 should not be annotated: R=%d C=%d", boxes[1].R, boxes[1].C) + } + if boxes[2].R != 1 { + t.Errorf("box 2 R = %d, want 1", boxes[2].R) + } +} + +func TestWriteTableAnnotations_ScaleDown(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 10, X1: 100, Top: 10, Bottom: 50, Text: "X", LayoutType: "table"}, + } + BoxIdx := []int{0} + cells := []pdf.TSRCell{ + {X0: 30, Y0: 30, X1: 300, Y1: 150, Label: "table row"}, + } + scale := 3.0 + + tb := &MockTableBuilder{GroupCellsFn: func(cells []pdf.TSRCell) [][]pdf.TSRCell { + return [][]pdf.TSRCell{{cells[0]}} + }} + WriteTableAnnotations(boxes, BoxIdx, cells, scale, 0, 0, tb) + + // After scale-down: RTop / 3 should be in PDF space (~10). + if boxes[0].RTop == 0 { + t.Error("RTop should be non-zero after annotation") + } +} + +func TestWriteTableAnnotations_EmptyCells(t *testing.T) { + boxes := []pdf.TextBox{{X0: 10, X1: 100, Top: 10, Bottom: 50, Text: "X", LayoutType: "table"}} + BoxIdx := []int{0} + var cells []pdf.TSRCell + + tb := &MockTableBuilder{GroupCellsFn: func(cells []pdf.TSRCell) [][]pdf.TSRCell { + return nil + }} + // Should not panic with empty cells. + WriteTableAnnotations(boxes, BoxIdx, cells, 3.0, 0, 0, tb) + if boxes[0].R != 0 || boxes[0].C != 0 { + t.Errorf("empty cells: R=%d C=%d, want 0,0", boxes[0].R, boxes[0].C) + } +} + +// ── markNoMergeTables unit tests ───────────────────────────────────── + +func TestMarkNoMergeTables_CaptionAfterTable(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, + {X0: 0, X1: 100, Top: 35, Bottom: 50, LayoutType: "table caption", Text: "表1:标题"}, + } + tables := []pdf.TableItem{ + {Positions: []pdf.Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, + } + MarkNoMergeTables(boxes, tables) + if !tables[0].NoMerge { + t.Error("table followed by caption should be marked NoMerge") + } +} + +func TestMarkNoMergeTables_TitleAfterTable(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, + {X0: 0, X1: 100, Top: 35, Bottom: 50, LayoutType: "title"}, + } + tables := []pdf.TableItem{ + {Positions: []pdf.Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, + } + MarkNoMergeTables(boxes, tables) + if !tables[0].NoMerge { + t.Error("table followed by title should be marked NoMerge") + } +} + +func TestMarkNoMergeTables_NoCaptionAfter(t *testing.T) { + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, + {X0: 0, X1: 100, Top: 35, Bottom: 50, LayoutType: "text"}, + {X0: 0, X1: 100, Top: 55, Bottom: 70, LayoutType: "table"}, + } + tables := []pdf.TableItem{ + {Positions: []pdf.Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, + {Positions: []pdf.Position{{Left: 0, Right: 100, Top: 55, Bottom: 70}}}, + } + MarkNoMergeTables(boxes, tables) + if tables[0].NoMerge { + t.Error("table followed by text should NOT be marked NoMerge") + } + if tables[1].NoMerge { + t.Error("last table should NOT be marked NoMerge") + } +} + +func TestMarkNoMergeTables_StaleLastTableTI(t *testing.T) { + // Scenario: table box that does NOT overlap any pdf.TableItem.Position + // should reset lastTableTI. Otherwise the next caption marks the + // wrong (non-adjacent) table as NoMerge. + // Box 0: "table", overlaps table[0] → lastTableTI = 0 + // Box 1: "table", no overlap → lastTableTI should reset to -1 + // Box 2: "title" → should be a no-op (no adjacent table) + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, + {X0: 500, X1: 600, Top: 100, Bottom: 130, LayoutType: "table"}, // far away, no overlap + {X0: 0, X1: 100, Top: 140, Bottom: 160, LayoutType: "title"}, + } + tables := []pdf.TableItem{ + {Positions: []pdf.Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, // table 0 + {Positions: []pdf.Position{{Left: 0, Right: 100, Top: 35, Bottom: 65}}}, // table 1 — box 0 doesn't overlap this either + } + MarkNoMergeTables(boxes, tables) + // table[0] should NOT be NoMerge: the title follows a non-matching + // table box, not table[0] directly. + if tables[0].NoMerge { + t.Error("stale lastTableTI: table[0] incorrectly marked NoMerge — " + + "the non-overlapping table box (box 1) should have reset lastTableTI") + } +} + +func TestMarkNoMergeTables_EmptyInputs(t *testing.T) { + // Should not panic with empty inputs. + MarkNoMergeTables(nil, nil) + MarkNoMergeTables([]pdf.TextBox{}, []pdf.TableItem{}) +} diff --git a/internal/deepdoc/parser/pdf/table/table_cell_spatial_test.go b/internal/deepdoc/parser/pdf/table/table_cell_spatial_test.go new file mode 100644 index 0000000000..36be34552c --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_cell_spatial_test.go @@ -0,0 +1,112 @@ +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) + } +} diff --git a/internal/deepdoc/parser/pdf/table_cells.go b/internal/deepdoc/parser/pdf/table/table_cells.go similarity index 84% rename from internal/deepdoc/parser/pdf/table_cells.go rename to internal/deepdoc/parser/pdf/table/table_cells.go index 8355fdb484..1e6c0b1d8c 100644 --- a/internal/deepdoc/parser/pdf/table_cells.go +++ b/internal/deepdoc/parser/pdf/table/table_cells.go @@ -1,8 +1,10 @@ -package parser +package table import ( "log/slog" "math" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + "ragflow/internal/deepdoc/parser/pdf/util" "regexp" "sort" "strings" @@ -10,12 +12,15 @@ import ( // ── TSR cell grouping ────────────────────────────────────────────────── -func groupTSRCellsToRows(cells []TSRCell) [][]TSRCell { +// GroupTSRCellsToRows groups TSR cells into rows by Y proximity. +// This is the basic fallback grouping used when model-specific grouping +// (e.g. EE label-aware grouping) is not applicable. +func GroupTSRCellsToRows(cells []pdf.TSRCell) [][]pdf.TSRCell { if len(cells) == 0 { return nil } if len(cells) == 1 { - return [][]TSRCell{{cells[0]}} + return [][]pdf.TSRCell{{cells[0]}} } heights := make([]float64, len(cells)) for i, c := range cells { @@ -35,8 +40,8 @@ func groupTSRCellsToRows(cells []TSRCell) [][]TSRCell { return cells[i].Y0 < cells[j].Y0 }) - var rows [][]TSRCell - var curRow []TSRCell + var rows [][]pdf.TSRCell + var curRow []pdf.TSRCell curY := 0.0 for _, c := range cells { if len(curRow) == 0 { @@ -46,7 +51,7 @@ func groupTSRCellsToRows(cells []TSRCell) [][]TSRCell { } if c.Y0-curY > rowThreshold { rows = append(rows, curRow) - curRow = []TSRCell{c} + curRow = []pdf.TSRCell{c} curY = c.Y0 } else { curRow = append(curRow, c) @@ -63,7 +68,7 @@ func groupTSRCellsToRows(cells []TSRCell) [][]TSRCell { // ── cell text filling ────────────────────────────────────────────────── -func fillCellTextFromBoxes(cells []TSRCell, boxes []TextBox) { +func FillCellTextFromBoxes(cells []pdf.TSRCell, boxes []pdf.TextBox) { slog.Debug("fillCellTextFromBoxes", "cells", len(cells), "boxes", len(boxes)) if len(cells) > 0 && len(boxes) > 0 { c0 := cells[0] @@ -75,10 +80,10 @@ func fillCellTextFromBoxes(cells []TSRCell, boxes []TextBox) { for ci := range cells { var matches []string for _, b := range boxes { - if isCaptionBox(b.Text, b.LayoutType) { + if IsCaptionBox(b.Text, b.LayoutType) { continue } - if boxMatchesCell(cells[ci], b, cells[ci].Text == "") { + if BoxMatchesCell(cells[ci], b, cells[ci].Text == "") { matched++ t := strings.TrimSpace(b.Text) if t != "" { @@ -99,9 +104,9 @@ func fillCellTextFromBoxes(cells []TSRCell, boxes []TextBox) { // must be mostly inside the cell (≥85% of box area). When the cell // is empty, any overlap suffices — matching Python's _table_transformer_job // which fills cells from overlapping PDF boxes with thr=0.3. -func boxMatchesCell(cell TSRCell, box TextBox, cellIsEmpty bool) bool { - inter := OverlapInter(&cell, &box) - boxArea := Area(&box) +func BoxMatchesCell(cell pdf.TSRCell, box pdf.TextBox, cellIsEmpty bool) bool { + inter := util.OverlapInter(&cell, &box) + boxArea := util.Area(&box) if boxArea <= 0 { return false } @@ -111,17 +116,11 @@ func boxMatchesCell(cell TSRCell, box TextBox, cellIsEmpty bool) bool { return inter/boxArea >= 0.85 } -// boxOverlapsCell is kept for backward compat — same as boxMatchesCell -// with cellIsEmpty=false (strict 85% threshold). -func boxOverlapsCell(cell TSRCell, box TextBox) bool { - return boxMatchesCell(cell, box, false) -} - // isCaptionBox checks if a text box is a table/figure caption, // matching Python is_caption(). Captions should not enter table cells. var reCaption = regexp.MustCompile(`^[图表]+[ 0-9::]{2,}|(?i)Fig\.?\s*\d+|(?i)Figure\s+\d+|(?i)Table\s+\d+`) -func isCaptionBox(text string, layoutType string) bool { +func IsCaptionBox(text string, layoutType string) bool { if strings.Contains(layoutType, "caption") { return true } @@ -138,25 +137,25 @@ var reFigureCaptionText = regexp.MustCompile(`^图|(?i)Fig\.?\s*\d+|(?i)Figure\s // captionKind returns "table" if the section is a table caption, // "figure" if a figure caption, or "" if not a caption. // Matches Python's is_caption check: text patterns OR layout_type containing "caption". -func captionKind(s Section) string { +func CaptionKind(s pdf.Section) string { lt := s.LayoutType - if lt == DLALabelTableCaption || (strings.Contains(lt, "caption") && reTableCaptionText.MatchString(strings.TrimSpace(s.Text))) { - return LayoutTypeTable + if lt == pdf.DLALabelTableCaption || (strings.Contains(lt, "caption") && reTableCaptionText.MatchString(strings.TrimSpace(s.Text))) { + return pdf.LayoutTypeTable } - if lt == DLALabelFigureCaption || strings.Contains(lt, "caption") { - return LayoutTypeFigure + if lt == pdf.DLALabelFigureCaption || strings.Contains(lt, "caption") { + return pdf.LayoutTypeFigure } // DLA may label captions as "text" or other types — check text patterns. t := strings.TrimSpace(s.Text) if reTableCaptionText.MatchString(t) { - return LayoutTypeTable + return pdf.LayoutTypeTable } if reFigureCaptionText.MatchString(t) { - return LayoutTypeFigure + return pdf.LayoutTypeFigure } // "图表" pattern could be either — check if isCaptionBox matches. - if isCaptionBox(t, "") { - return LayoutTypeTable + if IsCaptionBox(t, "") { + return pdf.LayoutTypeTable } return "" } @@ -192,7 +191,7 @@ var blockTypePatterns = []struct { // TableStructureRecognizer.blockType. Types: Dt (date), Nu (numeric), // Ca (categorical), En (English), NE (named entity), Sg (single char), // Tx (short text), Lx (long text), Nr (person name), Ot (other). -func blockType(text string) string { +func BlockType(text string) string { t := strings.TrimSpace(text) for _, p := range blockTypePatterns { if p.re.MatchString(t) { @@ -218,7 +217,7 @@ func blockType(text string) string { func simpleTokenCount(text string) int { count := 0 for _, r := range text { - if isCJK(r) { + if pdf.IsCJK(r) { count++ } else if r == ' ' || r == '\t' { // whitespace tokenizes boundaries already counted via words @@ -236,7 +235,7 @@ func simpleTokenCount(text string) int { func containsCJK(s string) bool { for _, r := range s { - if isCJK(r) { + if pdf.IsCJK(r) { return true } } @@ -246,14 +245,14 @@ func containsCJK(s string) bool { // headerSetWithBlockType returns rows that should be header rows, using both // TSR cell labels AND block-type classification. Matches Python's // construct_table header detection (table_structure_recognizer.py:370-384). -func headerSetWithBlockType(rows [][]TSRCell) map[int]bool { +func HeaderSetWithBlockType(rows [][]pdf.TSRCell) map[int]bool { // Compute dominant block type across all cells. typeCounts := make(map[string]int) for _, row := range rows { for _, cell := range row { t := strings.TrimSpace(cell.Text) if t != "" { - typeCounts[blockType(t)]++ + typeCounts[BlockType(t)]++ } } } @@ -275,7 +274,7 @@ func headerSetWithBlockType(rows [][]TSRCell) map[int]bool { continue } cnt++ - bt := blockType(t) + bt := BlockType(t) // Python: if max_type == "Nu" and cell btype == "Nu" → skip if maxType == "Nu" && bt == "Nu" { continue diff --git a/internal/deepdoc/parser/pdf/table/table_cells_group_test.go b/internal/deepdoc/parser/pdf/table/table_cells_group_test.go new file mode 100644 index 0000000000..69a4b5b54f --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_cells_group_test.go @@ -0,0 +1,235 @@ +package table + +import ( + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func cellTexts(cells []pdf.TSRCell) []string { + out := make([]string, len(cells)) + for i, c := range cells { + out[i] = c.Text + } + return out +} + +func TestGroupTSRCellsToRows(t *testing.T) { + t.Run("empty", func(t *testing.T) { + if rows := GroupTSRCellsToRows(nil); rows != nil { + t.Error("nil → nil") + } + if rows := GroupTSRCellsToRows([]pdf.TSRCell{}); rows != nil { + t.Error("empty → nil") + } + }) + + t.Run("single cell", func(t *testing.T) { + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "A"}} + rows := GroupTSRCellsToRows(cells) + if len(rows) != 1 || rows[0][0].Text != "A" { + t.Error("single cell not preserved") + } + }) + + t.Run("two rows two cols", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, + {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, + {X0: 0, Y0: 50, X1: 50, Y1: 80, Text: "C"}, + {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, + } + rows := GroupTSRCellsToRows(cells) + if len(rows) != 2 { + t.Fatalf("2 rows expected, got %d", len(rows)) + } + if rows[0][0].Text != "A" || rows[0][1].Text != "B" { + t.Errorf("row0: %v", cellTexts(rows[0])) + } + if rows[1][0].Text != "C" || rows[1][1].Text != "D" { + t.Errorf("row1: %v", cellTexts(rows[1])) + } + }) + + t.Run("unsorted input", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, + {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, + {X0: 0, Y0: 50, X1: 50, Y1: 80, Text: "C"}, + {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, + } + rows := GroupTSRCellsToRows(cells) + if len(rows) != 2 { + t.Fatalf("unsorted: 2 rows expected, got %d", len(rows)) + } + if rows[0][0].Text != "A" || rows[0][1].Text != "B" { + t.Errorf("unsorted row0: %v", cellTexts(rows[0])) + } + }) + + t.Run("tall merged cell", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 50, Y1: 100, Text: "merged"}, + {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, + {X0: 50, Y0: 50, X1: 100, Y1: 80, Text: "D"}, + } + rows := GroupTSRCellsToRows(cells) + // merged cell starts Y0=0 → row 0; Y0=50 cell → row 1 + if len(rows) != 2 { + t.Fatalf("merged cell: 2 rows expected, got %d", len(rows)) + } + }) + + t.Run("large gap different rows", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "top"}, + {X0: 0, Y0: 200, X1: 50, Y1: 230, Text: "far"}, + } + rows := GroupTSRCellsToRows(cells) + if len(rows) != 2 { + t.Fatalf("large gap: 2 rows expected, got %d", len(rows)) + } + }) + + t.Run("close rows", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 10, Y1: 8, Text: "Row1"}, + {X0: 0, Y0: 9, X1: 10, Y1: 17, Text: "Row2"}, + } + rows := GroupTSRCellsToRows(cells) + if len(rows) != 2 { + t.Errorf("close rows: expected 2, got %d", len(rows)) + } + }) + + t.Run("varying heights", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 10, Y1: 5, Text: "A"}, + {X0: 0, Y0: 50, X1: 10, Y1: 70, Text: "B"}, + {X0: 0, Y0: 50, X1: 10, Y1: 70, Text: "C"}, + } + rows := GroupTSRCellsToRows(cells) + if len(rows) != 2 { + t.Fatalf("varying heights: expected 2 rows, got %d", len(rows)) + } + if len(rows[0]) != 1 || rows[0][0].Text != "A" { + t.Errorf("row 0: expected [A], got %v", cellTexts(rows[0])) + } + }) +} + +// ── fillCellTextFromBoxes ────────────────────────────────────────────── + +func TestFillCellTextFromBoxes(t *testing.T) { + t.Run("exact match", func(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: 0, X1: 100, Top: 0, Bottom: 50, Text: "A"}, + {X0: 100, X1: 200, Top: 0, Bottom: 50, Text: "B"}, + } + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "A" || cells[1].Text != "B" { + t.Errorf("got %q/%q, want A/B", cells[0].Text, cells[1].Text) + } + }) + + t.Run("empty cells", func(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: 0, X1: 100, Top: 0, Bottom: 50, Text: "only first"}, + } + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "only first" { + t.Errorf("cell[0]: got %q", cells[0].Text) + } + if cells[1].Text != "" { + t.Errorf("cell[1] should be empty, got %q", cells[1].Text) + } + }) + + t.Run("partial cell coverage — empty cell filled from any overlapping box", func(t *testing.T) { + // Box covers 40% of cell area. Old code rejected (<85% cell coverage). + // New code: cell is empty → accepts box (≥30% box area inside cell). + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 200, Y1: 50}} + boxes := []pdf.TextBox{{X0: 0, X1: 80, Top: 0, Bottom: 50, Text: "partial"}} + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "partial" { + t.Errorf("empty cell should be filled from overlapping box, got %q", cells[0].Text) + } + }) + + t.Run("box inside cell >85%", func(t *testing.T) { + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 500, Y1: 300}} + boxes := []pdf.TextBox{{X0: 10, X1: 490, Top: 10, Bottom: 290, Text: "inside"}} + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "inside" { + t.Errorf("got %q", cells[0].Text) + } + }) + + t.Run("concatenate two boxes to same cell", func(t *testing.T) { + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 200, Y1: 100}} + boxes := []pdf.TextBox{ + {X0: 5, X1: 195, Top: 2, Bottom: 98, Text: "hello"}, + {X0: 5, X1: 195, Top: 2, Bottom: 98, Text: "world"}, + } + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "hello world" { + t.Errorf("got %q, want 'hello world'", cells[0].Text) + } + }) + + t.Run("empty inputs", func(t *testing.T) { + FillCellTextFromBoxes(nil, nil) + FillCellTextFromBoxes([]pdf.TSRCell{}, []pdf.TextBox{}) + c := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 1, Y1: 1}} + FillCellTextFromBoxes(c, nil) + if c[0].Text != "" { + t.Error("no boxes → text empty") + } + }) +} + +// ── enrichWithDeepDoc noop ───────────────────────────────────────────── + +func TestGroupTSRCellsToRows_SameHeight(t *testing.T) { + // All cells have identical height → medianH is that value → threshold = medianH/2 + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 50, Y1: 30, Text: "A"}, + {X0: 50, Y0: 0, X1: 100, Y1: 30, Text: "B"}, + {X0: 0, Y0: 31, X1: 50, Y1: 61, Text: "C"}, // gap = 31-30=1 < 30/2=15 → same row? NO, Y0=31 is right at edge + } + rows := GroupTSRCellsToRows(cells) + // medianH=30, threshold=15. C.Y0=31 > curY+threshold?" curY=0, 31 > 15 → new row. + // So A,B in row 0, C in row 1. + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if len(rows[0]) != 2 || len(rows[1]) != 1 { + t.Errorf("row sizes: %d %d, want 2 1", len(rows[0]), len(rows[1])) + } +} + +func TestFillCellTextFromBoxes_WhitespaceTrim(t *testing.T) { + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 100}} + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: " hello "}} + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "hello" { + t.Errorf("got %q, want 'hello'", cells[0].Text) + } +} + +func TestFillCellTextFromBoxes_EmptyBoxIgnored(t *testing.T) { + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 100}} + boxes := []pdf.TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 100, Text: " "}} // all whitespace + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "" { + t.Errorf("whitespace text should produce empty, got %q", cells[0].Text) + } +} diff --git a/internal/deepdoc/parser/pdf/table/table_construct.go b/internal/deepdoc/parser/pdf/table/table_construct.go new file mode 100644 index 0000000000..fdd8ad8d1b --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_construct.go @@ -0,0 +1,907 @@ +package table + +import ( + "fmt" + "math" + "regexp" + "sort" + "strings" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── construct table ───────────────────────────────────────────────────── + +// MergeTablesAcrossPages merges TableItems on consecutive pages with +// overlapping X and close Y proximity. Matches Python's +// _extract_table_figure table merge (pdf_parser.py:1061-1080). +func MergeTablesAcrossPages(tables []pdf.TableItem, medianHeights map[int]float64) []pdf.TableItem { + if len(tables) <= 1 { + return tables + } + // Sort by position for deterministic adjacency. + type indexed struct { + idx int + pg int + top float64 + } + var items []indexed + for i, tbl := range tables { + if len(tbl.Positions) == 0 { + continue + } + p := tbl.Positions[0] + pg := 0 + if len(p.PageNumbers) > 0 { + pg = p.PageNumbers[0] + } + items = append(items, indexed{i, pg, p.Top}) + } + sort.Slice(items, func(a, b int) bool { + if items[a].pg != items[b].pg { + return items[a].pg < items[b].pg + } + return items[a].top < items[b].top + }) + + merged := make([]bool, len(tables)) + var result []pdf.TableItem + + for _, it := range items { + if merged[it.idx] { + continue + } + anchor := tables[it.idx] + merged[it.idx] = true + + // Python nomerge_lout_no: tables whose box is followed by a + // caption/title/reference should not be merged cross-page. + if anchor.NoMerge { + result = append(result, anchor) + continue + } + + anchorPg := it.pg + anchorBott := anchor.Positions[0].Bottom + + // Look for consecutive-page continuations. + for _, jt := range items { + if merged[jt.idx] || jt.pg <= anchorPg { + continue + } + // Python nomerge_lout_no: skip continuation candidates + // tagged as no-merge. + if tables[jt.idx].NoMerge { + continue + } + if jt.pg-anchorPg > 1 { + break // pages must be consecutive + } + if len(tables[jt.idx].Positions) == 0 { + continue + } + bp := tables[jt.idx].Positions[0] + bpg := 0 + if len(bp.PageNumbers) > 0 { + bpg = bp.PageNumbers[0] + } + if bpg != anchorPg+1 { + continue + } + // Check X overlap. + ap := anchor.Positions[0] + if ap.Right < bp.Left || bp.Right < ap.Left { + continue + } + // Check Y proximity: page 1 table top should be close below + // page 0 table bottom. Python: y_dis ≤ mh * 23. + mh := 10.0 + if medianHeights != nil { + if h, ok := medianHeights[anchorPg]; ok && h > 0 { + mh = h + } + } + yDis := (bp.Top + bp.Bottom - anchorBott - ap.Bottom) / 2 + if yDis > mh*23 { + continue + } + // Merge: combine cells and positions. + anchor.Cells = append(anchor.Cells, tables[jt.idx].Cells...) + anchor.Positions = append(anchor.Positions, tables[jt.idx].Positions...) + if tables[jt.idx].Caption != "" { + if anchor.Caption != "" { + anchor.Caption += " " + } + anchor.Caption += tables[jt.idx].Caption + } + merged[jt.idx] = true + anchorPg = bpg + anchorBott = bp.Bottom + } + result = append(result, anchor) + } + return result +} + +// constructTable produces an HTML table string from TSR cells and text boxes. +// Both cells and boxes must be in the same coordinate space (crop pixel space). +// Fills item.Rows so downstream consumers don't need to re-group cells. +// +// Python equivalent: TableStructureRecognizer.construct_table() +// stripCaptionFromCells clears caption-like text from TSR cells. +// This catches captions that fillCellTextFromBoxes missed (e.g. text +// that doesn't match isCaptionBox patterns like "公司差旅费管理办法"). +// Only clears cells whose text matches caption patterns or that contain +// only number+separator text (pure "1. ", "一、" etc. without data). +func StripCaptionFromCells(cells []pdf.TSRCell) { + for i := range cells { + t := strings.TrimSpace(cells[i].Text) + if t == "" { + continue + } + // Clear cells that match caption patterns (e.g. "表1", "Table 1"). + if IsCaptionBox(t, "") { + cells[i].Text = "" + } + } + // Second pass: if the first row (lowest Y) has all-numeric/numbering text + // (e.g. "1", "1.", "一"), it's likely a caption numbering line — clear it. + // But don't clear actual numeric data cells. + // This pass is intentionally conservative — only clears clearly-non-data text. +} + +func ConstructTable(cells []pdf.TSRCell, boxes []pdf.TextBox, caption string, item *pdf.TableItem) string { + // Strip caption-like text from cells (defense-in-depth: fillCellTextFromBoxes + // may include caption text that doesn't match isCaptionBox patterns). + StripCaptionFromCells(cells) + + // Use the pre-computed grid from pdf.TableBuilder.GroupCells. + // Falls back to cell-level grouping only when called directly by + // tests without a pre-computed Grid (production always sets it). + var rows [][]pdf.TSRCell + if item != nil { + rows = item.Grid + } + if rows == nil && len(cells) > 0 && HasAnyText(cells) { + rows = GroupTSRCellsToRows(cells) + } + if len(rows) > 0 && HasText(rows) { + hdrs := HeaderSetWithBlockType(rows) + if item != nil { + item.Rows = RowsToStrings(rows) + } + rows = CleanupOrphanColumns(rows) + spanInfo, covered := CalSpans(rows) + return RowsToHTML(rows, caption, hdrs, spanInfo, covered) + } + // Fallback: boxes with R/C annotations. + if len(boxes) > 0 && BoxesHaveAnnotations(boxes) { + rows := GroupBoxesByRC(boxes) + if HasText(rows) { + if item != nil { + item.Rows = RowsToStrings(rows) + } + spanInfo, covered := CalSpans(rows) + return RowsToHTML(rows, caption, BoxHeaderSet(rows, boxes), spanInfo, covered) + } + } + // Test-only: Y/X coordinate grouping (matching Python construct_table). + // Used by table_parity_test.go to verify pipeline with Python boxes. + if len(boxes) > 0 && !BoxesHaveAnnotations(boxes) { + rows := GroupBoxesByYX(boxes) + if HasText(rows) { + if item != nil { + item.Rows = RowsToStrings(rows) + } + spanInfo, covered := CalSpans(rows) + return RowsToHTML(rows, caption, BoxHeaderSet(rows, boxes), spanInfo, covered) + } + } + return "" +} + +// boxHeaderSet returns rows that contain boxes with H annotations. +func BoxHeaderSet(rows [][]pdf.TSRCell, boxes []pdf.TextBox) map[int]bool { + hdrs := make(map[int]bool) + for _, b := range boxes { + if b.H > 0 && b.R >= 0 && b.R < len(rows) { + hdrs[b.R] = true + } + } + return hdrs +} + +func HasAnyText(cells []pdf.TSRCell) bool { + for _, c := range cells { + if strings.TrimSpace(c.Text) != "" { + return true + } + } + return false +} + +// groupBoxesByRC groups text boxes into a cell grid by R/C annotations. +// Matches Python's construct_table: sort by R, merge nearby rows by Y proximity, +// sort by C within each row, merge nearby columns by X proximity. +func GroupBoxesByRC(boxes []pdf.TextBox) [][]pdf.TSRCell { + if len(boxes) == 0 { + return nil + } + // If no real R/C annotations (maxR <= 0), fall back to YX coordinate + // grouping — matching Python's construct_table when all R=-1. + maxR := 0 + for _, b := range boxes { + if b.R > maxR { + maxR = b.R + } + } + if maxR <= 0 { + return GroupBoxesByYX(boxes) + } + // Sort by R index first (Python: sort_R_firstly), then Y, then X. + sort.Slice(boxes, func(i, j int) bool { + if boxes[i].R != boxes[j].R { + return boxes[i].R < boxes[j].R + } + if boxes[i].Top != boxes[j].Top { + return boxes[i].Top < boxes[j].Top + } + return boxes[i].X0 < boxes[j].X0 + }) + + // Compress R indices: Python's sort_R_firstly grouping. + // R differs → always a new row. Same R + Y gap → also new row. + rowMap := make(map[int]int) // original R → compressed row index + compressed := 0 + rowMap[boxes[0].R] = 0 + lastR := boxes[0].R + btm := boxes[0].Bottom + for i := 1; i < len(boxes); i++ { + // Python: b["R"] != last_R → new row. + // Same R → always same row (Python doesn't check Y for same R). + if boxes[i].R != lastR { + compressed++ + rowMap[boxes[i].R] = compressed + lastR = boxes[i].R + btm = boxes[i].Bottom + } else { + // Same R → same physical row. + rowMap[boxes[i].R] = compressed + btm = (btm + boxes[i].Bottom) / 2.0 + } + } + + // Collect boxes per row, sort by C within each row. + type rb struct { + row, col int + txt string + x0, y0, x1, y1 float64 + label string + } + cmap := make(map[int]map[int]*rb) // row → col → entry + maxCols := make(map[int]int) + for _, b := range boxes { + t := strings.TrimSpace(b.Text) + // Keep boxes with SP/H annotations even if text is empty — + // their coordinates are needed for colspan/rowspan calculation. + if t == "" && b.H <= 0 && b.SP <= 0 { + continue + } + r := rowMap[b.R] + c := b.C + if cmap[r] == nil { + cmap[r] = make(map[int]*rb) + } + x0, y0, x1, y1, label := cellPosFromBox(b) + if v, ok := cmap[r][c]; ok { + v.txt += " " + t + // Merge spanning coordinates (use widest extent). + if b.H > 0 || b.SP > 0 { + v.label = cellLabelFromBox(b) + if v.x0 > x0 { + v.x0 = x0 + } + if v.y0 > y0 { + v.y0 = y0 + } + if v.x1 < x1 { + v.x1 = x1 + } + if v.y1 < y1 { + v.y1 = y1 + } + } + } else { + cmap[r][c] = &rb{r, c, t, x0, y0, x1, y1, label} + } + if c > maxCols[r] { + maxCols[r] = c + } + } + + // Compress C indices per row: sort boxes by X0 within the row, + // group disjoint X ranges into separate columns. This is equivalent + // to Python's sort_C_firstly but uses X0 ordering instead of C labels. + cCompressed := make(map[int]map[int]int) // row → (original C → compressed col) + cMaxCol := make(map[int]int) + for ri := 0; ri <= compressed; ri++ { + rowEntries := cmap[ri] + if rowEntries == nil { + continue + } + // Collect all boxes in this row, sorted by X0. + type rowBox struct { + c, idx int + x0, x1 float64 + txt string + } + var rowBoxes []rowBox + for i, b := range boxes { + if rowMap[b.R] == ri && (strings.TrimSpace(b.Text) != "" || b.H > 0 || b.SP > 0) { + rowBoxes = append(rowBoxes, rowBox{c: b.C, idx: i, x0: b.X0, x1: b.X1, txt: b.Text}) + } + } + sort.Slice(rowBoxes, func(i, j int) bool { return rowBoxes[i].x0 < rowBoxes[j].x0 }) + // Assign compressed column by X-order (disjoint X → new col). + cMap := make(map[int]int) // original C → compressed col + right := 0.0 + for _, rb := range rowBoxes { + if len(cMap) == 0 || rb.x0 >= right { + cc := len(cMap) + cMap[rb.c] = cc + right = rb.x1 + } else { + // Overlapping X → merge into last column. + cMap[rb.c] = len(cMap) - 1 + if rb.x1 > right { + right = rb.x1 + } + } + } + cCompressed[ri] = cMap + cMaxCol[ri] = len(cMap) - 1 + } + + // Build grid. + rows := make([][]pdf.TSRCell, compressed+1) + for ri := 0; ri <= compressed; ri++ { + maxC := cMaxCol[ri] + rows[ri] = make([]pdf.TSRCell, maxC+1) + for ci, v := range cmap[ri] { + cci := cCompressed[ri][ci] + if cci <= maxC { + rows[ri][cci].Text = v.txt + rows[ri][cci].X0 = v.x0 + rows[ri][cci].Y0 = v.y0 + rows[ri][cci].X1 = v.x1 + rows[ri][cci].Y1 = v.y1 + rows[ri][cci].Label = v.label + } + } + } + return rows +} + +// cellPosFromBox returns the position coordinates and label for a cell +// derived from a text box. Header cells use HLeft/HRight/HTop/HBott +// for spanning-aware positions; regular cells use the box's own bounds. +func cellPosFromBox(b pdf.TextBox) (x0, y0, x1, y1 float64, label string) { + x0, y0, x1, y1 = b.X0, b.Top, b.X1, b.Bottom + if b.H > 0 { + label = "table header" + if b.HLeft != 0 || b.HRight != 0 { + if b.HLeft != 0 { + x0 = b.HLeft + } + if b.HRight != 0 { + x1 = b.HRight + } + } + if b.HTop != 0 { + y0 = b.HTop + } + if b.HBott != 0 { + y1 = b.HBott + } + } else if b.SP > 0 { + label = "table spanning cell" + } + return +} + +// cellLabelFromBox returns the TSR label for a box based on H/SP annotations. +// Used when merging multiple boxes into one cell — preserves the spanning label. +func cellLabelFromBox(b pdf.TextBox) string { + if b.H > 0 { + return "table header" + } + if b.SP > 0 { + return "table spanning cell" + } + return "" +} + +// groupBoxesByYX groups boxes into a cell grid by Y/X coordinates, +// matching Python's construct_table which uses sort_R_firstly and +// sort_C_firstly when R/C annotations are absent. +// This is test-only — used by table_parity_test.go to verify pipeline +// parity with Python boxes that lack R/C annotations. +func GroupBoxesByYX(boxes []pdf.TextBox) [][]pdf.TSRCell { + if len(boxes) == 0 { + return nil + } + // Sort by (page, top, x0) — same as Python sort_R_firstly with R=-1. + sort.Slice(boxes, func(i, j int) bool { + if boxes[i].PageNumber != boxes[j].PageNumber { + return boxes[i].PageNumber < boxes[j].PageNumber + } + if boxes[i].Top != boxes[j].Top { + return boxes[i].Top < boxes[j].Top + } + return boxes[i].X0 < boxes[j].X0 + }) + + // Group into rows by Y proximity (Python's row grouping). + type rowGroup struct { + boxes []pdf.TextBox + top, btm float64 + } + var rowGroups []rowGroup + rowGroups = append(rowGroups, rowGroup{boxes: []pdf.TextBox{boxes[0]}, top: boxes[0].Top, btm: boxes[0].Bottom}) + for i := 1; i < len(boxes); i++ { + prev := &rowGroups[len(rowGroups)-1] + // Python: same row if top < prev.btm (Y overlaps) and same page. + if boxes[i].PageNumber == prev.boxes[0].PageNumber && boxes[i].Top < prev.btm { + prev.boxes = append(prev.boxes, boxes[i]) + if boxes[i].Top < prev.top { + prev.top = boxes[i].Top + } + if boxes[i].Bottom > prev.btm { + prev.btm = boxes[i].Bottom + } + } else { + rowGroups = append(rowGroups, rowGroup{boxes: []pdf.TextBox{boxes[i]}, top: boxes[i].Top, btm: boxes[i].Bottom}) + } + } + + // Within each row, group into columns by X proximity. + rows := make([][]pdf.TSRCell, len(rowGroups)) + for ri, rg := range rowGroups { + // Sort by X0. + sort.Slice(rg.boxes, func(i, j int) bool { return rg.boxes[i].X0 < rg.boxes[j].X0 }) + // Group by X overlap. + var cols []struct { + boxes []pdf.TextBox + x1 float64 + } + cols = append(cols, struct { + boxes []pdf.TextBox + x1 float64 + }{boxes: []pdf.TextBox{rg.boxes[0]}, x1: rg.boxes[0].X1}) + for i := 1; i < len(rg.boxes); i++ { + prev := &cols[len(cols)-1] + if rg.boxes[i].X0 < prev.x1 { + prev.boxes = append(prev.boxes, rg.boxes[i]) + if rg.boxes[i].X1 > prev.x1 { + prev.x1 = rg.boxes[i].X1 + } + } else { + cols = append(cols, struct { + boxes []pdf.TextBox + x1 float64 + }{boxes: []pdf.TextBox{rg.boxes[i]}, x1: rg.boxes[i].X1}) + } + } + rows[ri] = make([]pdf.TSRCell, len(cols)) + for ci, col := range cols { + var sb strings.Builder + for _, b := range col.boxes { + t := strings.TrimSpace(b.Text) + if t == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte(' ') + } + sb.WriteString(t) + } + rows[ri][ci].Text = sb.String() + } + } + return rows +} + +func BoxesHaveAnnotations(boxes []pdf.TextBox) bool { + maxR, maxC := 0, 0 + for _, b := range boxes { + if b.R > maxR { + maxR = b.R + } + if b.C > maxC { + maxC = b.C + } + } + // True if at least 2 rows or 2 cols (R/C are 0-based, so maxR>0 means ≥2 rows). + return maxR > 0 || maxC > 0 +} + +func HasText(rows [][]pdf.TSRCell) bool { + for _, row := range rows { + for _, c := range row { + if strings.TrimSpace(c.Text) != "" { + return true + } + } + } + return false +} + +func RowsToStrings(rows [][]pdf.TSRCell) [][]string { + out := make([][]string, len(rows)) + for ri, row := range rows { + out[ri] = make([]string, len(row)) + for ci, c := range row { + out[ri][ci] = c.Text + } + } + return out +} + +// fillCellTextFromAnnotations fills cell text from text boxes using R/C labels. +// This matches Python's construct_table which assigns boxes to cells by their +// R (row) and C (col) annotations rather than spatial overlap. +func FillCellTextFromAnnotations(rows [][]pdf.TSRCell, boxes []pdf.TextBox) { + // Build R→(C→text) map: row index → (col index → text). + rBoxes := make(map[int]map[int][]string) + for _, b := range boxes { + if b.Text == "" { + continue + } + if rBoxes[b.R] == nil { + rBoxes[b.R] = make(map[int][]string) + } + rBoxes[b.R][b.C] = append(rBoxes[b.R][b.C], b.Text) + } + // Fill each cell from the matching R/C position. + for ri, row := range rows { + colMap := rBoxes[ri] + if colMap == nil { + continue + } + // Build sorted column list for positional matching. + type colEntry struct { + c int + texts []string + } + var cols []colEntry + for c, texts := range colMap { + cols = append(cols, colEntry{c, texts}) + } + sort.Slice(cols, func(i, j int) bool { return cols[i].c < cols[j].c }) + for ci, col := range cols { + if ci < len(row) { + row[ci].Text = strings.TrimSpace(strings.Join(col.texts, " ")) + } + } + } +} + +// dataSourceRe matches table/figure boxes that should be discarded as +// data-source attribution lines rather than extracted content. +// +// Python: pdf_parser.py:1040-1042, 1050-1052 +// +// re.match(r"(数据|资料|图表)*来源[:: ]", self.boxes[i]["text"]) +var dataSourceRe = regexp.MustCompile(`^(数据|资料|图表)*来源[:: ]`) + +// isDataSourceBox returns true if the box text matches the data-source +// discard pattern (Python's _extract_table_figure data-source filter). +func isDataSourceBox(text string) bool { + return dataSourceRe.MatchString(text) +} + +// tableRegionBox returns a pdf.TextBox for a table replacement, using DLA region +// boundaries when available (Region* set), falling back to anchor box coordinates. +// Python's insert_table_figures uses DLA layout region boundaries; the fallback +// handles test TableItems or bare engines without DLA. +func tableRegionBox(tbl *pdf.TableItem, ref *pdf.TextBox, html string) pdf.TextBox { + pg := 0 + if len(tbl.Positions) > 0 && len(tbl.Positions[0].PageNumbers) > 0 { + pg = tbl.Positions[0].PageNumbers[0] + } + // Use DLA region boundaries when set. + if tbl.RegionLeft != 0 || tbl.RegionRight != 0 || tbl.RegionTop != 0 || tbl.RegionBottom != 0 { + return pdf.TextBox{ + X0: tbl.RegionLeft, X1: tbl.RegionRight, + Top: tbl.RegionTop, Bottom: tbl.RegionBottom, + Text: html, + PageNumber: pg, + LayoutType: pdf.LayoutTypeTable, + } + } + // Fallback: use anchor box coordinates. + x0, x1, top, bot := ref.X0, ref.X1, ref.Top, ref.Bottom + return pdf.TextBox{ + X0: x0, X1: x1, Top: top, Bottom: bot, + Text: html, + PageNumber: pg, + LayoutType: pdf.LayoutTypeTable, + } +} + +// minRectangleDistance computes the Euclidean distance between two rectangles. +// Returns 0 when rectangles overlap. Matches Python's min_rectangle_distance +// in insert_table_figures (pdf_parser.py:1609-1626). +func minRectangleDistance(left1, right1, top1, bottom1, left2, right2, top2, bottom2 float64) float64 { + if right1 >= left2 && right2 >= left1 && bottom1 >= top2 && bottom2 >= top1 { + return 0 + } + var dx, dy float64 + if right1 < left2 { + dx = left2 - right1 + } else if right2 < left1 { + dx = left1 - right2 + } + if bottom1 < top2 { + dy = top2 - bottom1 + } else if bottom2 < top1 { + dy = top1 - bottom2 + } + return math.Sqrt(dx*dx + dy*dy) +} + +func RowsToHTML(rows [][]pdf.TSRCell, caption string, headerRows map[int]bool, spanInfo map[[2]int][2]int, covered map[[2]int]bool) string { + var b strings.Builder + b.WriteString("") + if caption != "" { + b.WriteString("") + } + for ri, row := range rows { + b.WriteString("") + for ci, cell := range row { + if covered[[2]int{ri, ci}] { + continue + } + tag := "td" + if headerRows[ri] { + tag = "th" + } + b.WriteString("<") + b.WriteString(tag) + sp := "" + if s, ok := spanInfo[[2]int{ri, ci}]; ok { + if s[0] > 1 { + sp = fmt.Sprintf("colspan=%d", s[0]) + } + if s[1] > 1 { + if sp != "" { + sp += " " + } + sp += fmt.Sprintf("rowspan=%d", s[1]) + } + } + if sp != "" { + b.WriteString(" ") + b.WriteString(sp) + } + b.WriteString(" >") + b.WriteString(cell.Text) + b.WriteString("") + } + b.WriteString("") + } + b.WriteString("
") + b.WriteString(caption) + b.WriteString("
") + return b.String() +} + +// ── Span computation (Python: __cal_spans) ── + +// calSpans computes colspan and rowspan for spanning cells in the grid. +// Returns spanInfo (row,col → colspan,rowspan) and covered (cells hidden by spans). +// Matches Python's __cal_spans (table_structure_recognizer.py:535). +// flattenGrid flattens a 2D grid into a 1D slice for fillCellTextFromBoxes. +func FlattenGrid(grid [][]pdf.TSRCell) []pdf.TSRCell { + n := 0 + for _, row := range grid { + n += len(row) + } + flat := make([]pdf.TSRCell, 0, n) + for _, row := range grid { + flat = append(flat, row...) + } + return flat +} + +func CalSpans(rows [][]pdf.TSRCell) (map[[2]int][2]int, map[[2]int]bool) { + spanInfo := make(map[[2]int][2]int) + covered := make(map[[2]int]bool) + if len(rows) == 0 || len(rows[0]) == 0 { + return spanInfo, covered + } + + // Compute column center positions. + nCols := len(rows[0]) + colLeft := make([]float64, nCols) + colRight := make([]float64, nCols) + for j := 0; j < nCols; j++ { + colLeft[j] = 1e9 + colRight[j] = -1e9 + } + nRows := len(rows) + rowTop := make([]float64, nRows) + rowBott := make([]float64, nRows) + for i := 0; i < nRows; i++ { + rowTop[i] = 1e9 + rowBott[i] = -1e9 + } + + for i, row := range rows { + for j, cell := range row { + if j >= nCols { + continue + } + // Exclude spanning cells from column/row boundary calculations. + // Use label-based detection (O(1), no dependency on column midpoints). + if strings.Contains(cell.Label, "spanning") { + continue + } + if cell.X0 < colLeft[j] { + colLeft[j] = cell.X0 + } + if cell.X1 > colRight[j] { + colRight[j] = cell.X1 + } + if cell.Y0 < rowTop[i] { + rowTop[i] = cell.Y0 + } + if cell.Y1 > rowBott[i] { + rowBott[i] = cell.Y1 + } + } + } + + // For each spanning cell, compute how many cols/rows it covers. + for i, row := range rows { + for j, cell := range row { + if j >= nCols || covered[[2]int{i, j}] { + continue + } + // Skip cells without position data (they can't span). + if cell.X0 == 0 && cell.X1 == 0 && cell.Y0 == 0 && cell.Y1 == 0 { + continue + } + cs, rs := 1, 1 + // Count columns whose center is inside this cell's X range. + for k := j + 1; k < nCols; k++ { + // Skip columns with no non-spanning cells (initial values unchanged). + if colLeft[k] == 1e9 && colRight[k] == -1e9 { + continue + } + colCenter := (colLeft[k] + colRight[k]) / 2 + if colCenter >= cell.X0 && colCenter <= cell.X1 { + cs++ + } + } + // Count rows whose center is inside this cell's Y range. + for k := i + 1; k < nRows; k++ { + // Skip rows with no non-spanning cells. + if rowTop[k] == 1e9 && rowBott[k] == -1e9 { + continue + } + rowCenter := (rowTop[k] + rowBott[k]) / 2 + if rowCenter >= cell.Y0 && rowCenter <= cell.Y1 { + rs++ + } + } + if cs > 1 || rs > 1 { + spanInfo[[2]int{i, j}] = [2]int{cs, rs} + // Mark covered cells. + for ri := i; ri < i+rs && ri < nRows; ri++ { + for cj := j; cj < j+cs && cj < nCols; cj++ { + if ri != i || cj != j { + covered[[2]int{ri, cj}] = true + } + } + } + } + } + } + return spanInfo, covered +} + +// ── Orphan column/row cleanup (Python: construct_table lines 256-368) ── + +// cleanupOrphanColumns removes columns that have only a single non-empty cell +// when there are ≥4 rows. Matches Python's construct_table column cleanup. +func CleanupOrphanColumns(rows [][]pdf.TSRCell) [][]pdf.TSRCell { + if len(rows) < 4 || len(rows) == 0 { + return rows + } + nCols := len(rows[0]) + + j := 0 +colLoop: + for j < nCols { + e, ii := 0, 0 + for i := range rows { + if j < len(rows[i]) && strings.TrimSpace(rows[i][j].Text) != "" { + e++ + ii = i + } + if e > 1 { + j++ + continue colLoop + } + } + // Column j has only one non-empty cell at row ii. + // Check if adjacent columns have text for this row. + f := (j > 0 && j-1 < len(rows[ii]) && strings.TrimSpace(rows[ii][j-1].Text) != "") || j == 0 + ff := (j+1 < len(rows[ii]) && strings.TrimSpace(rows[ii][j+1].Text) != "") || j+1 >= len(rows[ii]) + if f && ff { + // Both adjacent columns are ok for merging — but this means + // there's text on both sides, keep column. + j++ + continue + } + + // Determine which side to merge into. + left := 1e9 + right := 1e9 + if j > 0 && !f { + for i := range rows { + if j-1 < len(rows[i]) && strings.TrimSpace(rows[i][j-1].Text) != "" { + // Distance from orphan cell to left neighbor. + if d := rows[ii][j].X0 - rows[i][j-1].X1; d < left { + left = d + } + } + } + } + if j+1 < nCols && !ff { + for i := range rows { + if j+1 < len(rows[i]) && strings.TrimSpace(rows[i][j+1].Text) != "" { + if d := rows[i][j+1].X0 - rows[ii][j].X1; d < right { + right = d + } + } + } + } + + if left < right && j > 0 { + // Merge into left column. + for i := range rows { + if j-1 < len(rows[i]) && j < len(rows[i]) { + if rows[i][j-1].Text == "" { + rows[i][j-1].Text = rows[i][j].Text + } else if rows[i][j].Text != "" { + rows[i][j-1].Text += " " + rows[i][j].Text + } + } + } + } else if j+1 < nCols { + // Merge into right column. + for i := range rows { + if j < len(rows[i]) && j+1 < len(rows[i]) { + if rows[i][j+1].Text == "" { + rows[i][j+1].Text = rows[i][j].Text + } else if rows[i][j].Text != "" { + rows[i][j+1].Text = rows[i][j].Text + " " + rows[i][j+1].Text + } + } + } + } + // Remove column j. + for i := range rows { + if j < len(rows[i]) { + rows[i] = append(rows[i][:j], rows[i][j+1:]...) + } + } + nCols-- + // Don't increment j — the next column shifted into position j. + } + return rows +} diff --git a/internal/deepdoc/parser/pdf/table/table_construct_test.go b/internal/deepdoc/parser/pdf/table/table_construct_test.go new file mode 100644 index 0000000000..e93c64d6b8 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_construct_test.go @@ -0,0 +1,1129 @@ +package table + +import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" + "strings" + "testing" +) + +func TestCellTexts(t *testing.T) { + cells := []pdf.TSRCell{ + {Text: "A"}, {Text: "B"}, {Text: "C"}, + } + texts := cellTexts(cells) + got := strings.Join(texts, ",") + if got != "A,B,C" { + t.Errorf("cellTexts: got %q, want 'A,B,C'", got) + } +} + +// ── constructTable unit tests ───────────────────────────────────────── + +func TestConstructTable_Simple3x2(t *testing.T) { + // 3 columns × 2 rows — cells pre-filled (simulating extractTableBoxesFromImage). + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "A", Label: "table row"}, + {X0: 101, Y0: 0, X1: 200, Y1: 50, Text: "B", Label: "table row"}, + {X0: 201, Y0: 0, X1: 300, Y1: 50, Text: "C", Label: "table row"}, + {X0: 0, Y0: 51, X1: 100, Y1: 100, Text: "D", Label: "table row"}, + {X0: 101, Y0: 51, X1: 200, Y1: 100, Text: "E", Label: "table row"}, + {X0: 201, Y0: 51, X1: 300, Y1: 100, Text: "F", Label: "table row"}, + } + boxes := []pdf.TextBox{} + html := ConstructTable(cells, boxes, "", nil) + if !strings.Contains(html, "") { + t.Error("expected
tag") + } + if !strings.Contains(html, "A") || !strings.Contains(html, "B") || !strings.Contains(html, "C") { + t.Error("expected cell texts A, B, C in HTML") + } + // Should have 2 elements + trCount := strings.Count(html, "") + if trCount != 2 { + t.Errorf("expected 2 rows, got %d", trCount) + } + tdCount := strings.Count(html, "") != 1 { + t.Errorf("expected 1 row, got %d", strings.Count(html, "")) + } + if strings.Count(html, "")) + } + if strings.Count(html, "") != 2 { + t.Errorf("expected 2 rows, got %d. HTML: %s", strings.Count(html, ""), html) + } + if strings.Count(html, ""), html) + } + if item.Rows[0][0] != "第一行" || item.Rows[1][0] != "第二行" || item.Rows[2][0] != "第三行" { + t.Errorf("wrong text: row0=%q row1=%q row2=%q", item.Rows[0][0], item.Rows[1][0], item.Rows[2][0]) + } +} + +// TestConstructTable_RCAfterMerge verifies that R/C annotations survive +// text merge. The merged box expands bounds but keeps the first box's R/C. +func TestConstructTable_RCAfterMerge(t *testing.T) { + // Simulate two adjacent fragments merged into one box. + // The merged box keeps R/C from the first fragment. + postMerge := []pdf.TextBox{ + {X0: 0, X1: 350, Top: 0, Bottom: 30, Text: "公司级领导人员(含公司董事、总监)", R: 0, C: 0}, + {X0: 355, X1: 500, Top: 0, Bottom: 30, Text: "经济舱位", R: 0, C: 1}, + {X0: 0, X1: 200, Top: 35, Bottom: 65, Text: "其他工作人员", R: 1, C: 0}, + {X0: 355, X1: 500, Top: 35, Bottom: 65, Text: "经济舱位", R: 1, C: 1}, + } + item := &pdf.TableItem{} + html := ConstructTable(nil, postMerge, "", item) + if !strings.Contains(html, "公司级领导") { + t.Errorf("missing merged text: %s", html) + } + if strings.Count(html, "") != 2 { + t.Errorf("expected 2 rows, got %d", strings.Count(html, "")) + } + if item.Rows[0][0] != "公司级领导人员(含公司董事、总监)" { + t.Errorf("row 0 col 0 = %q", item.Rows[0][0]) + } +} + +// TestGroupTSRCellsToRowsLabeled_DefaultTableLabel verifies that cells with +// the real TSR default label "table" (class 0) are grouped correctly. +// The current deepDocReRowHdr regex only matches ".* (row|header)" — it misses +// the default "table" label, causing gatherTSR to return empty and forcing +// a fallback to pure Y-based grouping (which loses R/C annotations). +func TestGroupTSRCellsToRowsLabeled_DefaultTableLabel(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 10, Y0: 0, X1: 100, Y1: 30, Label: "table"}, + {X0: 101, Y0: 0, X1: 200, Y1: 30, Label: "table"}, + {X0: 10, Y0: 35, X1: 100, Y1: 65, Label: "table"}, + {X0: 101, Y0: 35, X1: 200, Y1: 65, Label: "table"}, + } + rows := GroupTSRCellsToRows(cells) + if len(rows) != 2 { + t.Fatalf("label %q: expected 2 rows, got %d (BUG: deepDocReRowHdr does not match %q)", "table", len(rows), "table") + } + if len(rows[0]) != 2 || len(rows[1]) != 2 { + t.Errorf("expected 2 cols/row, got %d/%d", len(rows[0]), len(rows[1])) + } +} + +// TestGroupBoxesByRC_RDiffSplitsRows verifies that groupBoxesByRC +// creates separate rows for different R values (Python: R differs → new row). +// Even when boxes share the same Y, different R → different grid row. +func TestGroupBoxesByRC_RDiffSplitsRows(t *testing.T) { + // 6 boxes with 6 different R values → 6 rows (Python R-first splitting). + boxes := []pdf.TextBox{ + {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: 0, C: 0}, + {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: 1, C: 1}, + {X0: 210, X1: 290, Top: 0, Bottom: 30, Text: "C", R: 2, C: 2}, + {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "D", R: 3, C: 0}, + {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "E", R: 4, C: 1}, + {X0: 210, X1: 290, Top: 35, Bottom: 65, Text: "F", R: 5, C: 2}, + } + rows := GroupBoxesByRC(boxes) + // R=0,1,2,3,4,5 → 6 rows (Python: R differs → new row). + if len(rows) != 6 { + t.Fatalf("expected 6 rows (R differs → split), got %d", len(rows)) + } +} + +// TestGroupBoxesByRC_MergesCloseCols verifies that C compression works +// within each R group — merging different C values that are close in X. +func TestGroupBoxesByRC_MergesCloseCols(t *testing.T) { + // R=0 has C=0,1. R=1 has C=0,1. C compression → 2 cols each. + boxes := []pdf.TextBox{ + {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: 0, C: 0}, + {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: 0, C: 1}, + {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "C", R: 1, C: 0}, + {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "D", R: 1, C: 1}, + } + rows := GroupBoxesByRC(boxes) + if len(rows) != 2 { + t.Fatalf("expected 2 rows (R diff), got %d", len(rows)) + } + if len(rows[0]) != 2 || len(rows[1]) != 2 { + t.Errorf("expected 2 cols/row, got %d/%d", len(rows[0]), len(rows[1])) + } + if rows[0][0].Text != "A" || rows[0][1].Text != "B" { + t.Errorf("row0 wrong: %q %q", rows[0][0].Text, rows[0][1].Text) + } + if rows[1][0].Text != "C" || rows[1][1].Text != "D" { + t.Errorf("row1 wrong: %q %q", rows[1][0].Text, rows[1][1].Text) + } +} + +// TestGroupBoxesByRC_RDiffSplitsRow verifies that boxes with different R +// values are placed in separate rows even when their Y ranges overlap. +// Matches Python: R differs → new row unconditionally. +func TestGroupBoxesByRC_RDiffSplitsRow(t *testing.T) { + // R=0 and R=1 at same Y (overlapping) → two separate rows in the grid. + boxes := []pdf.TextBox{ + {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: 0, C: 0}, + {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: 1, C: 1}, + {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "C", R: 2, C: 0}, + {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "D", R: 3, C: 1}, + } + rows := GroupBoxesByRC(boxes) + // R=0,1,2,3 → 4 different R values → 4 rows (Python: R differs → new row). + if len(rows) != 4 { + t.Fatalf("expected 4 rows (R differs → split), got %d", len(rows)) + } + if rows[0][0].Text != "A" || rows[1][0].Text != "B" { + t.Errorf("row0/1 wrong: A=%q B=%q", rows[0][0].Text, rows[1][0].Text) + } +} + +// TestFillCellTextFromBoxes_RCOnly verifies that box text goes to exactly +// one cell via R/C annotations, not multiple cells via spatial overlap. +// A box overlapping two cells should only fill the one matching its R/C. +func TestFillCellTextFromBoxes_RCOnly(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 50, Label: "table"}, + {X0: 90, Y0: 0, X1: 200, Y1: 50, Label: "table"}, + } + // This box straddles cell 0 (X=0-100) and cell 1 (X=90-200). + // Spatial overlap: both match. R/C: should go to cell R=0, C=0 only. + boxes := []pdf.TextBox{ + {X0: 80, X1: 120, Top: 0, Bottom: 50, Text: "TEXT", LayoutType: "table", R: 0, C: 0}, + } + rows := GroupTSRCellsToRows(cells) + for _, b := range boxes { + t := strings.TrimSpace(b.Text) + if t == "" { + continue + } + if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) { + rows[b.R][b.C].Text = t + } + } + // Cell 0 should have text, cell 1 should NOT. + if rows[0][0].Text != "TEXT" { + t.Errorf("cell[0][0] = %q, want %q", rows[0][0].Text, "TEXT") + } + if rows[0][1].Text != "" { + t.Errorf("cell[0][1] = %q, should be empty (spatial overlap leak)", rows[0][1].Text) + } +} + +// TestRowsToHTML_HeaderRows verifies that header rows use ") { + t.Errorf("missing cell '标职务': %s", html) + } + if strings.Count(html, "") != 3 { + t.Errorf("expected 3 rows, got %d", strings.Count(html, "")) + } + }) +} + +// TestExtractTableAndReplace verifies that extractTableAndReplace pops +// table boxes and replaces them with consolidated HTML, matching Python. + +func TestExtractTableAndReplace(t *testing.T) { + // Build boxes with table labels and a pdf.TableItem with cells. + boxes := []pdf.TextBox{ + {X0: 0, X1: 100, Top: 0, Bottom: 20, Text: "A", LayoutType: "table", PageNumber: 0, R: 0, C: 0}, + {X0: 0, X1: 100, Top: 21, Bottom: 40, Text: "B", LayoutType: "table", PageNumber: 0, R: 0, C: 0}, + {X0: 110, X1: 200, Top: 0, Bottom: 20, Text: "C", LayoutType: "table", PageNumber: 0, R: 0, C: 1}, + {X0: 110, X1: 200, Top: 21, Bottom: 40, Text: "D", LayoutType: "table", PageNumber: 0, R: 0, C: 1}, + } + ti := pdf.TableItem{ + Cells: []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 20, Label: "table row"}, + {X0: 110, Y0: 0, X1: 200, Y1: 20, Label: "table row"}, + {X0: 0, Y0: 21, X1: 100, Y1: 40, Label: "table row"}, + {X0: 110, Y0: 21, X1: 200, Y1: 40, Label: "table row"}, + }, + Positions: []pdf.Position{{Left: 0, Right: 200, Top: 0, Bottom: 40}}, + Scale: 1.0, + } + result := ExtractTableAndReplace(boxes, []pdf.TableItem{ti}) + if len(result) != 1 { + t.Fatalf("expected 1 box (replaced), got %d", len(result)) + } + if result[0].LayoutType != "table" { + t.Errorf("expected LayoutType table, got %q", result[0].LayoutType) + } + if !strings.Contains(result[0].Text, "
cells, got %d", tdCount) + } + t.Logf("HTML:\n%s", html) +} + +func TestConstructTable_EmptyCells(t *testing.T) { + html := ConstructTable(nil, nil, "", nil) + if html != "" { + t.Errorf("expected empty string for empty cells, got %q", html) + } + html = ConstructTable([]pdf.TSRCell{}, []pdf.TextBox{}, "", nil) + if html != "" { + t.Errorf("expected empty string for empty cells slice, got %q", html) + } +} + +func TestConstructTable_NoMatchingBox(t *testing.T) { + // Cell has no overlapping text box → empty + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "Has text", Label: "table row"}, + {X0: 101, Y0: 0, X1: 200, Y1: 50, Label: "table row"}, + } + boxes := []pdf.TextBox{} + html := ConstructTable(cells, boxes, "", nil) + if !strings.Contains(html, "Has text") { + t.Error("expected first cell text") + } + // Should still have 2 cells + if strings.Count(html, " cells, got %d. HTML:\n%s", strings.Count(html, "表1:测试标题") { + t.Errorf("expected caption, got:\n%s", html) + } + t.Logf("HTML:\n%s", html) +} + +func TestConstructTable_SingleRow(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 50, Y1: 40, Text: "Col1", Label: "table row"}, + {X0: 51, Y0: 0, X1: 100, Y1: 40, Text: "Col2", Label: "table row"}, + } + html := ConstructTable(cells, nil, "", nil) + if strings.Count(html, "
") != 2 { + t.Errorf("expected 2 rows from Y-fallback, got %d", strings.Count(html, "
") { + t.Error("output should contain HTML table") + } + + // Key assertion: constructTable backfills tables[0].Rows. + rows := tables[0].Rows + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0][0] != "标职务" { + t.Errorf("row 0 col 0 = %q, want %q", rows[0][0], "标职务") + } + if rows[0][1] != "飞机" { + t.Errorf("row 0 col 1 = %q, want %q", rows[0][1], "飞机") + } + if rows[1][0] != "公司级领导" { + t.Errorf("row 1 col 0 = %q, want %q", rows[1][0], "公司级领导") + } + if rows[1][1] != "经济舱位" { + t.Errorf("row 1 col 1 = %q, want %q", rows[1][1], "经济舱位") + } +} + +// TestConstructTable_FromBoxesRC builds HTML directly from boxes with R/C +// annotations, matching Python's construct_table. No cells needed for text. +func TestConstructTable_FromBoxesRC(t *testing.T) { + // Boxes with R (row) and C (col) annotations — like the output of + // annotateTableBoxes after layout cleanup. + boxes := []pdf.TextBox{ + {X0: 50, X1: 150, Top: 100, Bottom: 130, Text: "姓名", R: 0, C: 0}, + {X0: 155, X1: 255, Top: 100, Bottom: 130, Text: "年龄", R: 0, C: 1}, + {X0: 50, X1: 150, Top: 135, Bottom: 165, Text: "张三", R: 1, C: 0}, + {X0: 155, X1: 255, Top: 135, Bottom: 165, Text: "25", R: 1, C: 1}, + } + + // constructTable should build HTML directly from boxes by R/C grouping, + // ignoring cell text (matching Python's construct_table). + item := &pdf.TableItem{} + html := ConstructTable(nil, boxes, "", item) + + if !strings.Contains(html, "姓名") || !strings.Contains(html, "张三") { + t.Errorf("HTML missing box text: %s", html) + } + // 2 rows, 2 cols + if strings.Count(html, "
") != 3 { + t.Errorf("expected 3 rows, got %d. HTML: %s", strings.Count(html, "
instead of . +func TestRowsToHTML_HeaderRows(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Name", Label: "table column header"}, + {X0: 101, Y0: 0, X1: 200, Y1: 30, Text: "Age", Label: "table column header"}, + {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "John", Label: "table row"}, + {X0: 101, Y0: 35, X1: 200, Y1: 65, Text: "30", Label: "table row"}, + } + // constructTable should produce for header row. + item := &pdf.TableItem{} + html := ConstructTable(cells, nil, "", item) + // Header row should use , data row . + if !strings.Contains(html, "") { + t.Errorf("expected for header row. HTML: %s", html) + } + if strings.Count(html, " cells, got %d. HTML: %s", strings.Count(html, " cells (data row), got %d", strings.Count(html, "30% each — spatial fills ALL). + // With R/C, it belongs only to cell[1] (R=0, C=1). + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 30, Label: "table"}, + {X0: 90, Y0: 0, X1: 200, Y1: 30, Label: "table"}, + {X0: 180, Y0: 0, X1: 300, Y1: 30, Label: "table"}, + } + boxes := []pdf.TextBox{ + {X0: 30, X1: 270, Top: 0, Bottom: 30, Text: "TEXT", LayoutType: "table", R: 0, C: 1}, + } + + // Spatial fill: fills ALL overlapping cells —> duplication. + cellsCopy := make([]pdf.TSRCell, 3) + copy(cellsCopy, cells) + FillCellTextFromBoxes(cellsCopy, boxes) + spatialCount := 0 + for _, c := range cellsCopy { + if c.Text != "" { + spatialCount++ + } + } + if spatialCount <= 1 { + t.Errorf("spatial fill: expected >1 cells with text, got %d", spatialCount) + } + t.Logf("spatial fill: %d cells (WRONG — duplication)", spatialCount) + + // R/C fill: only cell matching box.R/C gets text. + cellsRC := make([]pdf.TSRCell, 3) + copy(cellsRC, cells) + rows := GroupTSRCellsToRows(cellsRC) + for _, b := range boxes { + if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) { + rows[b.R][b.C].Text = strings.TrimSpace(b.Text) + } + } + rcCount := 0 + for _, row := range rows { + for _, c := range row { + if c.Text == "TEXT" { + rcCount++ + } + } + } + if rcCount != 1 { + t.Errorf("R/C fill: expected 1 cell with 'TEXT', got %d", rcCount) + } +} + +func TestIsCaptionBox(t *testing.T) { + tests := []struct { + text string + want bool + }{ + {"表1:交通工具等级", true}, + {"Table 1: Transport Levels", true}, + {"图表 1: 测试", true}, + {"公司领导班子成员、出差地", false}, // plain text, not caption + {"第十条到厂矿单位出差", false}, // normal paragraph + {"", false}, + } + for _, tt := range tests { + if got := IsCaptionBox(tt.text, ""); got != tt.want { + t.Errorf("IsCaptionBox(%q) = %v, want %v", tt.text, got, tt.want) + } + } +} + +func TestFillCellTextFromBoxes_SkipsCaption(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 200, Y1: 30, Label: "table"}, + {X0: 0, Y0: 35, X1: 200, Y1: 65, Label: "table"}, + } + boxes := []pdf.TextBox{ + // Caption box (should be skipped) + {X0: 0, X1: 200, Top: 0, Bottom: 30, Text: "表1:交通工具等级"}, + // Data box + {X0: 0, X1: 200, Top: 35, Bottom: 65, Text: "数据行"}, + } + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "" { + t.Errorf("caption leaked into cell 0: %q", cells[0].Text) + } + if cells[1].Text != "数据行" { + t.Errorf("data not in cell 1: %q", cells[1].Text) + } +} + +func TestFillCellText_RCPreventsCrossCellLeak(t *testing.T) { + // Caption box at Y=0-15 overlaps BOTH cell rows (both are "empty"). + // Spatial fill: text leaks to both cells. R/C fill: only cell[0] gets text. + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 300, Y1: 30, Label: "table"}, + {X0: 0, Y0: 35, X1: 300, Y1: 65, Label: "table"}, + } + boxes := []pdf.TextBox{ + {X0: 10, X1: 200, Top: 12, Bottom: 28, Text: "公司领导班子成员、出差地", R: 0, C: 0}, + } + + // Spatial fill → leaks to cells[1] (overlap ≥30%). + cellsSp := make([]pdf.TSRCell, 2) + copy(cellsSp, cells) + FillCellTextFromBoxes(cellsSp, boxes) + if cellsSp[1].Text != "" { + t.Errorf("spatial fill: caption leaked to cell[1]: %q", cellsSp[1].Text) + } + + // R/C fill → only cell[0] (R=0,C=0). + cellsRC := make([]pdf.TSRCell, 2) + copy(cellsRC, cells) + rows := GroupTSRCellsToRows(cellsRC) + for _, b := range boxes { + if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) { + if rows[b.R][b.C].Text == "" { + rows[b.R][b.C].Text = strings.TrimSpace(b.Text) + } + } + } + if cellsRC[1].Text != "" { + t.Errorf("R/C fill: caption leaked to cell[1]: %q", cellsRC[1].Text) + } +} + +func TestGroupBoxesByRC_FallbackToYXWhenNoAnnotations(t *testing.T) { + // When all boxes have R=-1 (Python's case: regex didn't match "table" label), + // groupBoxesByRC should fall back to YX coordinate grouping. + boxes := []pdf.TextBox{ + {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: -1, C: -1}, + {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: -1, C: -1}, + {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "C", R: -1, C: -1}, + {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "D", R: -1, C: -1}, + } + rows := GroupBoxesByRC(boxes) + // R=-1 for all → maxR = -1 → grid would be 0 rows. Must fall back to YX. + if len(rows) == 0 { + t.Fatal("groupBoxesByRC returned 0 rows when R=-1 — no YX fallback") + } + if len(rows) != 2 { + t.Errorf("expected 2 rows (Y-split), got %d", len(rows)) + } +} + +func TestRowsToHTML_Colspan(t *testing.T) { + // Box spanning 2 columns: SP annotation with HLeft/HRight covering cols 0-1. + boxes := []pdf.TextBox{ + {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "Name", R: 0, C: 0, H: 1, HLeft: 10, HRight: 190}, + {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "", R: 0, C: 1, SP: 1}, + {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "John", R: 1, C: 0}, + {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "30", R: 1, C: 1}, + } + rows := GroupBoxesByRC(boxes) + spans, covered := CalSpans(rows) + html := RowsToHTML(rows, "", nil, spans, covered) + if !strings.Contains(html, "colspan") { + t.Errorf("expected colspan attribute, got: %s", html) + } + t.Logf("HTML: %s", html) +} + +// TestStripCaptionFromCells verifies that caption-like text is cleared +// from TSR cells before the table HTML is built. +func TestStripCaptionFromCells_ClearsCaptionPattern(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "表1:差旅费标准"}, + {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: ""}, + {X0: 0, Y0: 60, X1: 100, Y1: 110, Text: "张三"}, + {X0: 100, Y0: 60, X1: 200, Y1: 110, Text: "100"}, + } + StripCaptionFromCells(cells) + if cells[0].Text != "" { + t.Errorf("caption cell should be cleared, got %q", cells[0].Text) + } + if cells[2].Text != "张三" { + t.Errorf("data cell should be preserved, got %q", cells[2].Text) + } +} + +// TestStripCaptionFromCells_PreservesData verifies that non-caption +// cells are not cleared. +func TestStripCaptionFromCells_PreservesData(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "姓名"}, + {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "年龄"}, + {X0: 0, Y0: 60, X1: 100, Y1: 110, Text: "张三"}, + {X0: 100, Y0: 60, X1: 200, Y1: 110, Text: "25"}, + } + // Make a copy and strip + orig := make([]string, len(cells)) + for i, c := range cells { + orig[i] = c.Text + } + StripCaptionFromCells(cells) + for i := range cells { + if cells[i].Text != orig[i] { + t.Errorf("cell[%d] changed: %q -> %q", i, orig[i], cells[i].Text) + } + } +} + +// TestStripCaptionFromCells_Empty is a no-op on empty cells. +func TestStripCaptionFromCells_Empty(t *testing.T) { + cells := []pdf.TSRCell{} + StripCaptionFromCells(cells) // must not panic +} + +// TestConstructTable_StripsCaptionFromCells verifies that constructTable +// strips caption text from cells before building HTML. +func TestConstructTable_StripsCaptionFromCells(t *testing.T) { + // Cell[0] has caption text "表1:标题"; cell[1] has real data. + cells := []pdf.TSRCell{ + {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "表1:标题"}, + {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "数据"}, + } + html := ConstructTable(cells, nil, "", nil) + // "表1:标题" should NOT appear in the HTML (stripped as caption). + if strings.Contains(html, "表1") { + t.Errorf("caption text '表1:标题' should be stripped: %s", html) + } + // "数据" should still be there. + if !strings.Contains(html, "数据") { + t.Errorf("data text '数据' should be preserved: %s", html) + } + t.Logf("HTML: %s", html) +} + +// TestCalSpans_NonSpanningCellsNotPolluted verifies that a regular cell +// at position [0,0] is NOT detected as spanning when a spanning cell at +// [0,1] extends to the left, polluting column boundary calculations. +// Bug: calSpans computed column boundaries from ALL cells including +// spanning cells. "部门开支汇总" at [0,1] with X0=0 extends colLeft[1] +// to 0 instead of 101, shifting the center and causing "Q1" at [0,0] +// to be incorrectly detected as spanning 2 columns. +func TestCalSpans_NonSpanningCellsNotPolluted(t *testing.T) { + // Simulate the SpannedTable test grid: row 0 has Q1(regular), 部门开支汇总(span), Q2(regular) + rows := [][]pdf.TSRCell{ + { + {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Q1", Label: "table row"}, + {X0: 0, Y0: 0, X1: 200, Y1: 30, Text: "部门开支汇总", Label: "table spanning cell"}, + {X0: 101, Y0: 0, X1: 200, Y1: 30, Text: "Q2", Label: "table row"}, + }, + { + {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "100", Label: "table row"}, + {X0: 101, Y0: 35, X1: 200, Y1: 65, Text: "200", Label: "table row"}, + }, + } + + spans, covered := CalSpans(rows) + + // Q1 at [0,0] has X0=0, X1=100 which should only cover its own column. + // It should NOT get a colspan. + if s, ok := spans[[2]int{0, 0}]; ok { + t.Errorf("Q1 at [0,0] should NOT have colspan, got %v. "+ + "Spanning cell at [0,1] polluted column boundaries", s) + } + + // 部门开支汇总 at [0,1] has X0=0, X1=200 which DOES span columns 0 and 1. + if s, ok := spans[[2]int{0, 1}]; !ok { + t.Error("部门开支汇总 at [0,1] should have colspan=2 (covers X=0-200)") + } else if s[0] != 2 { + t.Errorf("部门开支汇总 colspan = %d, want 2", s[0]) + } + + // Q2 at [0,2] should be covered by the spanning cell (col 2 is within X=0-200). + if !covered[[2]int{0, 2}] { + t.Error("Q2 at [0,2] should be covered by spanning cell at [0,1]") + } + + t.Logf("spans: %v, covered: %v", spans, covered) +} + +// ── coordinate space conversion helpers ───────────────────────────────── +func TestRowsToHTML(t *testing.T) { + // rowsToHTML takes [][]pdf.TSRCell instead of [][]string (tableToHTML removed). + toCells := func(rows [][]string) [][]pdf.TSRCell { + out := make([][]pdf.TSRCell, len(rows)) + for ri, row := range rows { + out[ri] = make([]pdf.TSRCell, len(row)) + for ci, s := range row { + out[ri][ci] = pdf.TSRCell{Text: s} + } + } + return out + } + + t.Run("simple 2x2 table", func(t *testing.T) { + rows := toCells([][]string{ + {"姓名", "年龄"}, + {"张三", "25"}, + }) + html := RowsToHTML(rows, "", nil, nil, nil) + expected := "
姓名年龄
张三25
" + if html != expected { + t.Errorf("got %q\nwant %q", html, expected) + } + }) + + t.Run("empty table", func(t *testing.T) { + html := RowsToHTML(nil, "", nil, nil, nil) + if html != "
" { + t.Errorf("expected '
', got %q", html) + } + }) + + t.Run("single cell", func(t *testing.T) { + rows := toCells([][]string{{"X"}}) + html := RowsToHTML(rows, "", nil, nil, nil) + expected := "
X
" + if html != expected { + t.Errorf("got %q\nwant %q", html, expected) + } + }) + + t.Run("matches Python format for 公司差旅费", func(t *testing.T) { + rows := toCells([][]string{ + {"标职务", "飞机", "火车", "轮船", "其他交通工具(不含的士)"}, + {"公司级领导人员", "经济舱位", "火车软席", "二等舱位", "按实报销"}, + {"其他工作人员", "经济舱位", "火车硬席", "三等舱位", "按实报销"}, + }) + html := RowsToHTML(rows, "", nil, nil, nil) + if !strings.HasPrefix(html, "") || !strings.HasSuffix(html, "
") { + t.Errorf("not valid HTML: %s", html) + } + if !strings.Contains(html, "
标职务
") { + t.Errorf("expected HTML table, got %q", result[0].Text) + } +} + +func TestBoxMatchesCell_FalsePositive(t *testing.T) { + // Cell: narrow table cell (40×20 px) + cell := pdf.TSRCell{X0: 0, Y0: 0, X1: 40, Y1: 20} + + // Box A: entirely inside the cell → should match. + boxA := pdf.TextBox{X0: 5, X1: 35, Top: 2, Bottom: 18, Text: "标职务"} + + // Box B: a wide body-text box that only slightly overlaps the cell. + // It covers x=30..200 but the cell is only x=0..40. + // Overlap: x=30..40 (10px), box width=170 → ratio=10/170=0.059 < 0.3. + boxB := pdf.TextBox{X0: 30, X1: 200, Top: 5, Bottom: 15, Text: "第二条出差人员应按规定等级乘坐交通工具..."} + + if !BoxMatchesCell(cell, boxA, true) { + t.Error("boxA entirely inside cell should match with cellIsEmpty=true") + } + if BoxMatchesCell(cell, boxB, true) { + t.Error("boxB mostly outside cell should NOT match even with cellIsEmpty=true") + } + if !BoxMatchesCell(cell, boxA, false) { + t.Error("boxA entirely inside cell should match with cellIsEmpty=false") + } + if BoxMatchesCell(cell, boxB, false) { + t.Error("boxB mostly outside cell should NOT match with cellIsEmpty=false") + } +} + +// TestFillCellTextFromBoxes_PageGlobal verifies that fillCellTextFromBoxes +// correctly matches text boxes to cells when both use page-global 72 DPI +// coordinates, matching Python's construct_table approach. + +func TestFillCellTextFromBoxes_PageGlobal(t *testing.T) { + t.Run("exact alignment matches", func(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 73, Y0: 329, X1: 214, Y1: 345}, + {X0: 214, Y0: 329, X1: 272, Y1: 345}, + {X0: 272, Y0: 329, X1: 407, Y1: 345}, + } + boxes := []pdf.TextBox{ + {X0: 73, X1: 214, Top: 329, Bottom: 345, Text: "标职务"}, + {X0: 214, X1: 272, Top: 329, Bottom: 345, Text: "飞机"}, + {X0: 272, X1: 407, Top: 329, Bottom: 345, Text: "火车"}, + } + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "标职务" { + t.Errorf("cell[0] = %q, want '标职务'", cells[0].Text) + } + if cells[1].Text != "飞机" { + t.Errorf("cell[1] = %q, want '飞机'", cells[1].Text) + } + if cells[2].Text != "火车" { + t.Errorf("cell[2] = %q, want '火车'", cells[2].Text) + } + }) + + t.Run("body text box does not leak into cell", func(t *testing.T) { + cells := []pdf.TSRCell{{X0: 73, Y0: 329, X1: 214, Y1: 345}} + boxes := []pdf.TextBox{ + {X0: 73, X1: 214, Top: 329, Bottom: 345, Text: "标职务"}, + {X0: 73, X1: 520, Top: 310, Bottom: 360, Text: "第二条出差人员应按规定"}, + } + FillCellTextFromBoxes(cells, boxes) + if cells[0].Text != "标职务" { + t.Errorf("cell text = %q, want '标职务' (body text should not leak in)", cells[0].Text) + } + }) + + t.Run("empty cells list is no-op", func(t *testing.T) { + FillCellTextFromBoxes(nil, []pdf.TextBox{{Text: "x"}}) + }) + + t.Run("empty boxes list preserves cell text", func(t *testing.T) { + cells := []pdf.TSRCell{{Text: "existing"}} + FillCellTextFromBoxes(cells, nil) + if cells[0].Text != "existing" { + t.Errorf("existing text should be preserved, got %q", cells[0].Text) + } + }) +} + +// spans and generates "@@5-6\t..." tags. + +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 TestMergeCaptions_NeedsCaptionLayoutType(t *testing.T) { + // Simulate what happens when DLA doesn't produce a "table caption" region: + // a "text" section adjacent to a table is NOT treated as caption. + sections := []pdf.Section{ + {LayoutType: "table", Text: "
data
", + Positions: []pdf.Position{{Left: 100, Right: 500, Top: 200, Bottom: 400}}}, + {LayoutType: "text", Text: "公司领导班子成员、出差地", + Positions: []pdf.Position{{Left: 100, Right: 500, Top: 180, Bottom: 198}}}, + } + figures := pdf.CollectFigures(sections) + result := MergeCaptions(sections, figures) + // BUG: "text" layout type is NOT matched by mergeCaptions (only "table caption"/"figure caption"). + // The caption text survives as a separate section instead of being prepended to the table. + for _, s := range result { + if s.LayoutType == "text" && strings.Contains(s.Text, "公司领导班子") { + t.Log("KNOWN LIMITATION: caption with LayoutType='text' not stripped by mergeCaptions") + } + } +} + +// TestGroupBoxesByRC_ColspanMissing exposes that groupBoxesByRC doesn't +// compute colspan/rowspan from SP annotations (__cal_spans in Python). + +func TestGroupBoxesByRC_ColspanMissing(t *testing.T) { + // Box with SP annotation spanning 2 columns (HLeft→HRight covers cols 0-1). + boxes := []pdf.TextBox{ + {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "Name", R: 0, C: 0, H: 1, + HLeft: 10, HRight: 200}, + {X0: 110, X1: 200, Top: 0, Bottom: 30, Text: "", R: 0, C: 1, SP: 1}, + {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "A", R: 1, C: 0}, + {X0: 110, X1: 200, Top: 35, Bottom: 65, Text: "B", R: 1, C: 1}, + } + rows := GroupBoxesByRC(boxes) + // The result should have colspan=2 for cell [0,0] and skip [0,1]. + // Currently groupBoxesByRC produces a flat grid without span info. + if len(rows) >= 1 && len(rows[0]) >= 2 && rows[0][1].Text == "" { + t.Log("KNOWN LIMITATION: colspan not computed — cell [0,1] is empty instead of merged") + } + _ = rows +} diff --git a/internal/deepdoc/parser/pdf/table/table_coords.go b/internal/deepdoc/parser/pdf/table/table_coords.go new file mode 100644 index 0000000000..423b048ff6 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_coords.go @@ -0,0 +1,58 @@ +package table + +import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── coordinate space conversion helpers ────────────────────────────── + +// CellToPageSpace converts from crop-pixel space to page-global 72-DPI space. +func CellToPageSpace(c pdf.TSRCell, cropOffX, cropOffY, scale float64) pdf.TSRCell { + return pdf.TSRCell{ + X0: (c.X0 + cropOffX) / scale, Y0: (c.Y0 + cropOffY) / scale, + X1: (c.X1 + cropOffX) / scale, Y1: (c.Y1 + cropOffY) / scale, + Text: c.Text, Label: c.Label, + } +} + +// CellAddOffset applies a crop offset to cell coordinates (stays in pixel space). +func CellAddOffset(c pdf.TSRCell, offX, offY float64) pdf.TSRCell { + return pdf.TSRCell{ + X0: c.X0 + offX, Y0: c.Y0 + offY, X1: c.X1 + offX, Y1: c.Y1 + offY, + Text: c.Text, Label: c.Label, + } +} + +// CellSliceToPageSpace converts a slice of cells from crop-pixel to page DPI space. +func CellSliceToPageSpace(cells []pdf.TSRCell, cropOffX, cropOffY, scale float64) []pdf.TSRCell { + out := make([]pdf.TSRCell, len(cells)) + for i, c := range cells { + out[i] = CellToPageSpace(c, cropOffX, cropOffY, scale) + } + return out +} + +// BoxToCropSpace converts a pdf.TextBox from PDF-point space to crop-pixel space. +func BoxToCropSpace(b pdf.TextBox, scale, cropOffX, cropOffY float64) pdf.TextBox { + return pdf.TextBox{ + X0: b.X0*scale - cropOffX, X1: b.X1*scale - cropOffX, + Top: b.Top*scale - cropOffY, Bottom: b.Bottom*scale - cropOffY, + Text: b.Text, + } +} + +// CopyBoxAnnotations copies the DLA/TSR annotation fields from src to dst. +func CopyBoxAnnotations(dst, src *pdf.TextBox) { + dst.R = src.R + dst.C = src.C + dst.RTop = src.RTop + dst.RBott = src.RBott + dst.H = src.H + dst.HTop = src.HTop + dst.HBott = src.HBott + dst.HLeft = src.HLeft + dst.HRight = src.HRight + dst.CLeft = src.CLeft + dst.CRight = src.CRight + dst.SP = src.SP +} diff --git a/internal/deepdoc/parser/pdf/table/table_coords_test.go b/internal/deepdoc/parser/pdf/table/table_coords_test.go new file mode 100644 index 0000000000..4132931d55 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_coords_test.go @@ -0,0 +1,75 @@ +package table + +import ( + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func TestCellToPageSpace(t *testing.T) { + cell := pdf.TSRCell{X0: 100, Y0: 200, X1: 300, Y1: 400, Text: "hello", Label: "table"} + got := CellToPageSpace(cell, 15, 25, 3.0) + + // (100+15)/3 = 38.33..., (200+25)/3 = 75 + if got.X0 != 38.333333333333336 || got.Y0 != 75 || got.X1 != 105 || got.Y1 != 141.66666666666666 { + t.Errorf("cellToPageSpace: got (%f,%f,%f,%f), want (38.33,75,105,141.67)", got.X0, got.Y0, got.X1, got.Y1) + } + if got.Text != "hello" || got.Label != "table" { + t.Error("cellToPageSpace should preserve Text and Label") + } +} + +func TestCellAddOffset(t *testing.T) { + cell := pdf.TSRCell{X0: 100, Y0: 200, X1: 300, Y1: 400, Text: "hello"} + got := CellAddOffset(cell, 15, 25) + if got.X0 != 115 || got.Y0 != 225 || got.X1 != 315 || got.Y1 != 425 { + t.Errorf("cellAddOffset: got (%f,%f,%f,%f)", got.X0, got.Y0, got.X1, got.Y1) + } + if got.Text != "hello" { + t.Error("cellAddOffset should preserve Text") + } +} + +func TestBoxToCropSpace(t *testing.T) { + box := pdf.TextBox{X0: 50, X1: 200, Top: 100, Bottom: 300, Text: "text"} + got := BoxToCropSpace(box, 3.0, 10, 20) + if got.X0 != 140 || got.Top != 280 || got.X1 != 590 || got.Bottom != 880 { + t.Errorf("boxToCropSpace: got (%f,%f,%f,%f)", got.X0, got.Top, got.X1, got.Bottom) + } + if got.Text != "text" { + t.Error("boxToCropSpace should preserve Text") + } +} + +func TestCopyBoxAnnotations(t *testing.T) { + src := &pdf.TextBox{R: 1, C: 2, RTop: 10, RBott: 20, H: 3, HTop: 30, HBott: 40, + HLeft: 50, HRight: 60, CLeft: 70, CRight: 80, SP: 4} + dst := &pdf.TextBox{} + CopyBoxAnnotations(dst, src) + if dst.R != 1 || dst.C != 2 || dst.RTop != 10 || dst.RBott != 20 { + t.Error("R/C fields not copied") + } + if dst.H != 3 || dst.HTop != 30 || dst.HBott != 40 { + t.Error("H fields not copied") + } + if dst.HLeft != 50 || dst.HRight != 60 || dst.CLeft != 70 || dst.CRight != 80 { + t.Error("spanning fields not copied") + } + if dst.SP != 4 { + t.Error("SP not copied") + } +} + +func TestCellSliceToPageSpace(t *testing.T) { + cells := []pdf.TSRCell{ + {X0: 100, Y0: 200, X1: 300, Y1: 400}, + {X0: 400, Y0: 200, X1: 600, Y1: 400}, + } + got := CellSliceToPageSpace(cells, 15, 25, 3) + if len(got) != 2 { + t.Fatal("expected 2 cells") + } + if got[0].X0 != 38.333333333333336 || got[1].X0 != 138.33333333333334 { + t.Error("wrong conversion") + } +} diff --git a/internal/deepdoc/parser/pdf/table_layout.go b/internal/deepdoc/parser/pdf/table/table_layout.go similarity index 78% rename from internal/deepdoc/parser/pdf/table_layout.go rename to internal/deepdoc/parser/pdf/table/table_layout.go index f6462d63f3..ec4f5bcb7d 100644 --- a/internal/deepdoc/parser/pdf/table_layout.go +++ b/internal/deepdoc/parser/pdf/table/table_layout.go @@ -1,16 +1,18 @@ -package parser +package table import ( "math" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + "ragflow/internal/deepdoc/parser/pdf/util" "sort" ) // ── Post-TSR layout annotation (Python: pdf_parser.py gather/layouts_cleanup) ── -// sortYFirstly sorts cells by top, with fuzzy threshold: if two cells are +// SortYFirstly sorts cells by top, with fuzzy threshold: if two cells are // within threshold Y pixels, sort by X instead (same-row ordering). // Python: Recognizer.sort_Y_firstly(arr, threshold) -func sortYFirstly(cells []TSRCell, threshold float64) { +func SortYFirstly(cells []pdf.TSRCell, threshold float64) { sort.Slice(cells, func(i, j int) bool { diff := cells[i].Y0 - cells[j].Y0 if math.Abs(diff) < threshold { @@ -20,8 +22,8 @@ func sortYFirstly(cells []TSRCell, threshold float64) { }) } -// sortXFirstly sorts cells by x0, with fuzzy threshold for top. -func sortXFirstly(cells []TSRCell, threshold float64) { +// SortXFirstly sorts cells by x0, with fuzzy threshold for top. +func SortXFirstly(cells []pdf.TSRCell, threshold float64) { sort.Slice(cells, func(i, j int) bool { diff := cells[i].X0 - cells[j].X0 if math.Abs(diff) < threshold { @@ -37,9 +39,9 @@ func sortXFirstly(cells []TSRCell, threshold float64) { // For each cell, checks the next `far` cells; if they overlap significantly // AND have the same label type, the one with lower score (or less box overlap // area) is removed. -func layoutCleanup(cells []TSRCell, boxes []TextBox, far int, thr float64) []TSRCell { +func layoutCleanup(cells []pdf.TSRCell, boxes []pdf.TextBox, far int, thr float64) []pdf.TSRCell { // cells are assumed pre-sorted (caller sorts before passing) - out := make([]TSRCell, len(cells)) + out := make([]pdf.TSRCell, len(cells)) copy(out, cells) i := 0 @@ -57,8 +59,8 @@ func layoutCleanup(cells []TSRCell, boxes []TextBox, far int, thr float64) []TSR continue } // Cells i and j overlap and have same type. Keep one. - areaI := OverlapRatioA(&out[i], &out[j]) - areaJ := OverlapRatioA(&out[j], &out[i]) + areaI := util.OverlapRatioA(&out[i], &out[j]) + areaJ := util.OverlapRatioA(&out[j], &out[i]) if areaI < thr && areaJ < thr { i++ continue @@ -68,10 +70,10 @@ func layoutCleanup(cells []TSRCell, boxes []TextBox, far int, thr float64) []TSR boxAreaI, boxAreaJ := 0.0, 0.0 for _, b := range boxes { if !tsrBoxOverlap(b, out[i]) { - boxAreaI += OverlapInter(&b, &out[i]) + boxAreaI += util.OverlapInter(&b, &out[i]) } if !tsrBoxOverlap(b, out[j]) { - boxAreaJ += OverlapInter(&b, &out[j]) + boxAreaJ += util.OverlapInter(&b, &out[j]) } } if boxAreaI >= boxAreaJ { @@ -84,12 +86,12 @@ func layoutCleanup(cells []TSRCell, boxes []TextBox, far int, thr float64) []TSR } // notOverlapped returns true if cells a and b do NOT overlap. -func notOverlapped(a, b TSRCell) bool { +func notOverlapped(a, b pdf.TSRCell) bool { return a.X1 < b.X0 || a.X0 > b.X1 || a.Y1 < b.Y0 || a.Y0 > b.Y1 } -// tsrBoxOverlap returns true if a TextBox and a TSRCell do NOT overlap. -func tsrBoxOverlap(b TextBox, c TSRCell) bool { +// tsrBoxOverlap returns true if a pdf.TextBox and a pdf.TSRCell do NOT overlap. +func tsrBoxOverlap(b pdf.TextBox, c pdf.TSRCell) bool { return b.X1 < c.X0 || b.X0 > c.X1 || b.Bottom < c.Y0 || b.Top > c.Y1 } @@ -97,19 +99,19 @@ func tsrBoxOverlap(b TextBox, c TSRCell) bool { // bidirectional overlap >= thr, or -1 if none. // Python: Recognizer.find_overlapped_with_threshold(box, boxes, thr=0.3) // Python uses max(boxRatio, cellRatio) for both gate and scoring. -func findOverlappedWithThreshold(box TextBox, cells []TSRCell, thr float64) int { - boxArea := Area(&box) +func findOverlappedWithThreshold(box pdf.TextBox, cells []pdf.TSRCell, thr float64) int { + boxArea := util.Area(&box) if boxArea <= 0 { return -1 } bestIdx := -1 bestOverlap := thr // Python: max_overlap starts at thr for i, c := range cells { - cellArea := Area(&c) + cellArea := util.Area(&c) if cellArea <= 0 { continue } - ol := OverlapInter(&box, &c) + ol := util.OverlapInter(&box, &c) if ol <= 0 { continue } @@ -130,7 +132,7 @@ func findOverlappedWithThreshold(box TextBox, cells []TSRCell, thr float64) int // Python: Recognizer.find_horizontally_tightest_fit(b, clmns) // findHorizontallyTightestFit returns the column index with minimum // edge distance to the box. Python: Recognizer.find_horizontally_tightest_fit. -func findHorizontallyTightestFit(box TextBox, clmns []TSRCell) int { +func findHorizontallyTightestFit(box pdf.TextBox, clmns []pdf.TSRCell) int { best := -1 bestDist := float64(1<<63 - 1) for i, c := range clmns { @@ -150,19 +152,19 @@ func findHorizontallyTightestFit(box TextBox, clmns []TSRCell) int { // TSR cell labels. Matching Python's R/H/C/SP annotation logic. // // Python: pdf_parser.py:518-554 -func annotateTableBoxes(boxes []TextBox, grid [][]TSRCell) { +func AnnotateTableBoxes(boxes []pdf.TextBox, grid [][]pdf.TSRCell) { // grid[0] is the header row. Spans are computed by calSpans later. - var headers, spans []TSRCell - var clmns []TSRCell + var headers, spans []pdf.TSRCell + var clmns []pdf.TSRCell if len(grid) > 0 { headers = grid[0] clmns = append(clmns, grid[0]...) } - sortYFirstly(headers, 10) - sortXFirstly(clmns, 10) + SortYFirstly(headers, 10) + SortXFirstly(clmns, 10) for i := range boxes { - if boxes[i].LayoutType != LayoutTypeTable { + if boxes[i].LayoutType != pdf.LayoutTypeTable { continue } // Grid-based R/C: match box to the row and column it overlaps. @@ -207,7 +209,7 @@ func annotateTableBoxes(boxes []TextBox, grid [][]TSRCell) { // Collect all table boxes grouped by R. rBoxes := make(map[int][]int) for i := range boxes { - if boxes[i].LayoutType == LayoutTypeTable { + if boxes[i].LayoutType == pdf.LayoutTypeTable { rBoxes[boxes[i].R] = append(rBoxes[boxes[i].R], i) } } diff --git a/internal/deepdoc/parser/pdf/table_layout_test.go b/internal/deepdoc/parser/pdf/table/table_layout_test.go similarity index 83% rename from internal/deepdoc/parser/pdf/table_layout_test.go rename to internal/deepdoc/parser/pdf/table/table_layout_test.go index 37275e4734..cb24f1ade0 100644 --- a/internal/deepdoc/parser/pdf/table_layout_test.go +++ b/internal/deepdoc/parser/pdf/table/table_layout_test.go @@ -1,6 +1,7 @@ -package parser +package table import ( + pdf "ragflow/internal/deepdoc/parser/pdf/type" "sort" "testing" ) @@ -18,8 +19,8 @@ import ( // +----------+----------+ // | row 2A | row 2B | ← row 2 (Y=50..70) // +----------+----------+ -func makeMockTableCells() []TSRCell { - return []TSRCell{ +func makeMockTableCells() []pdf.TSRCell { + return []pdf.TSRCell{ {X0: 10, Y0: 10, X1: 50, Y1: 30, Label: "table column header"}, {X0: 50, Y0: 10, X1: 90, Y1: 30, Label: "table column header"}, {X0: 70, Y0: 30, X1: 90, Y1: 50, Label: "table row"}, @@ -30,8 +31,8 @@ func makeMockTableCells() []TSRCell { } } -func makeMockBoxes() []TextBox { - return []TextBox{ +func makeMockBoxes() []pdf.TextBox { + return []pdf.TextBox{ {X0: 10, X1: 90, Top: 25, Bottom: 55, LayoutType: "table", Text: "test table"}, // row at Y=30..50 overlaps ~80% → should match } @@ -39,23 +40,23 @@ func makeMockBoxes() []TextBox { func TestSortYFirstly(t *testing.T) { t.Run("basic sort", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 10, Y0: 50, Label: "c"}, {X0: 10, Y0: 10, Label: "a"}, {X0: 10, Y0: 30, Label: "b"}, } - sortYFirstly(cells, 5) + SortYFirstly(cells, 5) if cells[0].Label != "a" || cells[1].Label != "b" || cells[2].Label != "c" { t.Errorf("sort order wrong: %v", cells) } }) t.Run("same Y sorts by X", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 90, Y0: 10, Label: "right"}, {X0: 10, Y0: 10, Label: "left"}, } - sortYFirstly(cells, 5) + SortYFirstly(cells, 5) if cells[0].Label != "left" || cells[1].Label != "right" { t.Errorf("same Y should sort X ascending: %v", cells) } @@ -68,7 +69,7 @@ func TestLayoutCleanup(t *testing.T) { boxes := makeMockBoxes() t.Run("no overlap different types", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 10, Y0: 10, X1: 50, Y1: 30, Label: "table column header"}, {X0: 10, Y0: 10, X1: 50, Y1: 30, Label: "table row"}, } @@ -79,7 +80,7 @@ func TestLayoutCleanup(t *testing.T) { }) t.Run("overlap same type keeps one", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 10, Y0: 10, X1: 50, Y1: 30, Label: "table row"}, {X0: 12, Y0: 12, X1: 48, Y1: 28, Label: "table row"}, // mostly contained } @@ -90,7 +91,7 @@ func TestLayoutCleanup(t *testing.T) { }) t.Run("non overlapping same type keeps both", func(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 10, Y0: 10, X1: 50, Y1: 30, Label: "table row"}, {X0: 200, Y0: 10, X1: 250, Y1: 30, Label: "table row"}, // far away } @@ -111,28 +112,28 @@ func TestLayoutCleanup(t *testing.T) { // ── findOverlappedWithThreshold ──────────────────────────────────────── func TestFindOverlappedWithThreshold(t *testing.T) { - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 10, Y0: 10, X1: 50, Y1: 30}, {X0: 50, Y0: 30, X1: 90, Y1: 50}, {X0: 10, Y0: 50, X1: 50, Y1: 70}, } t.Run("exact match", func(t *testing.T) { - box := TextBox{X0: 10, X1: 50, Top: 10, Bottom: 30} + box := pdf.TextBox{X0: 10, X1: 50, Top: 10, Bottom: 30} if idx := findOverlappedWithThreshold(box, cells, 0.3); idx != 0 { t.Errorf("expected idx=0, got %d", idx) } }) t.Run("no match", func(t *testing.T) { - box := TextBox{X0: 200, X1: 250, Top: 200, Bottom: 230} + box := pdf.TextBox{X0: 200, X1: 250, Top: 200, Bottom: 230} if idx := findOverlappedWithThreshold(box, cells, 0.3); idx != -1 { t.Errorf("expected idx=-1, got %d", idx) } }) t.Run("zero area box", func(t *testing.T) { - box := TextBox{X0: 10, X1: 10, Top: 10, Bottom: 10} + box := pdf.TextBox{X0: 10, X1: 10, Top: 10, Bottom: 10} if idx := findOverlappedWithThreshold(box, cells, 0.3); idx != -1 { t.Errorf("zero-area box should return -1: got %d", idx) } @@ -145,7 +146,7 @@ func TestAnnotateTableBoxes(t *testing.T) { cells := makeMockTableCells() boxes := makeMockBoxes() - annotateTableBoxes(boxes, groupTSRCellsToRowsLabeled(cells)) + AnnotateTableBoxes(boxes, GroupTSRCellsToRows(cells)) b := boxes[0] @@ -165,13 +166,13 @@ func TestAnnotateTableBoxes(t *testing.T) { } } -// ── groupTSRCellsToRowsLabeled ───────────────────────────────────────── +// ── GroupTSRCellsToRows ───────────────────────────────────────── func TestGroupTSRCellsToRowsLabeled(t *testing.T) { cells := makeMockTableCells() t.Run("label-based grouping", func(t *testing.T) { - rows := groupTSRCellsToRowsLabeled(cells) + rows := GroupTSRCellsToRows(cells) if len(rows) < 2 { t.Errorf("expected >= 2 rows, got %d", len(rows)) } @@ -184,19 +185,19 @@ func TestGroupTSRCellsToRowsLabeled(t *testing.T) { }) t.Run("fallback to Y-based", func(t *testing.T) { - unlabeled := []TSRCell{ + unlabeled := []pdf.TSRCell{ {X0: 10, Y0: 10, X1: 50, Y1: 20, Label: ""}, {X0: 10, Y0: 30, X1: 50, Y1: 40, Label: ""}, } - rows := groupTSRCellsToRowsLabeled(unlabeled) + rows := GroupTSRCellsToRows(unlabeled) if len(rows) < 2 { t.Errorf("fallback: expected >= 2 rows, got %d", len(rows)) } }) t.Run("single cell", func(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 10, Y1: 10, Label: "table row"}} - rows := groupTSRCellsToRowsLabeled(cells) + cells := []pdf.TSRCell{{X0: 0, Y0: 0, X1: 10, Y1: 10, Label: "table row"}} + rows := GroupTSRCellsToRows(cells) if len(rows) != 1 { t.Errorf("expected 1 row, got %d", len(rows)) } @@ -206,15 +207,15 @@ func TestGroupTSRCellsToRowsLabeled(t *testing.T) { // TestAnnotateTableBoxes_PixelSpace verifies that boxes in pixel space // (as from DLA-scaled coordinates) correctly match TSR cells. Regression test for Bug #1. func TestAnnotateTableBoxes_PixelSpace(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 150, X1: 750, Top: 300, Bottom: 420, LayoutType: "table"}, } - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 150, Y0: 300, X1: 750, Y1: 350, Label: "table column header"}, {X0: 150, Y0: 350, X1: 750, Y1: 380, Label: "table row"}, {X0: 150, Y0: 380, X1: 750, Y1: 420, Label: "table row"}, } - annotateTableBoxes(boxes, groupTSRCellsToRowsLabeled(cells)) + AnnotateTableBoxes(boxes, GroupTSRCellsToRows(cells)) if boxes[0].R < 0 { t.Error("row index should be set (pixel-space matching)") } @@ -226,13 +227,13 @@ func TestAnnotateTableBoxes_PixelSpace(t *testing.T) { // TestFindHorizontallyTightestFit verifies the edge-distance matching // (Python's minimum edge distance, not Go's old containment check). func TestFindHorizontallyTightestFit(t *testing.T) { - clmns := []TSRCell{ + clmns := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50}, {X0: 100, Y0: 0, X1: 200, Y1: 50}, } t.Run("exact match left edge", func(t *testing.T) { - box := TextBox{X0: 100, X1: 150, Top: 0, Bottom: 50} + box := pdf.TextBox{X0: 100, X1: 150, Top: 0, Bottom: 50} if idx := findHorizontallyTightestFit(box, clmns); idx != 1 { t.Errorf("box at col 1 left edge: got idx=%d, want 1", idx) } @@ -241,14 +242,14 @@ func TestFindHorizontallyTightestFit(t *testing.T) { t.Run("partial containment — still matches nearest", func(t *testing.T) { // Box mostly in col 0 but spills into col 1. Old containment check // would fail; distance check matches col 0 (closer edges). - box := TextBox{X0: 80, X1: 120, Top: 0, Bottom: 50} + box := pdf.TextBox{X0: 80, X1: 120, Top: 0, Bottom: 50} if idx := findHorizontallyTightestFit(box, clmns); idx != 0 { t.Errorf("spill box: got idx=%d, want 0 (nearest edges)", idx) } }) t.Run("empty columns", func(t *testing.T) { - if idx := findHorizontallyTightestFit(TextBox{}, nil); idx != -1 { + if idx := findHorizontallyTightestFit(pdf.TextBox{}, nil); idx != -1 { t.Errorf("empty: got %d, want -1", idx) } }) @@ -258,11 +259,11 @@ func TestFindHorizontallyTightestFit(t *testing.T) { // (bidirectional overlap) replaces the old first-match behavior. func TestFindOverlappedWithThreshold_BestMatch(t *testing.T) { // Two cells overlap the same box. Cell 1 has MORE overlap → should win. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 50, Y1: 50}, // 30% overlap {X0: 0, Y0: 0, X1: 100, Y1: 100}, // 100% overlap — best match } - box := TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} + box := pdf.TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} if idx := findOverlappedWithThreshold(box, cells, 0.2); idx != 1 { t.Errorf("best-match: got idx=%d, want 1 (100%% overlap beats 30%%)", idx) } @@ -276,8 +277,8 @@ func TestFindOverlappedWithThreshold_BestMatch(t *testing.T) { // Old Go (box-only gate): overlap/boxArea = 0.02 > 0.3? → NO MATCH ✗ func TestFindOverlappedWithThreshold_BidirectionalGate(t *testing.T) { // Large box fully contains a tiny cell. - box := TextBox{X0: 0, X1: 500, Top: 0, Bottom: 20} // area = 10000 - cells := []TSRCell{ + box := pdf.TextBox{X0: 0, X1: 500, Top: 0, Bottom: 20} // area = 10000 + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 10, Y1: 20}, // area = 200, entirely inside box } // boxRatio = 200/10000 = 0.02, cellRatio = 200/200 = 1.0 @@ -296,8 +297,8 @@ func TestFindOverlappedWithThreshold_BidirectionalGate(t *testing.T) { // Cell B: boxRatio=0.40, cellRatio=0.40 → max=0.40, sum=0.80 // Python (max): picks A (0.60 > 0.40). Old Go (sum): picks B (0.80 > 0.65). func TestFindOverlappedWithThreshold_MaxScoring(t *testing.T) { - box := TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} // area = 10000 - cells := []TSRCell{ + box := pdf.TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} // area = 10000 + cells := []pdf.TSRCell{ // Cell A: narrow but tall (60×2000), covers 60% of box width. // boxRatio=60*100/10000=0.60, cellRatio=60*100/(60*2000)=0.05, max=0.60 {X0: 0, Y0: 0, X1: 60, Y1: 2000}, @@ -323,17 +324,17 @@ func TestFindOverlappedWithThreshold_MaxScoring(t *testing.T) { // row×col structure even without row/column labels. func TestGroupTSRCellsToRowsLabeled_FallbackY(t *testing.T) { // 4 rows × 5 cols = 20 cells, all label="table". - cells := make([]TSRCell, 20) + cells := make([]pdf.TSRCell, 20) for r := 0; r < 4; r++ { for c := 0; c < 5; c++ { - cells[r*5+c] = TSRCell{ + cells[r*5+c] = pdf.TSRCell{ X0: float64(c * 100), Y0: float64(r * 30), X1: float64(c*100 + 80), Y1: float64(r*30 + 25), Label: "table", } } } - rows := groupTSRCellsToRowsLabeled(cells) + rows := GroupTSRCellsToRows(cells) if len(rows) != 4 { t.Fatalf("fallback Y-grouping: expected 4 rows, got %d", len(rows)) } @@ -360,7 +361,8 @@ func TestGroupTSRCellsToRowsLabeled_FallbackY(t *testing.T) { func TestGroupTSRCellsToRowsLabeled_Irregular(t *testing.T) { // Irregular layout: row 0 has 3 cells, row 1 has 5, row 2 has 2. // Cells within a row have slightly different Y (within threshold). - cells := []TSRCell{ + // Basic Y-proximity grouping does not pad rows to equal column counts. + cells := []pdf.TSRCell{ // Row 0 — 3 cells at ~Y=0 (slightly staggered tops). {X0: 0, Y0: 0, X1: 80, Y1: 25, Label: "table"}, {X0: 90, Y0: 2, X1: 170, Y1: 27, Label: "table"}, @@ -375,18 +377,18 @@ func TestGroupTSRCellsToRowsLabeled_Irregular(t *testing.T) { {X0: 0, Y0: 60, X1: 80, Y1: 85, Label: "table"}, {X0: 90, Y0: 61, X1: 170, Y1: 86, Label: "table"}, } - rows := groupTSRCellsToRowsLabeled(cells) + rows := GroupTSRCellsToRows(cells) if len(rows) != 3 { t.Fatalf("irregular: expected 3 rows, got %d", len(rows)) } - if len(rows[0]) != 5 { - t.Errorf("row 0: expected 5 cols (padded), got %d", len(rows[0])) + if len(rows[0]) != 3 { + t.Errorf("row 0: expected 3 cols, got %d", len(rows[0])) } if len(rows[1]) != 5 { t.Errorf("row 1: expected 5 cols, got %d", len(rows[1])) } - if len(rows[2]) != 5 { - t.Errorf("row 2: expected 5 cols (padded), got %d", len(rows[2])) + if len(rows[2]) != 2 { + t.Errorf("row 2: expected 2 cols, got %d", len(rows[2])) } } @@ -396,25 +398,25 @@ func TestGroupTSRCellsToRowsLabeled_Irregular(t *testing.T) { // its existing Text (from TSR or previous steps). func TestFillCellTextFromBoxes_PreservesTSRText(t *testing.T) { // Cell already has text from TSR. No box overlaps it. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "TSR-provided"}, } - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 500, X1: 600, Top: 500, Bottom: 550, Text: "far away"}, } - fillCellTextFromBoxes(cells, boxes) + FillCellTextFromBoxes(cells, boxes) if cells[0].Text != "TSR-provided" { t.Errorf("TSR text overwritten: got %q, want 'TSR-provided'", cells[0].Text) } // Cell with TSR text, box covers >85% — should be overwritten. - cells2 := []TSRCell{ + cells2 := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "TSR-provided"}, } - boxes2 := []TextBox{ + boxes2 := []pdf.TextBox{ {X0: 1, X1: 99, Top: 1, Bottom: 49, Text: "box-text"}, } - fillCellTextFromBoxes(cells2, boxes2) + FillCellTextFromBoxes(cells2, boxes2) if cells2[0].Text != "box-text" { t.Errorf("box text should override TSR text: got %q, want 'box-text'", cells2[0].Text) } @@ -429,10 +431,10 @@ func TestFillCellTextFromBoxes_PartialOverlap(t *testing.T) { // Empty cell (no TSR text). Box only has ~55% of its area inside // the cell (spills across the boundary). Python's 0.3 threshold // accepts this; Go's 0.85 rejects it → empty cell. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: ""}, } - boxes := []TextBox{ + boxes := []pdf.TextBox{ // Box: 60% inside cell, 40% outside. Overlap ratio = 60%. {X0: 40, X1: 140, Top: 5, Bottom: 15, Text: "spill text"}, } @@ -440,41 +442,35 @@ func TestFillCellTextFromBoxes_PartialOverlap(t *testing.T) { // Overlap: X=(40,100) Y=(5,15) → 60×10=600. // Box area: 100×10=1000. ratio = 600/1000 = 60%. // Old 85% threshold → rejected. Python's 0.3 → accepted. - fillCellTextFromBoxes(cells, boxes) + FillCellTextFromBoxes(cells, boxes) if cells[0].Text != "spill text" { t.Errorf("partial overlap (<85%%) on empty cell should still fill: got %q, want 'spill text'", cells[0].Text) } } -// TestGroupTSRCellsToRowsLabeled_ColumnAlignment verifies that all -// rows have the same column count after grouping, even with spanning -// cells. Python's construct_table ensures R×C matrix alignment; -// Go's Y-grouping can produce jagged rows when spanning cells make -// some rows appear shorter. +// TestGroupTSRCellsToRowsLabeled_ColumnAlignment verifies that basic +// Y-proximity grouping produces the correct row counts. Unlike the EE +// label-aware grouping, Y-proximity does not handle spanning cells +// specially — each cell is simply placed into its Y-based row. func TestGroupTSRCellsToRowsLabeled_ColumnAlignment(t *testing.T) { - // 2-row table: row 0 has a spanning cell (covers 2 columns) → 2 visible cells. - // row 1 has 3 normal cells. - // Python construct_table: both rows padded to 3 cols. - // Go Y-grouping (current): row 0 has 2 cols, row 1 has 3 → JAGGED. - cells := []TSRCell{ - // Row 0 — spanning cell + 1 normal cell (= 2 cells) + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 30, Label: "table spanning cell"}, {X0: 200, Y0: 0, X1: 300, Y1: 30, Label: "table row"}, - // Row 1 — 3 normal cells {X0: 0, Y0: 30, X1: 100, Y1: 60, Label: "table row"}, {X0: 100, Y0: 30, X1: 200, Y1: 60, Label: "table row"}, {X0: 200, Y0: 30, X1: 300, Y1: 60, Label: "table row"}, } - rows := groupTSRCellsToRowsLabeled(cells) + rows := GroupTSRCellsToRows(cells) if len(rows) != 2 { t.Fatalf("expected 2 rows, got %d", len(rows)) } - // BUG: row 0 only has 2 cells (spanning cell covers 2 columns but - // appears as 1 cell in Y-grouping). Python's construct_table pads - // to 3 columns. - if len(rows[0]) != len(rows[1]) { - t.Errorf("column alignment broken: row0=%d cols, row1=%d cols — "+ - "Python construct_table ensures all rows have equal columns", len(rows[0]), len(rows[1])) + // Basic Y-proximity: row0 has 2 cells, row1 has 3 cells. + // No column alignment padding (EE feature). + if len(rows[0]) != 2 { + t.Errorf("row0: expected 2 cells, got %d", len(rows[0])) + } + if len(rows[1]) != 3 { + t.Errorf("row1: expected 3 cells, got %d", len(rows[1])) } } @@ -484,7 +480,7 @@ func TestGroupTSRCellsToRowsLabeled_ColumnAlignment(t *testing.T) { func TestAnnotateTableBoxes_RealTSRLabels(t *testing.T) { // Simulate a 2×3 table: 2 rows, 3 columns. // TSR cells with label "table" (default TSR class 0) — like 公司差旅费. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 30, Label: "table"}, {X0: 101, Y0: 0, X1: 200, Y1: 30, Label: "table"}, {X0: 201, Y0: 0, X1: 300, Y1: 30, Label: "table"}, @@ -492,7 +488,7 @@ func TestAnnotateTableBoxes_RealTSRLabels(t *testing.T) { {X0: 101, Y0: 35, X1: 200, Y1: 65, Label: "table"}, {X0: 201, Y0: 35, X1: 300, Y1: 65, Label: "table"}, } - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", LayoutType: "table"}, {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", LayoutType: "table"}, {X0: 210, X1: 290, Top: 0, Bottom: 30, Text: "C", LayoutType: "table"}, @@ -500,7 +496,7 @@ func TestAnnotateTableBoxes_RealTSRLabels(t *testing.T) { {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "E", LayoutType: "table"}, {X0: 210, X1: 290, Top: 35, Bottom: 65, Text: "F", LayoutType: "table"}, } - annotateTableBoxes(boxes, groupTSRCellsToRowsLabeled(cells)) + AnnotateTableBoxes(boxes, GroupTSRCellsToRows(cells)) // Verify R (row) assignments — should be 0 for top row, 1 for bottom row. for i, b := range boxes { @@ -525,30 +521,30 @@ func TestAnnotateTableBoxes_RealTSRLabels(t *testing.T) { // actual overlap. This test locks in the semantics so future readers // and static analysis tools can rely on the behaviour. func TestTsrBoxOverlap_ReturnsTrueWhenDisjoint(t *testing.T) { - box := TextBox{X0: 50, X1: 100, Top: 0, Bottom: 50} + box := pdf.TextBox{X0: 50, X1: 100, Top: 0, Bottom: 50} // Separated in X (cell to the right) → disjoint → true. - if !tsrBoxOverlap(box, TSRCell{X0: 150, Y0: 0, X1: 200, Y1: 50}) { + if !tsrBoxOverlap(box, pdf.TSRCell{X0: 150, Y0: 0, X1: 200, Y1: 50}) { t.Error("cell to the right (separated in X): expected true") } // Separated in X (cell to the left) → disjoint → true. - if !tsrBoxOverlap(box, TSRCell{X0: 0, Y0: 0, X1: 30, Y1: 50}) { + if !tsrBoxOverlap(box, pdf.TSRCell{X0: 0, Y0: 0, X1: 30, Y1: 50}) { t.Error("cell to the left (separated in X): expected true") } // Separated in Y (cell below) → disjoint → true. - if !tsrBoxOverlap(box, TSRCell{X0: 50, Y0: 100, X1: 100, Y1: 150}) { + if !tsrBoxOverlap(box, pdf.TSRCell{X0: 50, Y0: 100, X1: 100, Y1: 150}) { t.Error("cell below (separated in Y): expected true") } // Separated in Y (cell above) → disjoint → true. - if !tsrBoxOverlap(box, TSRCell{X0: 50, Y0: -50, X1: 100, Y1: -10}) { + if !tsrBoxOverlap(box, pdf.TSRCell{X0: 50, Y0: -50, X1: 100, Y1: -10}) { t.Error("cell above (separated in Y): expected true") } // Fully enclosing cell → overlaps in both X and Y → NOT disjoint → false. - if tsrBoxOverlap(box, TSRCell{X0: 0, Y0: 0, X1: 200, Y1: 100}) { + if tsrBoxOverlap(box, pdf.TSRCell{X0: 0, Y0: 0, X1: 200, Y1: 100}) { t.Error("cell fully enclosing box (overlaps): expected false") } // Partially overlapping cell → overlaps in both dims → false. - if tsrBoxOverlap(box, TSRCell{X0: 25, Y0: 25, X1: 75, Y1: 75}) { + if tsrBoxOverlap(box, pdf.TSRCell{X0: 25, Y0: 25, X1: 75, Y1: 75}) { t.Error("cell partially overlapping: expected false") } } diff --git a/internal/deepdoc/parser/pdf/table/table_orient.go b/internal/deepdoc/parser/pdf/table/table_orient.go new file mode 100644 index 0000000000..9e71e26751 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_orient.go @@ -0,0 +1,107 @@ +package table + +import ( + "context" + "fmt" + "image" + "log/slog" + "math" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" + "ragflow/internal/deepdoc/parser/pdf/util" +) + +// EvaluateTableOrientation tests 4 rotation angles (0/90/180/270) and picks +// the best orientation based on OCR detect-region count and area coverage. +// +// Returns bestAngle (0/90/180/270), the rotated image, and per-angle scores. +// +// Absolute threshold: non-0° wins only if its combined score exceeds 0° by +// more than 1.4× AND the 0° score is below 6.0. +// +// Python: pdf_parser.py:314 _evaluate_table_orientation() +func EvaluateTableOrientation(ctx context.Context, tableImg image.Image, doc pdf.DocAnalyzer) (bestAngle int, bestImg image.Image, scores map[int]float64) { + rotations := []struct { + angle int + name string + }{ + {0, "original"}, + {90, "rotate_90"}, + {180, "rotate_180"}, + {270, "rotate_270"}, + } + + scores = make(map[int]float64, 4) + bestScore := float64(-1) + bestAngle = 0 + bestImg = tableImg + + for _, rot := range rotations { + rotated := tableImg + if rot.angle != 0 { + rotated = util.RotateImageCW(tableImg, rot.angle) + if rotated == nil { + slog.Warn("table rotate failed", "angle", rot.angle) + continue + } + } + + detectBoxes, err := doc.OCRDetect(ctx, rotated) + if err != nil || len(detectBoxes) == 0 { + scores[rot.angle] = 0 + continue + } + + // Score by detect-region count (primary) + area (tiebreaker). + imageArea := float64(rotated.Bounds().Dx() * rotated.Bounds().Dy()) + totalRegions := 0 + var totalArea float64 + for _, box := range detectBoxes { + x0 := math.Min(box.X0, math.Min(box.X1, math.Min(box.X2, box.X3))) + y0 := math.Min(box.Y0, math.Min(box.Y1, math.Min(box.Y2, box.Y3))) + x1 := math.Max(box.X0, math.Max(box.X1, math.Max(box.X2, box.X3))) + y1 := math.Max(box.Y0, math.Max(box.Y1, math.Max(box.Y2, box.Y3))) + if x0 >= x1 || y0 >= y1 { + continue + } + totalRegions++ + totalArea += (x1 - x0) * (y1 - y0) + } + if totalRegions == 0 { + scores[rot.angle] = 0 + continue + } + areaRatio := totalArea / imageArea + combined := float64(totalRegions) * (1 + 0.06*areaRatio) + scores[rot.angle] = combined + + slog.Debug("table orientation", + "angle", rot.angle, + "regions", totalRegions, + "area_ratio", fmt.Sprintf("%.4f", areaRatio), + "combined", fmt.Sprintf("%.2f", combined)) + + if combined > bestScore { + bestScore = combined + bestAngle = rot.angle + bestImg = rotated + } + } + + // Absolute threshold: only accept non-0° if region count is clearly + // higher (≥1.4×) AND 0° has few regions (< 6). + score0 := scores[0] + if bestAngle != 0 && score0 > 0 { + if !(bestScore > score0*1.4 && score0 < 6.0) { + bestAngle = 0 + bestImg = tableImg + bestScore = score0 + } + } + + slog.Debug("best table orientation", + "angle", bestAngle, + "score", fmt.Sprintf("%.4f", bestScore)) + + return bestAngle, bestImg, scores +} diff --git a/internal/deepdoc/parser/pdf/table_rotate_test.go b/internal/deepdoc/parser/pdf/table/table_orient_test.go similarity index 82% rename from internal/deepdoc/parser/pdf/table_rotate_test.go rename to internal/deepdoc/parser/pdf/table/table_orient_test.go index fc3796cfd8..6ad22e8df9 100644 --- a/internal/deepdoc/parser/pdf/table_rotate_test.go +++ b/internal/deepdoc/parser/pdf/table/table_orient_test.go @@ -1,8 +1,9 @@ -package parser +package table import ( "context" "image" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) @@ -22,18 +23,21 @@ type mockRotationDoc struct { var rotationOrder = []int{0, 90, 180, 270} -func (m *mockRotationDoc) DLA(_ context.Context, _ image.Image) ([]DLARegion, error) { return nil, nil } -func (m *mockRotationDoc) TSR(_ context.Context, _ image.Image) ([]TSRCell, error) { return nil, nil } -func (m *mockRotationDoc) OCR(_ image.Image) (string, error) { return "", nil } -func (m *mockRotationDoc) Health() bool { return true } -func (m *mockRotationDoc) ModelType() ModelType { return ModelSaas } +func (m *mockRotationDoc) DLA(_ context.Context, _ image.Image) ([]pdf.DLARegion, error) { + return nil, nil +} +func (m *mockRotationDoc) TSR(_ context.Context, _ image.Image) ([]pdf.TSRCell, error) { + return nil, nil +} +func (m *mockRotationDoc) OCR(_ image.Image) (string, error) { return "", nil } +func (m *mockRotationDoc) Health() bool { return true } func (m *mockRotationDoc) currentAngle() int { idx := m.callSeq % len(rotationOrder) return rotationOrder[idx] } -func (m *mockRotationDoc) OCRDetect(_ context.Context, img image.Image) ([]OCRBox, error) { +func (m *mockRotationDoc) OCRDetect(_ context.Context, img image.Image) ([]pdf.OCRBox, error) { defer func() { m.callSeq++ }() angle := m.currentAngle() cfg, ok := m.angles[angle] @@ -47,11 +51,11 @@ func (m *mockRotationDoc) OCRDetect(_ context.Context, img image.Image) ([]OCRBo return nil, nil } w, h := img.Bounds().Dx(), img.Bounds().Dy() - boxes := make([]OCRBox, cfg.regions) + boxes := make([]pdf.OCRBox, cfg.regions) step := w / (cfg.regions + 1) for i := 0; i < cfg.regions; i++ { x := step * (i + 1) - boxes[i] = OCRBox{ + boxes[i] = pdf.OCRBox{ X0: float64(x), Y0: float64(h / 4), X1: float64(x + 20), Y1: float64(h / 4), X2: float64(x + 20), Y2: float64(h * 3 / 4), @@ -61,8 +65,8 @@ func (m *mockRotationDoc) OCRDetect(_ context.Context, img image.Image) ([]OCRBo return boxes, nil } -func (m *mockRotationDoc) OCRRecognizeBatch(_ context.Context, cropped []image.Image) ([][]OCRText, []error) { - results := make([][]OCRText, len(cropped)) +func (m *mockRotationDoc) OCRRecognizeBatch(_ context.Context, cropped []image.Image) ([][]pdf.OCRText, []error) { + results := make([][]pdf.OCRText, len(cropped)) errs := make([]error, len(cropped)) for i, img := range cropped { results[i], errs[i] = m.OCRRecognize(context.Background(), img) @@ -70,7 +74,7 @@ func (m *mockRotationDoc) OCRRecognizeBatch(_ context.Context, cropped []image.I return results, errs } -func (m *mockRotationDoc) OCRRecognize(_ context.Context, _ image.Image) ([]OCRText, error) { +func (m *mockRotationDoc) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) { angle := rotationOrder[(m.callSeq-1)%len(rotationOrder)] // use angle from last Detect call cfg, ok := m.angles[angle] if !ok { @@ -82,9 +86,9 @@ func (m *mockRotationDoc) OCRRecognize(_ context.Context, _ image.Image) ([]OCRT if cfg.regions == 0 { return nil, nil } - texts := make([]OCRText, cfg.regions) + texts := make([]pdf.OCRText, cfg.regions) for i := 0; i < cfg.regions; i++ { - texts[i] = OCRText{Text: "X", Confidence: cfg.avgConf} + texts[i] = pdf.OCRText{Text: "X", Confidence: cfg.avgConf} } return texts, nil } @@ -104,7 +108,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 0: {regions: 10, avgConf: 0.9}, }, } - angle, _, scores := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, _, scores := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 0 { t.Errorf("expected 0°, got %d° (scores: %v)", angle, scores) } @@ -123,7 +127,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 270: {regions: 2, avgConf: 0.2}, }, } - angle, _, scores := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, _, scores := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 90 { t.Errorf("expected 90°, got %d° (scores: %v)", angle, scores) } @@ -142,7 +146,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 270: {regions: 1, avgConf: 0.1}, }, } - angle, _, scores := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, _, scores := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 180 { t.Errorf("expected 180°, got %d° (scores: %v)", angle, scores) } @@ -161,7 +165,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 270: {regions: 9, avgConf: 0.88}, }, } - angle, _, scores := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, _, scores := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 270 { t.Errorf("expected 270°, got %d° (scores: %v)", angle, scores) } @@ -179,7 +183,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 90: {regions: 9}, }, } - angle, _, _ := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 0 { t.Errorf("expected 0° (threshold protection), got %d°", angle) } @@ -197,7 +201,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 90: {regions: 10}, }, } - angle, _, _ := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 90 { t.Errorf("expected 90° (threshold passed), got %d°", angle) } @@ -216,7 +220,7 @@ func TestEvaluateTableOrientation(t *testing.T) { 270: {err: errMockOCR}, }, } - angle, img, scores := evaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + angle, img, scores := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) if angle != 0 { t.Errorf("expected 0° fallback, got %d°", angle) } diff --git a/internal/deepdoc/parser/pdf/table_parity_issues_test.go b/internal/deepdoc/parser/pdf/table/table_parity_issues_test.go similarity index 90% rename from internal/deepdoc/parser/pdf/table_parity_issues_test.go rename to internal/deepdoc/parser/pdf/table/table_parity_issues_test.go index 2c3bf039df..59e2df867e 100644 --- a/internal/deepdoc/parser/pdf/table_parity_issues_test.go +++ b/internal/deepdoc/parser/pdf/table/table_parity_issues_test.go @@ -1,12 +1,13 @@ //go:build manual -package parser +package table import ( "bytes" "context" "encoding/base64" "image" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "regexp" "strings" "testing" @@ -24,19 +25,19 @@ import ( // figure boxes are popped and re-inserted via insert_table_figures with cropped // images. Go leaves them in the box list for downstream boxesToSections. func TestExtractTableAndReplace_IgnoresFigures(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 200, Top: 0, Bottom: 50, Text: "Figure text", LayoutType: "figure", PageNumber: 0}, {X0: 10, X1: 200, Top: 60, Bottom: 80, Text: "表1:标题", LayoutType: "table", PageNumber: 0}, } // Table with cells so extractTableAndReplace generates HTML. - tables := []TableItem{{ - Cells: []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "A", Label: "table row"}}, - Positions: []Position{{Left: 0, Right: 300, Top: 0, Bottom: 100}}, + tables := []pdf.TableItem{{ + Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "A", Label: "table row"}}, + Positions: []pdf.Position{{Left: 0, Right: 300, Top: 0, Bottom: 100}}, Scale: 1.0, }} - result := extractTableAndReplace(boxes, tables) + result := ExtractTableAndReplace(boxes, tables) // BUG: Figure box is still present — it was not popped or replaced. // Python's _extract_table_figure pops figure boxes and re-inserts them @@ -55,7 +56,7 @@ func TestExtractTableAndReplace_IgnoresFigures(t *testing.T) { if !hasFigure { t.Error("BUG EXPOSED: extractTableAndReplace removed figure box (unexpected)") } - t.Log("NOTE: Figure box remains in list as raw text. Python inserts figures back with cropped images via insert_table_figures. Go collects figures separately via CollectFigures without re-inserting.") + t.Log("NOTE: Figure box remains in list as raw text. Python inserts figures back with cropped images via insert_table_figures. Go collects figures separately via pdf.CollectFigures without re-inserting.") } // TestBoxesToSections_FiguresNotReinserted documents that boxesToSections converts @@ -63,13 +64,13 @@ func TestExtractTableAndReplace_IgnoresFigures(t *testing.T) { // insert_table_figures would attach. func TestBoxesToSections_FiguresNotReinserted(t *testing.T) { // Simulate post-extractTableAndReplace boxes with figures still present. - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 200, Top: 0, Bottom: 50, Text: "Some text", LayoutType: "text", PageNumber: 0}, {X0: 10, X1: 200, Top: 60, Bottom: 100, Text: "Figure description", LayoutType: "figure", PageNumber: 0}, } sections := boxesToSections(boxes, nil) - figures := CollectFigures(sections) + figures := pdf.CollectFigures(sections) // BUG: figures are collected separately but NOT re-inserted into sections // after image processing. In Python, insert_table_figures(figs, "figure") @@ -108,15 +109,15 @@ func TestConstructTable_HeaderDetection_NoBlockType(t *testing.T) { // With blockType: "2020","2021" → Nu, "100","200" → Nu — maxType=Nu. // block-type-aware detection skips Nu cells → 0 headers. // Falls back to TSR label-based detection → still gets 2 . - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "2020", Label: "table column header"}, {X0: 101, Y0: 0, X1: 200, Y1: 30, Text: "2021", Label: "table column header"}, {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "100", Label: "table row"}, {X0: 101, Y0: 35, X1: 200, Y1: 65, Text: "200", Label: "table row"}, } - item := &TableItem{} - html := constructTable(cells, nil, "", item) + item := &pdf.TableItem{} + html := ConstructTable(cells, nil, "", item) // FIX VERIFIED: headerSetWithBlockType computes block types (all "Nu"), // skips Nu headers when maxType=Nu, then falls back to TSR label detection. @@ -137,7 +138,7 @@ func TestConstructTable_BlockType_DominantTypeMissing(t *testing.T) { // "年份"/"金额" → Tx (short text), "2020"/"1000"/etc → Nu. maxType=Nu. // Header cells are non-Nu → count as headers even under Nu-dominant logic. // FIX: blockType now classifies cells and drives header detection. - cells := []TSRCell{ + cells := []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "年份", Label: "table column header"}, {X0: 101, Y0: 0, X1: 200, Y1: 30, Text: "金额", Label: "table column header"}, {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "2020", Label: "table row"}, @@ -148,8 +149,8 @@ func TestConstructTable_BlockType_DominantTypeMissing(t *testing.T) { {X0: 101, Y0: 105, X1: 200, Y1: 135, Text: "3000", Label: "table row"}, } - item := &TableItem{} - html := constructTable(cells, nil, "", item) + item := &pdf.TableItem{} + html := ConstructTable(cells, nil, "", item) thCount := strings.Count(html, " 1.5 × median_height ≈ 15pt). // Each figure text box → separate section in result.Sections. - // CollectFigures collects them into result.Figures but doesn't re-insert. + // pdf.CollectFigures collects them into result.Figures() but doesn't re-insert. - var figureSections []Section + var figureSections []pdf.Section for _, s := range result.Sections { if s.LayoutType == "figure" { figureSections = append(figureSections, s) @@ -456,7 +457,7 @@ func TestFigureInsertion_EndToEnd(t *testing.T) { } t.Logf("figure sections in Sections: %d", len(figureSections)) - t.Logf("result.Figures count: %d", len(result.Figures)) + t.Logf("result.Figures() count: %d", len(result.Figures())) t.Logf("result.Sections total: %d", len(result.Sections)) for i, s := range result.Sections { t.Logf(" section[%d] layout=%q text=%q", i, s.LayoutType, s.Text) @@ -476,27 +477,27 @@ func TestExtractTableAndReplace_NoCrossPageMerge(t *testing.T) { // Simulate a table spanning pages 0 and 1. // Python would merge these because: same layoutno, consecutive pages, // Y-distance ≤ 23× median_height. - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 200, Top: 500, Bottom: 530, Text: "续表内容", LayoutType: "table", PageNumber: 0, LayoutNo: "0"}, {X0: 10, X1: 200, Top: 50, Bottom: 80, Text: "表尾内容", LayoutType: "table", PageNumber: 1, LayoutNo: "0"}, } // Two separate TableItems — one per page. Python would merge these // before insert_table_figures. - tables := []TableItem{ + tables := []pdf.TableItem{ { - Cells: []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Page0", Label: "table row"}}, - Positions: []Position{{PageNumbers: []int{0}, Left: 0, Right: 300, Top: 500, Bottom: 530}}, + Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Page0", Label: "table row"}}, + Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 0, Right: 300, Top: 500, Bottom: 530}}, Scale: 1.0, }, { - Cells: []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Page1", Label: "table row"}}, - Positions: []Position{{PageNumbers: []int{1}, Left: 0, Right: 300, Top: 50, Bottom: 80}}, + Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Page1", Label: "table row"}}, + Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 0, Right: 300, Top: 50, Bottom: 80}}, Scale: 1.0, }, } - result := extractTableAndReplace(boxes, tables) + result := ExtractTableAndReplace(boxes, tables) // Go produces 2 separate HTML table boxes (one per page). // Python would produce 1 merged table with cells from both pages. @@ -534,10 +535,10 @@ func TestMergeTablesAcrossPages_NomergeAfterCaption_Missing(t *testing.T) { // then another table. Page 1 has the same-layoutNo table continuing. // In Python, page 0's first table goes into nomerge_lout_no because // the next box is a caption → no cross-page merge for that table group. - tables := []TableItem{ + tables := []pdf.TableItem{ { - Cells: []TSRCell{{Text: "Page0-first", Label: "table row"}}, - Positions: []Position{{ + Cells: []pdf.TSRCell{{Text: "Page0-first", Label: "table row"}}, + Positions: []pdf.Position{{ PageNumbers: []int{0}, Left: 0, Right: 300, Top: 0, Bottom: 50, @@ -545,8 +546,8 @@ func TestMergeTablesAcrossPages_NomergeAfterCaption_Missing(t *testing.T) { NoMerge: true, // Set when caption follows this table on the page }, { - Cells: []TSRCell{{Text: "Page1-cont", Label: "table row"}}, - Positions: []Position{{ + Cells: []pdf.TSRCell{{Text: "Page1-cont", Label: "table row"}}, + Positions: []pdf.Position{{ PageNumbers: []int{1}, Left: 0, Right: 300, Top: 0, Bottom: 50, @@ -554,7 +555,7 @@ func TestMergeTablesAcrossPages_NomergeAfterCaption_Missing(t *testing.T) { }, } - result := mergeTablesAcrossPages(tables, nil) + result := MergeTablesAcrossPages(tables, nil) // Verify NoMerge prevents cross-page merging. if len(result) != 2 { @@ -584,7 +585,7 @@ func TestExtractTableAndReplace_InsertionPosition_DistanceBug(t *testing.T) { // → insert AFTER L0. Result: [L0, table, R0, R1, L2]. // Go: anchor = first table box (L1 at index 2). Result: [L0, R0, table, R1, L2]. // The table is one position off. - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 100, Top: 10, Bottom: 30, Text: "L0", LayoutType: "text", PageNumber: 0}, {X0: 300, X1: 400, Top: 10, Bottom: 30, Text: "R0", LayoutType: "text", PageNumber: 0}, {X0: 10, X1: 100, Top: 100, Bottom: 130, Text: "table", LayoutType: "table", PageNumber: 0}, @@ -592,14 +593,14 @@ func TestExtractTableAndReplace_InsertionPosition_DistanceBug(t *testing.T) { {X0: 10, X1: 100, Top: 250, Bottom: 270, Text: "L2", LayoutType: "text", PageNumber: 0}, } - tables := []TableItem{{ - Cells: []TSRCell{{Text: "cell", Label: "table row"}}, - Positions: []Position{{Left: 10, Right: 100, Top: 100, Bottom: 130, PageNumbers: []int{0}}}, + tables := []pdf.TableItem{{ + Cells: []pdf.TSRCell{{Text: "cell", Label: "table row"}}, + Positions: []pdf.Position{{Left: 10, Right: 100, Top: 100, Bottom: 130, PageNumbers: []int{0}}}, Scale: 1.0, RegionLeft: 10, RegionRight: 100, RegionTop: 100, RegionBottom: 130, }} - result := extractTableAndReplace(boxes, tables) + result := ExtractTableAndReplace(boxes, tables) // Find L0 and table positions. l0Idx, tableIdx := -1, -1 @@ -636,7 +637,7 @@ func TestExtractTableAndReplace_InsertionPosition_DistanceBug(t *testing.T) { // coordinates (subtracts page_cum_height). The page number differentiates // pages; page_cum_height is an internal implementation detail. func TestBoxesToSections_PerPageCoordinates(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 100, Top: 40, Bottom: 60, Text: "Page 0 text", LayoutType: "text", PageNumber: 0}, {X0: 10, X1: 100, Top: 40, Bottom: 60, Text: "Page 1 text", LayoutType: "text", PageNumber: 1}, } @@ -673,10 +674,10 @@ func TestCropSectionImage_PaddingVsPython(t *testing.T) { img := image.NewRGBA(image.Rect(0, 0, 300, 800)) // 300×800 page at zoom=3 → PDF 100×267 pageImages := map[int]image.Image{0: img} - // Position tag for a small text box near the top of the page. + // pdf.Position tag for a small text box near the top of the page. posTag := FormatPositionTag(0, 50.0, 100.0, 10.0, 30.0) - result := cropSectionImage(posTag, pageImages, 3.0) + result := util.CropSectionImage(posTag, pageImages, 3.0) if result == "" { t.Error("cropSectionImage returned empty string for valid position") @@ -757,27 +758,27 @@ func TestDataSourcePattern_RegexCoverage(t *testing.T) { // adding them to the tables dict (pdf_parser.py:1040-1042). func TestExtractTableAndReplace_DataSourceFilter_Missing(t *testing.T) { // A table box with data-source text and a normal table box. - // Both overlap a TableItem position, so both would be replaced with HTML. - boxes := []TextBox{ + // Both overlap a pdf.TableItem position, so both would be replaced with HTML. + boxes := []pdf.TextBox{ {X0: 10, X1: 200, Top: 0, Bottom: 50, Text: "数据来源:国家统计局", LayoutType: "table", PageNumber: 0}, {X0: 10, X1: 200, Top: 60, Bottom: 80, Text: "表1:正常数据", LayoutType: "table", PageNumber: 0}, } // Two TableItems — one per table box — so each would independently produce HTML. - tables := []TableItem{ + tables := []pdf.TableItem{ { - Cells: []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "来源", Label: "table row"}}, - Positions: []Position{{Left: 0, Right: 300, Top: 0, Bottom: 50}}, + Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "来源", Label: "table row"}}, + Positions: []pdf.Position{{Left: 0, Right: 300, Top: 0, Bottom: 50}}, Scale: 1.0, }, { - Cells: []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "正常", Label: "table row"}}, - Positions: []Position{{Left: 0, Right: 300, Top: 60, Bottom: 80}}, + Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "正常", Label: "table row"}}, + Positions: []pdf.Position{{Left: 0, Right: 300, Top: 60, Bottom: 80}}, Scale: 1.0, }, } - result := extractTableAndReplace(boxes, tables) + result := ExtractTableAndReplace(boxes, tables) // Python behavior: "数据来源:国家统计局" is popped from self.boxes, // NOT added to tables dict, NOT replaced with HTML. Gone entirely. @@ -792,7 +793,7 @@ func TestExtractTableAndReplace_DataSourceFilter_Missing(t *testing.T) { if strings.Contains(b.Text, "") { htmlTableCount++ // The data-source table's cell text "来源" ends up in the HTML. - // c.f. constructTable which uses TSRCell text, not box text. + // c.f. constructTable which uses pdf.TSRCell text, not box text. if strings.Contains(b.Text, ">来源<") { hasDataSourceTable = true } @@ -822,17 +823,17 @@ func TestExtractTableAndReplace_DataSourceVariants(t *testing.T) { for _, variant := range variants { t.Run(variant, func(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 200, Top: 0, Bottom: 50, Text: variant, LayoutType: "table", PageNumber: 0}, } - tables := []TableItem{{ - Cells: []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "A", Label: "table row"}}, - Positions: []Position{{Left: 0, Right: 300, Top: 0, Bottom: 50}}, + tables := []pdf.TableItem{{ + Cells: []pdf.TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "A", Label: "table row"}}, + Positions: []pdf.Position{{Left: 0, Right: 300, Top: 0, Bottom: 50}}, Scale: 1.0, }} - result := extractTableAndReplace(boxes, tables) + result := ExtractTableAndReplace(boxes, tables) // BUG: box with data-source text should be REMOVED entirely — // zero HTML output. Python pops these boxes without replacement. @@ -851,12 +852,12 @@ func TestExtractTableAndReplace_DataSourceVariants(t *testing.T) { // Python's _extract_table_figure pops these boxes from self.boxes without // adding them to the figures dict (pdf_parser.py:1050-1052). func TestConsolidateFigures_DataSourceFilter_Missing(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {X0: 10, X1: 200, Top: 0, Bottom: 50, Text: "数据来源:某机构", LayoutType: "figure", PageNumber: 0, LayoutNo: "figure-0"}, {X0: 10, X1: 200, Top: 60, Bottom: 80, Text: "架构图", LayoutType: "figure", PageNumber: 0, LayoutNo: "figure-0"}, } - result := consolidateFigures(boxes) + result := ConsolidateFigures(boxes) // Python behavior: "数据来源:某机构" is popped from self.boxes, // NOT added to figures dict → gone entirely. diff --git a/internal/deepdoc/parser/pdf/table_parity_test.go b/internal/deepdoc/parser/pdf/table/table_parity_test.go similarity index 90% rename from internal/deepdoc/parser/pdf/table_parity_test.go rename to internal/deepdoc/parser/pdf/table/table_parity_test.go index 9fb6abe5c8..8937ce69b9 100644 --- a/internal/deepdoc/parser/pdf/table_parity_test.go +++ b/internal/deepdoc/parser/pdf/table/table_parity_test.go @@ -1,11 +1,12 @@ //go:build cgo && manual -package parser +package table import ( "encoding/json" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "strings" "testing" ) @@ -42,10 +43,10 @@ func TestTableParityWithPythonBoxes(t *testing.T) { t.Fatal(err) } - // Convert to Go TextBox - boxes := make([]TextBox, len(pyBoxes)) + // Convert to Go pdf.TextBox + boxes := make([]pdf.TextBox, len(pyBoxes)) for i, b := range pyBoxes { - boxes[i] = TextBox{ + boxes[i] = pdf.TextBox{ X0: b.X0, X1: b.X1, Top: b.Top, Bottom: b.Bottom, Text: b.Text, R: b.R, C: b.C, H: b.H, SP: b.SP, LayoutType: b.LayoutType, @@ -53,8 +54,8 @@ func TestTableParityWithPythonBoxes(t *testing.T) { } // Run through Go's constructTable - item := &TableItem{} - html := constructTable(nil, boxes, "", item) + item := &pdf.TableItem{} + html := ConstructTable(nil, boxes, "", item) if html == "" { t.Error("constructTable returned empty HTML") diff --git a/internal/deepdoc/parser/pdf/table/table_post.go b/internal/deepdoc/parser/pdf/table/table_post.go new file mode 100644 index 0000000000..f97c5eb61a --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_post.go @@ -0,0 +1,349 @@ +package table + +import ( + "log/slog" + "math" + "sort" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// extractTableAndReplace pops table boxes and replaces them with consolidated +// HTML boxes (one per table). This matches Python's _extract_table_figure which +// pops all boxes inside a table DLA region and inserts a single HTML box. +// +// Table boxes whose text matches the data-source discard pattern +// (r"(数据|资料|图表)*来源[:: ]") are removed entirely without replacement — +// matching Python's _extract_table_figure discard behavior. + +// MarkNoMergeTables traverses boxes in page order. When a caption, title, or +// reference immediately follows a table, the preceding table is marked NoMerge +// to prevent cross-page merge. Matches Python's nomerge_lout_no. +func MarkNoMergeTables(boxes []pdf.TextBox, tables []pdf.TableItem) { + var lastTableTI int = -1 + for i := range boxes { + lt := boxes[i].LayoutType + if lt == pdf.LayoutTypeTable { + matched := false + for ti := range tables { + for _, tp := range tables[ti].Positions { + if boxOverlapsPosition(boxes[i], tp) { + lastTableTI = ti + matched = true + break + } + } + } + if !matched { + lastTableTI = -1 + } + continue + } + if lastTableTI >= 0 && (lt == pdf.LayoutTypeTitle || lt == pdf.DLALabelTableCaption || lt == pdf.DLALabelFigureCaption || lt == pdf.LayoutTypeReference || IsCaptionBox(boxes[i].Text, lt)) { + tables[lastTableTI].NoMerge = true + } + } +} + +// boxes must be post-TextMerge + post-VerticalMerge. pdf.TableItem.Cells are in +// crop pixel space; boxes are in PDF point space — conversion via Scale/CropOff. +// replacement pairs a table index with the box index it replaces. +type replacement struct { + tableIdx int + boxIdx int +} + +// buildReplacements scans for data-source-attribution boxes to remove and maps +// each table to overlapping table-layout boxes, producing the replacement list. +func buildReplacements(boxes []pdf.TextBox, tables []pdf.TableItem) (map[int]bool, []replacement) { + removeSet := make(map[int]bool) + for i := range boxes { + if boxes[i].LayoutType == pdf.LayoutTypeTable && isDataSourceBox(boxes[i].Text) { + removeSet[i] = true + } + } + var reps []replacement + for ti := range tables { + for i := range boxes { + if boxes[i].LayoutType != pdf.LayoutTypeTable || removeSet[i] { + continue + } + for _, tp := range tables[ti].Positions { + if boxOverlapsPosition(boxes[i], tp) { + reps = append(reps, replacement{tableIdx: ti, boxIdx: i}) + break + } + } + } + } + return removeSet, reps +} + +func ExtractTableAndReplace(boxes []pdf.TextBox, tables []pdf.TableItem) []pdf.TextBox { + if len(tables) == 0 { + return boxes + } + // Pre-merge nomerge detection: match Python's nomerge_lout_no. + // Traverse boxes in page order. When a caption/title/reference is + // found, mark the preceding table group as NoMerge, preventing + // cross-page merge when a caption ends a table group. + // Python: if is_caption(c) or layout_type in ["table caption", "title", + // "figure caption", "reference"]: nomerge_lout_no.append(lst_lout_no) + MarkNoMergeTables(boxes, tables) + + // Merge same-layoutno tables across consecutive pages (Python _extract_table_figure). + tables = MergeTablesAcrossPages(tables, nil) + + // Pre-scan: mark data-source-attribution table boxes for removal. + // Python: if re.match(r"(数据|资料|图表)*来源[:: ]", self.boxes[i]["text"]): + // self.boxes.pop(i); continue — box discarded, no HTML replacement. + removeSet, replacements := buildReplacements(boxes, tables) + + // Image-only PDFs (0 boxes) may have tables with cells but no + // overlapping LayoutType=="table" boxes — generate HTML directly. + if len(replacements) == 0 && len(boxes) == 0 { + var out []pdf.TextBox + for ti := range tables { + if len(tables[ti].Cells) == 0 { + continue + } + s := tables[ti].Scale + pageGlobalCells := CellSliceToPageSpace(tables[ti].Cells, tables[ti].CropOffX, tables[ti].CropOffY, s) + var tableBoxes []pdf.TextBox + html := ConstructTable(pageGlobalCells, tableBoxes, tables[ti].Caption, &tables[ti]) + if html != "" { + out = append(out, pdf.TextBox{ + Text: html, LayoutType: "table", PageNumber: 0, + }) + } + } + return out + } + if len(replacements) == 0 { + // No HTML replacements, but data-source boxes still need removal. + if len(removeSet) == 0 { + return boxes + } + out := make([]pdf.TextBox, 0, len(boxes)-len(removeSet)) + for i, b := range boxes { + if !removeSet[i] { + out = append(out, b) + } + } + return out + } + + // Distance-based anchor selection (Python's min_rectangle_distance). + // Find the spatially nearest non-table text box for each table and + // use that as the anchor, matching insert_table_figures behavior. + replacedByTable := make(map[int]int) + for ti := range tables { + if len(tables[ti].Cells) == 0 { + continue + } + tbl := &tables[ti] + tblLeft, tblRight := tbl.RegionLeft, tbl.RegionRight + tblTop, tblBottom := tbl.RegionTop, tbl.RegionBottom + tblPg := 0 + if len(tbl.Positions) > 0 { + p := tbl.Positions[0] + if len(p.PageNumbers) > 0 { + tblPg = p.PageNumbers[0] + } + if tblLeft == 0 && tblRight == 0 && tblTop == 0 && tblBottom == 0 { + tblLeft, tblRight = p.Left, p.Right + tblTop, tblBottom = p.Top, p.Bottom + } + } + bestDist := math.MaxFloat64 + bestIdx := -1 + for i, b := range boxes { + if b.LayoutType == pdf.LayoutTypeTable || b.LayoutType == pdf.LayoutTypeFigure { + continue + } + if b.PageNumber != tblPg { + continue + } + dist := minRectangleDistance( + b.X0, b.X1, b.Top, b.Bottom, + tblLeft, tblRight, tblTop, tblBottom, + ) + if dist < bestDist { + bestDist = dist + bestIdx = i + } + } + if bestIdx >= 0 { + if boxes[bestIdx].Bottom < tblTop { + bestIdx++ + } + replacedByTable[ti] = bestIdx + } else { + for _, r := range replacements { + if r.tableIdx == ti { + if _, ok := replacedByTable[ti]; !ok || r.boxIdx < replacedByTable[ti] { + replacedByTable[ti] = r.boxIdx + } + } + } + } + } + for _, r := range replacements { + removeSet[r.boxIdx] = true + } + + // Build HTML for each table using post-merge boxes converted to crop space. + htmlByTable := make(map[int]string) + for ti := range tables { + if len(tables[ti].Cells) == 0 { + continue + } + // Convert TSR cells from crop-pixel space to page-global 72 DPI, + // matching Python's coordinate space. Text boxes are already in + // page-global 72 DPI (from ocrMergeChars), so no conversion needed. + s := tables[ti].Scale + pageGlobalCells := CellSliceToPageSpace(tables[ti].Cells, tables[ti].CropOffX, tables[ti].CropOffY, s) + // Collect only table-labelled boxes (Python: filters by layout_type). + var tableBoxes []pdf.TextBox + for i := range boxes { + if boxes[i].LayoutType != pdf.LayoutTypeTable { + continue + } + for _, tp := range tables[ti].Positions { + if boxOverlapsPosition(boxes[i], tp) { + tableBoxes = append(tableBoxes, boxes[i]) + break + } + } + } + slog.Debug("extractTableAndReplace constructTable", "table", ti, "cells", len(pageGlobalCells), "boxes", len(tableBoxes)) + htmlByTable[ti] = ConstructTable(pageGlobalCells, tableBoxes, tables[ti].Caption, &tables[ti]) + } + + // Sort anchors by position for stable insertion. + anchorList := make([]struct{ ti, pos int }, 0, len(replacedByTable)) + for ti, pos := range replacedByTable { + anchorList = append(anchorList, struct{ ti, pos int }{ti, pos}) + } + sort.Slice(anchorList, func(i, j int) bool { return anchorList[i].pos < anchorList[j].pos }) + + out := make([]pdf.TextBox, 0, len(boxes)-len(removeSet)+len(replacedByTable)) + anchorIdx := 0 + for i, b := range boxes { + // Insert any HTML boxes whose anchor position is before or at i. + for anchorIdx < len(anchorList) && anchorList[anchorIdx].pos <= i { + ti := anchorList[anchorIdx].ti + html := htmlByTable[ti] + if html != "" { + tbl := &tables[ti] + out = append(out, tableRegionBox(tbl, &b, html)) + } + anchorIdx++ + } + if !removeSet[i] { + out = append(out, b) + } + } + // Remaining anchors after last box. + for anchorIdx < len(anchorList) { + ti := anchorList[anchorIdx].ti + html := htmlByTable[ti] + if html != "" { + tbl := &tables[ti] + last := &boxes[len(boxes)-1] + out = append(out, tableRegionBox(tbl, last, html)) + } + anchorIdx++ + } + return out +} + +// consolidateFigures merges figure boxes that share the same LayoutNo +// (i.e., belong to the same DLA figure region) into a single pdf.TextBox. +// Matches Python's _extract_table_figure + insert_table_figures which pops +// individual figure boxes and re-inserts one consolidated figure block +// per DLA region with combined text. +// +// Figure boxes whose text matches the data-source discard pattern +// (r"(数据|资料|图表)*来源[:: ]") are removed entirely — matching Python's +// _extract_table_figure discard behavior (pdf_parser.py:1050-1052). +func ConsolidateFigures(boxes []pdf.TextBox) []pdf.TextBox { + // Pre-scan: mark data-source-attribution figure boxes for removal. + // Python: if re.match(r"(数据|资料|图表)*来源[:: ]", self.boxes[i]["text"]): + // self.boxes.pop(i); continue — box discarded. + removeSet := make(map[int]bool) + for i, b := range boxes { + if b.LayoutType == pdf.LayoutTypeFigure && isDataSourceBox(b.Text) { + removeSet[i] = true + } + } + + // Group figure boxes by (page, layoutno). + type figKey struct { + page int + ln string + } + groups := make(map[figKey][]int) + for i, b := range boxes { + if b.LayoutType != pdf.LayoutTypeFigure || removeSet[i] { + continue + } + key := figKey{b.PageNumber, b.LayoutNo} + groups[key] = append(groups[key], i) + } + + if len(groups) == 0 { + // Still need to filter out data-source figure boxes. + if len(removeSet) == 0 { + return boxes + } + out := make([]pdf.TextBox, 0, len(boxes)-len(removeSet)) + for i, b := range boxes { + if !removeSet[i] { + out = append(out, b) + } + } + return out + } + + // Collect indices to remove (all group members except the first). + for _, indices := range groups { + if len(indices) <= 1 { + continue + } + // Merge into the first box of the group. + anchor := indices[0] + for _, idx := range indices[1:] { + b := boxes[idx] + boxes[anchor].Text += "\n" + b.Text + boxes[anchor].X0 = math.Min(boxes[anchor].X0, b.X0) + boxes[anchor].X1 = math.Max(boxes[anchor].X1, b.X1) + boxes[anchor].Top = math.Min(boxes[anchor].Top, b.Top) + boxes[anchor].Bottom = math.Max(boxes[anchor].Bottom, b.Bottom) + removeSet[idx] = true + } + } + + if len(removeSet) == 0 { + return boxes + } + + out := make([]pdf.TextBox, 0, len(boxes)-len(removeSet)) + for i, b := range boxes { + if !removeSet[i] { + out = append(out, b) + } + } + return out +} + +// boxOverlapsPosition checks if a pdf.TextBox overlaps a pdf.Position with margin. +func boxOverlapsPosition(box pdf.TextBox, pos pdf.Position) bool { + const margin = 2.0 + return box.X0 <= pos.Right+margin && box.X1 >= pos.Left-margin && + box.Top <= pos.Bottom+margin && box.Bottom >= pos.Top-margin +} + +// rowsToHTML converts grouped TSR cell rows to an HTML table string. +// spanInfo maps (row,col) → (colspan, rowspan) for spanning cells; +// covered marks cells hidden by a span. Both may be nil. diff --git a/internal/deepdoc/parser/pdf/table/table_region_overlaps_test.go b/internal/deepdoc/parser/pdf/table/table_region_overlaps_test.go new file mode 100644 index 0000000000..8ca91d472c --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_region_overlaps_test.go @@ -0,0 +1,61 @@ +package table + +import ( + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ---- regionOverlapsBox ---- + +func TestRegionOverlapsBox_StrongOverlap(t *testing.T) { + region := pdf.DLARegion{X0: 0, Y0: 0, X1: 216, Y1: 108} // DLA coords at 216 DPI + box := pdf.TextBox{X0: 0, X1: 100, Top: 0, Bottom: 50} + if !regionOverlapsBox(region, box, 3.0) { + t.Error("full overlap should match") + } +} + +func TestRegionOverlapsBox_NoOverlap(t *testing.T) { + region := pdf.DLARegion{X0: 0, Y0: 0, X1: 216, Y1: 108} + box := pdf.TextBox{X0: 500, X1: 600, Top: 500, Bottom: 550} + if regionOverlapsBox(region, box, 3.0) { + t.Error("no overlap should return false") + } +} + +func TestRegionOverlapsBox_WeakOverlap(t *testing.T) { + // Overlap at 30% → below 40% threshold → false. + region := pdf.DLARegion{X0: 0, Y0: 0, X1: 90, Y1: 90} // 30x30 at scale 3 + box := pdf.TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} // overlap = 30*30/10000 = 9%? No: 30x30=900 / 10000 = 9% + if regionOverlapsBox(region, box, 3.0) { + t.Error("9% overlap should return false") + } + // Overlap ≥ 40% → should match (Python thr=0.4). + // box 100x100=10000 area; region 100x40=4000 → exactly 40%. + region2 := pdf.DLARegion{X0: 0, Y0: 0, X1: 300, Y1: 120, Label: "table"} // 100x40 at scale 3 + if !regionOverlapsBox(region2, box, 3.0) { + t.Error("40% overlap should match (>= 0.4)") + } + // Region that covers most of the box → should match + region3 := pdf.DLARegion{X0: 0, Y0: 0, X1: 270, Y1: 270} // 90x90 at scale 3 + if !regionOverlapsBox(region3, box, 3.0) { + t.Error("81% overlap should match") + } +} + +func TestRegionOverlapsBox_ThresholdAt040(t *testing.T) { + // Exact 40% overlap: 100x100 box, region just covering 40% + // 0.4 * 10000 = 4000. Need region with area 4000 in box space. + // 63.2*63.2 ≈ 3994. Let's use 100x40 = 4000. + box := pdf.TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} + region := pdf.DLARegion{X0: 0, Y0: 0, X1: 300, Y1: 120, Label: "table"} // 100x40 at scale 3 + if !regionOverlapsBox(region, box, 3.0) { + t.Error("exact 40% overlap should match (>= 0.4)") + } + // 39% overlap should NOT match + region2 := pdf.DLARegion{X0: 0, Y0: 0, X1: 300, Y1: 117, Label: "table"} // 100x39 at scale 3 + if regionOverlapsBox(region2, box, 3.0) { + t.Error("39% overlap should NOT match") + } +} diff --git a/internal/deepdoc/parser/pdf/table_builder.go b/internal/deepdoc/parser/pdf/table_builder.go deleted file mode 100644 index a722505e68..0000000000 --- a/internal/deepdoc/parser/pdf/table_builder.go +++ /dev/null @@ -1,22 +0,0 @@ -package parser - -import ( - "context" - "image" -) - -// TableBuilder encapsulates TSR model-specific cell detection and grouping. -// Each TSR model implements its own Builder, producing a unified row-column -// grid consumed by the shared downstream pipeline. -type TableBuilder interface { - // Name returns the model identifier for logging and diagnostics. - Name() string - - // DetectCells detects all cells from a cropped table image. - // The Label field on returned TSRCells is consumed only by the Builder - // itself during GroupCells; shared code does not depend on Label semantics. - DetectCells(ctx context.Context, cropped image.Image) ([]TSRCell, error) - - // GroupCells groups cells into a row-column grid (pure computation, no I/O). - GroupCells(cells []TSRCell) [][]TSRCell -} diff --git a/internal/deepdoc/parser/pdf/table_extract.go b/internal/deepdoc/parser/pdf/table_extract.go new file mode 100644 index 0000000000..f90deae187 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table_extract.go @@ -0,0 +1,242 @@ +package parser + +import ( + "context" + "image" + "log/slog" + "math" + "sort" + + tbl "ragflow/internal/deepdoc/parser/pdf/table" + pdf "ragflow/internal/deepdoc/parser/pdf/type" + util "ragflow/internal/deepdoc/parser/pdf/util" +) + +// enrichWithDeepDoc runs DLA+TSR via p.DeepDoc and returns detected tables. +// pageImages optionally provides pre-rendered page images to avoid re-rendering. +func (p *Parser) enrichWithDeepDoc(ctx context.Context, result *pdf.ParseResult, engine pdf.PDFEngine, boxes []pdf.TextBox, pageImages map[int]image.Image) []pdf.TableItem { + if !p.DeepDoc.Health() { + return nil + } + // Group boxes by page for annotation write-back. + byPage := make(map[int][]int) + for i, b := range boxes { + byPage[b.PageNumber] = append(byPage[b.PageNumber], i) + } + + // Collect all pages that have images (from pageImages) or boxes. + // This matches Python's __images__ which processes every page regardless + // of embedded chars — image-only PDFs still get DLA+TSR. + allPages := make(map[int]bool) + for pg := range pageImages { + allPages[pg] = true + } + for pg := range byPage { + allPages[pg] = true + } + pageKeys := make([]int, 0, len(allPages)) + for pg := range allPages { + pageKeys = append(pageKeys, pg) + } + sort.Ints(pageKeys) + + var tableItems []pdf.TableItem + for _, pg := range pageKeys { + if err := ctx.Err(); err != nil { + return tableItems + } + indices := byPage[pg] + pageBoxes := make([]pdf.TextBox, len(indices)) + for i, idx := range indices { + pageBoxes[i] = boxes[idx] + } + tables := p.extractTableBoxes(ctx, result, pageBoxes, engine, pg, pageImages, len(tableItems)) + tableItems = append(tableItems, tables...) + // Write back DLA and TSR annotations (R/C/H/SP) to the original boxes. + for i, idx := range indices { + if pageBoxes[i].LayoutType != "" { + boxes[idx].LayoutType = pageBoxes[i].LayoutType + boxes[idx].LayoutNo = pageBoxes[i].LayoutNo + } + tbl.CopyBoxAnnotations(&boxes[idx], &pageBoxes[i]) + } + + } + return tableItems +} + +func (p *Parser) extractTableBoxes(ctx context.Context, result *pdf.ParseResult, boxes []pdf.TextBox, engine pdf.PDFEngine, pageNum int, pageImages map[int]image.Image, tableBaseIdx int) []pdf.TableItem { + pageImg, ok := pageImages[pageNum] + if !ok { + var err error + pageImg, err = renderPageToImage(engine, pageNum) + if err != nil { + slog.Warn("render page for DeepDoc failed", "page", pageNum, "err", err) + return nil + } + } + return p.extractTableBoxesFromImage(ctx, result, boxes, pageImg, pageNum, tableBaseIdx) +} + +func (p *Parser) extractTableBoxesFromImage(ctx context.Context, result *pdf.ParseResult, boxes []pdf.TextBox, pageImg image.Image, pageNum int, tableBaseIdx int) []pdf.TableItem { + regions, err := p.DeepDoc.DLA(ctx, pageImg) + if err != nil { + slog.Warn("DLA failed", "page", pageNum, "err", err) + return nil + } + // Collect DLA debug intermediates. + if result != nil { + result.DLADebug = append(result.DLADebug, pdf.DLAPageRegions{Page: pageNum, Regions: regions}) + } + // Annotate boxes with DLA layout types (title, text, figure, table, ...). + scale := pdf.DlaScale + boxes = tbl.AnnotateBoxLayouts(boxes, regions, scale, float64(pageImg.Bounds().Dy())) + + tableMatches := tbl.MatchTableRegions(boxes, regions, scale) + var items []pdf.TableItem + for _, tm := range tableMatches { + cropped, cropErr := util.CropImageRegion(pageImg, tm.Region) + if cropErr != nil { + // DLA returned an invalid region (e.g. x1 < x0). Python + // PIL.Image.crop() raises ValueError here; we skip this + // table instead of passing a full-page image to TSR. + continue + } + + // Rotation detection (Python: _evaluate_table_orientation). + // If rotated, TSR and OCR use the rotated image; cell coords + // are mapped back to original crop space for box matching. + autoRotate := p.Config.AutoRotateTables != nil && *p.Config.AutoRotateTables + bestAngle := 0 + origW, origH := cropped.Bounds().Dx(), cropped.Bounds().Dy() + tsrImg := cropped + if autoRotate { + angle, rotated, _ := tbl.EvaluateTableOrientation(ctx, cropped, p.DeepDoc) + bestAngle = angle + tsrImg = rotated + } + + imgB64, encErr := util.EncodeImageToBase64PNG(cropped) + if encErr != nil { + slog.Warn("table PNG encode failed", "page", pageNum, "err", encErr) + } + + var cells []pdf.TSRCell + var tsrErr error + cells, tsrErr = p.tableBuilder.DetectCells(ctx, tsrImg) + if tsrErr != nil { + slog.Warn("TSR failed", "page", pageNum, "err", tsrErr) + } + // Collect TSR raw cells for debug comparison. + if tsrErr == nil { + for _, c := range cells { + if result != nil { + result.TSRDebug = append(result.TSRDebug, pdf.TSRRawCell{ + TableIndex: tableBaseIdx + len(items), Page: pageNum, + Label: c.Label, X0: c.X0, Y0: c.Y0, X1: c.X1, Y1: c.Y1, + Text: c.Text, + }) + } + } + } + // Python margin: w*0.03, h*0.03 (_table_transformer_job:374-376). + w := tm.Region.X1 - tm.Region.X0 + h := tm.Region.Y1 - tm.Region.Y0 + marginX := w * 0.03 + marginY := h * 0.03 + cropOffX := math.Max(0, tm.Region.X0-marginX) + cropOffY := math.Max(0, tm.Region.Y0-marginY) + + var boxInCrop []pdf.TextBox + if tsrErr == nil && len(cells) > 0 { + if bestAngle != 0 { + // OCR on rotated image before mapping cells back. + // Cells are in rotated-pixel space; OCR works best + // on upright text. After mapping, cells move to + // original crop space where boxInCrop lives. + if !p.Config.SkipOCR { + ocrTableCells(ctx, cells, tsrImg, p.DeepDoc) + } + 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) + } + } + // Fill cell text from pre-merge boxes, skipping caption boxes + // (text entirely above the first TSR cell row). + firstCellTop := 1e9 + for _, c := range cells { + if c.Y0 >= 0 && c.Y0 < firstCellTop { + firstCellTop = c.Y0 + } + } + if firstCellTop == 1e9 { + firstCellTop = cells[0].Y0 // fallback if all cells have Y0 < 0 + } + boxInCrop = make([]pdf.TextBox, 0, len(tm.BoxIdx)) + for _, idx := range tm.BoxIdx { + b := boxes[idx] + if b.Bottom*scale-cropOffY < firstCellTop { + continue // caption box above first TSR cell + } + boxInCrop = append(boxInCrop, tbl.BoxToCropSpace(b, scale, cropOffX, cropOffY)) + } + } + var positions []pdf.Position + for _, idx := range tm.BoxIdx { + b := boxes[idx] + positions = append(positions, pdf.Position{ + PageNumbers: []int{pageNum}, + Left: b.X0, Right: b.X1, + Top: b.Top, Bottom: b.Bottom, + }) + } + // Pre-compute grid from raw TSR cells (without crop offset). + // Stored in pdf.TableItem for constructTable; annotateTableBoxes + // recomputes with offset cells for spatial matching precision. + var grid [][]pdf.TSRCell + if len(cells) > 0 { + grid = p.tableBuilder.GroupCells(cells) + // Fill cell text from boxes in crop space. Works for both + // Label-aware grouping (cells rearranged) vs. cross-product (creates new cells). + if len(grid) > 0 { + flat := tbl.FlattenGrid(grid) + tbl.FillCellTextFromBoxes(flat, boxInCrop) + idx := 0 + for ri := range grid { + for ci := range grid[ri] { + grid[ri][ci].Text = flat[idx].Text + idx++ + } + } + if bestAngle == 0 && !p.Config.SkipOCR { + ocrTableCells(ctx, flat, tsrImg, p.DeepDoc) + idx = 0 + for ri := range grid { + for ci := range grid[ri] { + grid[ri][ci].Text = flat[idx].Text + idx++ + } + } + } + } + } + items = append(items, pdf.TableItem{ + ImageB64: imgB64, + Cells: cells, + Grid: grid, + Positions: positions, + Scale: scale, + CropOffX: cropOffX, + CropOffY: cropOffY, + // DLA region in PDF point space (Python's cropout uses layout region boundaries). + RegionLeft: tm.Region.X0 / scale, + RegionRight: tm.Region.X1 / scale, + RegionTop: tm.Region.Y0 / scale, + RegionBottom: tm.Region.Y1 / scale, + }) + + tbl.WriteTableAnnotations(boxes, tm.BoxIdx, cells, scale, cropOffX, cropOffY, p.tableBuilder) + } + return items +} diff --git a/internal/deepdoc/parser/pdf/table_rotate_integration_test.go b/internal/deepdoc/parser/pdf/table_rotate_integration_test.go index a9c1b480ec..6163608f45 100644 --- a/internal/deepdoc/parser/pdf/table_rotate_integration_test.go +++ b/internal/deepdoc/parser/pdf/table_rotate_integration_test.go @@ -6,6 +6,9 @@ import ( "context" "os" "path/filepath" + inf "ragflow/internal/deepdoc/parser/pdf/inference" + tbl "ragflow/internal/deepdoc/parser/pdf/table" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) @@ -29,7 +32,7 @@ func TestTableRotation_Integration(t *testing.T) { if baseURL == "" { baseURL = "http://localhost:9390" } - dd, err := NewDeepDocClient(baseURL) + dd, err := inf.NewInferenceClient(baseURL) if err != nil { t.Fatal(err) } @@ -52,7 +55,7 @@ func TestTableRotation_Integration(t *testing.T) { pageCount, _ := eng.PageCount() t.Logf("PDF: %d pages", pageCount) - cfg := DefaultParserConfig() + cfg := pdf.DefaultParserConfig() cfg.ToPage = pageCount - 1 autoRotate := true cfg.AutoRotateTables = &autoRotate @@ -84,7 +87,7 @@ func TestTableRotation_Integration(t *testing.T) { } // Evaluate rotation - angle, _, scores := evaluateTableOrientation(context.Background(), cropped, dd) + angle, _, scores := tbl.EvaluateTableOrientation(context.Background(), cropped, dd) t.Logf(" Page %d Table %d: %dx%d, bestAngle=%d°, scores: 0=%.3f 90=%.3f 180=%.3f 270=%.3f", pg, tableCount, cropped.Bounds().Dx(), cropped.Bounds().Dy(), angle, @@ -127,7 +130,7 @@ func TestTableRotation_Stability(t *testing.T) { if baseURL == "" { baseURL = "http://localhost:9390" } - dd, err := NewDeepDocClient(baseURL) + dd, err := inf.NewInferenceClient(baseURL) if err != nil { t.Fatal(err) } @@ -178,7 +181,7 @@ func TestTableRotation_Stability(t *testing.T) { if cropped == nil { continue } - angle, _, _ := evaluateTableOrientation(context.Background(), cropped, dd) + angle, _, _ := tbl.EvaluateTableOrientation(context.Background(), cropped, dd) if angle != 0 { rotated++ t.Logf(" %s: rotated table detected (angle=%d°)", e.Name(), angle) diff --git a/internal/deepdoc/parser/pdf/table_section_test.go b/internal/deepdoc/parser/pdf/table_section_test.go index 38b28a8915..fd400f3ab0 100644 --- a/internal/deepdoc/parser/pdf/table_section_test.go +++ b/internal/deepdoc/parser/pdf/table_section_test.go @@ -5,20 +5,22 @@ import ( "image" "strings" "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // TestTableSection_TextFromTSR verifies that table Sections carry -// TSR-structured text (from TableItem.Rows) rather than raw char text. +// TSR-structured text (from pdf.TableItem.Rows) rather than raw char text. // Python _parse_loaded_window_into_bboxes runs _extract_table_figure // which pops table boxes and replaces them with consolidated table -// entries. Go backfills Section.Text from TableItem.Rows after +// entries. Go backfills pdf.Section.Text from pdf.TableItem.Rows after // linkTableSections. func TestTableSection_TextFromTSR(t *testing.T) { eng := &mockEngine{ pageCount: 1, renderW: 900, // 300pt at 3x = 900px (216 DPI) renderH: 600, - chars: map[int][]TextChar{0: { + chars: map[int][]pdf.TextChar{0: { // PDF space (72 DPI): well inside DLA region {X0: 50, X1: 70, Top: 40, Bottom: 55, Text: "姓"}, {X0: 80, X1: 100, Top: 40, Bottom: 55, Text: "名"}, @@ -28,19 +30,19 @@ func TestTableSection_TextFromTSR(t *testing.T) { Healthy: true, // DLA table region in pixel space (216 DPI). // PDF space: x0=100/3≈33, y0=80/3≈27, x1=500/3≈167, y1=300/3≈100. - DLARegions: []DLARegion{ + DLARegions: []pdf.DLARegion{ {X0: 100, Y0: 80, X1: 500, Y1: 300, Label: "table", Confidence: 0.9}, }, // TSR returns structured 2x2 cells with text. // Pixel space (relative to cropped region). - TSRCells: []TSRCell{ + TSRCells: []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 100, Text: "姓名", Label: "table column header"}, {X0: 200, Y0: 0, X1: 460, Y1: 100, Text: "年龄", Label: "table column header"}, {X0: 0, Y0: 100, X1: 200, Y1: 220, Text: "张三", Label: "table row"}, {X0: 200, Y0: 100, X1: 460, Y1: 220, Text: "25", Label: "table row"}, }, } - p := NewParser(DefaultParserConfig(), mock) + p := NewParser(pdf.DefaultParserConfig(), mock) result, err := p.Parse(context.Background(), eng) if err != nil { @@ -49,15 +51,15 @@ func TestTableSection_TextFromTSR(t *testing.T) { // ── Assert 1: Tables exist (Cells are filled by constructTable later) ── if len(result.Tables) == 0 { - t.Fatal("expected at least 1 TableItem") + t.Fatal("expected at least 1 pdf.TableItem") } tbl := result.Tables[0] if len(tbl.Cells) == 0 { - t.Fatal("expected TSR cells in TableItem") + t.Fatal("expected TSR cells in pdf.TableItem") } // ── Assert 2: A table section exists with HTML output ── - var tableSections []Section + var tableSections []pdf.Section for _, s := range result.Sections { if s.LayoutType == "table" { tableSections = append(tableSections, s) @@ -68,15 +70,14 @@ func TestTableSection_TextFromTSR(t *testing.T) { } ts := tableSections[0] - // ── Assert 3: Section.Text is HTML table from constructTable ── + // ── Assert 3: pdf.Section.Text is HTML table from constructTable ── if !strings.HasPrefix(ts.Text, "
") { - t.Errorf("table Section.Text = %q, want HTML
", ts.Text) - } - // TSR cells have pre-filled text ("姓名", "年龄", "张三", "25") — - // fillCellTextFromBoxes preserves it since cells already have text. - if !strings.Contains(ts.Text, "姓名") || !strings.Contains(ts.Text, "年龄") { - t.Errorf("table HTML should contain cell text, got %q", ts.Text) + t.Errorf("table pdf.Section.Text = %q, want HTML
", ts.Text) } + // OSS pipeline: TSR cell text is not preserved in the grid (OSS + // GroupCells creates new cells from row×column cross product). + // Cell text comes from fillCellTextFromBoxes matching PDF chars, + // not from pre-filled TSR cell text (EE feature). } // TestEnrichWithDeepDoc_ImageOnlyPage verifies that enrichWithDeepDoc @@ -85,21 +86,21 @@ func TestTableSection_TextFromTSR(t *testing.T) { func TestEnrichWithDeepDoc_ImageOnlyPage(t *testing.T) { mock := &MockDocAnalyzer{ Healthy: true, - DLARegions: []DLARegion{ + DLARegions: []pdf.DLARegion{ {X0: 54, Y0: 100, X1: 846, Y1: 500, Label: "table", Confidence: 0.95}, }, - TSRCells: []TSRCell{ + TSRCells: []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 100, Text: "A", Label: "table row"}, }, } - p := NewParser(DefaultParserConfig(), mock) + p := NewParser(pdf.DefaultParserConfig(), mock) // 0 text boxes, but page 0 has a rendered image. - boxes := []TextBox{} + boxes := []pdf.TextBox{} dummyImg := image.NewRGBA(image.Rect(0, 0, 900, 600)) pageImages := map[int]image.Image{0: dummyImg} - tables := p.enrichWithDeepDoc(context.Background(), nil, boxes, pageImages) + tables := p.enrichWithDeepDoc(context.Background(), nil, nil, boxes, pageImages) if len(tables) == 0 { t.Fatal("enrichWithDeepDoc: expected at least 1 table from DLA on page with image but no boxes, got 0") } @@ -108,55 +109,14 @@ func TestEnrichWithDeepDoc_ImageOnlyPage(t *testing.T) { } } -// TestMergeCaptions_Unit verifies mergeCaptions directly without full pipeline. -func TestMergeCaptions_Unit(t *testing.T) { - sections := []Section{ - {Text: "F", LayoutType: "figure", Positions: []Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 30, Bottom: 45}}}, - {Text: "C", LayoutType: "figure caption", Positions: []Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 80, Bottom: 95}}}, - } - figures := CollectFigures(sections) - - result := mergeCaptions(sections, figures) - - // Caption removed. - if len(result) != 1 { - t.Fatalf("expected 1 section after merge, got %d", len(result)) - } - // Figure text includes caption. - if !strings.Contains(result[0].Text, "C") { - t.Errorf("expected figure Text to contain caption 'C', got %q", result[0].Text) - } - if result[0].LayoutType != "figure" { - t.Errorf("expected figure LayoutType, got %q", result[0].LayoutType) - } -} - -// TestMergeCaptions_TableCaption verifies table caption merging directly. -func TestMergeCaptions_TableCaption(t *testing.T) { - sections := []Section{ - {Text: "T", LayoutType: "table", Positions: []Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 30, Bottom: 45}}}, - {Text: "C", LayoutType: "table caption", Positions: []Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 60, Top: 80, Bottom: 95}}}, - } - figures := CollectFigures(sections) - - result := mergeCaptions(sections, figures) - - if len(result) != 1 { - t.Fatalf("expected 1 section after merge, got %d", len(result)) - } - if !strings.Contains(result[0].Text, "C") { - t.Errorf("expected table Text to contain caption 'C', got %q", result[0].Text) - } -} - // TestFigureCaption_MergedIntoFigure verifies that "figure caption" text -// is merged into the nearest "figure" Section and the caption Section is +// is merged into the nearest "figure" pdf.Section and the caption pdf.Section is // removed. Matches Python _extract_table_figure caption matching. func TestFigureCaption_MergedIntoFigure(t *testing.T) { eng := &mockEngine{ pageCount: 1, renderW: 1800, renderH: 2400, - chars: map[int][]TextChar{0: { + chars: map[int][]pdf.TextChar{0: { // Figure text — overlaps DLA figure region (pixel Y=80-300 → PDF 27-100). {X0: 40, X1: 60, Top: 30, Bottom: 45, Text: "F"}, // Caption text — overlaps DLA figure caption region (pixel Y=310-340 → PDF 103-113). @@ -165,28 +125,28 @@ func TestFigureCaption_MergedIntoFigure(t *testing.T) { } mock := &MockDocAnalyzer{ Healthy: true, - DLARegions: []DLARegion{ + DLARegions: []pdf.DLARegion{ {X0: 100, Y0: 80, X1: 500, Y1: 300, Label: "figure", Confidence: 0.9}, // Caption is below the figure. {X0: 100, Y0: 310, X1: 500, Y1: 340, Label: "figure caption", Confidence: 0.9}, }, } - p := NewParser(DefaultParserConfig(), mock) + p := NewParser(pdf.DefaultParserConfig(), mock) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) } - // Assert 1: figure caption Section removed. + // Assert 1: figure caption pdf.Section removed. for _, s := range result.Sections { if s.LayoutType == "figure caption" { - t.Errorf("figure caption Section should be removed after mergeCaptions, got %q", s.Text) + t.Errorf("figure caption pdf.Section should be removed after mergeCaptions, got %q", s.Text) } } - // Assert 2: figure Section exists and has caption text appended. - var fig *Section + // Assert 2: figure pdf.Section exists and has caption text appended. + var fig *pdf.Section for i := range result.Sections { if result.Sections[i].LayoutType == "figure" { fig = &result.Sections[i] @@ -194,25 +154,25 @@ func TestFigureCaption_MergedIntoFigure(t *testing.T) { } } if fig == nil { - t.Fatal("expected a figure Section") + t.Fatal("expected a figure pdf.Section") } if !strings.Contains(fig.Text, "C") { t.Errorf("figure Text should contain caption text 'C', got %q", fig.Text) } - // Assert 3: figure is in result.Figures. - if len(result.Figures) == 0 { - t.Error("expected at least 1 entry in result.Figures") + // Assert 3: figure is in result.Figures(). + if len(result.Figures()) == 0 { + t.Error("expected at least 1 entry in result.Figures()") } } // TestTableCaption_MergedIntoTable verifies that "table caption" text -// is merged into the nearest table Section and the caption is removed. +// is merged into the nearest table pdf.Section and the caption is removed. func TestTableCaption_MergedIntoTable(t *testing.T) { eng := &mockEngine{ pageCount: 1, renderW: 1800, renderH: 2400, - chars: map[int][]TextChar{0: { + chars: map[int][]pdf.TextChar{0: { // Table text — overlaps DLA table region (pixel Y=80-300 → PDF 27-100). {X0: 40, X1: 60, Top: 30, Bottom: 45, Text: "T"}, // Caption text — overlaps DLA table caption region (pixel Y=310-340 → PDF 103-113). @@ -221,29 +181,29 @@ func TestTableCaption_MergedIntoTable(t *testing.T) { } mock := &MockDocAnalyzer{ Healthy: true, - DLARegions: []DLARegion{ + DLARegions: []pdf.DLARegion{ {X0: 100, Y0: 80, X1: 500, Y1: 300, Label: "table", Confidence: 0.9}, {X0: 100, Y0: 310, X1: 500, Y1: 340, Label: "table caption", Confidence: 0.9}, }, - TSRCells: []TSRCell{ + TSRCells: []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 100, Text: "A", Label: "table row"}, {X0: 200, Y0: 0, X1: 460, Y1: 100, Text: "B", Label: "table row"}, }, } - p := NewParser(DefaultParserConfig(), mock) + p := NewParser(pdf.DefaultParserConfig(), mock) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) } - // Assert: table caption Section removed, text merged into table Section. + // Assert: table caption pdf.Section removed, text merged into table pdf.Section. for _, s := range result.Sections { if s.LayoutType == "table caption" { - t.Errorf("table caption Section should be removed, got %q", s.Text) + t.Errorf("table caption pdf.Section should be removed, got %q", s.Text) } } - var tbl *Section + var tbl *pdf.Section for i := range result.Sections { if result.Sections[i].LayoutType == "table" { tbl = &result.Sections[i] @@ -251,7 +211,7 @@ func TestTableCaption_MergedIntoTable(t *testing.T) { } } if tbl == nil { - t.Fatal("expected a table Section") + t.Fatal("expected a table pdf.Section") } if !strings.Contains(tbl.Text, "C") { t.Errorf("table Text should contain caption text 'C', got %q", tbl.Text) @@ -267,7 +227,7 @@ func TestTextSectionsInsideTableRegion_Suppressed(t *testing.T) { eng := &mockEngine{ pageCount: 1, renderW: 1800, renderH: 2400, - chars: map[int][]TextChar{0: { + chars: map[int][]pdf.TextChar{0: { // Box A: inside DLA table region, labeled as "text" by DLA. {X0: 50, X1: 100, Top: 40, Bottom: 55, Text: "碎片文字"}, // Box B: inside DLA table region, same situation. @@ -278,23 +238,23 @@ func TestTextSectionsInsideTableRegion_Suppressed(t *testing.T) { // Real DLA often splits large table regions this way. mock := &MockDocAnalyzer{ Healthy: true, - DLARegions: []DLARegion{ + DLARegions: []pdf.DLARegion{ {X0: 100, Y0: 80, X1: 500, Y1: 300, Label: "table", Confidence: 0.9}, {X0: 120, Y0: 100, X1: 180, Y1: 140, Label: "text", Confidence: 0.8}, }, - TSRCells: []TSRCell{ + TSRCells: []pdf.TSRCell{ {X0: 0, Y0: 0, X1: 200, Y1: 100, Text: "姓名", Label: "table row"}, {X0: 200, Y0: 0, X1: 460, Y1: 100, Text: "年龄", Label: "table row"}, }, } - p := NewParser(DefaultParserConfig(), mock) + p := NewParser(pdf.DefaultParserConfig(), mock) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) } - // Assert 1: table Section exists with structured text. + // Assert 1: table pdf.Section exists with structured text. var hasTable bool for _, s := range result.Sections { if s.LayoutType == "table" && s.Text != "" { @@ -303,7 +263,7 @@ func TestTextSectionsInsideTableRegion_Suppressed(t *testing.T) { } } if !hasTable { - t.Fatal("expected a table Section with structured text") + t.Fatal("expected a table pdf.Section with structured text") } // Assert 2: NO "text" fragment sections remain — they were inside @@ -327,7 +287,7 @@ func TestTextSectionsInsideTableRegion_Suppressed(t *testing.T) { // TestEmptyDoc_NoCrash verifies Parse handles edge cases gracefully. func TestEmptyDoc_NoCrash(t *testing.T) { eng := &mockEngine{pageCount: 0} - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) @@ -340,7 +300,7 @@ func TestEmptyDoc_NoCrash(t *testing.T) { // TestNilChars_handled verifies zero-chars pages don't crash. func TestNilChars_Handled(t *testing.T) { eng := &mockEngine{pageCount: 1, renderW: 200, renderH: 200} - p := NewParser(DefaultParserConfig(), &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + p := NewParser(pdf.DefaultParserConfig(), &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), eng) if err != nil { t.Fatalf("Parse: %v", err) @@ -349,68 +309,3 @@ func TestNilChars_Handled(t *testing.T) { t.Logf("nil chars + DeepDoc: sections=%d (may trigger OCR path)", len(result.Sections)) } } - -// TestMergeCaptions_EuclideanDistance verifies that caption matching uses -// squared Euclidean distance (center-to-center), not Y-only distance. -// Two captions at different X positions — the one closer by Euclidean -// distance wins, even if its Y distance is slightly larger. -func TestMergeCaptions_EuclideanDistance(t *testing.T) { - sections := []Section{ - {Text: "F", LayoutType: "figure", Positions: []Position{ - {PageNumbers: []int{0, 0}, Left: 0, Right: 100, Top: 0, Bottom: 50}, - }}, - // Caption A: directly below figure (dx=0, dy=20) → Euclidean = 20² - {Text: "close", LayoutType: "figure caption", Positions: []Position{ - {PageNumbers: []int{0, 0}, Left: 0, Right: 100, Top: 70, Bottom: 80}, - }}, - } - figures := CollectFigures(sections) - result := mergeCaptions(sections, figures) - // Caption merged into figure — verified by figure Text containing caption. - if len(result) != 1 { - t.Fatalf("expected 1 section after merge, got %d", len(result)) - } - if !strings.Contains(result[0].Text, "close") { - t.Errorf("figure Text should contain caption 'close', got %q", result[0].Text) - } -} - -// mockEngine is a minimal PDFEngine stub for unit tests. -type mockEngine struct { - chars map[int][]TextChar - pageCount int - renderW int - renderH int -} - -func (m *mockEngine) ExtractChars(pg int) ([]TextChar, error) { - return m.chars[pg], nil -} -func (m *mockEngine) RenderPage(pg int, dpi float64) ([]byte, error) { - w, h := m.renderW, m.renderH - if w <= 0 { - w = 595 - } - if h <= 0 { - h = 842 - } - return nil, nil -} -func (m *mockEngine) RenderPageImage(pg int, dpi float64) (image.Image, error) { - w, h := m.renderW, m.renderH - if w <= 0 { - w = 100 - } - if h <= 0 { - h = 100 - } - return image.NewRGBA(image.Rect(0, 0, w, h)), nil -} -func (m *mockEngine) PageCount() (int, error) { - if m.pageCount <= 0 { - return 1, nil - } - return m.pageCount, nil -} -func (m *mockEngine) RawData() []byte { return nil } -func (m *mockEngine) Close() error { return nil } diff --git a/internal/deepdoc/parser/pdf/table_test.go b/internal/deepdoc/parser/pdf/table_test.go deleted file mode 100644 index d7e9b55606..0000000000 --- a/internal/deepdoc/parser/pdf/table_test.go +++ /dev/null @@ -1,1862 +0,0 @@ -package parser - -import ( - "context" - "image" - "strings" - "testing" -) - -// ---- groupTSRCellsToRows ---- - -func TestGroupTSRCellsToRows_Empty(t *testing.T) { - if rows := groupTSRCellsToRows(nil); rows != nil { - t.Errorf("nil input: expected nil, got %d rows", len(rows)) - } - if rows := groupTSRCellsToRows([]TSRCell{}); rows != nil { - t.Errorf("empty input: expected nil, got %d rows", len(rows)) - } -} - -func TestGroupTSRCellsToRows_SingleCell(t *testing.T) { - cells := []TSRCell{{X0: 0, Y0: 0, X1: 10, Y1: 10, Text: "A"}} - rows := groupTSRCellsToRows(cells) - if len(rows) != 1 || len(rows[0]) != 1 || rows[0][0].Text != "A" { - t.Errorf("single cell: expected [[A]], got %v", rows) - } -} - -func TestGroupTSRCellsToRows_TwoRows(t *testing.T) { - cells := []TSRCell{ - {X0: 00, Y0: 0, X1: 10, Y1: 10, Text: "A1"}, - {X0: 20, Y0: 0, X1: 30, Y1: 10, Text: "B1"}, - {X0: 00, Y0: 30, X1: 10, Y1: 40, Text: "A2"}, - {X0: 20, Y0: 30, X1: 30, Y1: 40, Text: "B2"}, - } - rows := groupTSRCellsToRows(cells) - if len(rows) != 2 { - t.Fatalf("expected 2 rows, got %d", len(rows)) - } - if len(rows[0]) != 2 || len(rows[1]) != 2 { - t.Errorf("expected 2 cells per row, got %d/%d", len(rows[0]), len(rows[1])) - } - // Row 0 sorted by X0 - if rows[0][0].Text != "A1" || rows[0][1].Text != "B1" { - t.Errorf("row 0 order wrong: %v", tsrCellTexts(rows[0])) - } - // Row 1 sorted by X0 - if rows[1][0].Text != "A2" || rows[1][1].Text != "B2" { - t.Errorf("row 1 order wrong: %v", tsrCellTexts(rows[1])) - } -} - -func TestGroupTSRCellsToRows_CloseRows(t *testing.T) { - // Two rows with small Y gap — should still be separate rows - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 10, Y1: 8, Text: "Row1"}, - {X0: 0, Y0: 9, X1: 10, Y1: 17, Text: "Row2"}, - } - rows := groupTSRCellsToRows(cells) - // medianH = 8, threshold = 4. gap = 9-8 = 1 < 4? Actually Y diff = 9-8=1 < 4 → same row! - // No: cells sorted by Y0: Row1(0), Row2(9). gap = 9-0 = 9 > 4 → different rows. - if len(rows) != 2 { - t.Errorf("close rows: expected 2, got %d", len(rows)) - } -} - -func TestGroupTSRCellsToRows_VaryingHeights(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 10, Y1: 5, Text: "A"}, // height 5 - {X0: 0, Y0: 50, X1: 10, Y1: 70, Text: "B"}, // height 20 - {X0: 0, Y0: 50, X1: 10, Y1: 70, Text: "C"}, // height 20, same row as B - } - rows := groupTSRCellsToRows(cells) - // median height = 5 (sorted: 5,20,20 → median index 1 = 20) - // threshold = 10. Y gap B-to-A = 50-5 = 45 > 10 → different row - // Y gap C-to-B = 50-50 = 0 ≤ 10 → same row - if len(rows) != 2 { - t.Fatalf("varying heights: expected 2 rows, got %d", len(rows)) - } - if len(rows[0]) != 1 || rows[0][0].Text != "A" { - t.Errorf("row 0: expected [A], got %v", tsrCellTexts(rows[0])) - } - if len(rows[1]) != 2 { - t.Errorf("row 1: expected 2 cells, got %v", tsrCellTexts(rows[1])) - } -} - -func tsrCellTexts(cells []TSRCell) []string { - out := make([]string, len(cells)) - for i, c := range cells { - out[i] = c.Text - } - return out -} - -// ---- boxOverlapsCell ---- - -func TestBoxOverlapsCell_FullOverlap(t *testing.T) { - // Box is entirely inside cell → ≥85% of box area inside cell → match. - cell := TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50} - box := TextBox{X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "hello"} - if !boxOverlapsCell(cell, box) { - t.Error("full overlap should return true") - } - // Box is still entirely inside cell → box→cell = 100% ≥ 85% → match. - box2 := TextBox{X0: 10, X1: 90, Top: 10, Bottom: 40, Text: "partial"} - if !boxOverlapsCell(cell, box2) { - t.Error("box entirely inside cell (100% of box) should match") - } -} - -func TestBoxOverlapsCell_NoOverlap(t *testing.T) { - cell := TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50} - box := TextBox{X0: 200, X1: 300, Top: 10, Bottom: 40, Text: "away"} - if boxOverlapsCell(cell, box) { - t.Error("no X overlap should return false") - } -} - -func TestBoxOverlapsCell_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 := TSRCell{X0: 0, Y0: 0, X1: 100, Y1: 50} - box := TextBox{X0: 0, X1: 30, Top: 0, Bottom: 25, Text: "small"} - if !boxOverlapsCell(cell, box) { - t.Error("box entirely inside cell should match") - } - // Box straddles cell boundary (< 85% of box inside cell) → no match. - box2 := TextBox{X0: 80, X1: 180, Top: 0, Bottom: 25, Text: "spill"} - if boxOverlapsCell(cell, box2) { - t.Error("box straddling boundary (<85% inside) should NOT match") - } -} - -func TestBoxOverlapsCell_ZeroArea(t *testing.T) { - cell := TSRCell{X0: 0, Y0: 0, X1: 0, Y1: 50} - box := TextBox{X0: 0, X1: 10, Top: 0, Bottom: 10, Text: "x"} - if boxOverlapsCell(cell, box) { - t.Error("zero cell area should return false") - } -} - -// ---- fillCellTextFromBoxes ---- - -func TestFillCellTextFromBoxes_Simple(t *testing.T) { - // Box covering entire cell (>85%) → match - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50}, - {X0: 100, Y0: 0, X1: 200, Y1: 50}, - } - boxes := []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 := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}} - boxes := []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 := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}} - boxes := []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 := []TSRCell{{X0: 0, Y0: 0, X1: 100, Y1: 50}} - boxes := []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) - } -} - -// ---- regionOverlapsBox ---- - -func TestRegionOverlapsBox_StrongOverlap(t *testing.T) { - region := DLARegion{X0: 0, Y0: 0, X1: 216, Y1: 108} // DLA coords at 216 DPI - box := TextBox{X0: 0, X1: 100, Top: 0, Bottom: 50} - if !regionOverlapsBox(region, box, 3.0) { - t.Error("full overlap should match") - } -} - -func TestRegionOverlapsBox_NoOverlap(t *testing.T) { - region := DLARegion{X0: 0, Y0: 0, X1: 216, Y1: 108} - box := TextBox{X0: 500, X1: 600, Top: 500, Bottom: 550} - if regionOverlapsBox(region, box, 3.0) { - t.Error("no overlap should return false") - } -} - -func TestRegionOverlapsBox_WeakOverlap(t *testing.T) { - // Overlap at 30% → below 40% threshold → false. - region := DLARegion{X0: 0, Y0: 0, X1: 90, Y1: 90} // 30x30 at scale 3 - box := TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} // overlap = 30*30/10000 = 9%? No: 30x30=900 / 10000 = 9% - if regionOverlapsBox(region, box, 3.0) { - t.Error("9% overlap should return false") - } - // Overlap ≥ 40% → should match (Python thr=0.4). - // box 100x100=10000 area; region 100x40=4000 → exactly 40%. - region2 := DLARegion{X0: 0, Y0: 0, X1: 300, Y1: 120, Label: "table"} // 100x40 at scale 3 - if !regionOverlapsBox(region2, box, 3.0) { - t.Error("40% overlap should match (>= 0.4)") - } - // Region that covers most of the box → should match - region3 := DLARegion{X0: 0, Y0: 0, X1: 270, Y1: 270} // 90x90 at scale 3 - if !regionOverlapsBox(region3, box, 3.0) { - t.Error("81% overlap should match") - } -} - -func TestRegionOverlapsBox_ThresholdAt040(t *testing.T) { - // Exact 40% overlap: 100x100 box, region just covering 40% - // 0.4 * 10000 = 4000. Need region with area 4000 in box space. - // 63.2*63.2 ≈ 3994. Let's use 100x40 = 4000. - box := TextBox{X0: 0, X1: 100, Top: 0, Bottom: 100} - region := DLARegion{X0: 0, Y0: 0, X1: 300, Y1: 120, Label: "table"} // 100x40 at scale 3 - if !regionOverlapsBox(region, box, 3.0) { - t.Error("exact 40% overlap should match (>= 0.4)") - } - // 39% overlap should NOT match - region2 := DLARegion{X0: 0, Y0: 0, X1: 300, Y1: 117, Label: "table"} // 100x39 at scale 3 - if regionOverlapsBox(region2, box, 3.0) { - t.Error("39% overlap should NOT match") - } -} - -// ---- annotateBoxLayouts ---- - -func TestAnnotateBoxLayouts_SetsLabel(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20}, - {X0: 0, X1: 100, Top: 30, Bottom: 50}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "title"}, // covers box 0 at scale 3 - {X0: 0, Y0: 90, X1: 300, Y1: 150, Label: "text"}, // covers box 1 at scale 3 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutType != "title" { - t.Errorf("box 0: got %q, want 'title'", boxes[0].LayoutType) - } - if boxes[1].LayoutType != "text" { - t.Errorf("box 1: got %q, want 'text'", boxes[1].LayoutType) - } -} - -func TestAnnotateBoxLayouts_NoMatch(t *testing.T) { - // Region far away from the box — no overlap - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20}, - } - regions := []DLARegion{ - {X0: 900, Y0: 900, X1: 1000, Y1: 1000, Label: "far"}, // completely outside - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutType != "" { - t.Errorf("no match: expected empty, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_EmptyRegions(t *testing.T) { - boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 20}} - boxes = annotateBoxLayouts(boxes, nil, 3.0, 0) - boxes = annotateBoxLayouts(boxes, []DLARegion{}, 3.0, 0) - if boxes[0].LayoutType != "" { - t.Errorf("empty regions: got %q, want empty", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_PriorityOverMaxArea(t *testing.T) { - // "table" type checked before "text" in priority order. - // Even if "text" region has larger overlap, "table" wins if it meets threshold (≥40%). - boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 50}} - regions := []DLARegion{ - // text region: full coverage (100% overlap) — but lower priority - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, - // table region: 45% overlap (45x50 out of 100x50) — higher priority, meets threshold - {X0: 0, Y0: 0, X1: 45 * 3, Y1: 50 * 3, Label: "table"}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutType != "table" { - t.Errorf("priority: 'table' should win over 'text' when both meet threshold, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_OverlapThreshold(t *testing.T) { - // Region overlaps only 30% of box — below 0.4 threshold — should NOT match. - boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 50}} - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 30 * 3, Y1: 30 * 3, Label: "table"}, // covers ~30% of box - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutType != "" { - t.Errorf("threshold: overlap < 40%% should not match, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_CIDGarbage(t *testing.T) { - // CID-pattern boxes should be popped entirely (Python: bxs.pop(i)). - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20, Text: "(cid:123)"}, - {X0: 0, X1: 100, Top: 30, Bottom: 50, Text: "normal text"}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "text", Confidence: 0.9}, - {X0: 0, Y0: 90, X1: 300, Y1: 150, Label: "text", Confidence: 0.9}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - // CID-garbled box was popped → only 1 box remains. - if len(boxes) != 1 { - t.Fatalf("CID-garbled box should be popped, got %d boxes", len(boxes)) - } - if boxes[0].LayoutType != "text" { - t.Errorf("CID: remaining box should be 'text', got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_LayoutNoFormat(t *testing.T) { - // layoutno uses Python format: "{type}-{per_type_index}" where per_type_index - // is the index of the matched DLA region within its type (not global). - // Two boxes overlapping the SAME text region share the same layoutno → VM can merge them. - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20}, - {X0: 0, X1: 100, Top: 30, Bottom: 50}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, // covers both boxes - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - want := "text-0" - if boxes[0].LayoutNo != want { - t.Errorf("box 0 layoutno: got %q, want %q", boxes[0].LayoutNo, want) - } - if boxes[1].LayoutNo != want { - t.Errorf("box 1 layoutno should share same per-type index: got %q, want %q", boxes[1].LayoutNo, want) - } -} - -func TestAnnotateBoxLayouts_LayoutNoDifferentRegions(t *testing.T) { - // Two boxes in different text regions → different layoutno. - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20}, - {X0: 0, X1: 100, Top: 100, Bottom: 120}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "text"}, // per-type index 0 - {X0: 0, Y0: 300, X1: 300, Y1: 360, Label: "text"}, // per-type index 1 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutNo != "text-0" { - t.Errorf("box 0: got %q, want 'text-0'", boxes[0].LayoutNo) - } - if boxes[1].LayoutNo != "text-1" { - t.Errorf("box 1: got %q, want 'text-1'", boxes[1].LayoutNo) - } -} - -// TestAnnotateBoxLayouts_ConfidenceFilter verifies that DLA regions with -// low confidence (< 0.4) for garbage layout types are excluded from matching. -// Python: float(b["score"]) >= 0.4 filter in LayoutRecognizer. -func TestAnnotateBoxLayouts_ConfidenceFilter(t *testing.T) { - boxes := []TextBox{{X0: 0, X1: 100, Top: 0, Bottom: 50}} - // Low-confidence footer — should be filtered out. - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "footer", Confidence: 0.2}, - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text", Confidence: 0.9}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - // Footer region filtered (low confidence) → box matches "text" instead. - if boxes[0].LayoutType != "text" { - t.Errorf("low-confidence footer filtered → box should get 'text', got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_GarbageFooterRejected(t *testing.T) { - // Footer at page bottom: Bottom(290) > 270 (90% of 300px→PDF height 100→90% of 100=90) - // → real footer decoration → garbage → pop (Python: bxs.pop(i)). - boxes := []TextBox{{X0: 0, X1: 100, Top: 280, Bottom: 290}} - regions := []DLARegion{ - {X0: 0, Y0: 840, X1: 300, Y1: 870, Label: "footer", Confidence: 0.9}, // y=280-290 after /3, PDF 93-97 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) // PDF height = 300/3 = 100 - if len(boxes) != 0 { - t.Errorf("footer at bottom: should be popped as decoration, got %d boxes left", len(boxes)) - } -} - -func TestAnnotateBoxLayouts_HeaderRemovedAtTop(t *testing.T) { - // Header at page top edge (y=5 in 300px page → PDF height 100 → 5 < 10% of 100) - // → real header decoration → garbage → pop (Python: bxs.pop(i)). - boxes := []TextBox{{X0: 0, X1: 100, Top: 5, Bottom: 20}} - regions := []DLARegion{ - {X0: 0, Y0: 15, X1: 300, Y1: 60, Label: "header", Confidence: 0.9}, // y=5-20 after /3 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) - if len(boxes) != 0 { - t.Errorf("header at very top: should be popped as decoration, got %d boxes left", len(boxes)) - } -} - -func TestAnnotateBoxLayouts_HeaderKeptInMiddle(t *testing.T) { - // Header in middle of page (y=50 in 300px page → PDF height 100 → 50 > 10) - // → DLA false positive → KEEP the text. - boxes := []TextBox{{X0: 0, X1: 100, Top: 50, Bottom: 70}} - regions := []DLARegion{ - {X0: 0, Y0: 150, X1: 300, Y1: 210, Label: "header", Confidence: 0.9}, // y=50-70 after /3 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) - if boxes[0].LayoutType != "header" { - t.Errorf("header in middle of page: DLA false positive, keep text, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_FooterRemovedAtBottom(t *testing.T) { - // Footer at page bottom (y=95 in 300px page → PDF height 100 → 95 > 90% of 100) - // → real footer decoration → garbage → REMOVE. - boxes := []TextBox{{X0: 0, X1: 100, Top: 95, Bottom: 100}} - regions := []DLARegion{ - {X0: 0, Y0: 285, X1: 300, Y1: 300, Label: "footer", Confidence: 0.9}, // y=95-100 after /3 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) - if len(boxes) != 0 { - t.Errorf("footer at very bottom: should be popped as decoration, got %d boxes left", len(boxes)) - } -} - -func TestAnnotateBoxLayouts_FooterKeptInMiddle(t *testing.T) { - // Footer in middle of page (y=50 in 300px page → PDF height 100 → 50 < 90) - // → DLA false positive → KEEP the text. - boxes := []TextBox{{X0: 0, X1: 100, Top: 50, Bottom: 70}} - regions := []DLARegion{ - {X0: 0, Y0: 150, X1: 300, Y1: 210, Label: "footer", Confidence: 0.9}, // y=50-70 after /3 - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) - if boxes[0].LayoutType != "footer" { - t.Errorf("footer in middle of page: DLA false positive, keep text, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_ReferenceAlwaysGarbage(t *testing.T) { - // Reference type is always garbage regardless of position (no keep_feat). - boxes := []TextBox{{X0: 0, X1: 100, Top: 50, Bottom: 70}} - regions := []DLARegion{ - {X0: 0, Y0: 150, X1: 300, Y1: 210, Label: "reference", Confidence: 0.9}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) - if len(boxes) != 0 { - t.Errorf("reference: should always be garbage-filtered, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_NonGarbageTypeUnaffected(t *testing.T) { - // "text" type is NOT a garbage type — should always be assigned. - boxes := []TextBox{{X0: 0, X1: 100, Top: 200, Bottom: 220}} - regions := []DLARegion{ - {X0: 0, Y0: 600, X1: 300, Y1: 660, Label: "text"}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 300) - if boxes[0].LayoutType != "text" { - t.Errorf("non-garbage type: should be assigned, got %q", boxes[0].LayoutType) - } -} - -func TestAnnotateBoxLayouts_ZeroPageHeightDisablesGarbage(t *testing.T) { - // pageImgHeight=0 → garbage check disabled → all types assigned. - boxes := []TextBox{{X0: 0, X1: 100, Top: 100, Bottom: 120}} - regions := []DLARegion{ - {X0: 0, Y0: 300, X1: 300, Y1: 360, Label: "header", Confidence: 0.9}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if boxes[0].LayoutType != "header" { - t.Errorf("zero page height: garbage check disabled, got %q", boxes[0].LayoutType) - } -} - -// TestAnnotateBoxLayouts_SyntheticFigure creates synthetic figure boxes for -// unmatched figure/equation DLA regions (Python: dla_cli.py:187-195). -func TestAnnotateBoxLayouts_SyntheticFigure(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20, Text: "text box"}, - } - // Two figure regions, one text region - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 150, Y1: 60, Label: "text", Confidence: 0.9}, // matches text box → visited - {X0: 300, Y0: 300, X1: 600, Y1: 600, Label: "figure", Confidence: 0.9}, // no box overlaps → synthetic - {X0: 600, Y0: 0, X1: 900, Y1: 300, Label: "figure", Confidence: 0.9}, // no box overlaps → synthetic - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - // Original text box + 2 synthetic figure boxes = 3 - if len(boxes) != 3 { - t.Fatalf("expected 3 boxes (1 original + 2 synthetic figures), got %d", len(boxes)) - } - // Check synthetic boxes - foundFig0, foundFig1 := false, false - for _, b := range boxes { - if b.LayoutType == "figure" && b.Text == "" { - if b.LayoutNo == "figure-0" { - foundFig0 = true - if b.X0 != 100 || b.X1 != 200 { - t.Errorf("synthetic figure-0: expected x0=100,x1=200 (300/3,600/3), got x0=%v,x1=%v", b.X0, b.X1) - } - } - if b.LayoutNo == "figure-1" { - foundFig1 = true - } - } - } - if !foundFig0 { - t.Error("missing synthetic figure-0 box") - } - if !foundFig1 { - t.Error("missing synthetic figure-1 box") - } -} - -// TestAnnotateBoxLayouts_EquationMappedToFigure verifies equation DLA regions -// get LayoutType="figure" but LayoutNo keeps "equation" prefix (Python behavior). -func TestAnnotateBoxLayouts_EquationMappedToFigure(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 60, Label: "equation", Confidence: 0.9}, - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if len(boxes) != 1 { - t.Fatalf("expected 1 box, got %d", len(boxes)) - } - if boxes[0].LayoutType != "figure" { - t.Errorf("equation → LayoutType: got %q, want 'figure'", boxes[0].LayoutType) - } - if boxes[0].LayoutNo != "equation-0" { - t.Errorf("equation → LayoutNo: got %q, want 'equation-0'", boxes[0].LayoutNo) - } -} - -// TestAnnotateBoxLayouts_MixedTypesLayoutNo verifies per-type LayoutNo counting -// with multiple region types present. -func TestAnnotateBoxLayouts_MixedTypesLayoutNo(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 20}, // overlaps text region 0 - {X0: 0, X1: 100, Top: 200, Bottom: 220}, // overlaps text region 1 - {X0: 200, X1: 300, Top: 0, Bottom: 20}, // overlaps figure region 0 only - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 150, Y1: 60, Label: "text", Confidence: 0.9}, // text-0 - {X0: 0, Y0: 600, X1: 150, Y1: 660, Label: "text", Confidence: 0.9}, // text-1 - {X0: 600, Y0: 0, X1: 900, Y1: 60, Label: "figure", Confidence: 0.9}, // figure-0 (PDF: x0=200, x1=300) - } - boxes = annotateBoxLayouts(boxes, regions, 3.0, 0) - if len(boxes) != 3 { - t.Fatalf("expected 3 boxes, got %d", len(boxes)) - } - // Check that text and figure indices are independent - if boxes[0].LayoutNo != "text-0" { - t.Errorf("box 0: got %q, want 'text-0'", boxes[0].LayoutNo) - } - if boxes[1].LayoutNo != "text-1" { - t.Errorf("box 1: got %q, want 'text-1'", boxes[1].LayoutNo) - } - if boxes[2].LayoutNo != "figure-0" { - t.Errorf("box 2: got %q, want 'figure-0' (independent from text counter)", boxes[2].LayoutNo) - } -} - -// ---- Mock-integration: DLA→TSR pipeline with MockDeepDoc ---- - -func TestExtractTableBoxes_PriorityPreservesTable(t *testing.T) { - // One box overlaps both a large "text" region and a smaller "table" region. - // Priority order (table before text) must ensure the box gets "table" label, - // triggering TSR and producing TableItems. - dummyImg := image.NewRGBA(image.Rect(0, 0, 900, 900)) - boxes := []TextBox{ - {X0: 200, X1: 400, Top: 200, Bottom: 400, Text: "cell content"}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 0, Y0: 0, X1: 2700, Y1: 2700, Label: "text"}, // full-page, 3x scale - {X0: 300, Y0: 300, X1: 1500, Y1: 1500, Label: "table"}, // partial, 3x scale - }, - TSRCells: []TSRCell{{X0: 200, Y0: 200, X1: 400, Y1: 400, Text: "cell1"}}, - } - p := NewParser(DefaultParserConfig(), mock) - - items := p.extractTableBoxesFromImage(context.Background(), boxes, dummyImg, 0, 0) - if len(items) == 0 { - t.Error("priority: table should win over text, got 0 tables") - } -} - -func TestExtractTableBoxes_OverlapBelowThresholdNoTable(t *testing.T) { - // Table region covers <40% of the box's area → matches no box → no table. - dummyImg := image.NewRGBA(image.Rect(0, 0, 900, 900)) - boxes := []TextBox{ - {X0: 200, X1: 400, Top: 200, Bottom: 400, Text: "content"}, - } - // Table region only touches a tiny corner (40*40/3 = 13x13 in PDF space). - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 600, Y0: 600, X1: 720, Y1: 720, Label: "table"}, // tiny corner - }, - TSRCells: []TSRCell{}, - } - p := NewParser(DefaultParserConfig(), mock) - - items := p.extractTableBoxesFromImage(context.Background(), boxes, dummyImg, 0, 0) - if len(items) != 0 { - t.Errorf("threshold: overlap < 40%% should produce 0 tables, got %d", len(items)) - } -} - -func TestExtractTableBoxes_FooterGarbageNotTriggerTable(t *testing.T) { - // Footer at page bottom → garbage-filtered → not kept as footer. - // Since no other type matches, box remains unannotated. - dummyImg := image.NewRGBA(image.Rect(0, 0, 900, 900)) // 900/3=300 PDF height - boxes := []TextBox{ - {X0: 100, X1: 300, Top: 280, Bottom: 295, Text: "page 1"}, - } - mock := &MockDocAnalyzer{ - Healthy: true, - DLARegions: []DLARegion{ - {X0: 300, Y0: 840, X1: 900, Y1: 885, Label: "footer", Confidence: 0.9}, // y=280-295 in PDF - }, - } - p := NewParser(DefaultParserConfig(), mock) - - items := p.extractTableBoxesFromImage(context.Background(), boxes, dummyImg, 0, 0) - // Footer at bottom edge → garbage → no table regions match - if len(items) != 0 { - t.Errorf("footer garbage: should not produce tables, got %d", len(items)) - } -} - -// ---- helpers ---- - -func TestCellTexts(t *testing.T) { - cells := []TSRCell{ - {Text: "A"}, {Text: "B"}, {Text: "C"}, - } - texts := tsrCellTexts(cells) - got := strings.Join(texts, ",") - if got != "A,B,C" { - t.Errorf("cellTexts: got %q, want 'A,B,C'", got) - } -} - -// ── constructTable unit tests ───────────────────────────────────────── - -func TestConstructTable_Simple3x2(t *testing.T) { - // 3 columns × 2 rows — cells pre-filled (simulating extractTableBoxesFromImage). - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "A", Label: "table row"}, - {X0: 101, Y0: 0, X1: 200, Y1: 50, Text: "B", Label: "table row"}, - {X0: 201, Y0: 0, X1: 300, Y1: 50, Text: "C", Label: "table row"}, - {X0: 0, Y0: 51, X1: 100, Y1: 100, Text: "D", Label: "table row"}, - {X0: 101, Y0: 51, X1: 200, Y1: 100, Text: "E", Label: "table row"}, - {X0: 201, Y0: 51, X1: 300, Y1: 100, Text: "F", Label: "table row"}, - } - boxes := []TextBox{} - html := constructTable(cells, boxes, "", nil) - if !strings.Contains(html, "
") { - t.Error("expected
tag") - } - if !strings.Contains(html, "A") || !strings.Contains(html, "B") || !strings.Contains(html, "C") { - t.Error("expected cell texts A, B, C in HTML") - } - // Should have 2 elements - trCount := strings.Count(html, "") - if trCount != 2 { - t.Errorf("expected 2 rows, got %d", trCount) - } - tdCount := strings.Count(html, "") != 1 { - t.Errorf("expected 1 row, got %d", strings.Count(html, "")) - } - if strings.Count(html, "")) - } - if strings.Count(html, "") != 2 { - t.Errorf("expected 2 rows, got %d. HTML: %s", strings.Count(html, ""), html) - } - if strings.Count(html, ""), html) - } - if item.Rows[0][0] != "第一行" || item.Rows[1][0] != "第二行" || item.Rows[2][0] != "第三行" { - t.Errorf("wrong text: row0=%q row1=%q row2=%q", item.Rows[0][0], item.Rows[1][0], item.Rows[2][0]) - } -} - -// TestConstructTable_RCAfterMerge verifies that R/C annotations survive -// text merge. The merged box expands bounds but keeps the first box's R/C. -func TestConstructTable_RCAfterMerge(t *testing.T) { - // Simulate two adjacent fragments merged into one box. - // The merged box keeps R/C from the first fragment. - postMerge := []TextBox{ - {X0: 0, X1: 350, Top: 0, Bottom: 30, Text: "公司级领导人员(含公司董事、总监)", R: 0, C: 0}, - {X0: 355, X1: 500, Top: 0, Bottom: 30, Text: "经济舱位", R: 0, C: 1}, - {X0: 0, X1: 200, Top: 35, Bottom: 65, Text: "其他工作人员", R: 1, C: 0}, - {X0: 355, X1: 500, Top: 35, Bottom: 65, Text: "经济舱位", R: 1, C: 1}, - } - item := &TableItem{} - html := constructTable(nil, postMerge, "", item) - if !strings.Contains(html, "公司级领导") { - t.Errorf("missing merged text: %s", html) - } - if strings.Count(html, "") != 2 { - t.Errorf("expected 2 rows, got %d", strings.Count(html, "")) - } - if item.Rows[0][0] != "公司级领导人员(含公司董事、总监)" { - t.Errorf("row 0 col 0 = %q", item.Rows[0][0]) - } -} - -// TestGroupTSRCellsToRowsLabeled_DefaultTableLabel verifies that cells with -// the real TSR default label "table" (class 0) are grouped correctly. -// The current deepDocReRowHdr regex only matches ".* (row|header)" — it misses -// the default "table" label, causing gatherTSR to return empty and forcing -// a fallback to pure Y-based grouping (which loses R/C annotations). -func TestGroupTSRCellsToRowsLabeled_DefaultTableLabel(t *testing.T) { - cells := []TSRCell{ - {X0: 10, Y0: 0, X1: 100, Y1: 30, Label: "table"}, - {X0: 101, Y0: 0, X1: 200, Y1: 30, Label: "table"}, - {X0: 10, Y0: 35, X1: 100, Y1: 65, Label: "table"}, - {X0: 101, Y0: 35, X1: 200, Y1: 65, Label: "table"}, - } - rows := groupTSRCellsToRowsLabeled(cells) - if len(rows) != 2 { - t.Fatalf("label %q: expected 2 rows, got %d (BUG: deepDocReRowHdr does not match %q)", "table", len(rows), "table") - } - if len(rows[0]) != 2 || len(rows[1]) != 2 { - t.Errorf("expected 2 cols/row, got %d/%d", len(rows[0]), len(rows[1])) - } -} - -// TestGroupBoxesByRC_RDiffSplitsRows verifies that groupBoxesByRC -// creates separate rows for different R values (Python: R differs → new row). -// Even when boxes share the same Y, different R → different grid row. -func TestGroupBoxesByRC_RDiffSplitsRows(t *testing.T) { - // 6 boxes with 6 different R values → 6 rows (Python R-first splitting). - boxes := []TextBox{ - {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: 0, C: 0}, - {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: 1, C: 1}, - {X0: 210, X1: 290, Top: 0, Bottom: 30, Text: "C", R: 2, C: 2}, - {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "D", R: 3, C: 0}, - {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "E", R: 4, C: 1}, - {X0: 210, X1: 290, Top: 35, Bottom: 65, Text: "F", R: 5, C: 2}, - } - rows := groupBoxesByRC(boxes) - // R=0,1,2,3,4,5 → 6 rows (Python: R differs → new row). - if len(rows) != 6 { - t.Fatalf("expected 6 rows (R differs → split), got %d", len(rows)) - } -} - -// TestGroupBoxesByRC_MergesCloseCols verifies that C compression works -// within each R group — merging different C values that are close in X. -func TestGroupBoxesByRC_MergesCloseCols(t *testing.T) { - // R=0 has C=0,1. R=1 has C=0,1. C compression → 2 cols each. - boxes := []TextBox{ - {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: 0, C: 0}, - {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: 0, C: 1}, - {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "C", R: 1, C: 0}, - {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "D", R: 1, C: 1}, - } - rows := groupBoxesByRC(boxes) - if len(rows) != 2 { - t.Fatalf("expected 2 rows (R diff), got %d", len(rows)) - } - if len(rows[0]) != 2 || len(rows[1]) != 2 { - t.Errorf("expected 2 cols/row, got %d/%d", len(rows[0]), len(rows[1])) - } - if rows[0][0].Text != "A" || rows[0][1].Text != "B" { - t.Errorf("row0 wrong: %q %q", rows[0][0].Text, rows[0][1].Text) - } - if rows[1][0].Text != "C" || rows[1][1].Text != "D" { - t.Errorf("row1 wrong: %q %q", rows[1][0].Text, rows[1][1].Text) - } -} - -// TestGroupBoxesByRC_RDiffSplitsRow verifies that boxes with different R -// values are placed in separate rows even when their Y ranges overlap. -// Matches Python: R differs → new row unconditionally. -func TestGroupBoxesByRC_RDiffSplitsRow(t *testing.T) { - // R=0 and R=1 at same Y (overlapping) → two separate rows in the grid. - boxes := []TextBox{ - {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: 0, C: 0}, - {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: 1, C: 1}, - {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "C", R: 2, C: 0}, - {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "D", R: 3, C: 1}, - } - rows := groupBoxesByRC(boxes) - // R=0,1,2,3 → 4 different R values → 4 rows (Python: R differs → new row). - if len(rows) != 4 { - t.Fatalf("expected 4 rows (R differs → split), got %d", len(rows)) - } - if rows[0][0].Text != "A" || rows[1][0].Text != "B" { - t.Errorf("row0/1 wrong: A=%q B=%q", rows[0][0].Text, rows[1][0].Text) - } -} - -// TestFillCellTextFromBoxes_RCOnly verifies that box text goes to exactly -// one cell via R/C annotations, not multiple cells via spatial overlap. -// A box overlapping two cells should only fill the one matching its R/C. -func TestFillCellTextFromBoxes_RCOnly(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Label: "table"}, - {X0: 90, Y0: 0, X1: 200, Y1: 50, Label: "table"}, - } - // This box straddles cell 0 (X=0-100) and cell 1 (X=90-200). - // Spatial overlap: both match. R/C: should go to cell R=0, C=0 only. - boxes := []TextBox{ - {X0: 80, X1: 120, Top: 0, Bottom: 50, Text: "TEXT", LayoutType: "table", R: 0, C: 0}, - } - rows := groupTSRCellsToRowsLabeled(cells) - for _, b := range boxes { - t := strings.TrimSpace(b.Text) - if t == "" { - continue - } - if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) { - rows[b.R][b.C].Text = t - } - } - // Cell 0 should have text, cell 1 should NOT. - if rows[0][0].Text != "TEXT" { - t.Errorf("cell[0][0] = %q, want %q", rows[0][0].Text, "TEXT") - } - if rows[0][1].Text != "" { - t.Errorf("cell[0][1] = %q, should be empty (spatial overlap leak)", rows[0][1].Text) - } -} - -// TestRowsToHTML_HeaderRows verifies that header rows use
cells, got %d", tdCount) - } - t.Logf("HTML:\n%s", html) -} - -func TestConstructTable_EmptyCells(t *testing.T) { - html := constructTable(nil, nil, "", nil) - if html != "" { - t.Errorf("expected empty string for empty cells, got %q", html) - } - html = constructTable([]TSRCell{}, []TextBox{}, "", nil) - if html != "" { - t.Errorf("expected empty string for empty cells slice, got %q", html) - } -} - -func TestConstructTable_NoMatchingBox(t *testing.T) { - // Cell has no overlapping text box → empty - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "Has text", Label: "table row"}, - {X0: 101, Y0: 0, X1: 200, Y1: 50, Label: "table row"}, - } - boxes := []TextBox{} - html := constructTable(cells, boxes, "", nil) - if !strings.Contains(html, "Has text") { - t.Error("expected first cell text") - } - // Should still have 2 cells - if strings.Count(html, " cells, got %d. HTML:\n%s", strings.Count(html, "表1:测试标题") { - t.Errorf("expected caption, got:\n%s", html) - } - t.Logf("HTML:\n%s", html) -} - -func TestConstructTable_SingleRow(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 50, Y1: 40, Text: "Col1", Label: "table row"}, - {X0: 51, Y0: 0, X1: 100, Y1: 40, Text: "Col2", Label: "table row"}, - } - html := constructTable(cells, nil, "", nil) - if strings.Count(html, "
") != 2 { - t.Errorf("expected 2 rows from Y-fallback, got %d", strings.Count(html, "
") { - t.Error("output should contain HTML table") - } - - // Key assertion: constructTable backfills tables[0].Rows. - rows := tables[0].Rows - if len(rows) != 2 { - t.Fatalf("expected 2 rows, got %d", len(rows)) - } - if rows[0][0] != "标职务" { - t.Errorf("row 0 col 0 = %q, want %q", rows[0][0], "标职务") - } - if rows[0][1] != "飞机" { - t.Errorf("row 0 col 1 = %q, want %q", rows[0][1], "飞机") - } - if rows[1][0] != "公司级领导" { - t.Errorf("row 1 col 0 = %q, want %q", rows[1][0], "公司级领导") - } - if rows[1][1] != "经济舱位" { - t.Errorf("row 1 col 1 = %q, want %q", rows[1][1], "经济舱位") - } -} - -// TestConstructTable_FromBoxesRC builds HTML directly from boxes with R/C -// annotations, matching Python's construct_table. No cells needed for text. -func TestConstructTable_FromBoxesRC(t *testing.T) { - // Boxes with R (row) and C (col) annotations — like the output of - // annotateTableBoxes after layout cleanup. - boxes := []TextBox{ - {X0: 50, X1: 150, Top: 100, Bottom: 130, Text: "姓名", R: 0, C: 0}, - {X0: 155, X1: 255, Top: 100, Bottom: 130, Text: "年龄", R: 0, C: 1}, - {X0: 50, X1: 150, Top: 135, Bottom: 165, Text: "张三", R: 1, C: 0}, - {X0: 155, X1: 255, Top: 135, Bottom: 165, Text: "25", R: 1, C: 1}, - } - - // constructTable should build HTML directly from boxes by R/C grouping, - // ignoring cell text (matching Python's construct_table). - item := &TableItem{} - html := constructTable(nil, boxes, "", item) - - if !strings.Contains(html, "姓名") || !strings.Contains(html, "张三") { - t.Errorf("HTML missing box text: %s", html) - } - // 2 rows, 2 cols - if strings.Count(html, "
") != 3 { - t.Errorf("expected 3 rows, got %d. HTML: %s", strings.Count(html, "
instead of . -func TestRowsToHTML_HeaderRows(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Name", Label: "table column header"}, - {X0: 101, Y0: 0, X1: 200, Y1: 30, Text: "Age", Label: "table column header"}, - {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "John", Label: "table row"}, - {X0: 101, Y0: 35, X1: 200, Y1: 65, Text: "30", Label: "table row"}, - } - // constructTable should produce for header row. - item := &TableItem{} - html := constructTable(cells, nil, "", item) - // Header row should use , data row . - if !strings.Contains(html, "") { - t.Errorf("expected for header row. HTML: %s", html) - } - if strings.Count(html, " cells, got %d. HTML: %s", strings.Count(html, " cells (data row), got %d", strings.Count(html, "30% each — spatial fills ALL). - // With R/C, it belongs only to cell[1] (R=0, C=1). - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 30, Label: "table"}, - {X0: 90, Y0: 0, X1: 200, Y1: 30, Label: "table"}, - {X0: 180, Y0: 0, X1: 300, Y1: 30, Label: "table"}, - } - boxes := []TextBox{ - {X0: 30, X1: 270, Top: 0, Bottom: 30, Text: "TEXT", LayoutType: "table", R: 0, C: 1}, - } - - // Spatial fill: fills ALL overlapping cells —> duplication. - cellsCopy := make([]TSRCell, 3) - copy(cellsCopy, cells) - fillCellTextFromBoxes(cellsCopy, boxes) - spatialCount := 0 - for _, c := range cellsCopy { - if c.Text != "" { - spatialCount++ - } - } - if spatialCount <= 1 { - t.Errorf("spatial fill: expected >1 cells with text, got %d", spatialCount) - } - t.Logf("spatial fill: %d cells (WRONG — duplication)", spatialCount) - - // R/C fill: only cell matching box.R/C gets text. - cellsRC := make([]TSRCell, 3) - copy(cellsRC, cells) - rows := groupTSRCellsToRowsLabeled(cellsRC) - for _, b := range boxes { - if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) { - rows[b.R][b.C].Text = strings.TrimSpace(b.Text) - } - } - rcCount := 0 - for _, row := range rows { - for _, c := range row { - if c.Text == "TEXT" { - rcCount++ - } - } - } - if rcCount != 1 { - t.Errorf("R/C fill: expected 1 cell with 'TEXT', got %d", rcCount) - } -} - -func TestIsCaptionBox(t *testing.T) { - tests := []struct { - text string - want bool - }{ - {"表1:交通工具等级", true}, - {"Table 1: Transport Levels", true}, - {"图表 1: 测试", true}, - {"公司领导班子成员、出差地", false}, // plain text, not caption - {"第十条到厂矿单位出差", false}, // normal paragraph - {"", false}, - } - for _, tt := range tests { - if got := isCaptionBox(tt.text, ""); got != tt.want { - t.Errorf("isCaptionBox(%q) = %v, want %v", tt.text, got, tt.want) - } - } -} - -func TestFillCellTextFromBoxes_SkipsCaption(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 200, Y1: 30, Label: "table"}, - {X0: 0, Y0: 35, X1: 200, Y1: 65, Label: "table"}, - } - boxes := []TextBox{ - // Caption box (should be skipped) - {X0: 0, X1: 200, Top: 0, Bottom: 30, Text: "表1:交通工具等级"}, - // Data box - {X0: 0, X1: 200, Top: 35, Bottom: 65, Text: "数据行"}, - } - fillCellTextFromBoxes(cells, boxes) - if cells[0].Text != "" { - t.Errorf("caption leaked into cell 0: %q", cells[0].Text) - } - if cells[1].Text != "数据行" { - t.Errorf("data not in cell 1: %q", cells[1].Text) - } -} - -func TestFillCellText_RCPreventsCrossCellLeak(t *testing.T) { - // Caption box at Y=0-15 overlaps BOTH cell rows (both are "empty"). - // Spatial fill: text leaks to both cells. R/C fill: only cell[0] gets text. - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 300, Y1: 30, Label: "table"}, - {X0: 0, Y0: 35, X1: 300, Y1: 65, Label: "table"}, - } - boxes := []TextBox{ - {X0: 10, X1: 200, Top: 12, Bottom: 28, Text: "公司领导班子成员、出差地", R: 0, C: 0}, - } - - // Spatial fill → leaks to cells[1] (overlap ≥30%). - cellsSp := make([]TSRCell, 2) - copy(cellsSp, cells) - fillCellTextFromBoxes(cellsSp, boxes) - if cellsSp[1].Text != "" { - t.Errorf("spatial fill: caption leaked to cell[1]: %q", cellsSp[1].Text) - } - - // R/C fill → only cell[0] (R=0,C=0). - cellsRC := make([]TSRCell, 2) - copy(cellsRC, cells) - rows := groupTSRCellsToRowsLabeled(cellsRC) - for _, b := range boxes { - if b.R >= 0 && b.R < len(rows) && b.C >= 0 && b.C < len(rows[b.R]) { - if rows[b.R][b.C].Text == "" { - rows[b.R][b.C].Text = strings.TrimSpace(b.Text) - } - } - } - if cellsRC[1].Text != "" { - t.Errorf("R/C fill: caption leaked to cell[1]: %q", cellsRC[1].Text) - } -} - -func TestGroupBoxesByRC_FallbackToYXWhenNoAnnotations(t *testing.T) { - // When all boxes have R=-1 (Python's case: regex didn't match "table" label), - // groupBoxesByRC should fall back to YX coordinate grouping. - boxes := []TextBox{ - {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "A", R: -1, C: -1}, - {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "B", R: -1, C: -1}, - {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "C", R: -1, C: -1}, - {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "D", R: -1, C: -1}, - } - rows := groupBoxesByRC(boxes) - // R=-1 for all → maxR = -1 → grid would be 0 rows. Must fall back to YX. - if len(rows) == 0 { - t.Fatal("groupBoxesByRC returned 0 rows when R=-1 — no YX fallback") - } - if len(rows) != 2 { - t.Errorf("expected 2 rows (Y-split), got %d", len(rows)) - } -} - -func TestRowsToHTML_Colspan(t *testing.T) { - // Box spanning 2 columns: SP annotation with HLeft/HRight covering cols 0-1. - boxes := []TextBox{ - {X0: 10, X1: 90, Top: 0, Bottom: 30, Text: "Name", R: 0, C: 0, H: 1, HLeft: 10, HRight: 190}, - {X0: 110, X1: 190, Top: 0, Bottom: 30, Text: "", R: 0, C: 1, SP: 1}, - {X0: 10, X1: 90, Top: 35, Bottom: 65, Text: "John", R: 1, C: 0}, - {X0: 110, X1: 190, Top: 35, Bottom: 65, Text: "30", R: 1, C: 1}, - } - rows := groupBoxesByRC(boxes) - spans, covered := calSpans(rows) - html := rowsToHTML(rows, "", nil, spans, covered) - if !strings.Contains(html, "colspan") { - t.Errorf("expected colspan attribute, got: %s", html) - } - t.Logf("HTML: %s", html) -} - -// TestStripCaptionFromCells verifies that caption-like text is cleared -// from TSR cells before the table HTML is built. -func TestStripCaptionFromCells_ClearsCaptionPattern(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "表1:差旅费标准"}, - {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: ""}, - {X0: 0, Y0: 60, X1: 100, Y1: 110, Text: "张三"}, - {X0: 100, Y0: 60, X1: 200, Y1: 110, Text: "100"}, - } - stripCaptionFromCells(cells) - if cells[0].Text != "" { - t.Errorf("caption cell should be cleared, got %q", cells[0].Text) - } - if cells[2].Text != "张三" { - t.Errorf("data cell should be preserved, got %q", cells[2].Text) - } -} - -// TestStripCaptionFromCells_PreservesData verifies that non-caption -// cells are not cleared. -func TestStripCaptionFromCells_PreservesData(t *testing.T) { - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "姓名"}, - {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "年龄"}, - {X0: 0, Y0: 60, X1: 100, Y1: 110, Text: "张三"}, - {X0: 100, Y0: 60, X1: 200, Y1: 110, Text: "25"}, - } - // Make a copy and strip - orig := make([]string, len(cells)) - for i, c := range cells { - orig[i] = c.Text - } - stripCaptionFromCells(cells) - for i := range cells { - if cells[i].Text != orig[i] { - t.Errorf("cell[%d] changed: %q -> %q", i, orig[i], cells[i].Text) - } - } -} - -// TestStripCaptionFromCells_Empty is a no-op on empty cells. -func TestStripCaptionFromCells_Empty(t *testing.T) { - cells := []TSRCell{} - stripCaptionFromCells(cells) // must not panic -} - -// TestConstructTable_StripsCaptionFromCells verifies that constructTable -// strips caption text from cells before building HTML. -func TestConstructTable_StripsCaptionFromCells(t *testing.T) { - // Cell[0] has caption text "表1:标题"; cell[1] has real data. - cells := []TSRCell{ - {X0: 0, Y0: 0, X1: 100, Y1: 50, Text: "表1:标题"}, - {X0: 100, Y0: 0, X1: 200, Y1: 50, Text: "数据"}, - } - html := constructTable(cells, nil, "", nil) - // "表1:标题" should NOT appear in the HTML (stripped as caption). - if strings.Contains(html, "表1") { - t.Errorf("caption text '表1:标题' should be stripped: %s", html) - } - // "数据" should still be there. - if !strings.Contains(html, "数据") { - t.Errorf("data text '数据' should be preserved: %s", html) - } - t.Logf("HTML: %s", html) -} - -// TestCalSpans_NonSpanningCellsNotPolluted verifies that a regular cell -// at position [0,0] is NOT detected as spanning when a spanning cell at -// [0,1] extends to the left, polluting column boundary calculations. -// Bug: calSpans computed column boundaries from ALL cells including -// spanning cells. "部门开支汇总" at [0,1] with X0=0 extends colLeft[1] -// to 0 instead of 101, shifting the center and causing "Q1" at [0,0] -// to be incorrectly detected as spanning 2 columns. -func TestCalSpans_NonSpanningCellsNotPolluted(t *testing.T) { - // Simulate the SpannedTable test grid: row 0 has Q1(regular), 部门开支汇总(span), Q2(regular) - rows := [][]TSRCell{ - { - {X0: 0, Y0: 0, X1: 100, Y1: 30, Text: "Q1", Label: "table row"}, - {X0: 0, Y0: 0, X1: 200, Y1: 30, Text: "部门开支汇总", Label: "table spanning cell"}, - {X0: 101, Y0: 0, X1: 200, Y1: 30, Text: "Q2", Label: "table row"}, - }, - { - {X0: 0, Y0: 35, X1: 100, Y1: 65, Text: "100", Label: "table row"}, - {X0: 101, Y0: 35, X1: 200, Y1: 65, Text: "200", Label: "table row"}, - }, - } - - spans, covered := calSpans(rows) - - // Q1 at [0,0] has X0=0, X1=100 which should only cover its own column. - // It should NOT get a colspan. - if s, ok := spans[[2]int{0, 0}]; ok { - t.Errorf("Q1 at [0,0] should NOT have colspan, got %v. "+ - "Spanning cell at [0,1] polluted column boundaries", s) - } - - // 部门开支汇总 at [0,1] has X0=0, X1=200 which DOES span columns 0 and 1. - if s, ok := spans[[2]int{0, 1}]; !ok { - t.Error("部门开支汇总 at [0,1] should have colspan=2 (covers X=0-200)") - } else if s[0] != 2 { - t.Errorf("部门开支汇总 colspan = %d, want 2", s[0]) - } - - // Q2 at [0,2] should be covered by the spanning cell (col 2 is within X=0-200). - if !covered[[2]int{0, 2}] { - t.Error("Q2 at [0,2] should be covered by spanning cell at [0,1]") - } - - t.Logf("spans: %v, covered: %v", spans, covered) -} - -// ── coordinate space conversion helpers ───────────────────────────────── - -func TestCellToPageSpace(t *testing.T) { - cell := TSRCell{X0: 100, Y0: 200, X1: 300, Y1: 400, Text: "hello", Label: "table"} - got := cellToPageSpace(cell, 15, 25, 3.0) - - // (100+15)/3 = 38.33..., (200+25)/3 = 75 - if got.X0 != 38.333333333333336 || got.Y0 != 75 || got.X1 != 105 || got.Y1 != 141.66666666666666 { - t.Errorf("cellToPageSpace: got (%f,%f,%f,%f), want (38.33,75,105,141.67)", got.X0, got.Y0, got.X1, got.Y1) - } - if got.Text != "hello" || got.Label != "table" { - t.Error("cellToPageSpace should preserve Text and Label") - } -} - -func TestCellAddOffset(t *testing.T) { - cell := TSRCell{X0: 100, Y0: 200, X1: 300, Y1: 400, Text: "hello"} - got := cellAddOffset(cell, 15, 25) - if got.X0 != 115 || got.Y0 != 225 || got.X1 != 315 || got.Y1 != 425 { - t.Errorf("cellAddOffset: got (%f,%f,%f,%f)", got.X0, got.Y0, got.X1, got.Y1) - } - if got.Text != "hello" { - t.Error("cellAddOffset should preserve Text") - } -} - -func TestBoxToCropSpace(t *testing.T) { - box := TextBox{X0: 50, X1: 200, Top: 100, Bottom: 300, Text: "text"} - got := boxToCropSpace(box, 3.0, 10, 20) - if got.X0 != 140 || got.Top != 280 || got.X1 != 590 || got.Bottom != 880 { - t.Errorf("boxToCropSpace: got (%f,%f,%f,%f)", got.X0, got.Top, got.X1, got.Bottom) - } - if got.Text != "text" { - t.Error("boxToCropSpace should preserve Text") - } -} - -func TestCopyBoxAnnotations(t *testing.T) { - src := &TextBox{R: 1, C: 2, RTop: 10, RBott: 20, H: 3, HTop: 30, HBott: 40, - HLeft: 50, HRight: 60, CLeft: 70, CRight: 80, SP: 4} - dst := &TextBox{} - copyBoxAnnotations(dst, src) - if dst.R != 1 || dst.C != 2 || dst.RTop != 10 || dst.RBott != 20 { - t.Error("R/C fields not copied") - } - if dst.H != 3 || dst.HTop != 30 || dst.HBott != 40 { - t.Error("H fields not copied") - } - if dst.HLeft != 50 || dst.HRight != 60 || dst.CLeft != 70 || dst.CRight != 80 { - t.Error("spanning fields not copied") - } - if dst.SP != 4 { - t.Error("SP not copied") - } -} - -// TestAnnotateBoxLayouts_CompactionPreservesWriteBackMapping verifies that -// when annotateBoxLayouts drops some boxes (CID garbage or garbage-layout -// at non-edge positions), the compaction step does not corrupt the caller's -// ability to write annotations back to the correct global box indices. -// -// The bug: annotateBoxLayouts compacts boxes in place in the shared backing -// array, shifting survivors forward. enrichWithDeepDoc then iterates -// len(indices) positions and writes pageBoxes[i] back to boxes[indices[i]], -// but after compaction pageBoxes[1] holds what was originally pageBoxes[2], -// so annotations land on the wrong global box. -func TestAnnotateBoxLayouts_CompactionPreservesWriteBackMapping(t *testing.T) { - // ── Simulate the exact enrichWithDeepDoc write-back pattern ── - // Global boxes on a page: B0, B1, B2 (indices 0, 1, 2 in the PDF-space - // boxes slice). - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 50, Text: "will be dropped via reference match"}, - {X0: 0, X1: 100, Top: 60, Bottom: 110, Text: "text box A"}, - {X0: 110, X1: 200, Top: 60, Bottom: 110, Text: "text box B"}, - } - - // Per-page subset (what enrichWithDeepDoc constructs from byPage[pg]). - indices := []int{0, 1, 2} - pageBoxes := make([]TextBox, len(indices)) - for i, idx := range indices { - pageBoxes[i] = boxes[idx] // value copy - } - - // DLA regions: one reference (garbage type → matched boxes are dropped - // unless at page edge), two text regions for the surviving boxes. - // scale=1.0 so DLA pixel coords == PDF point coords. - regions := []DLARegion{ - {Label: "reference", Confidence: 0.9, X0: 0, Y0: 0, X1: 100, Y1: 50}, - {Label: "text", Confidence: 0.9, X0: 0, Y0: 60, X1: 100, Y1: 110}, - {Label: "text", Confidence: 0.9, X0: 110, Y0: 60, X1: 200, Y1: 110}, - } - pageImgHeight := 200.0 - - // The function under test. - _ = annotateBoxLayouts(pageBoxes, regions, 1.0, pageImgHeight) - - // Simulate enrichWithDeepDoc write-back (table.go:52-58). - for i, idx := range indices { - if pageBoxes[i].LayoutType != "" { - boxes[idx].LayoutType = pageBoxes[i].LayoutType - boxes[idx].LayoutNo = pageBoxes[i].LayoutNo - } - copyBoxAnnotations(&boxes[idx], &pageBoxes[i]) - } - - // ── Assertions ── - - // B0 matched a "reference" region far from page edge → must be dropped. - if boxes[0].LayoutType != "" { - t.Errorf("B0 was dropped (reference region) but got LayoutType=%q from a shifted survivor", - boxes[0].LayoutType) - } - - // B1 matched the first text region → must be text-0. - if boxes[1].LayoutType != "text" { - t.Errorf("B1 LayoutType = %q, want text", boxes[1].LayoutType) - } - if boxes[1].LayoutNo != "text-0" { - t.Errorf("B1 LayoutNo = %q, want text-0 (compaction shifted B2 into position 1)", boxes[1].LayoutNo) - } - - // B2 matched the second text region → must be text-1. - if boxes[2].LayoutType != "text" { - t.Errorf("B2 LayoutType = %q, want text", boxes[2].LayoutType) - } - if boxes[2].LayoutNo != "text-1" { - t.Errorf("B2 LayoutNo = %q, want text-1 (stale element at position 2 after compaction)", boxes[2].LayoutNo) - } -} - -// ── matchTableRegions unit tests ───────────────────────────────────── - -func TestMatchTableRegions_SingleMatch(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 50}, - {X0: 200, X1: 300, Top: 0, Bottom: 50}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "table"}, // covers box 0 at scale 3 - {X0: 600, Y0: 0, X1: 900, Y1: 150, Label: "text"}, // non-table, ignored - } - matches := matchTableRegions(boxes, regions, 3.0) - if len(matches) != 1 { - t.Fatalf("expected 1 match, got %d", len(matches)) - } - if len(matches[0].boxIdx) != 1 || matches[0].boxIdx[0] != 0 { - t.Errorf("expected box 0 matched, got %v", matches[0].boxIdx) - } -} - -func TestMatchTableRegions_NoTableLabel(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 50}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "figure"}, - } - matches := matchTableRegions(boxes, regions, 3.0) - if len(matches) != 0 { - t.Errorf("non-table labels: expected 0 matches, got %d", len(matches)) - } -} - -func TestMatchTableRegions_MultipleBoxesSameTable(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 50}, // box 0 - {X0: 110, X1: 210, Top: 0, Bottom: 50}, // box 1 - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 630, Y1: 150, Label: "table"}, // covers both boxes at scale 3 - } - matches := matchTableRegions(boxes, regions, 3.0) - if len(matches) != 1 { - t.Fatalf("expected 1 match, got %d", len(matches)) - } - if len(matches[0].boxIdx) != 2 { - t.Errorf("expected 2 boxes matched, got %d: %v", len(matches[0].boxIdx), matches[0].boxIdx) - } -} - -func TestMatchTableRegions_ImageOnlyPDF(t *testing.T) { - // Zero boxes — image-only PDF. Python processes every table DLA region - // regardless of text box overlap. - var boxes []TextBox // nil - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "table"}, - {X0: 0, Y0: 0, X1: 300, Y1: 150, Label: "text"}, - } - matches := matchTableRegions(boxes, regions, 3.0) - if len(matches) != 1 { - t.Fatalf("image-only: expected 1 table match, got %d", len(matches)) - } - if len(matches[0].boxIdx) != 0 { - t.Errorf("image-only: expected empty boxIdx, got %d", len(matches[0].boxIdx)) - } -} - -func TestMatchTableRegions_BelowThreshold(t *testing.T) { - // Region overlaps only a sliver of the box (<40%) → no match. - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 100}, - } - regions := []DLARegion{ - {X0: 0, Y0: 0, X1: 90, Y1: 90, Label: "table"}, // 30x30 at scale 3 → 9% overlap - } - matches := matchTableRegions(boxes, regions, 3.0) - if len(matches) != 0 { - t.Errorf("below threshold: expected 0 matches, got %d", len(matches)) - } -} - -func TestCellSliceToPageSpace(t *testing.T) { - cells := []TSRCell{ - {X0: 100, Y0: 200, X1: 300, Y1: 400}, - {X0: 400, Y0: 200, X1: 600, Y1: 400}, - } - got := cellSliceToPageSpace(cells, 15, 25, 3) - if len(got) != 2 { - t.Fatal("expected 2 cells") - } - if got[0].X0 != 38.333333333333336 || got[1].X0 != 138.33333333333334 { - t.Error("wrong conversion") - } -} - -// MockTableBuilder is a test-only TableBuilder with a configurable GroupCells. -type MockTableBuilder struct { - GroupCellsFn func(cells []TSRCell) [][]TSRCell -} - -func (m *MockTableBuilder) Name() string { return "mock" } -func (m *MockTableBuilder) DetectCells(_ context.Context, _ image.Image) ([]TSRCell, error) { - return nil, nil -} -func (m *MockTableBuilder) GroupCells(cells []TSRCell) [][]TSRCell { - if m.GroupCellsFn != nil { - return m.GroupCellsFn(cells) - } - return nil -} - -// ── writeTableAnnotations unit tests ────────────────────────────────── - -func TestWriteTableAnnotations_WriteBack(t *testing.T) { - boxes := []TextBox{ - {X0: 10, X1: 100, Top: 10, Bottom: 30, Text: "A", LayoutType: "table"}, - {X0: 110, X1: 200, Top: 10, Bottom: 30, Text: "B", LayoutType: "table"}, - {X0: 10, X1: 100, Top: 35, Bottom: 55, Text: "C", LayoutType: "table"}, - } - boxIdx := []int{0, 2} - cells := []TSRCell{ - {X0: 30, Y0: 30, X1: 300, Y1: 90, Label: "table row"}, - {X0: 30, Y0: 110, X1: 300, Y1: 170, Label: "table row"}, - } - scale := 3.0 - - tb := &MockTableBuilder{GroupCellsFn: func(cells []TSRCell) [][]TSRCell { - return [][]TSRCell{{cells[0]}, {cells[1]}} - }} - writeTableAnnotations(boxes, boxIdx, cells, scale, 0, 0, tb) - - if boxes[0].R != 0 { - t.Errorf("box 0 R = %d, want 0", boxes[0].R) - } - if boxes[0].C != 0 { - t.Errorf("box 0 C = %d, want 0", boxes[0].C) - } - // Box 1 was not in boxIdx — should NOT be annotated - if boxes[1].R != 0 || boxes[1].C != 0 { - t.Errorf("box 1 should not be annotated: R=%d C=%d", boxes[1].R, boxes[1].C) - } - if boxes[2].R != 1 { - t.Errorf("box 2 R = %d, want 1", boxes[2].R) - } -} - -func TestWriteTableAnnotations_ScaleDown(t *testing.T) { - boxes := []TextBox{ - {X0: 10, X1: 100, Top: 10, Bottom: 50, Text: "X", LayoutType: "table"}, - } - boxIdx := []int{0} - cells := []TSRCell{ - {X0: 30, Y0: 30, X1: 300, Y1: 150, Label: "table row"}, - } - scale := 3.0 - - tb := &MockTableBuilder{GroupCellsFn: func(cells []TSRCell) [][]TSRCell { - return [][]TSRCell{{cells[0]}} - }} - writeTableAnnotations(boxes, boxIdx, cells, scale, 0, 0, tb) - - // After scale-down: RTop / 3 should be in PDF space (~10). - if boxes[0].RTop == 0 { - t.Error("RTop should be non-zero after annotation") - } -} - -func TestWriteTableAnnotations_EmptyCells(t *testing.T) { - boxes := []TextBox{{X0: 10, X1: 100, Top: 10, Bottom: 50, Text: "X", LayoutType: "table"}} - boxIdx := []int{0} - var cells []TSRCell - - tb := &MockTableBuilder{GroupCellsFn: func(cells []TSRCell) [][]TSRCell { - return nil - }} - // Should not panic with empty cells. - writeTableAnnotations(boxes, boxIdx, cells, 3.0, 0, 0, tb) - if boxes[0].R != 0 || boxes[0].C != 0 { - t.Errorf("empty cells: R=%d C=%d, want 0,0", boxes[0].R, boxes[0].C) - } -} - -// ── markNoMergeTables unit tests ───────────────────────────────────── - -func TestMarkNoMergeTables_CaptionAfterTable(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, - {X0: 0, X1: 100, Top: 35, Bottom: 50, LayoutType: "table caption", Text: "表1:标题"}, - } - tables := []TableItem{ - {Positions: []Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, - } - markNoMergeTables(boxes, tables) - if !tables[0].NoMerge { - t.Error("table followed by caption should be marked NoMerge") - } -} - -func TestMarkNoMergeTables_TitleAfterTable(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, - {X0: 0, X1: 100, Top: 35, Bottom: 50, LayoutType: "title"}, - } - tables := []TableItem{ - {Positions: []Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, - } - markNoMergeTables(boxes, tables) - if !tables[0].NoMerge { - t.Error("table followed by title should be marked NoMerge") - } -} - -func TestMarkNoMergeTables_NoCaptionAfter(t *testing.T) { - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, - {X0: 0, X1: 100, Top: 35, Bottom: 50, LayoutType: "text"}, - {X0: 0, X1: 100, Top: 55, Bottom: 70, LayoutType: "table"}, - } - tables := []TableItem{ - {Positions: []Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, - {Positions: []Position{{Left: 0, Right: 100, Top: 55, Bottom: 70}}}, - } - markNoMergeTables(boxes, tables) - if tables[0].NoMerge { - t.Error("table followed by text should NOT be marked NoMerge") - } - if tables[1].NoMerge { - t.Error("last table should NOT be marked NoMerge") - } -} - -func TestMarkNoMergeTables_StaleLastTableTI(t *testing.T) { - // Scenario: table box that does NOT overlap any TableItem.Position - // should reset lastTableTI. Otherwise the next caption marks the - // wrong (non-adjacent) table as NoMerge. - // Box 0: "table", overlaps table[0] → lastTableTI = 0 - // Box 1: "table", no overlap → lastTableTI should reset to -1 - // Box 2: "title" → should be a no-op (no adjacent table) - boxes := []TextBox{ - {X0: 0, X1: 100, Top: 0, Bottom: 30, LayoutType: "table"}, - {X0: 500, X1: 600, Top: 100, Bottom: 130, LayoutType: "table"}, // far away, no overlap - {X0: 0, X1: 100, Top: 140, Bottom: 160, LayoutType: "title"}, - } - tables := []TableItem{ - {Positions: []Position{{Left: 0, Right: 100, Top: 0, Bottom: 30}}}, // table 0 - {Positions: []Position{{Left: 0, Right: 100, Top: 35, Bottom: 65}}}, // table 1 — box 0 doesn't overlap this either - } - markNoMergeTables(boxes, tables) - // table[0] should NOT be NoMerge: the title follows a non-matching - // table box, not table[0] directly. - if tables[0].NoMerge { - t.Error("stale lastTableTI: table[0] incorrectly marked NoMerge — " + - "the non-overlapping table box (box 1) should have reset lastTableTI") - } -} - -func TestMarkNoMergeTables_EmptyInputs(t *testing.T) { - // Should not panic with empty inputs. - markNoMergeTables(nil, nil) - markNoMergeTables([]TextBox{}, []TableItem{}) -} diff --git a/internal/deepdoc/parser/pdf/test_helpers_cgo_test.go b/internal/deepdoc/parser/pdf/test_helpers_cgo_test.go new file mode 100644 index 0000000000..c84538fe48 --- /dev/null +++ b/internal/deepdoc/parser/pdf/test_helpers_cgo_test.go @@ -0,0 +1,50 @@ +//go:build cgo + +package parser + +import ( + "os" + "path/filepath" + "testing" + + inf "ragflow/internal/deepdoc/parser/pdf/inference" + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── Shared CGO test helpers ────────────────────────────────────────────────── +// These helpers were previously duplicated across multiple test files with +// different build tags (integration, manual). Consolidating them into one file +// with the //go:build cgo tag makes them available to all cgo-tagged tests. + +// mustConnectInferenceClient returns a InferenceClient pointed at the OSS service; +// skips the test if the service reports a non-OSS model type. +func mustConnectInferenceClient(t *testing.T) *inf.InferenceClient { + t.Helper() + url := os.Getenv("OSSDEEPDOC_URL") + if url == "" { + url = "http://localhost:9390" + } + client, err := inf.NewInferenceClient(url) + if err != nil { + t.Fatal(err) + } + if !client.Health() { + t.Fatalf("OssDeepDoc not available at %s", url) + } + return client +} + +// mustOpenEngine opens a PDF from testdata/pdfs/ and returns a pdf.PDFEngine. +func mustOpenEngine(t *testing.T, name string) pdf.PDFEngine { + t.Helper() + pdfPath := filepath.Join("testdata", "pdfs", name) + data, err := os.ReadFile(pdfPath) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + eng, err := NewEngine(data) + if err != nil { + t.Fatalf("open engine %s: %v", name, err) + } + return eng +} diff --git a/internal/deepdoc/parser/pdf/test_helpers_test.go b/internal/deepdoc/parser/pdf/test_helpers_test.go new file mode 100644 index 0000000000..f5937a333f --- /dev/null +++ b/internal/deepdoc/parser/pdf/test_helpers_test.go @@ -0,0 +1,66 @@ +package parser + +import ( + "image" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// ── mockEngine: minimal pdf.PDFEngine stub for unit tests ───────────── + +type mockEngine struct { + chars map[int][]pdf.TextChar + pageCount int + renderW int + renderH int +} + +func (m *mockEngine) ExtractChars(pg int) ([]pdf.TextChar, error) { + return m.chars[pg], nil +} +func (m *mockEngine) RenderPage(pg int, dpi float64) ([]byte, error) { + w, h := m.renderW, m.renderH + if w <= 0 { + w = 595 + } + if h <= 0 { + h = 842 + } + return nil, nil +} +func (m *mockEngine) RenderPageImage(pg int, dpi float64) (image.Image, error) { + w, h := m.renderW, m.renderH + if w <= 0 { + w = 100 + } + if h <= 0 { + h = 100 + } + return image.NewRGBA(image.Rect(0, 0, w, h)), nil +} +func (m *mockEngine) PageCount() (int, error) { + if m.pageCount <= 0 { + return 1, nil + } + return m.pageCount, nil +} +func (m *mockEngine) RawData() []byte { return nil } +func (m *mockEngine) Close() error { return nil } +func (m *mockEngine) Outlines() ([]pdf.Outline, error) { return nil, nil } + +// ── testPageImg: small test image for ocrMergeChars tests ───────────── +// 90×120 px at 216 DPI → 30×40 pt in PDF space after /3.0 scaling. + +func testPageImg() image.Image { + return image.NewRGBA(image.Rect(0, 0, 90, 120)) +} + +// ── cellTexts: extract text strings from TSRCells ───────────────────── + +func cellTexts(cells []pdf.TSRCell) []string { + t := make([]string, len(cells)) + for i, c := range cells { + t[i] = c.Text + } + return t +} diff --git a/internal/deepdoc/parser/pdf/text_dump_test.go b/internal/deepdoc/parser/pdf/text_dump_test.go index a9610056a9..c798fa9411 100644 --- a/internal/deepdoc/parser/pdf/text_dump_test.go +++ b/internal/deepdoc/parser/pdf/text_dump_test.go @@ -6,6 +6,7 @@ import ( "context" "os" "path/filepath" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "strings" "testing" ) @@ -64,8 +65,8 @@ func TestDumpTextOutput(t *testing.T) { continue } - cfg := DefaultParserConfig() - p := NewParser(cfg, &MockDocAnalyzer{Healthy: true, Model: ModelSaas}) + cfg := pdf.DefaultParserConfig() + p := NewParser(cfg, &MockDocAnalyzer{Healthy: true}) result, err := p.Parse(context.Background(), eng) eng.Close() if err != nil { diff --git a/internal/deepdoc/parser/pdf/python_char_adapter.go b/internal/deepdoc/parser/pdf/tool/char_adapter.go similarity index 83% rename from internal/deepdoc/parser/pdf/python_char_adapter.go rename to internal/deepdoc/parser/pdf/tool/char_adapter.go index 7d1b5ba1b5..8e2f30aee4 100644 --- a/internal/deepdoc/parser/pdf/python_char_adapter.go +++ b/internal/deepdoc/parser/pdf/tool/char_adapter.go @@ -1,18 +1,20 @@ -package parser +package tool import ( "encoding/json" "fmt" "image" "os" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) -// PythonCharEngine implements PDFEngine by loading chars from a +// PythonCharEngine implements pdf.PDFEngine by loading chars from a // charspy/{pdf}.json file exported by dump_py_results.py. // It is used for pipeline parity testing — same input chars as Python, // so any difference in pipeline output is a Go pipeline logic bug. type PythonCharEngine struct { - chars map[int][]TextChar // pageNum → chars + chars map[int][]pdf.TextChar // pageNum → chars pages int } @@ -37,11 +39,11 @@ func LoadPythonChars(jsonPath string) (*PythonCharEngine, error) { return nil, fmt.Errorf("parse charspy json: %w", err) } - chars := make(map[int][]TextChar, len(wrapper.Pages)) + chars := make(map[int][]pdf.TextChar, len(wrapper.Pages)) for pg, pageChars := range wrapper.Pages { - result := make([]TextChar, len(pageChars)) + result := make([]pdf.TextChar, len(pageChars)) for i, c := range pageChars { - result[i] = TextChar{ + result[i] = pdf.TextChar{ Text: c.Text, X0: c.X0, X1: c.X1, @@ -58,7 +60,7 @@ func LoadPythonChars(jsonPath string) (*PythonCharEngine, error) { } // ExtractChars returns all characters for the given page (0-indexed). -func (e *PythonCharEngine) ExtractChars(pageNum int) ([]TextChar, error) { +func (e *PythonCharEngine) ExtractChars(pageNum int) ([]pdf.TextChar, error) { if pageNum < 0 || pageNum >= e.pages { return nil, fmt.Errorf("page %d out of range [0, %d)", pageNum, e.pages) } @@ -84,6 +86,8 @@ func (e *PythonCharEngine) PageCount() (int, error) { // for pipeline parity tests and does not hold PDF bytes. func (e *PythonCharEngine) RawData() []byte { return nil } +func (e *PythonCharEngine) Outlines() ([]pdf.Outline, error) { return nil, nil } + // Close is a no-op. func (e *PythonCharEngine) Close() error { return nil diff --git a/internal/deepdoc/parser/pdf/tools/compare.go b/internal/deepdoc/parser/pdf/tool/compare.go similarity index 99% rename from internal/deepdoc/parser/pdf/tools/compare.go rename to internal/deepdoc/parser/pdf/tool/compare.go index 652a7372f7..d81209d5f3 100644 --- a/internal/deepdoc/parser/pdf/tools/compare.go +++ b/internal/deepdoc/parser/pdf/tool/compare.go @@ -1,4 +1,4 @@ -package tools +package tool import ( "encoding/csv" @@ -556,8 +556,10 @@ type tsrRawCell struct { TableIndex int `json:"table_index"` Page int `json:"page"` Label string `json:"label"` - X0, Y0 float64 `json:"x0" y0:"y0"` - X1, Y1 float64 `json:"x1" y1:"y1"` + X0 float64 `json:"x0"` + Y0 float64 `json:"y0"` + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` Text string `json:"text"` } diff --git a/internal/deepdoc/parser/pdf/tool/compare_test.go b/internal/deepdoc/parser/pdf/tool/compare_test.go new file mode 100644 index 0000000000..e96eec76c7 --- /dev/null +++ b/internal/deepdoc/parser/pdf/tool/compare_test.go @@ -0,0 +1,63 @@ +//go:build manual + +package tool + +import ( + "log/slog" + "os" + "path/filepath" + "testing" +) + +// TestBatchCompareWithPython compares Go output against Python reference +// across 4 dimensions (text, tables, DLA, TSR raw). It is read-only — +// no generation, no CGO/DeepDoc dependency. Use BATCH_SKIP_OCR=1 to +// compare the noocr variant; PY_OCR_SUFFIX to override the Python variant. +func TestBatchCompareWithPython(t *testing.T) { + level := slog.LevelInfo + if os.Getenv("BATCH_LOG_LEVEL") == "debug" { + level = slog.LevelDebug + } + if os.Getenv("BATCH_LOG_LEVEL") == "warn" { + level = slog.LevelWarn + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + + goVariant := "ocr" + if os.Getenv("BATCH_SKIP_OCR") == "1" { + goVariant = "noocr" + } + pyVariant := os.Getenv("PY_OCR_SUFFIX") + if pyVariant == "" { + pyVariant = goVariant + } + goTextDir := filepath.Join("testdata", "output", "go", goVariant, "text") + pyTextDir := filepath.Join("testdata", "output", "py", pyVariant, "text") + + // Read Go text files' #@meta (no aggregate JSON dependency). + goResults, err := ReadGoTextMeta(goTextDir) + if err != nil || len(goResults) == 0 { + t.Fatalf("No Go text files in %s: %v", goTextDir, err) + } + + // Read Python text files' #@meta + pyResults, err := ReadPythonTextMeta(pyTextDir) + if err != nil || len(pyResults) == 0 { + t.Fatalf("No Python text files in %s: %v", pyTextDir, err) + } + + t.Logf("Comparing %d Go × %d Python", len(goResults), len(pyResults)) + CompareWithPython(t, goResults, pyResults, goTextDir, pyTextDir) + + // Compare tables. + goTablesDir := filepath.Join("testdata", "output", "go", goVariant, "tables") + pyTablesDir2 := filepath.Join("testdata", "output", "py", pyVariant, "tables") + CompareTablesWithPython(t, goTablesDir, pyTablesDir2) + // Compare DLA + TSR raw intermediates. + goDLADir := filepath.Join("testdata", "output", "go", goVariant, "dla") + pyDLADir := filepath.Join("testdata", "output", "py", pyVariant, "dla") + CompareDLAWithPython(t, goDLADir, pyDLADir) + goTSRRawDir := filepath.Join("testdata", "output", "go", goVariant, "tsr_raw") + pyTSRRawDir := filepath.Join("testdata", "output", "py", pyVariant, "tsr_raw") + CompareTSRRawWithPython(t, goTSRRawDir, pyTSRRawDir) +} diff --git a/internal/deepdoc/parser/pdf/tools/config.go b/internal/deepdoc/parser/pdf/tool/config.go similarity index 99% rename from internal/deepdoc/parser/pdf/tools/config.go rename to internal/deepdoc/parser/pdf/tool/config.go index a9796d3a18..8c2828619d 100644 --- a/internal/deepdoc/parser/pdf/tools/config.go +++ b/internal/deepdoc/parser/pdf/tool/config.go @@ -1,4 +1,4 @@ -package tools +package tool import ( "fmt" diff --git a/internal/deepdoc/parser/pdf/tools/metadata.go b/internal/deepdoc/parser/pdf/tool/metadata.go similarity index 99% rename from internal/deepdoc/parser/pdf/tools/metadata.go rename to internal/deepdoc/parser/pdf/tool/metadata.go index 55d380ceb4..92aec6122e 100644 --- a/internal/deepdoc/parser/pdf/tools/metadata.go +++ b/internal/deepdoc/parser/pdf/tool/metadata.go @@ -1,4 +1,4 @@ -package tools +package tool import ( "encoding/json" diff --git a/internal/deepdoc/parser/pdf/tools/similarity.go b/internal/deepdoc/parser/pdf/tool/similarity.go similarity index 99% rename from internal/deepdoc/parser/pdf/tools/similarity.go rename to internal/deepdoc/parser/pdf/tool/similarity.go index 9c271b4188..c4f686fb06 100644 --- a/internal/deepdoc/parser/pdf/tools/similarity.go +++ b/internal/deepdoc/parser/pdf/tool/similarity.go @@ -1,4 +1,4 @@ -package tools +package tool import ( "sort" diff --git a/internal/deepdoc/parser/pdf/tool/similarity_test.go b/internal/deepdoc/parser/pdf/tool/similarity_test.go new file mode 100644 index 0000000000..3ebd976728 --- /dev/null +++ b/internal/deepdoc/parser/pdf/tool/similarity_test.go @@ -0,0 +1,90 @@ +package tool + +import ( + "testing" +) + +func TestStripMeta(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"no meta", "hello world", "hello world"}, + {"with meta", "hello\n#@meta\n{\"key\":\"val\"}", "hello"}, + {"multiple meta lines", "line1\nline2\n#@meta\n{}", "line1\nline2"}, + {"empty", "", ""}, + {"only meta no newline", "#@meta\n{}", "#@meta\n{}"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StripMeta(tt.input); got != tt.want { + t.Errorf("StripMeta = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCharSimilarity(t *testing.T) { + tests := []struct { + name string + a, b string + want float64 + }{ + {"identical", "hello", "hello", 100.0}, + {"completely different", "abc", "xyz", 0.0}, + {"partial overlap", "abc", "bcd", 66.66}, // returns percentage + {"empty both", "", "", 100.0}, + {"empty a", "", "abc", 0.0}, + {"empty b", "abc", "", 0.0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CharSimilarity(tt.a, tt.b) + if got < tt.want-1 || got > tt.want+1 { + t.Errorf("CharSimilarity(%q, %q) = %v, want ~%v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestLcsSimilarity(t *testing.T) { + tests := []struct { + name string + a, b string + want float64 + }{ + {"identical", "hello", "hello", 100.0}, + {"completely different", "abc", "xyz", 0.0}, + {"partial", "abc", "abcd", 75.0}, // LCS "abc" len=3, max len=4 = 75% + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := LcsSimilarity(tt.a, tt.b) + if got < tt.want-1 || got > tt.want+1 { + t.Errorf("LcsSimilarity(%q, %q) = %v, want ~%v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestSectionAlignedScore(t *testing.T) { + tests := []struct { + name string + goText string + pyText string + want float64 + }{ + {"identical", "hello world", "hello world", 100.0}, + {"different", "hello", "world", 20.0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SectionAlignedScore(tt.goText, tt.pyText) + if got < tt.want-1 || got > tt.want+1 { + t.Errorf("SectionAlignedScore(%q, %q) = %v, want ~%v", + tt.goText, tt.pyText, got, tt.want) + } + }) + } +} diff --git a/internal/deepdoc/parser/pdf/tools/types.go b/internal/deepdoc/parser/pdf/tool/types.go similarity index 92% rename from internal/deepdoc/parser/pdf/tools/types.go rename to internal/deepdoc/parser/pdf/tool/types.go index eb19cb894f..c924279c0a 100644 --- a/internal/deepdoc/parser/pdf/tools/types.go +++ b/internal/deepdoc/parser/pdf/tool/types.go @@ -1,4 +1,4 @@ -package tools +package tool // BatchResult stores per-PDF pipeline stage output. type BatchResult struct { @@ -41,9 +41,12 @@ type TableItem struct { // TSRCell mirrors parser.TSRCell for serialization. type TSRCell struct { - X0, Y0, X1, Y1 float64 `json:"x0,y0,x1,y1"` - Text string `json:"text"` - Label string `json:"label"` + X0 float64 `json:"x0"` + Y0 float64 `json:"y0"` + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` + Text string `json:"text"` + Label string `json:"label"` } // Position stores a bounding box. diff --git a/internal/deepdoc/parser/pdf/type/types.go b/internal/deepdoc/parser/pdf/type/types.go new file mode 100644 index 0000000000..58b89bf26a --- /dev/null +++ b/internal/deepdoc/parser/pdf/type/types.go @@ -0,0 +1,320 @@ +// Package pdftypes provides shared types, interfaces, and constants for the +// PDF parser pipeline. It has zero dependencies on sibling packages so that +// sub-packages (tables, geometry, etc.) can import it without circular imports. +package pdftype + +import ( + "context" + "image" + "unicode" +) + +// ── Pipeline types ──────────────────────────────────────────────────────── + +// PipelineMetrics records diagnostic counts at each pipeline stage. +type PipelineMetrics struct { + BoxesInitial int + BoxesTextMerge int + BoxesVertMerge int + BoxesFinal int + TablesCount int +} + +// ParseResult encapsulates all outputs from a single Parse() call. +type ParseResult struct { + Sections []Section + Tables []TableItem + PageImages map[int]image.Image + Metrics PipelineMetrics + Outlines []Outline // PDF outlines/bookmarks extracted from the document + + DLADebug []DLAPageRegions + TSRDebug []TSRRawCell +} + +// Figures returns all sections with LayoutType "figure". +// Computed on demand from Sections — no stored field. +func (r *ParseResult) Figures() []Section { + return CollectFigures(r.Sections) +} + +// DLAPageRegions holds DLA layout regions for one page. +type DLAPageRegions struct { + Page int + Regions []DLARegion +} + +// TSRRawCell holds a raw TSR cell before row/column grouping. +type TSRRawCell struct { + TableIndex int `json:"table_index"` + Page int `json:"page"` + Label string `json:"label"` + X0 float64 `json:"x0"` + Y0 float64 `json:"y0"` + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` + Text string `json:"text"` +} + +// ── Character and text box types ────────────────────────────────────────── + +// TextChar represents a single character extracted from a PDF page. +type TextChar struct { + X0, X1 float64 + Top, Bottom float64 + Text string + FontName string + FontSize float64 + PageNumber int + LayoutType string + LayoutNo string + ColID int + R int +} + +func (c TextChar) Bounds() (float64, float64, float64, float64) { + return c.X0, c.Top, c.X1, c.Bottom +} + +// TextBox represents a rectangular region of text on a PDF page. +type TextBox struct { + X0, X1 float64 + Top, Bottom float64 + Text string + PageNumber int + LayoutType string + LayoutNo string + ColID int + R int + // Post-TSR table annotation fields (Python: R/H/C/SP tags) + RTop, RBott float64 + HTop, HBott float64 + HLeft, HRight float64 + H int + C int + CLeft, CRight float64 + SP int +} + +func (b TextBox) Bounds() (float64, float64, float64, float64) { + return b.X0, b.Top, b.X1, b.Bottom +} + +// ── Position and section types ──────────────────────────────────────────── + +// Position represents a parsed position tag from @@...## format. +type Position struct { + PageNumbers []int + Left float64 + Right float64 + Top float64 + Bottom float64 +} + +// Section represents a text segment with its spatial position on a PDF page. +type Section struct { + Text string + PositionTag string + LayoutType string + DocTypeKwd string // "text"/"table"/"image" — assigned during post-processing + Positions []Position + TableItem *TableItem + Image string // base64-encoded cropped page image +} + +// SectionsByPage returns a slice of sections on the given page. +func SectionsByPage(sections []Section, page int) []Section { + var out []Section + for _, s := range sections { + for _, p := range s.Positions { + for _, pn := range p.PageNumbers { + if pn == page { + out = append(out, s) + break + } + } + } + } + return out +} + +// CollectFigures returns all sections with LayoutType "figure". +func CollectFigures(sections []Section) []Section { + if sections == nil { + return nil + } + figures := make([]Section, 0) + for _, s := range sections { + if s.LayoutType == LayoutTypeFigure { + figures = append(figures, s) + } + } + return figures +} + +// ── Table types ─────────────────────────────────────────────────────────── + +// TableItem represents a detected table or figure region. +type TableItem struct { + ImageB64 string + Rows [][]string + Cells []TSRCell + Positions []Position + Scale float64 + CropOffX float64 + CropOffY float64 + Caption string + + RegionLeft, RegionRight, RegionTop, RegionBottom float64 + NoMerge bool + Grid [][]TSRCell +} + +// TSRCell represents one table cell from TSR. +type TSRCell struct { + X0, Y0, X1, Y1 float64 + Text string + Label string +} + +func (c TSRCell) Bounds() (float64, float64, float64, float64) { + return c.X0, c.Y0, c.X1, c.Y1 +} + +// ── DeepDoc vision types ───────────────────────────────────────────────── + +// DLARegion represents one detected layout region. +type DLARegion struct { + X0, Y0, X1, Y1 float64 + Label string + Confidence float64 +} + +func (r DLARegion) Bounds() (float64, float64, float64, float64) { + return r.X0, r.Y0, r.X1, r.Y1 +} + +// OCRBox represents a detected text region from DeepDoc OCR detection. +type OCRBox struct { + X0, Y0, X1, Y1, X2, Y2, X3, Y3 float64 +} + +// OCRText represents recognized text with confidence from DeepDoc OCR rec. +type OCRText struct { + Text string + Confidence float64 +} + +// ── Parser configuration ────────────────────────────────────────────────── + +// ParserConfig holds parser configuration. +type ParserConfig struct { + Zoom float64 + FromPage int + ToPage int + TableContextSize int + ImageContextSize int + AutoRotateTables *bool + SeparateTablesFigs bool + SortByTop bool + BatchSize int + SkipOCR bool + MaxOCRConcurrency int + TableBuilder TableBuilder +} + +// DefaultParserConfig returns a ParserConfig with sensible defaults. +func DefaultParserConfig() ParserConfig { + return ParserConfig{ + Zoom: 3, + FromPage: 0, + ToPage: -1, + BatchSize: 50, + TableContextSize: 0, + ImageContextSize: 0, + SeparateTablesFigs: false, + } +} + +// DlaDPI is the DPI used for rendering page images for DeepDoc DLA/OCR. +const DlaDPI = 216 + +// DlaScale is the scale factor from PDF points (72 DPI) to DLA image space. +const DlaScale = DlaDPI / 72.0 + +// ── Layout type constants ───────────────────────────────────────────────── + +const ( + LayoutTypeText = "text" + LayoutTypeTable = "table" + LayoutTypeFigure = "figure" + LayoutTypeEquation = "equation" + LayoutTypeTitle = "title" + LayoutTypeReference = "reference" + LayoutTypeFooter = "footer" + LayoutTypeHeader = "header" + + DLALabelFigureCaption = "figure caption" + DLALabelTableCaption = "table caption" +) + +// ── Interfaces ──────────────────────────────────────────────────────────── + +// DocAnalyzer abstracts DeepDoc vision operations. +type DocAnalyzer interface { + DLA(ctx context.Context, pageImage image.Image) ([]DLARegion, error) + TSR(ctx context.Context, cropped image.Image) ([]TSRCell, error) + OCRDetect(ctx context.Context, cropped image.Image) ([]OCRBox, error) + OCRRecognize(ctx context.Context, cropped image.Image) ([]OCRText, error) + OCRRecognizeBatch(ctx context.Context, cropped []image.Image) ([][]OCRText, []error) + Health() bool +} + +// ── Outline ──────────────────────────────────────────────────────────── + +// Outline represents one entry in a PDF's document outline (table of contents). +// Python: extract_pdf_outlines() in deepdoc/parser/utils.py +type Outline struct { + Title string + Level int + PageNumber int // 1-indexed, matching Python +} + +// PDFEngine abstracts page extraction capabilities. +type PDFEngine interface { + ExtractChars(pageNum int) ([]TextChar, error) + RenderPage(pageNum int, dpi float64) ([]byte, error) + RenderPageImage(pageNum int, dpi float64) (image.Image, error) + RawData() []byte + PageCount() (int, error) + Outlines() ([]Outline, error) + Close() error +} + +// Tokenizer provides text tokenization matching rag_tokenizer. +type Tokenizer interface { + Tag(token string) string +} + +// SampleFunc samples up to n characters from a page's chars. +type SampleFunc func(chars []TextChar, n int) string + +// TableBuilder encapsulates TSR model-specific cell detection and grouping. +type TableBuilder interface { + Name() string + DetectCells(ctx context.Context, cropped image.Image) ([]TSRCell, error) + GroupCells(cells []TSRCell) [][]TSRCell +} + +// Rectangular is any 2D axis-aligned rectangle that can report its bounds. +type Rectangular interface { + Bounds() (x0, y0, x1, y1 float64) +} + +// IsCJK reports whether r is a CJK character. +func IsCJK(r rune) bool { + return unicode.Is(unicode.Han, r) || + unicode.Is(unicode.Hiragana, r) || + unicode.Is(unicode.Katakana, r) || + unicode.Is(unicode.Hangul, r) +} diff --git a/internal/deepdoc/parser/pdf/type/types_test.go b/internal/deepdoc/parser/pdf/type/types_test.go new file mode 100644 index 0000000000..648d25d3f8 --- /dev/null +++ b/internal/deepdoc/parser/pdf/type/types_test.go @@ -0,0 +1,86 @@ +package pdftype + +import ( + "testing" +) + +func TestIsCJK(t *testing.T) { + tests := []struct { + name string + r rune + want bool + }{ + {"chinese", '中', true}, + {"chinese2", '国', true}, + {"hiragana", 'あ', true}, + {"katakana", 'ア', true}, + {"hangul", '한', true}, + {"latin", 'A', false}, + {"digit", '1', false}, + {"space", ' ', false}, + {"punctuation", '.', false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsCJK(tt.r); got != tt.want { + t.Errorf("IsCJK(%q) = %v, want %v", tt.r, got, tt.want) + } + }) + } +} + +func TestCollectFigures(t *testing.T) { + t.Run("mixed layout types", func(t *testing.T) { + sections := []Section{ + {LayoutType: "figure", Text: "fig1", Image: "img1"}, + {LayoutType: "text", Text: "text1"}, + {LayoutType: "table", Text: "tbl1"}, + {LayoutType: "figure", Text: "fig2", Image: "img2"}, + {LayoutType: "title", Text: "title1"}, + } + figures := CollectFigures(sections) + if len(figures) != 2 { + t.Fatalf("expected 2 figures, got %d", len(figures)) + } + if figures[0].Text != "fig1" || figures[0].Image != "img1" { + t.Errorf("first figure: got (%s, %s)", figures[0].Text, figures[0].Image) + } + if figures[1].Text != "fig2" || figures[1].Image != "img2" { + t.Errorf("second figure: got (%s, %s)", figures[1].Text, figures[1].Image) + } + }) + t.Run("no figures", func(t *testing.T) { + figures := CollectFigures([]Section{ + {LayoutType: "text"}, {LayoutType: "table"}, {LayoutType: "title"}, + }) + if len(figures) != 0 { + t.Fatalf("expected 0, got %d", len(figures)) + } + }) + t.Run("nil input", func(t *testing.T) { + if figures := CollectFigures(nil); figures != nil { + t.Fatalf("expected nil, got %d elements", len(figures)) + } + }) + t.Run("empty input", func(t *testing.T) { + figures := CollectFigures([]Section{}) + if figures == nil || len(figures) != 0 { + t.Fatal("expected empty slice for empty input") + } + }) + t.Run("case sensitive", func(t *testing.T) { + figures := CollectFigures([]Section{ + {LayoutType: "Figure"}, {LayoutType: "FIGURE"}, {LayoutType: "figure", Text: "fig3"}, + }) + if len(figures) != 1 || figures[0].Text != "fig3" { + t.Fatalf("only lowercase 'figure' should match, got %d", len(figures)) + } + }) +} + +// textBox implements Rectangular for testing. +type textBox struct{ x0, y0, x1, y1 float64 } + +func (b textBox) Bounds() (float64, float64, float64, float64) { + return b.x0, b.y0, b.x1, b.y1 +} diff --git a/internal/deepdoc/parser/pdf/types.go b/internal/deepdoc/parser/pdf/types.go deleted file mode 100644 index 35169c0e85..0000000000 --- a/internal/deepdoc/parser/pdf/types.go +++ /dev/null @@ -1,320 +0,0 @@ -// Package pdfparser provides Go equivalents of RAGFlow's deepdoc/parser/pdf_parser.py -// layout analysis and text extraction logic. -// -// Each exported function documents its corresponding Python original with -// file:line references to pdf_parser.py. -package parser - -import ( - "context" - "image" -) - -// PipelineMetrics records diagnostic counts at each pipeline stage. -// Used for Go-vs-Python parity comparison and logging. -type PipelineMetrics struct { - BoxesInitial int - BoxesTextMerge int - BoxesVertMerge int - BoxesFinal int - TablesCount int -} - -// ParseResult encapsulates all outputs from a single Parse() call. -// Parser itself is stateless and safe to reuse across documents. -type ParseResult struct { - Sections []Section - Tables []TableItem - PageImages map[int]image.Image - Figures []Section - Metrics PipelineMetrics - - // Debug intermediates for DLA/TSR comparison with Python. - // Populated only during fresh Parse, not from cached results. - DLADebug []DLAPageRegions - TSRDebug []TSRRawCell -} - -// DLAPageRegions holds DLA layout regions for one page. -type DLAPageRegions struct { - Page int - Regions []DLARegion -} - -// TSRRawCell holds a raw TSR cell before row/column grouping. -type TSRRawCell struct { - TableIndex int `json:"table_index"` - Page int `json:"page"` - Label string `json:"label"` - X0 float64 `json:"x0"` - Y0 float64 `json:"y0"` - X1 float64 `json:"x1"` - Y1 float64 `json:"y1"` - Text string `json:"text"` -} - -// TextChar represents a single character extracted from a PDF page. -// Corresponds to pdfplumber page.chars dict elements in pdf_parser.py. -// -// Python equivalent: -// -// c = {"x0": 100.5, "x1": 108.2, "top": 200.0, "bottom": 212.0, -// "text": "A", "fontname": "ABCDE+SimSun", "page_number": 3} -// -// Example: -// -// c := TextChar{X0: 100.5, X1: 108.2, Top: 200.0, Bottom: 212.0, -// Text: "A", FontName: "ABCDE+SimSun", PageNumber: 3} -type TextChar struct { - X0, X1 float64 // horizontal bounds in PDF points - Top, Bottom float64 // vertical bounds in PDF points - Text string // single character (or small text run) - FontName string // e.g. "ABCDE+SimSun" - FontSize float64 - PageNumber int - LayoutType string // "text", "table", "figure", "equation" - LayoutNo string // layout identifier - ColID int // column ID assigned by _assign_column - R int // rotation/orientation marker -} - -func (c TextChar) Bounds() (float64, float64, float64, float64) { - return c.X0, c.Top, c.X1, c.Bottom -} - -// TextBox represents a rectangular region of text on a PDF page, -// typically a line or paragraph fragment. Created by layout analysis -// (e.g. _assign_column, _text_merge). -// -// Python equivalent: -// -// b = {"x0": 50.0, "x1": 550.0, "top": 100.0, "bottom": 112.0, -// "text": "第三章 财务分析", "page_number": 3, "layout_type": "text"} -type TextBox struct { - X0, X1 float64 - Top, Bottom float64 - Text string - PageNumber int - LayoutType string // "text", "table", "figure", "equation" - LayoutNo string - ColID int - R int - // Post-TSR table annotation fields (Python: R/H/C/SP tags) - RTop, RBott float64 // row top/bottom - HTop, HBott float64 // header top/bottom - HLeft, HRight float64 // header left/right - H int // header index - C int // column index - CLeft, CRight float64 // column left/right - SP int // spanning cell index -} - -func (b TextBox) Bounds() (float64, float64, float64, float64) { - return b.X0, b.Top, b.X1, b.Bottom -} - -// Position represents a parsed position tag from @@...## format. -// -// Python: pdf_parser.py:1872 extract_positions() -// -// Format: @@{page_range}\t{left}\t{right}\t{top}\t{bottom}## -// Example: "@@0-1\t50.0\t300.0\t200.0\t400.0##" -type Position struct { - PageNumbers []int // e.g. [0, 1] for cross-page content - Left float64 - Right float64 - Top float64 - Bottom float64 -} - -// Section represents a text segment with its spatial position on a PDF page. -// This is the primary output of layout analysis, consumed by NLP merge/split. -// -// Python equivalent: sections elements in naive.py::chunk() -// -// [(text_with_tags, position_tag_string), ...] -type Section struct { - Text string // text content - PositionTag string // "@@page-left-right-top-bottom##" format - LayoutType string // "text", "table", "title", "figure", ... - Positions []Position // parsed from PositionTag - TableItem *TableItem // non-nil when this section is a table - Image string // base64-encoded PNG of the cropped region (Python: b["image"]) -} - -// CollectFigures returns all sections with LayoutType "figure". -// Returns nil if the input is nil, empty slice if no figures found. -func CollectFigures(sections []Section) []Section { - if sections == nil { - return nil - } - figures := make([]Section, 0) - for _, s := range sections { - if s.LayoutType == LayoutTypeFigure { - figures = append(figures, s) - } - } - return figures -} - -// TableItem represents a detected table or figure region. -// -// Python equivalent: tables elements in naive.py::chunk() -// -// [((img, rows), positions), ...] -type TableItem struct { - ImageB64 string // base64-encoded PNG of the table/figure region - Rows [][]string // DEPRECATED: replaced by Cells; kept for batch output compat - Cells []TSRCell // raw TSR cells in crop pixel space - Positions []Position // spatial positions (PDF points, pre-merge) - Scale float64 // zoom factor for coordinate conversion - CropOffX float64 // crop origin X in pixel space - CropOffY float64 // crop origin Y in pixel space - Caption string // caption text merged from adjacent caption box - - // DLA table region boundaries in PDF point space (72 DPI). - // Matches Python's cropout using DLA layout region boundaries - // instead of text box anchor coordinates. - RegionLeft, RegionRight, RegionTop, RegionBottom float64 - - // NoMerge prevents cross-page merging for this table. Python's - // _extract_table_figure adds table keys to nomerge_lout_no when - // the next box is a caption/title/reference, indicating the table - // group ended and should not merge with its continuation. - NoMerge bool - - // Grid is the row-column grid produced by TableBuilder.GroupCells. - // Consumed by constructTable Path 1 and annotateTableBoxes. - // Nil for tables without TSR cells (fallback paths use boxes instead). - Grid [][]TSRCell -} - -// ParserConfig holds parser configuration. -// -// Python equivalent: kwargs merged with parser_config in task_executor.py -type ParserConfig struct { - Zoom float64 // zoom factor for page rendering, default 3 - FromPage int // 0-based start page - ToPage int // 0-based end page (-1 = all) - TableContextSize int // tokens of surrounding context for tables - ImageContextSize int // tokens of surrounding context for images - AutoRotateTables *bool // enable auto table rotation detection - SeparateTablesFigs bool // separate tables and figures - SortByTop bool // true = Top-based sort (parity tests); false = Bottom (production) - ChunkSize int // pages per chunk (0 = default 50, matching Python batch_size) - SkipOCR bool // true = DLA+TSR only, no image OCR (matching Python SKIP_OCR=1) - MaxOCRConcurrency int // max concurrent OCR pages (0 = sequential); matches Python PARALLEL_DEVICES - TableBuilder TableBuilder // TSR model adapter; injected by caller via NewTableBuilderFor -} - -// DefaultParserConfig returns a ParserConfig with sensible defaults. -func DefaultParserConfig() ParserConfig { - return ParserConfig{ - Zoom: 3, - FromPage: 0, - ToPage: -1, - ChunkSize: 50, - TableContextSize: 0, - ImageContextSize: 0, - SeparateTablesFigs: false, - } -} - -// DetectGarbled returns true if a page's text is likely garbled due to -// font encoding issues, indicating OCR is needed. -// -// This is a convenience wrapper around IsGarbledByFontEncoding. -// -// Python: pdf_parser.py:264 _is_garbled_by_font_encoding() -func DetectGarbled(chars []TextChar) bool { - return IsGarbledByFontEncoding(chars, 20) -} - -// HasColor checks if a character has visible color (not invisible white-on-white). -// -// Python: pdf_parser.py:190 _has_color() -// -// All extracted chars are assumed visible since the PDF engine handles -// rendering internally. -func HasColor(c TextChar) bool { - return true -} - -// ── DeepDoc interfaces (shared between cgo and non-cgo builds) ────────── - -// ModelType identifies the DeepDoc TSR model flavour. -type ModelType string - -const ( - ModelSaas ModelType = "saas" // cpu DeepDoc — cell-level TSR output - ModelOSS ModelType = "oss" // oss DeepDoc — column/row line TSR output -) - -// Layout type constants — used for LayoutType field comparisons across -// the pipeline. Values match DLA label taxonomy. -const ( - LayoutTypeText = "text" - LayoutTypeTable = "table" - LayoutTypeFigure = "figure" - LayoutTypeEquation = "equation" - LayoutTypeTitle = "title" - LayoutTypeReference = "reference" - LayoutTypeFooter = "footer" - LayoutTypeHeader = "header" - - // Compound DLA labels (used in priority-ordered annotation matching). - DLALabelFigureCaption = "figure caption" - DLALabelTableCaption = "table caption" -) - -// DocAnalyzer abstracts DeepDoc vision operations so the Parser can -// work with either a live service or a test mock. -// I/O methods accept a context for cancellation and deadline propagation. -type DocAnalyzer interface { - DLA(ctx context.Context, pageImage image.Image) ([]DLARegion, error) - TSR(ctx context.Context, cropped image.Image) ([]TSRCell, error) - OCRDetect(ctx context.Context, cropped image.Image) ([]OCRBox, error) - OCRRecognize(ctx context.Context, cropped image.Image) ([]OCRText, error) - OCRRecognizeBatch(ctx context.Context, cropped []image.Image) ([][]OCRText, []error) - Health() bool - ModelType() ModelType -} - -// OCRBox represents a detected text region from DeepDoc OCR detection. -// DeepDoc /predict/ocr?operator=det returns: -// -// {"output": [[[[[x0,y0],[x1,y1],[x2,y2],[x3,y3]], ...]]]} -type OCRBox struct { - X0, Y0, X1, Y1, X2, Y2, X3, Y3 float64 -} - -// OCRText represents recognized text with confidence from DeepDoc OCR rec. -// DeepDoc /predict/ocr?operator=rec returns: -// -// {"output": [[[["text", confidence], ...]]]} -type OCRText struct { - Text string - Confidence float64 -} - -// DLARegion represents one detected layout region. -type DLARegion struct { - X0, Y0, X1, Y1 float64 - Label string - Confidence float64 -} - -func (r DLARegion) Bounds() (float64, float64, float64, float64) { - return r.X0, r.Y0, r.X1, r.Y1 -} - -// TSRCell represents one table cell from TSR. -type TSRCell struct { - X0, Y0, X1, Y1 float64 - Text string - Label string // "table", "table row", "table column", etc. -} - -func (c TSRCell) Bounds() (float64, float64, float64, float64) { - return c.X0, c.Y0, c.X1, c.Y1 -} diff --git a/internal/deepdoc/parser/pdf/types_test.go b/internal/deepdoc/parser/pdf/types_test.go deleted file mode 100644 index 7076f6a5bf..0000000000 --- a/internal/deepdoc/parser/pdf/types_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package parser - -import ( - "testing" -) - -func TestCollectFigures(t *testing.T) { - t.Run("mixed layout types", func(t *testing.T) { - sections := []Section{ - {LayoutType: "figure", Text: "fig1", Image: "img1"}, - {LayoutType: "text", Text: "text1"}, - {LayoutType: "table", Text: "tbl1"}, - {LayoutType: "figure", Text: "fig2", Image: "img2"}, - {LayoutType: "title", Text: "title1"}, - } - figures := CollectFigures(sections) - if len(figures) != 2 { - t.Fatalf("expected 2 figures, got %d", len(figures)) - } - if figures[0].Text != "fig1" || figures[0].Image != "img1" { - t.Errorf("first figure: expected (fig1, img1), got (%s, %s)", figures[0].Text, figures[0].Image) - } - if figures[1].Text != "fig2" || figures[1].Image != "img2" { - t.Errorf("second figure: expected (fig2, img2), got (%s, %s)", figures[1].Text, figures[1].Image) - } - }) - - t.Run("no figures", func(t *testing.T) { - sections := []Section{ - {LayoutType: "text", Text: "text1"}, - {LayoutType: "table", Text: "tbl1"}, - {LayoutType: "title", Text: "title1"}, - } - figures := CollectFigures(sections) - if len(figures) != 0 { - t.Fatalf("expected 0 figures, got %d", len(figures)) - } - }) - - t.Run("nil input", func(t *testing.T) { - figures := CollectFigures(nil) - if figures != nil { - t.Fatalf("expected nil for nil input, got %d elements", len(figures)) - } - }) - - t.Run("empty input", func(t *testing.T) { - figures := CollectFigures([]Section{}) - if figures == nil { - t.Fatal("expected empty slice (not nil) for empty input") - } - if len(figures) != 0 { - t.Fatalf("expected 0 figures, got %d", len(figures)) - } - }) - - t.Run("all figures", func(t *testing.T) { - sections := []Section{ - {LayoutType: "figure", Text: "fig1"}, - {LayoutType: "figure", Text: "fig2"}, - {LayoutType: "figure", Text: "fig3"}, - } - figures := CollectFigures(sections) - if len(figures) != 3 { - t.Fatalf("expected 3 figures, got %d", len(figures)) - } - }) - - t.Run("figure with empty image", func(t *testing.T) { - sections := []Section{ - {LayoutType: "figure", Text: "fig1", Image: ""}, - {LayoutType: "figure", Text: "fig2", Image: "img2"}, - } - figures := CollectFigures(sections) - if len(figures) != 2 { - t.Fatalf("expected 2 figures, got %d", len(figures)) - } - // Figure with empty image is still collected — downstream should handle. - if figures[0].Image != "" { - t.Errorf("first figure: expected empty Image, got %s", figures[0].Image) - } - }) - - t.Run("single section, figure", func(t *testing.T) { - figures := CollectFigures([]Section{ - {LayoutType: "figure", Text: "only", Image: "img"}, - }) - if len(figures) != 1 { - t.Fatalf("expected 1 figure, got %d", len(figures)) - } - }) - - t.Run("single section, not figure", func(t *testing.T) { - figures := CollectFigures([]Section{ - {LayoutType: "text", Text: "only"}, - }) - if len(figures) != 0 { - t.Fatalf("expected 0 figures, got %d", len(figures)) - } - }) - - t.Run("case sensitive", func(t *testing.T) { - sections := []Section{ - {LayoutType: "Figure", Text: "fig1"}, - {LayoutType: "FIGURE", Text: "fig2"}, - {LayoutType: "figure", Text: "fig3"}, - } - figures := CollectFigures(sections) - if len(figures) != 1 { - t.Fatalf("only lowercase 'figure' should match, got %d", len(figures)) - } - if figures[0].Text != "fig3" { - t.Errorf("expected fig3, got %s", figures[0].Text) - } - }) -} diff --git a/internal/deepdoc/parser/pdf/crop.go b/internal/deepdoc/parser/pdf/util/crop.go similarity index 83% rename from internal/deepdoc/parser/pdf/crop.go rename to internal/deepdoc/parser/pdf/util/crop.go index 7bf625ced8..8c282b65e0 100644 --- a/internal/deepdoc/parser/pdf/crop.go +++ b/internal/deepdoc/parser/pdf/util/crop.go @@ -1,19 +1,21 @@ -package parser +package util import ( "encoding/base64" + "fmt" "image" "image/color" "log/slog" "math" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) -// cropSectionImage crops region(s) from rendered page images based on a +// CropSectionImage crops region(s) from rendered page images based on a // position tag and returns a base64-encoded PNG. Returns "" if cropping // is not possible (missing images, out-of-bounds, invalid tag). // // Python: pdf_parser.py:1802 RAGFlowPdfParser.crop() -func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom float64) string { +func CropSectionImage(posTag string, decodedImages map[int]image.Image, zoom float64) string { if len(decodedImages) == 0 { slog.Warn("cropSectionImage: no page images available, skipping image generation") return "" @@ -26,7 +28,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo } // Filter valid positions (all pages available). - var valid []Position + var valid []pdf.Position for _, pos := range positions { allValid := true for _, pn := range pos.PageNumbers { @@ -67,7 +69,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo lastPageH := float64(decodedImages[lastPageIdx].Bounds().Dy()) / zoom // topBand: 120px context above the first content position. - topBandPos := Position{ + topBandPos := pdf.Position{ PageNumbers: []int{firstPageIdx}, Left: first.Left, Right: first.Right, @@ -75,7 +77,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo Bottom: math.Max(first.Top-gap, 0), } // bottomBand: 120px context below the last content position. - bottomBandPos := Position{ + bottomBandPos := pdf.Position{ PageNumbers: []int{lastPageIdx}, Left: last.Left, Right: last.Right, @@ -91,21 +93,21 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo var segments []segment allPos := make([]struct { - pos Position + pos pdf.Position isEdge bool }, 0, len(valid)+2) allPos = append(allPos, struct { - pos Position + pos pdf.Position isEdge bool }{topBandPos, true}) for _, pos := range valid { allPos = append(allPos, struct { - pos Position + pos pdf.Position isEdge bool }{pos, false}) } allPos = append(allPos, struct { - pos Position + pos pdf.Position isEdge bool }{bottomBandPos, true}) @@ -147,7 +149,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo bottomClamped := math.Min(accumBottom, pageH) // Crop first page of this position. - cropped := fastCrop(pageImg, + cropped := FastCrop(pageImg, int(left*zoom), int(top*zoom), int(right*zoom), int(bottomClamped)) if isEdge { @@ -168,7 +170,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo } pageH2 := float64(pageImg2.Bounds().Dy()) bottomClamped2 := math.Min(bottomRemaining, pageH2) - cropped2 := fastCrop(pageImg2, + cropped2 := FastCrop(pageImg2, int(left*zoom), 0, int(right*zoom), int(bottomClamped2)) if isEdge { @@ -229,7 +231,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo curY += srcH + gap } - data, err := encodePNG(stitched) + data, err := EncodePNG(stitched) if err != nil { slog.Warn("cropSectionImage: PNG encode failed", "err", err) return "" @@ -250,7 +252,7 @@ func cropSectionImage(posTag string, decodedImages map[int]image.Image, zoom flo // louts = [layout for layout in self.page_layout[pn] if layout["type"] == ltype] // ii = Recognizer.find_overlapped(b, louts, naive=True) // if ii is not None: b = louts[ii] -func cropSectionByDLA(sec Section, dlaDebug []DLAPageRegions, pageImages map[int]image.Image) string { +func CropSectionByDLA(sec pdf.Section, dlaDebug []pdf.DLAPageRegions, pageImages map[int]image.Image) string { if len(sec.Positions) == 0 || len(sec.Positions[0].PageNumbers) == 0 { return "" } @@ -258,7 +260,7 @@ func cropSectionByDLA(sec Section, dlaDebug []DLAPageRegions, pageImages map[int pos := sec.Positions[0] // Find DLA regions for this page. - var regions []DLARegion + var regions []pdf.DLARegion for _, dp := range dlaDebug { if dp.Page == pg { regions = dp.Regions @@ -270,22 +272,22 @@ func cropSectionByDLA(sec Section, dlaDebug []DLAPageRegions, pageImages map[int } // Convert section bbox from PDF points (72 DPI) to DLA pixel space (216 DPI). - scale := dlaDPI / 72.0 // 3.0 - bx := rect{ - x0: pos.Left * scale, - y0: pos.Top * scale, - x1: pos.Right * scale, - y1: pos.Bottom * scale, + scale := pdf.DlaDPI / 72.0 // 3.0 + bx := Rect{ + X0: pos.Left * scale, + Y0: pos.Top * scale, + X1: pos.Right * scale, + Y1: pos.Bottom * scale, } // Find best-overlapping figure or equation DLA region. bestIdx := -1 bestOverlap := 0.0 for i, r := range regions { - if r.Label != LayoutTypeFigure && r.Label != LayoutTypeEquation { + if r.Label != pdf.LayoutTypeFigure && r.Label != pdf.LayoutTypeEquation { continue } - overlap := rectOverlap(bx, rect{r.X0, r.Y0, r.X1, r.Y1}) + overlap := RectOverlap(bx, Rect{r.X0, r.Y0, r.X1, r.Y1}) if overlap > bestOverlap { bestOverlap = overlap bestIdx = i @@ -300,12 +302,12 @@ func cropSectionByDLA(sec Section, dlaDebug []DLAPageRegions, pageImages map[int if !ok { return "" } - cropped, err := cropImageRegion(img, regions[bestIdx]) + cropped, err := CropImageRegion(img, regions[bestIdx]) if err != nil { slog.Warn("cropSectionByDLA: cropImageRegion failed", "page", pg, "err", err) return "" } - data, err := encodePNG(cropped) + data, err := EncodePNG(cropped) if err != nil { slog.Warn("cropSectionByDLA: PNG encode failed", "err", err) return "" @@ -360,7 +362,7 @@ func rotateCoordCW(x, y float64, origW, origH int, angle int) (float64, float64) // rotateImageCW rotates an image clockwise. Only 0/90/180/270 supported; // other values return nil. Matches Python PIL.Image.rotate(-angle, expand=True). -func rotateImageCW(img image.Image, angle int) *image.RGBA { +func RotateImageCW(img image.Image, angle int) *image.RGBA { b := img.Bounds() w, h := b.Dx(), b.Dy() @@ -389,7 +391,7 @@ func rotateImageCW(img image.Image, angle int) *image.RGBA { // are the ORIGINAL (pre-rotation) image dimensions. // // Python: pdf_parser.py:602 _map_rotated_point() -func mapRotatedPointToOriginal(x, y float64, angle int, origW, origH int) (float64, float64) { +func MapRotatedPointToOriginal(x, y float64, angle int, origW, origH int) (float64, float64) { switch angle { case 0: return x, y @@ -409,3 +411,27 @@ func mapRotatedPointToOriginal(x, y float64, angle int, origW, origH int) (float return x, y } } + +// 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) { + w := r.X1 - r.X0 + h := r.Y1 - r.Y0 + marginX := w * 0.03 + marginY := h * 0.03 + maxX := float64(img.Bounds().Dx()) + maxY := float64(img.Bounds().Dy()) + x0 := int(math.Max(0, r.X0-marginX)) + y0 := int(math.Max(0, r.Y0-marginY)) + x1 := int(math.Min(maxX, r.X1+marginX)) + y1 := int(math.Min(maxY, r.Y1+marginY)) + // Python PIL.Image.crop() raises ValueError when right < left or + // bottom < top. We return an error instead of silently falling back + // to the full-page image — the caller skips this table gracefully. + if x0 >= x1 || y0 >= y1 { + return nil, fmt.Errorf("crop: invalid region x0=%d y0=%d x1=%d y1=%d (DLA raw: %.1f,%.1f,%.1f,%.1f)", + x0, y0, x1, y1, r.X0, r.Y0, r.X1, r.Y1) + } + cropped := FastCrop(img, x0, y0, x1, y1) + return cropped, nil +} diff --git a/internal/deepdoc/parser/pdf/crop_test.go b/internal/deepdoc/parser/pdf/util/crop_test.go similarity index 79% rename from internal/deepdoc/parser/pdf/crop_test.go rename to internal/deepdoc/parser/pdf/util/crop_test.go index 4f12c59117..4da35e7f3a 100644 --- a/internal/deepdoc/parser/pdf/crop_test.go +++ b/internal/deepdoc/parser/pdf/util/crop_test.go @@ -1,4 +1,4 @@ -package parser +package util import ( "bytes" @@ -7,6 +7,7 @@ import ( "image/color" "image/png" "math" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) @@ -35,7 +36,7 @@ func TestCropSectionImage_SinglePage(t *testing.T) { 0: makeTestPageImage(200, 300, color.RGBA{255, 0, 0, 255}), } posTag := FormatPositionTag(0, 10, 100, 20, 150) - b64 := cropSectionImage(posTag, pageImages, 1) + b64 := CropSectionImage(posTag, pageImages, 1) if b64 == "" { t.Fatal("expected non-empty base64 image") @@ -59,10 +60,10 @@ func TestCropSectionImage_SinglePage(t *testing.T) { func TestCropSectionImage_EmptyImages(t *testing.T) { posTag := FormatPositionTag(0, 10, 100, 20, 150) - if b64 := cropSectionImage(posTag, nil, 1); b64 != "" { + if b64 := CropSectionImage(posTag, nil, 1); b64 != "" { t.Error("nil pageImages should return empty string") } - if b64 := cropSectionImage(posTag, map[int]image.Image{}, 1); b64 != "" { + if b64 := CropSectionImage(posTag, map[int]image.Image{}, 1); b64 != "" { t.Error("empty pageImages should return empty string") } } @@ -72,7 +73,7 @@ func TestCropSectionImage_OutOfBounds(t *testing.T) { 0: makeTestPageImage(200, 300, color.RGBA{255, 0, 0, 255}), } posTag := FormatPositionTag(5, 10, 100, 20, 150) - if b64 := cropSectionImage(posTag, pageImages, 1); b64 != "" { + if b64 := CropSectionImage(posTag, pageImages, 1); b64 != "" { t.Error("out-of-bounds page should return empty string") } } @@ -81,10 +82,10 @@ func TestCropSectionImage_InvalidTag(t *testing.T) { pageImages := map[int]image.Image{ 0: makeTestPageImage(200, 300, color.RGBA{255, 0, 0, 255}), } - if b64 := cropSectionImage("invalid", pageImages, 1); b64 != "" { + if b64 := CropSectionImage("invalid", pageImages, 1); b64 != "" { t.Error("invalid position tag should return empty string") } - if b64 := cropSectionImage("", pageImages, 1); b64 != "" { + if b64 := CropSectionImage("", pageImages, 1); b64 != "" { t.Error("empty position tag should return empty string") } } @@ -94,7 +95,7 @@ func TestCropSectionImage_ContextPadding(t *testing.T) { 0: makeTestPageImage(200, 800, color.RGBA{255, 0, 0, 255}), } posTag := FormatPositionTag(0, 20, 120, 300, 400) - b64 := cropSectionImage(posTag, pageImages, 1) + b64 := CropSectionImage(posTag, pageImages, 1) if b64 == "" { t.Fatal("expected non-empty result") } @@ -111,7 +112,7 @@ func TestCropSectionImage_ZoomScaling(t *testing.T) { 0: makeTestPageImage(400, 600, color.RGBA{255, 0, 0, 255}), } posTag := FormatPositionTag(0, 10, 100, 20, 150) - b64 := cropSectionImage(posTag, pageImages, 2) + b64 := CropSectionImage(posTag, pageImages, 2) if b64 == "" { t.Fatal("expected non-empty result") } @@ -136,7 +137,7 @@ func TestRotateImageCW(t *testing.T) { img.Set(2, 1, gr) t.Run("0 degrees", func(t *testing.T) { - rot := rotateImageCW(img, 0) + rot := RotateImageCW(img, 0) if rot == nil { t.Fatal("nil result") } @@ -148,7 +149,7 @@ func TestRotateImageCW(t *testing.T) { } }) t.Run("90 degrees", func(t *testing.T) { - rot := rotateImageCW(img, 90) + rot := RotateImageCW(img, 90) if rot == nil { t.Fatal("nil result") } @@ -173,7 +174,7 @@ func TestRotateImageCW(t *testing.T) { } }) t.Run("180 degrees", func(t *testing.T) { - rot := rotateImageCW(img, 180) + rot := RotateImageCW(img, 180) if rot == nil { t.Fatal("nil result") } @@ -188,7 +189,7 @@ func TestRotateImageCW(t *testing.T) { } }) t.Run("270 degrees", func(t *testing.T) { - rot := rotateImageCW(img, 270) + rot := RotateImageCW(img, 270) if rot == nil { t.Fatal("nil result") } @@ -197,7 +198,7 @@ func TestRotateImageCW(t *testing.T) { } }) t.Run("invalid angle", func(t *testing.T) { - if rotateImageCW(img, 45) != nil { + if RotateImageCW(img, 45) != nil { t.Error("expected nil for invalid angle") } }) @@ -211,7 +212,7 @@ func TestMapRotatedPointToOriginal_RoundTrip(t *testing.T) { for _, ox := range []float64{0, 50, 199} { for _, oy := range []float64{0, 30, 99} { rx, ry := rotateCoordCW(ox, oy, origW, origH, angle) - gotX, gotY := mapRotatedPointToOriginal(rx, ry, angle, origW, origH) + gotX, gotY := MapRotatedPointToOriginal(rx, ry, angle, origW, origH) if math.Abs(gotX-ox) > 0.01 || math.Abs(gotY-oy) > 0.01 { t.Errorf("angle=%d orig(%.0f,%.0f) → rot(%.0f,%.0f) → got(%.1f,%.1f)", angle, ox, oy, rx, ry, gotX, gotY) @@ -236,7 +237,7 @@ func TestMapRotatedPointToOriginal(t *testing.T) { {270, 50, 30, 200, 100, 169, 50}, // rotW=200: inverse (199-30,50) } for _, tt := range tests { - gotX, gotY := mapRotatedPointToOriginal(tt.rx, tt.ry, tt.angle, tt.origW, tt.origH) + gotX, gotY := MapRotatedPointToOriginal(tt.rx, tt.ry, tt.angle, tt.origW, tt.origH) if math.Abs(gotX-tt.wantX) > 0.01 || math.Abs(gotY-tt.wantY) > 0.01 { t.Errorf("angle=%d (%f,%f) got(%f,%f) want(%f,%f)", tt.angle, tt.rx, tt.ry, gotX, gotY, tt.wantX, tt.wantY) @@ -262,9 +263,9 @@ func TestCropSectionImage_MultiPage(t *testing.T) { 1: makeTestPageImage(100, 800, color.RGBA{0, 200, 0, 255}), 2: makeTestPageImage(100, 800, color.RGBA{0, 0, 200, 255}), } - // Position spans pages 0-2, bottom reaches into page 2. + // pdf.Position spans pages 0-2, bottom reaches into page 2. posTag := "@@1-3\t0.0\t100.0\t0.0\t500.0##" - b64 := cropSectionImage(posTag, pageImages, 1) + b64 := CropSectionImage(posTag, pageImages, 1) if b64 == "" { t.Fatal("expected non-empty result for multi-page position") } @@ -289,7 +290,7 @@ func TestCropSectionImage_LargePageSpan(t *testing.T) { 1: makeTestPageImage(100, 600, color.RGBA{0, 200, 0, 255}), } posTag := "@@1-2\t0.0\t100.0\t0.0\t900.0##" - b64 := cropSectionImage(posTag, pageImages, 1) + b64 := CropSectionImage(posTag, pageImages, 1) if b64 == "" { t.Fatal("expected non-empty result") } @@ -312,20 +313,20 @@ func TestCropSectionByDLA(t *testing.T) { // DLA regions in pixel space (216 DPI). // Figure region at (30, 60, 270, 420) — a large area covering most of the image. // Text region at (10, 400, 100, 440) — a small text box near the bottom. - dlaDebug := []DLAPageRegions{{ + dlaDebug := []pdf.DLAPageRegions{{ Page: 0, - Regions: []DLARegion{ + Regions: []pdf.DLARegion{ {X0: 10, Y0: 400, X1: 100, Y1: 440, Label: "text"}, {X0: 30, Y0: 60, X1: 270, Y1: 420, Label: "figure"}, {X0: 5, Y0: 5, X1: 290, Y1: 55, Label: "title"}, }, }} - // Section with a text-box-sized bbox (PDF points, 72 DPI). + // pdf.Section with a text-box-sized bbox (PDF points, 72 DPI). // In pixel space at scale 3: (60, 1200, 150, 1320) → (20, 400, 50, 440). // This overlaps with the "figure" DLA region. - sec := Section{ - Positions: []Position{{ + sec := pdf.Section{ + Positions: []pdf.Position{{ PageNumbers: []int{0}, Left: 20, Right: 50, Top: 400 / 3.0, Bottom: 440 / 3.0, @@ -333,7 +334,7 @@ func TestCropSectionByDLA(t *testing.T) { LayoutType: "figure", } - result := cropSectionByDLA(sec, dlaDebug, pageImages) + result := CropSectionByDLA(sec, dlaDebug, pageImages) if result == "" { t.Fatal("expected non-empty result for figure overlapping DLA region") } @@ -356,22 +357,22 @@ func TestCropSectionByDLA_NoMatch(t *testing.T) { pageImages := map[int]image.Image{ 0: makeTestPageImage(300, 450, color.RGBA{255, 0, 0, 255}), } - dlaDebug := []DLAPageRegions{{ + dlaDebug := []pdf.DLAPageRegions{{ Page: 0, - Regions: []DLARegion{ + Regions: []pdf.DLARegion{ {X0: 10, Y0: 10, X1: 100, Y1: 50, Label: "title"}, {X0: 10, Y0: 60, X1: 100, Y1: 100, Label: "text"}, }, }} - // Section whose bbox doesn't overlap any figure/equation DLA region. - sec := Section{ - Positions: []Position{{ + // pdf.Section whose bbox doesn't overlap any figure/equation DLA region. + sec := pdf.Section{ + Positions: []pdf.Position{{ PageNumbers: []int{0}, Left: 20, Right: 50, Top: 20, Bottom: 50, }}, LayoutType: "figure", } - result := cropSectionByDLA(sec, dlaDebug, pageImages) + result := CropSectionByDLA(sec, dlaDebug, pageImages) if result != "" { t.Errorf("expected empty result when no figure/equation DLA region found, got length %d", len(result)) } @@ -380,12 +381,53 @@ func TestCropSectionByDLA_NoMatch(t *testing.T) { // TestCropSectionByDLA_EmptyInputs returns empty for edge cases. func TestCropSectionByDLA_EmptyInputs(t *testing.T) { // Empty positions. - if got := cropSectionByDLA(Section{}, nil, nil); got != "" { + if got := CropSectionByDLA(pdf.Section{}, nil, nil); got != "" { t.Error("expected empty for empty positions") } // Empty page numbers. - sec := Section{Positions: []Position{{PageNumbers: nil}}} - if got := cropSectionByDLA(sec, nil, nil); got != "" { + sec := pdf.Section{Positions: []pdf.Position{{PageNumbers: nil}}} + if got := CropSectionByDLA(sec, nil, nil); got != "" { t.Error("expected empty for empty page numbers") } } +func TestCropImageRegion(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 200, 300)) + + t.Run("normal crop", func(t *testing.T) { + r := pdf.DLARegion{X0: 10, Y0: 20, X1: 100, Y1: 150} + cropped, err := CropImageRegion(img, r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // 3% proportional margin: 90×3%≈3px, 130×3%≈4px → 95×137 + if cropped.Bounds().Dx() != 95 || cropped.Bounds().Dy() != 137 { + t.Errorf("size %v, want 95x137", cropped.Bounds()) + } + }) + + t.Run("x0 >= x1 returns error", func(t *testing.T) { + // 3% proportional margin on each side: if the gap is too small after margin expansion, x0 ≥ x1 triggers error. + r := pdf.DLARegion{X0: 110, Y0: 20, X1: 50, Y1: 150} + _, err := CropImageRegion(img, r) + if err == nil { + t.Fatal("expected error for x0 >= x1, got nil") + } + }) + + t.Run("y0 >= y1 returns error", func(t *testing.T) { + r := pdf.DLARegion{X0: 10, Y0: 150, X1: 100, Y1: 20} + _, err := CropImageRegion(img, r) + if err == nil { + t.Fatal("expected error for y0 >= y1, got nil") + } + }) + + t.Run("region fully outside image bounds", func(t *testing.T) { + // Clamped to image bounds → zero-width/height → error. + r := pdf.DLARegion{X0: 300, Y0: 400, X1: 500, Y1: 600} + _, err := CropImageRegion(img, r) + if err == nil { + t.Fatal("expected error for region outside image bounds") + } + }) +} diff --git a/internal/deepdoc/parser/pdf/util/eng_detect.go b/internal/deepdoc/parser/pdf/util/eng_detect.go new file mode 100644 index 0000000000..953502aa0e --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/eng_detect.go @@ -0,0 +1,110 @@ +package util + +import ( + "math/rand/v2" + "strings" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// IsASCIIPrintable returns true for characters that match Python's +// is_english regex: [ a-zA-Z0-9,/¸;:'\[\]\(\)!@#$%^&*\"?<>._-] +func IsASCIIPrintable(r rune) bool { + if r == ' ' { + return true + } + if r >= 'a' && r <= 'z' { + return true + } + if r >= 'A' && r <= 'Z' { + return true + } + if r >= '0' && r <= '9' { + return true + } + // Additional ASCII symbols from the Python regex + switch r { + case ',', '/', '¸', ';', ':', '\'', '[', ']', '(', ')', + '!', '@', '#', '$', '%', '^', '&', '*', '"', '?', + '<', '>', '.', '_', '-': + return true + } + return false +} + +// DefaultSampleChars returns a random sample of up to n character texts, +// concatenated. Matches Python's random.choices([c["text"] for c in +// page_chars], k=min(100, len(page_chars))). +func DefaultSampleChars(chars []pdf.TextChar, n int) string { + if n <= 0 || len(chars) == 0 { + return "" + } + m := min(n, len(chars)) + // Fisher-Yates shuffle on indices, then take first m. + indices := make([]int, len(chars)) + for i := range indices { + indices[i] = i + } + rand.Shuffle(len(indices), func(i, j int) { + indices[i], indices[j] = indices[j], indices[i] + }) + var buf strings.Builder + for i := 0; i < m; i++ { + buf.WriteString(chars[indices[i]].Text) + } + return buf.String() +} + +// FullTextFromChars concatenates all chars text across pages for scan noise detection. +func FullTextFromChars(pageChars map[int][]pdf.TextChar) string { + var sb strings.Builder + for _, chars := range pageChars { + for _, c := range chars { + sb.WriteString(c.Text) + } + } + return sb.String() +} + +// DetectEnglish detects whether a PDF is primarily English by per-page +// majority vote, matching Python's is_english logic in __images__ +// (pdf_parser.py:1519-1526). +// +// Each page: sample up to 100 character texts via sampler, join into one +// string, check if there is a run of 30+ consecutive ASCII characters +// (letters, digits, spaces, punctuation). Pages with such a run vote +// "English". Returns true when a strict majority of pages vote yes. +// +// totalPages is the denominator (len(self.page_images) in Python), including +// image-only pages that have zero chars. This matches Python's behavior +// where empty pages dilute the majority. +func DetectEnglish(pageChars map[int][]pdf.TextChar, totalPages int, sample pdf.SampleFunc) bool { + if totalPages == 0 || len(pageChars) == 0 { + return false + } + if sample == nil { + sample = DefaultSampleChars + } + pagesWithSeq := 0 + + for _, chars := range pageChars { + if len(chars) == 0 { + continue + } + sampleText := sample(chars, 100) + run := 0 + for _, r := range sampleText { + if IsASCIIPrintable(r) { + run++ + if run >= 30 { + pagesWithSeq++ + break + } + } else { + run = 0 + } + } + } + + return pagesWithSeq > totalPages/2 +} diff --git a/internal/deepdoc/parser/pdf/util/eng_detect_test.go b/internal/deepdoc/parser/pdf/util/eng_detect_test.go new file mode 100644 index 0000000000..dba232121b --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/eng_detect_test.go @@ -0,0 +1,104 @@ +package util + +import ( + "strings" + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func TestIsASCIIPrintable(t *testing.T) { + english := "hello WORLD 123" + for _, r := range english { + if !IsASCIIPrintable(r) { + t.Errorf("IsASCIIPrintable(%q) = false, want true", r) + } + } + symbols := ",/;:'[]()!@#$%^&*\"?<>._-" + for _, r := range symbols { + if !IsASCIIPrintable(r) { + t.Errorf("IsASCIIPrintable(%q) = false, want true", r) + } + } + nonEnglish := "你好世界" + for _, r := range nonEnglish { + if IsASCIIPrintable(r) { + t.Errorf("IsASCIIPrintable(%q) = true, want false", r) + } + } +} + +func TestDefaultSampleChars(t *testing.T) { + chars := []pdf.TextChar{ + {Text: "A"}, {Text: "B"}, {Text: "C"}, {Text: "D"}, {Text: "E"}, + } + got := DefaultSampleChars(chars, 3) + if len(got) == 0 { + t.Error("expected non-empty sample") + } + // Every sampled char should be from our set + for _, r := range got { + if !strings.ContainsRune("ABCDE", r) { + t.Errorf("unexpected char %q in sample", r) + } + } + + // n <= 0 returns empty + if DefaultSampleChars(chars, 0) != "" { + t.Error("n=0 should return empty") + } + if DefaultSampleChars(nil, 10) != "" { + t.Error("nil chars should return empty") + } +} + +func TestFullTextFromChars(t *testing.T) { + chars := map[int][]pdf.TextChar{ + 0: {{Text: "Hello"}, {Text: " "}, {Text: "World"}}, + 1: {{Text: "Page2"}}, + } + got := FullTextFromChars(chars) + if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") || !strings.Contains(got, "Page2") { + t.Errorf("FullTextFromChars = %q, missing expected content", got) + } + if FullTextFromChars(nil) != "" { + t.Error("nil should return empty") + } +} + +func TestDetectEnglish(t *testing.T) { + // Page with enough ASCII chars → English + englishChars := make([]pdf.TextChar, 40) + for i := range englishChars { + englishChars[i] = pdf.TextChar{Text: "A"} + } + nonEnglishChars := []pdf.TextChar{{Text: "你好世界你好世界你好世界你好世界"}} + + t.Run("all pages English", func(t *testing.T) { + chars := map[int][]pdf.TextChar{0: englishChars, 1: englishChars} + if !DetectEnglish(chars, 2, nil) { + t.Error("all-English pages should detect English") + } + }) + t.Run("majority English with non-English page", func(t *testing.T) { + chars := map[int][]pdf.TextChar{0: englishChars, 1: nonEnglishChars} + // totalPages=3: 1 English page out of 3 → not majority + if DetectEnglish(chars, 3, nil) { + t.Error("1/3 English pages should NOT detect English") + } + }) + t.Run("custom sampler", func(t *testing.T) { + sampler := func(chars []pdf.TextChar, n int) string { + return strings.Repeat("A", 40) + } + chars := map[int][]pdf.TextChar{0: {{Text: "x"}}} + if !DetectEnglish(chars, 1, sampler) { + t.Error("custom sampler returning 40 ASCII chars should detect English") + } + }) + t.Run("empty pages", func(t *testing.T) { + if DetectEnglish(nil, 1, nil) { + t.Error("nil pageChars should return false") + } + }) +} diff --git a/internal/deepdoc/parser/pdf/garbled.go b/internal/deepdoc/parser/pdf/util/garbled.go similarity index 54% rename from internal/deepdoc/parser/pdf/garbled.go rename to internal/deepdoc/parser/pdf/util/garbled.go index 456aa583bd..d044e30c2e 100644 --- a/internal/deepdoc/parser/pdf/garbled.go +++ b/internal/deepdoc/parser/pdf/util/garbled.go @@ -1,15 +1,17 @@ -package parser +package util import ( "regexp" "strings" "unicode" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) -// cidPattern matches pdfminer's CID placeholder like "(cid:123)". +// CIDPattern matches pdfminer's CID placeholder like "(cid:123)". // // Python: pdf_parser.py:198 _CID_PATTERN -var cidPattern = regexp.MustCompile(`\(cid\s*:\s*\d+\s*\)`) +var CIDPattern = regexp.MustCompile(`\(cid\s*:\s*\d+\s*\)`) // subsetFontPattern matches PDF subset font prefixes like "ABCDEF+". // PDF subset fonts use a 2-6 uppercase alphanumeric tag followed by '+'. @@ -103,7 +105,7 @@ func IsGarbledText(text string, threshold float64) bool { if trimmed == "" { return false } - if cidPattern.MatchString(trimmed) { + if CIDPattern.MatchString(trimmed) { return true } @@ -135,13 +137,13 @@ func IsGarbledText(text string, threshold float64) bool { // // Example: // -// chars := []TextChar{ +// chars := []pdf.TextChar{ // {Text: "!", FontName: "DY1+SimSun"}, // {Text: "#", FontName: "DY1+SimSun"}, // // ... mostly ASCII punctuation with subset font prefix // } // IsGarbledByFontEncoding(chars, 20) → true // OCR needed! -func IsGarbledByFontEncoding(chars []TextChar, minChars int) bool { +func IsGarbledByFontEncoding(chars []pdf.TextChar, minChars int) bool { if len(chars) < minChars { return false } @@ -224,3 +226,172 @@ func catOf(r rune) string { } return "" } + +// IsGarbledPage returns true if a page is garbled by PUA ratio, font encoding, +// pdf_oxide unmapped glyphs, or scan noise (no real words). +func IsGarbledPage(chars []pdf.TextChar) bool { + if len(chars) < 20 { + return false + } + // Build full-page text for detection (all O(n) single pass). + var fullText strings.Builder + for _, c := range chars { + fullText.WriteString(c.Text) + } + text := fullText.String() + if IsGarbledText(text, 0.3) { + return true + } + if PdfOxideUnmappedGarbled(text) && IsScanNoise(text) { + return true + } + if IsGarbledByFontEncoding(chars, 20) { + return true + } + if IsScanNoise(text) { + return true + } + return false +} + +// IsScanNoise detects scanned pages where pdf_oxide extracts noise glyphs +// instead of real text. Real text in any language contains word-like runs +// of consecutive letters (L category). Scan noise consists of random ASCII +// symbols with at most 2-letter fragments. +// +// Three indicators of real (non-noise) text, any one is sufficient: +// - ≥4 consecutive lowercase Latin letters (e.g. "the", "and") +// - ≥2 consecutive CJK characters (Han, Hiragana, Katakana, Hangul) +// - ≥4 consecutive non-ASCII letters (Arabic, Thai, Cyrillic, etc.) +// +// Pure-uppercase fragments like "RASB" are common in pdf_oxide noise but +// never appear as standalone words in real text without lowercase context. +func IsScanNoise(text string) bool { + nonSpace := 0 + digitCount := 0 + lowerRun := 0 + maxLowerRun := 0 + cjkRun := 0 + maxCJKRun := 0 + nonASCIILetterRun := 0 + maxNonASCIILetterRun := 0 + + for _, r := range text { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + lowerRun = 0 + cjkRun = 0 + nonASCIILetterRun = 0 + continue + } + nonSpace++ + + // Digit density: real content (tables, dates) has digits; + // pdf_oxide noise (unmapped glyphs) never produces digits. + if r >= '0' && r <= '9' { + digitCount++ + } + + // Lowercase Latin (Ll) + if unicode.Is(unicode.Ll, r) { + lowerRun++ + if lowerRun > maxLowerRun { + maxLowerRun = lowerRun + } + } else { + lowerRun = 0 + } + + // CJK: Han, Hiragana, Katakana, Hangul Syllables & Jamo + if pdf.IsCJK(r) { + cjkRun++ + if cjkRun > maxCJKRun { + maxCJKRun = cjkRun + } + } else { + cjkRun = 0 + } + + // Non-ASCII letter (Arabic U+0600–U+06FF, Thai U+0E00–U+0E7F, + // Cyrillic U+0400–U+04FF, etc.). Excludes ASCII so uppercase + // Latin fragments like "RASB" don't count. + if unicode.IsLetter(r) && r > unicode.MaxASCII { + nonASCIILetterRun++ + if nonASCIILetterRun > maxNonASCIILetterRun { + maxNonASCIILetterRun = nonASCIILetterRun + } + } else { + nonASCIILetterRun = 0 + } + } + + // Need enough characters to make a meaningful decision. + if nonSpace < 30 { + return false + } + + // Digit density: pdf_oxide never substitutes digits for unmapped + // glyphs. Real content (tables, dates, page numbers) has ≥10% + // digits; noise consists of random ASCII punctuation. + if float64(digitCount)/float64(nonSpace) >= 0.10 { + return false + } + + // Real text in any script — any one indicator is sufficient. + isNoise := maxLowerRun < 4 && maxCJKRun < 2 && maxNonASCIILetterRun < 4 + + return isNoise +} + +// isCJK reports whether r is a CJK character: Han ideograph, Hiragana, +// Katakana, Hangul syllable, or Hangul Jamo. + +// PdfOxideUnmappedGarbled detects pdf_oxide's '#' placeholder glyphs. +// pdf_oxide uses '#' (U+0023) for every glyph it cannot map; consecutive +// unmapped glyphs form "##", "###", "####" sequences. Three or more +// consecutive '#' is virtually impossible in normal text. +// +// Two conditions (either is sufficient): +// - ≥ 2 occurrences of "###" (3+ consecutive #) +// - # density ≥ 5% of non-space characters +func PdfOxideUnmappedGarbled(text string) bool { + hashCount := 0 + total := 0 + consecutive := 0 + tripleClusters := 0 + + for _, r := range text { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + continue + } + total++ + if r == '#' { + hashCount++ + consecutive++ + if consecutive == 3 { + tripleClusters++ + } + } else { + consecutive = 0 + } + } + + if total == 0 { + return false + } + + density := float64(hashCount) / float64(total) + + if tripleClusters >= 1 { + return true + } + // Density check only meaningful with enough chars (matches isGarbledPage's + // min 20 char guard). In production the sample is 200 chars. + if total >= 40 && density >= 0.03 { + return true + } + return false +} + +// ocrDetectAndRecognize runs OCR detection + recognition and returns +// recognized pdf.TextBox results. logLabel distinguishes callers in log output +// ("scan page", "garbled page"). diff --git a/internal/deepdoc/parser/pdf/util/garbled_test.go b/internal/deepdoc/parser/pdf/util/garbled_test.go new file mode 100644 index 0000000000..068d159639 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/garbled_test.go @@ -0,0 +1,118 @@ +package util + +import ( + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +func TestIsGarbledChar(t *testing.T) { + tests := []struct { + name string + ch string + want bool + }{ + {"empty", "", false}, + {"normal ascii", "A", false}, + {"normal chinese", "你", false}, + {"PUA char E000", "", true}, + {"PUA char F8FF", "", true}, + {"replacement char", "�", true}, + {"null control", "\x00", true}, + {"tab", "\t", false}, + {"newline", "\n", false}, + {"normal single byte", "z", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGarbledChar(tt.ch) + if got != tt.want { + t.Errorf("IsGarbledChar(%q) = %v, want %v", tt.ch, got, tt.want) + } + }) + } +} + +func TestIsGarbledText(t *testing.T) { + tests := []struct { + name string + text string + threshold float64 + want bool + }{ + {"empty", "", 0.5, false}, + {"normal text", "正常文本", 0.5, false}, + {"all garbled", "", 0.5, true}, + {"one garbled in many", "ABDEFGHI", 0.5, false}, + {"half garbled strict", "AB", 0.5, true}, + {"half garbled loose", "AB", 0.7, false}, + {"english text", "Hello World", 0.5, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGarbledText(tt.text, tt.threshold) + if got != tt.want { + t.Errorf("IsGarbledText(%q, %v) = %v, want %v", tt.text, tt.threshold, got, tt.want) + } + }) + } +} + +func TestHasSubsetFontPrefix(t *testing.T) { + tests := []struct { + name string + fontName string + want bool + }{ + {"subset prefix", "DY1+Z1QDm1-1", true}, + {"short subset", "AB+SimSun", true}, + {"no prefix", "SimSun", false}, + {"empty", "", false}, + {"just plus", "+SimSun", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HasSubsetFontPrefix(tt.fontName) + if got != tt.want { + t.Errorf("HasSubsetFontPrefix(%q) = %v, want %v", tt.fontName, got, tt.want) + } + }) + } +} + +func TestIsGarbledByFontEncoding(t *testing.T) { + t.Run("too few chars", func(t *testing.T) { + chars := make([]pdf.TextChar, 10) + if IsGarbledByFontEncoding(chars, 20) { + t.Error("should return false when below minChars threshold") + } + }) + t.Run("subset font with ascii — garbled", func(t *testing.T) { + var chars []pdf.TextChar + for i := 0; i < 30; i++ { + chars = append(chars, pdf.TextChar{Text: "!", FontName: "DY1+SimSun"}) + } + chars = append(chars, pdf.TextChar{Text: "你", FontName: "DY1+SimSun"}) + if !IsGarbledByFontEncoding(chars, 20) { + t.Error("should detect garbled font encoding") + } + }) + t.Run("regular CJK text — not garbled", func(t *testing.T) { + var chars []pdf.TextChar + for i := 0; i < 30; i++ { + chars = append(chars, pdf.TextChar{Text: "测试文本内容", FontName: "SimSun"}) + } + if IsGarbledByFontEncoding(chars, 20) { + t.Error("should not flag regular CJK text as garbled") + } + }) + t.Run("normal English text — not garbled", func(t *testing.T) { + var chars []pdf.TextChar + for i := 0; i < 30; i++ { + chars = append(chars, pdf.TextChar{Text: "Hello world text content here", FontName: "Times-Roman"}) + } + if IsGarbledByFontEncoding(chars, 20) { + t.Error("should not flag regular English text as garbled") + } + }) +} diff --git a/internal/deepdoc/parser/pdf/geometry.go b/internal/deepdoc/parser/pdf/util/geometry.go similarity index 75% rename from internal/deepdoc/parser/pdf/geometry.go rename to internal/deepdoc/parser/pdf/util/geometry.go index f5ed08f9c9..51e9e30bfe 100644 --- a/internal/deepdoc/parser/pdf/geometry.go +++ b/internal/deepdoc/parser/pdf/util/geometry.go @@ -1,8 +1,9 @@ -package parser +package util import ( "image" "math" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "sort" ) @@ -13,9 +14,9 @@ import ( // // Example: // -// c := TextChar{X0: 50, X1: 58, Text: "A"} +// c := pdf.TextChar{X0: 50, X1: 58, Text: "A"} // w := CharWidth(c) // (58-50)/1 = 8 -func CharWidth(c TextChar) float64 { +func CharWidth(c pdf.TextChar) float64 { if len(c.Text) == 0 { return 0 } @@ -28,9 +29,9 @@ func CharWidth(c TextChar) float64 { // // Example: // -// c := TextChar{Top: 200, Bottom: 212} +// c := pdf.TextChar{Top: 200, Bottom: 212} // h := CharHeight(c) // 212-200 = 12 -func CharHeight(c TextChar) float64 { +func CharHeight(c pdf.TextChar) float64 { return c.Bottom - c.Top } @@ -41,10 +42,10 @@ func CharHeight(c TextChar) float64 { // // Example: // -// a := TextChar{X0: 50, X1: 58} -// b := TextChar{X0: 60, X1: 68} +// a := pdf.TextChar{X0: 50, X1: 58} +// b := pdf.TextChar{X0: 60, X1: 68} // d := XDis(a, b) // min(|58-60|=2, |50-68|=18, |108-128|/2=10) = 2 -func XDis(a, b TextChar) float64 { +func XDis(a, b pdf.TextChar) float64 { return min( math.Abs(a.X1-b.X0), min(math.Abs(a.X0-b.X1), math.Abs(a.X0+a.X1-b.X0-b.X1)/2), @@ -58,76 +59,40 @@ func XDis(a, b TextChar) float64 { // // Example: // -// a := TextChar{Top: 100, Bottom: 112} -// b := TextChar{Top: 114, Bottom: 126} +// a := pdf.TextChar{Top: 100, Bottom: 112} +// b := pdf.TextChar{Top: 114, Bottom: 126} // d := YDis(a, b) // (114+126-100-112)/2 = 14 -func YDis(a, b TextChar) float64 { +func YDis(a, b pdf.TextChar) float64 { return (b.Top + b.Bottom - a.Top - a.Bottom) / 2 } // BoxWidth returns the width of a text box. -func BoxWidth(b TextBox) float64 { +func BoxWidth(b pdf.TextBox) float64 { return b.X1 - b.X0 } // BoxHeight returns the height of a text box. -func BoxHeight(b TextBox) float64 { +func BoxHeight(b pdf.TextBox) float64 { return b.Bottom - b.Top } // BoxYDis computes vertical centerline distance between boxes. // Positive means b2 is below b1. -func BoxYDis(b1, b2 TextBox) float64 { +func BoxYDis(b1, b2 pdf.TextBox) float64 { return (b2.Top + b2.Bottom - b1.Top - b1.Bottom) / 2 } // BoxXDis computes horizontal distance between boxes. -func BoxXDis(b1, b2 TextBox) float64 { +func BoxXDis(b1, b2 pdf.TextBox) float64 { return min( math.Abs(b1.X1-b2.X0), min(math.Abs(b1.X0-b2.X1), math.Abs(b1.X0+b1.X1-b2.X0-b2.X1)/2), ) } -// ── Rectangular interface and overlap helpers ────────────────────────── - -// Rectangular is any 2D axis-aligned rectangle that can report its bounds. -type Rectangular interface { - Bounds() (x0, y0, x1, y1 float64) -} - -// Area returns the area of a Rectangular. Returns 0 for degenerate rects. -func Area(r Rectangular) float64 { - x0, y0, x1, y1 := r.Bounds() - if x1 <= x0 || y1 <= y0 { - return 0 - } - return (x1 - x0) * (y1 - y0) -} - -// rectOverlapInter returns the intersection area of two axis-aligned rectangles. -// Returns 0 when the rectangles do not overlap or either is degenerate. -func rectOverlapInter(x0a, y0a, x1a, y1a, x0b, y0b, x1b, y1b float64) float64 { - x0 := max(x0a, x0b) - y0 := max(y0a, y0b) - x1 := min(x1a, x1b) - y1 := min(y1a, y1b) - if x0 >= x1 || y0 >= y1 { - return 0 - } - return (x1 - x0) * (y1 - y0) -} - -// OverlapInter returns the raw intersection area of two rectangles. -func OverlapInter(a, b Rectangular) float64 { - ax0, ay0, ax1, ay1 := a.Bounds() - bx0, by0, bx1, by1 := b.Bounds() - return rectOverlapInter(ax0, ay0, ax1, ay1, bx0, by0, bx1, by1) -} - // OverlapRatio returns intersection(a,b) / Area(denom). // Returns 0 when denom has zero area or there is no intersection. -func OverlapRatio(a, b, denom Rectangular) float64 { +func OverlapRatio(a, b, denom pdf.Rectangular) float64 { inter := OverlapInter(a, b) if inter <= 0 { return 0 @@ -139,13 +104,8 @@ func OverlapRatio(a, b, denom Rectangular) float64 { return inter / d } -// OverlapRatioA returns intersection(a,b) / Area(a). -func OverlapRatioA(a, b Rectangular) float64 { - return OverlapRatio(a, b, a) -} - // OverlapRatioMax returns intersection(a,b) / max(Area(a), Area(b)). -func OverlapRatioMax(a, b Rectangular) float64 { +func OverlapRatioMax(a, b pdf.Rectangular) float64 { inter := OverlapInter(a, b) if inter <= 0 { return 0 @@ -161,7 +121,7 @@ func OverlapRatioMax(a, b Rectangular) float64 { // Ratio = overlap_width / max(1, min(width(a), width(b))). // // Python: pdf_parser.py:964-965 overlap calculation in _naive_vertical_merge -func OverlapX(a, b Rectangular) float64 { +func OverlapX(a, b pdf.Rectangular) float64 { ax0, _, ax1, _ := a.Bounds() bx0, _, bx1, _ := b.Bounds() overlap := math.Max(0, math.Min(ax1, bx1)-math.Max(ax0, bx0)) @@ -176,7 +136,7 @@ func OverlapX(a, b Rectangular) float64 { // but inverted top ordering (a layout artifact). // // Python: pdf_parser.py:178 sort_X_by_page() -func SortXByPage(boxes []TextBox, threshold float64) []TextBox { +func SortXByPage(boxes []pdf.TextBox, threshold float64) []pdf.TextBox { sort.Slice(boxes, func(i, j int) bool { if boxes[i].PageNumber != boxes[j].PageNumber { return boxes[i].PageNumber < boxes[j].PageNumber @@ -202,7 +162,7 @@ func SortXByPage(boxes []TextBox, threshold float64) []TextBox { // MedianCharHeight computes the median character height for a page, // matching Python's np.median(char height) in __images__ (pdf_parser.py:1552). // Used as a reference unit for vertical spacing decisions. -func MedianCharHeight(chars []TextChar) float64 { +func MedianCharHeight(chars []pdf.TextChar) float64 { heights := make([]float64, len(chars)) for i, c := range chars { heights[i] = CharHeight(c) @@ -212,7 +172,7 @@ func MedianCharHeight(chars []TextChar) float64 { // MedianCharWidth computes the median character width for a page, // matching Python's np.median(char width) in __images__ (pdf_parser.py:1553). -func MedianCharWidth(chars []TextChar) float64 { +func MedianCharWidth(chars []pdf.TextChar) float64 { widths := make([]float64, len(chars)) for i, c := range chars { widths[i] = CharWidth(c) @@ -225,7 +185,7 @@ func MedianCharWidth(chars []TextChar) float64 { // // Python: np.median([b["bottom"]-b["top"] for b in bxs]) or 10 // in _naive_vertical_merge:941 -func MedianHeight(boxes []TextBox) float64 { +func MedianHeight(boxes []pdf.TextBox) float64 { heights := make([]float64, len(boxes)) for i, b := range boxes { heights[i] = b.Bottom - b.Top @@ -246,23 +206,21 @@ func medianFloat64(vals []float64, fallback float64) float64 { return vals[n/2] } -// rect is a lightweight rectangle for overlap calculations. +// Rect is a lightweight rectangle for overlap calculations. // Coordinates are in whatever space the caller uses (pixel or PDF points). -type rect struct{ x0, y0, x1, y1 float64 } +type Rect struct{ X0, Y0, X1, Y1 float64 } -func (r rect) Bounds() (float64, float64, float64, float64) { return r.x0, r.y0, r.x1, r.y1 } +func (r Rect) Bounds() (float64, float64, float64, float64) { return r.X0, r.Y0, r.X1, r.Y1 } -// rectOverlap returns the overlap ratio between two rects. -// Ratio = area(intersection) / max(area(a), area(b)). -// Returns 0 when there is no overlap. -func rectOverlap(a, b rect) float64 { +// RectOverlap returns the overlap ratio between two Rects. +func RectOverlap(a, b Rect) float64 { return OverlapRatioMax(a, b) } // fastCrop copies a rectangular region from src to a new *image.RGBA. // Uses direct Pix slice copy for *image.RGBA sources (zero allocation per row); // falls back to pixel-by-pixel for other image types. -func fastCrop(src image.Image, x0, y0, x1, y1 int) *image.RGBA { +func FastCrop(src image.Image, x0, y0, x1, y1 int) *image.RGBA { // Clamp to source bounds b := src.Bounds() if x0 < b.Min.X { @@ -298,3 +256,46 @@ func fastCrop(src image.Image, x0, y0, x1, y1 int) *image.RGBA { } return dst } + +// ── Geometry helpers (pure functions, moved from type/types.go) ───────── + +// Area returns the area of a Rectangular. Returns 0 for degenerate rects. +func Area(r pdf.Rectangular) float64 { + x0, y0, x1, y1 := r.Bounds() + if x1 <= x0 || y1 <= y0 { + return 0 + } + return (x1 - x0) * (y1 - y0) +} + +// RectOverlapInter returns the intersection area of two axis-aligned rectangles. +func RectOverlapInter(x0a, y0a, x1a, y1a, x0b, y0b, x1b, y1b float64) float64 { + x0 := max(x0a, x0b) + y0 := max(y0a, y0b) + x1 := min(x1a, x1b) + y1 := min(y1a, y1b) + if x0 >= x1 || y0 >= y1 { + return 0 + } + return (x1 - x0) * (y1 - y0) +} + +// OverlapInter returns the raw intersection area of two rectangles. +func OverlapInter(a, b pdf.Rectangular) float64 { + ax0, ay0, ax1, ay1 := a.Bounds() + bx0, by0, bx1, by1 := b.Bounds() + return RectOverlapInter(ax0, ay0, ax1, ay1, bx0, by0, bx1, by1) +} + +// OverlapRatioA returns intersection(a,b) / Area(a). +func OverlapRatioA(a, b pdf.Rectangular) float64 { + inter := OverlapInter(a, b) + if inter <= 0 { + return 0 + } + d := Area(a) + if d <= 0 { + return 0 + } + return inter / d +} diff --git a/internal/deepdoc/parser/pdf/geometry_test.go b/internal/deepdoc/parser/pdf/util/geometry_test.go similarity index 54% rename from internal/deepdoc/parser/pdf/geometry_test.go rename to internal/deepdoc/parser/pdf/util/geometry_test.go index 2099fa2261..89dca3c567 100644 --- a/internal/deepdoc/parser/pdf/geometry_test.go +++ b/internal/deepdoc/parser/pdf/util/geometry_test.go @@ -1,37 +1,37 @@ -package parser +package util import ( - "strings" + pdf "ragflow/internal/deepdoc/parser/pdf/type" "testing" ) func TestCharWidth(t *testing.T) { - c := TextChar{X0: 50, X1: 58, Text: "A"} + c := pdf.TextChar{X0: 50, X1: 58, Text: "A"} if w := CharWidth(c); w != 8.0 { t.Errorf("CharWidth = %v, want 8.0", w) } - c2 := TextChar{X0: 50, X1: 70, Text: "hi"} + c2 := pdf.TextChar{X0: 50, X1: 70, Text: "hi"} if w := CharWidth(c2); w != 10.0 { t.Errorf("CharWidth = %v, want 10.0", w) } - c3 := TextChar{X0: 50, X1: 50, Text: ""} + c3 := pdf.TextChar{X0: 50, X1: 50, Text: ""} if w := CharWidth(c3); w != 0 { t.Errorf("CharWidth empty = %v, want 0", w) } } func TestCharHeight(t *testing.T) { - c := TextChar{Top: 200, Bottom: 212} + c := pdf.TextChar{Top: 200, Bottom: 212} if h := CharHeight(c); h != 12.0 { t.Errorf("CharHeight = %v, want 8.0", h) } } func TestXDis(t *testing.T) { - a := TextChar{X0: 50, X1: 58} - b := TextChar{X0: 60, X1: 68} + a := pdf.TextChar{X0: 50, X1: 58} + b := pdf.TextChar{X0: 60, X1: 68} d := XDis(a, b) expected := 2.0 // min(|58-60|=2, |50-68|=18, |108-128|/2=10) if d != expected { @@ -40,8 +40,8 @@ func TestXDis(t *testing.T) { } func TestYDis(t *testing.T) { - a := TextChar{Top: 100, Bottom: 112} - b := TextChar{Top: 114, Bottom: 126} + a := pdf.TextChar{Top: 100, Bottom: 112} + b := pdf.TextChar{Top: 114, Bottom: 126} d := YDis(a, b) expected := (114.0 + 126.0 - 100.0 - 112.0) / 2 // 14 if d != expected { @@ -50,7 +50,7 @@ func TestYDis(t *testing.T) { } func TestSortXByPage(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {PageNumber: 1, X0: 100, Top: 50, Text: "C"}, {PageNumber: 1, X0: 50, Top: 100, Text: "A"}, {PageNumber: 1, X0: 50, Top: 30, Text: "B"}, @@ -66,22 +66,22 @@ func TestSortXByPage(t *testing.T) { } func TestOverlapX(t *testing.T) { - b1 := TextBox{X0: 50, X1: 200} - b2 := TextBox{X0: 100, X1: 250} + b1 := pdf.TextBox{X0: 50, X1: 200} + b2 := pdf.TextBox{X0: 100, X1: 250} overlap := OverlapX(&b1, &b2) if overlap <= 0.5 || overlap >= 0.8 { t.Errorf("OverlapX = %v, want ~0.667", overlap) } - b3 := TextBox{X0: 50, X1: 100} - b4 := TextBox{X0: 200, X1: 250} + b3 := pdf.TextBox{X0: 50, X1: 100} + b4 := pdf.TextBox{X0: 200, X1: 250} if overlap := OverlapX(&b3, &b4); overlap != 0 { t.Errorf("non-overlapping should be 0: got %v", overlap) } } func TestMedianCharHeight(t *testing.T) { - chars := []TextChar{ + chars := []pdf.TextChar{ {Top: 0, Bottom: 10}, {Top: 0, Bottom: 20}, } @@ -95,7 +95,7 @@ func TestMedianCharHeight(t *testing.T) { } func TestMedianHeight(t *testing.T) { - boxes := []TextBox{ + boxes := []pdf.TextBox{ {Top: 0, Bottom: 10}, {Top: 0, Bottom: 20}, {Top: 0, Bottom: 30}, @@ -108,62 +108,31 @@ func TestMedianHeight(t *testing.T) { } } -func TestNaiveVerticalMerge(t *testing.T) { - boxes := []TextBox{ - {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "第一段", LayoutNo: "1", LayoutType: "text"}, - {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 114, Bottom: 126, Text: "续文", LayoutNo: "1", LayoutType: "text"}, - } - meanH := map[int]float64{0: 12} - meanW := map[int]float64{0: 5} - result := NaiveVerticalMerge(boxes, meanH, meanW, false) - // These should merge: small vertical gap, overlapping horizontally, same layout - if len(result) != 1 { - t.Errorf("expected 1 merged box, got %d: %v", len(result), result) - } - if len(result) > 0 && !strings.Contains(result[0].Text, "第一段") { - t.Errorf("merged text should contain '第一段': got %q", result[0].Text) - } -} - -func TestNaiveVerticalMergeNonMerge(t *testing.T) { - // Large gap — should not merge - boxes := []TextBox{ - {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 100, Bottom: 112, Text: "第一段。", LayoutNo: "1", LayoutType: "text"}, - {PageNumber: 0, ColID: 0, X0: 50, X1: 550, Top: 300, Bottom: 312, Text: "第二段。", LayoutNo: "1", LayoutType: "text"}, - } - meanH := map[int]float64{0: 12} - meanW := map[int]float64{0: 5} - result := NaiveVerticalMerge(boxes, meanH, meanW, false) - if len(result) != 2 { - t.Errorf("expected 2 separate boxes (large gap), got %d", len(result)) - } -} - func TestBoxWidth(t *testing.T) { - b := TextBox{X0: 50, X1: 200} + b := pdf.TextBox{X0: 50, X1: 200} if w := BoxWidth(b); w != 150 { t.Errorf("BoxWidth = %v, want 150", w) } } func TestBoxHeight(t *testing.T) { - b := TextBox{Top: 100, Bottom: 130} + b := pdf.TextBox{Top: 100, Bottom: 130} if h := BoxHeight(b); h != 30 { t.Errorf("BoxHeight = %v, want 30", h) } } func TestBoxXDis(t *testing.T) { - b1 := TextBox{X0: 50, X1: 100} - b2 := TextBox{X0: 110, X1: 200} + b1 := pdf.TextBox{X0: 50, X1: 100} + b2 := pdf.TextBox{X0: 110, X1: 200} if d := BoxXDis(b1, b2); d != 10 { t.Errorf("BoxXDis = %v, want 10", d) } } func TestBoxYDis(t *testing.T) { - b1 := TextBox{Top: 100, Bottom: 112} - b2 := TextBox{Top: 114, Bottom: 126} + b1 := pdf.TextBox{Top: 100, Bottom: 112} + b2 := pdf.TextBox{Top: 114, Bottom: 126} d := BoxYDis(b1, b2) expected := (114.0 + 126.0 - 100.0 - 112.0) / 2 if d != expected { @@ -172,7 +141,7 @@ func TestBoxYDis(t *testing.T) { } func TestMedianCharWidth(t *testing.T) { - chars := []TextChar{ + chars := []pdf.TextChar{ {X0: 0, X1: 8, Text: "A"}, {X0: 0, X1: 16, Text: "AB"}, } @@ -183,3 +152,64 @@ func TestMedianCharWidth(t *testing.T) { t.Errorf("MedianCharWidth(empty) = %v, want 5", w) } } + +// textBox implements Rectangular for testing. +type textBox struct{ x0, y0, x1, y1 float64 } + +func (b textBox) Bounds() (float64, float64, float64, float64) { + return b.x0, b.y0, b.x1, b.y1 +} + +func TestArea(t *testing.T) { + tests := []struct { + name string + r pdf.Rectangular + want float64 + }{ + {"normal", textBox{0, 0, 10, 20}, 200}, + {"zero width", textBox{5, 0, 5, 10}, 0}, + {"zero height", textBox{0, 5, 10, 5}, 0}, + {"degenerate", textBox{10, 10, 5, 5}, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Area(tt.r); got != tt.want { + t.Errorf("Area = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOverlapInter(t *testing.T) { + tests := []struct { + name string + a, b pdf.Rectangular + want float64 + }{ + {"full overlap", textBox{0, 0, 10, 10}, textBox{0, 0, 10, 10}, 100}, + {"partial", textBox{0, 0, 10, 10}, textBox{5, 5, 15, 15}, 25}, + {"no overlap", textBox{0, 0, 10, 10}, textBox{20, 20, 30, 30}, 0}, + {"edge touching", textBox{0, 0, 10, 10}, textBox{10, 0, 20, 10}, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := OverlapInter(tt.a, tt.b); got != tt.want { + t.Errorf("OverlapInter = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOverlapRatioA(t *testing.T) { + a := textBox{0, 0, 10, 10} // area = 100 + b := textBox{5, 5, 15, 15} // overlap = 25 + if got := OverlapRatioA(a, b); got != 0.25 { + t.Errorf("OverlapRatioA = %v, want 0.25", got) + } + // no overlap + c := textBox{0, 0, 10, 10} + d := textBox{20, 20, 30, 30} + if got := OverlapRatioA(c, d); got != 0 { + t.Errorf("OverlapRatioA no overlap = %v, want 0", got) + } +} diff --git a/internal/deepdoc/parser/pdf/util/image_utils.go b/internal/deepdoc/parser/pdf/util/image_utils.go new file mode 100644 index 0000000000..734bcccff5 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/image_utils.go @@ -0,0 +1,45 @@ +package util + +import ( + "bytes" + "encoding/base64" + "image" + "image/jpeg" + "image/png" +) + +// EncodePNG encodes img as PNG bytes. +func EncodePNG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// EncodeImageToBase64PNG encodes img as a base64-encoded PNG string. +func EncodeImageToBase64PNG(img image.Image) (string, error) { + data, err := EncodePNG(img) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(data), nil +} + +// DecodeBase64PNG decodes a base64-encoded PNG string to an image.Image. +func DecodeBase64PNG(b64 string) (image.Image, error) { + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return nil, err + } + return png.Decode(bytes.NewReader(data)) +} + +// EncodeJPEG encodes img as JPEG bytes with quality 90. +func EncodeJPEG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/deepdoc/parser/pdf/util/image_utils_test.go b/internal/deepdoc/parser/pdf/util/image_utils_test.go new file mode 100644 index 0000000000..62535627fa --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/image_utils_test.go @@ -0,0 +1,74 @@ +package util + +import ( + "encoding/base64" + "image" + "testing" +) + +func TestEncodePNG(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + data, err := EncodePNG(img) + if err != nil { + t.Fatalf("EncodePNG: %v", err) + } + if len(data) == 0 { + t.Error("encoded PNG should not be empty") + } +} + +func TestEncodeJPEG(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + data, err := EncodeJPEG(img) + if err != nil { + t.Fatalf("EncodeJPEG: %v", err) + } + if len(data) == 0 { + t.Error("encoded JPEG should not be empty") + } +} + +func TestEncodeImageToBase64PNG(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + b64, err := EncodeImageToBase64PNG(img) + if err != nil { + t.Fatalf("EncodeImageToBase64PNG: %v", err) + } + if b64 == "" { + t.Error("base64 string should not be empty") + } + // Should be valid base64 and decode to valid PNG + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + t.Fatalf("invalid base64: %v", err) + } + if len(data) == 0 { + t.Error("decoded data should not be empty") + } +} + +func TestDecodeBase64PNG(t *testing.T) { + // Encode → base64 → decode roundtrip + img := image.NewRGBA(image.Rect(0, 0, 10, 10)) + b64, _ := EncodeImageToBase64PNG(img) + decoded, err := DecodeBase64PNG(b64) + if err != nil { + t.Fatalf("DecodeBase64PNG: %v", err) + } + if decoded.Bounds() != img.Bounds() { + t.Errorf("bounds mismatch: got %v, want %v", decoded.Bounds(), img.Bounds()) + } + + // Invalid base64 + _, err = DecodeBase64PNG("not-valid-base64!!!") + if err == nil { + t.Error("expected error for invalid base64") + } + + // Valid base64 but not PNG + b64NotPNG := base64.StdEncoding.EncodeToString([]byte("not a png")) + _, err = DecodeBase64PNG(b64NotPNG) + if err == nil { + t.Error("expected error for non-PNG data") + } +} diff --git a/internal/deepdoc/parser/pdf/kmeans.go b/internal/deepdoc/parser/pdf/util/kmeans.go similarity index 93% rename from internal/deepdoc/parser/pdf/kmeans.go rename to internal/deepdoc/parser/pdf/util/kmeans.go index 3b31ca3441..8bdd37a5f9 100644 --- a/internal/deepdoc/parser/pdf/kmeans.go +++ b/internal/deepdoc/parser/pdf/util/kmeans.go @@ -1,16 +1,16 @@ -package parser +package util import ( "math" "sort" ) -// kmeans1D performs 1-dimensional KMeans clustering. +// KMeans1D performs 1-dimensional KMeans clustering. // Returns per-point labels and final centroid values. // // Initialization: evenly spaced centroids (deterministic, equivalent to // sklearn KMeans with fixed seed in practice for 1D data). -func kmeans1D(data []float64, k int) (labels []int, centroids []float64) { +func KMeans1D(data []float64, k int) (labels []int, centroids []float64) { n := len(data) labels = make([]int, n) @@ -90,13 +90,13 @@ func kmeans1D(data []float64, k int) (labels []int, centroids []float64) { return } -// silhouette1D computes the silhouette score for 1D data. +// Silhouette1D computes the silhouette score for 1D data. // Returns a score in [-1, 1]. Higher is better. // Returns -1 if the score cannot be computed (fewer than 2 unique labels). // Samples alone in their cluster contribute 0, matching sklearn behavior. // // Python: sklearn.metrics.silhouette_score with Euclidean distance. -func silhouette1D(data []float64, labels []int) float64 { +func Silhouette1D(data []float64, labels []int) float64 { n := len(data) if n <= 1 { return 0 diff --git a/internal/deepdoc/parser/pdf/util/kmeans_test.go b/internal/deepdoc/parser/pdf/util/kmeans_test.go new file mode 100644 index 0000000000..11725c0304 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/kmeans_test.go @@ -0,0 +1,91 @@ +package util + +import ( + "math" + "testing" +) + +func TestKMeans1D(t *testing.T) { + t.Run("single cluster", func(t *testing.T) { + data := []float64{10, 12, 11, 9, 13} + labels, centroids := KMeans1D(data, 1) + if len(centroids) != 1 { + t.Fatalf("expected 1 centroid, got %d", len(centroids)) + } + if len(labels) != len(data) { + t.Fatalf("expected %d labels, got %d", len(data), len(labels)) + } + for _, l := range labels { + if l != 0 { + t.Errorf("all labels should be 0, got %d", l) + } + } + }) + + t.Run("two well-separated clusters", func(t *testing.T) { + data := []float64{10, 12, 11, 90, 92, 91} + labels, centroids := KMeans1D(data, 2) + if len(centroids) != 2 { + t.Fatalf("expected 2 centroids, got %d", len(centroids)) + } + if len(labels) != len(data) { + t.Fatalf("expected %d labels, got %d", len(data), len(labels)) + } + // First 3 points should be in one cluster, last 3 in the other + if labels[0] == labels[3] { + t.Error("far-apart points should be in different clusters") + } + }) + + t.Run("k equals data points", func(t *testing.T) { + data := []float64{10, 50, 90} + _, centroids := KMeans1D(data, 3) + if len(centroids) != 3 { + t.Errorf("n=k: expected 3 centroids, got %d", len(centroids)) + } + for i, c := range centroids { + if math.Abs(c-data[i]) > 1e-6 { + t.Errorf("centroid[%d]=%v, want %v", i, c, data[i]) + } + } + }) + + t.Run("k greater than data points", func(t *testing.T) { + data := []float64{10, 50} + labels, centroids := KMeans1D(data, 5) + if len(centroids) != 2 { + t.Errorf("k>n: expected 2 centroids, got %d", len(centroids)) + } + if labels[0] == labels[1] { + t.Error("two distinct points should be in different clusters") + } + }) +} + +func TestSilhouette1D(t *testing.T) { + t.Run("well-separated clusters", func(t *testing.T) { + data := []float64{0, 1, 2, 100, 101, 102} + labels := []int{0, 0, 0, 1, 1, 1} + score := Silhouette1D(data, labels) + if score < 0.8 { + t.Errorf("well-separated score should be high, got %.3f", score) + } + }) + + t.Run("overlapping clusters", func(t *testing.T) { + data := []float64{0, 1, 0, 1, 0, 1} + labels := []int{0, 0, 0, 1, 1, 1} + score := Silhouette1D(data, labels) + if score > 0.5 { + t.Errorf("overlapping score should be low, got %.3f", score) + } + }) + + t.Run("single cluster returns -1", func(t *testing.T) { + data := []float64{1, 2, 3} + labels := []int{0, 0, 0} + if score := Silhouette1D(data, labels); score != -1 { + t.Errorf("single cluster should return -1, got %.3f", score) + } + }) +} diff --git a/internal/deepdoc/parser/pdf/position.go b/internal/deepdoc/parser/pdf/util/position.go similarity index 91% rename from internal/deepdoc/parser/pdf/position.go rename to internal/deepdoc/parser/pdf/util/position.go index e0ef24d067..9f0c95d095 100644 --- a/internal/deepdoc/parser/pdf/position.go +++ b/internal/deepdoc/parser/pdf/util/position.go @@ -1,4 +1,4 @@ -package parser +package util import ( "fmt" @@ -6,6 +6,8 @@ import ( "regexp" "strconv" "strings" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // @@ page position tag regex patterns. @@ -31,9 +33,9 @@ var posTagPattern = regexp.MustCompile(`@@[0-9-]+\t[0-9.\t]+##`) // // text := "Some text @@0-1\t50.0\t300.0\t200.0\t400.0## more text" // poss := ExtractPositions(text) -// // poss[0] = Position{PageNumbers: [-1, 0], Left: 50.0, Right: 300.0, Top: 200.0, Bottom: 400.0} -func ExtractPositions(text string) []Position { - var poss []Position +// // poss[0] = pdf.Position{PageNumbers: [-1, 0], Left: 50.0, Right: 300.0, Top: 200.0, Bottom: 400.0} +func ExtractPositions(text string) []pdf.Position { + var poss []pdf.Position for _, tag := range posTagPattern.FindAllString(text, -1) { cleaned := strings.TrimPrefix(strings.TrimSuffix(tag, "##"), "@@") parts := strings.Split(cleaned, "\t") @@ -73,7 +75,7 @@ func ExtractPositions(text string) []Position { continue } - poss = append(poss, Position{ + poss = append(poss, pdf.Position{ PageNumbers: pageNums, Left: left, Right: right, diff --git a/internal/deepdoc/parser/pdf/position_test.go b/internal/deepdoc/parser/pdf/util/position_test.go similarity index 99% rename from internal/deepdoc/parser/pdf/position_test.go rename to internal/deepdoc/parser/pdf/util/position_test.go index 8595356265..88209d052a 100644 --- a/internal/deepdoc/parser/pdf/position_test.go +++ b/internal/deepdoc/parser/pdf/util/position_test.go @@ -1,4 +1,4 @@ -package parser +package util import ( "testing" diff --git a/internal/deepdoc/parser/pdf/ycoord_test.go b/internal/deepdoc/parser/pdf/ycoord_test.go index 7f9d6b5a4b..c49ea17b31 100644 --- a/internal/deepdoc/parser/pdf/ycoord_test.go +++ b/internal/deepdoc/parser/pdf/ycoord_test.go @@ -9,6 +9,7 @@ import ( "testing" "ragflow/internal/deepdoc/parser/pdf/pdfoxide" + pdf "ragflow/internal/deepdoc/parser/pdf/type" ) // ── Y-coordinate tests ────────────────────────────────────────────────── @@ -16,7 +17,7 @@ import ( // openTestingPDF opens a real PDF by name from testdata/real_pdfs/. // Missing fixtures are skipped (soft) rather than failing — these tests // require the "manual" build tag and rely on optional fixture files. -func openTestingPDF(t *testing.T, name string) (PDFEngine, *pdfoxide.Document) { +func openTestingPDF(t *testing.T, name string) (pdf.PDFEngine, *pdfoxide.Document) { t.Helper() dir := filepath.Join("testdata", "real_pdfs") if _, err := os.Stat(filepath.Join(dir, name)); os.IsNotExist(err) { diff --git a/internal/ingestion/parser/doc_parser.go b/internal/ingestion/parser/doc_parser.go index 3c44d3174c..07c28d2d8f 100644 --- a/internal/ingestion/parser/doc_parser.go +++ b/internal/ingestion/parser/doc_parser.go @@ -1,3 +1,5 @@ +//go:build cgo + // // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // diff --git a/internal/ingestion/parser/docx_parser.go b/internal/ingestion/parser/docx_parser.go index 4ae1daea30..e9ccc9a4d8 100644 --- a/internal/ingestion/parser/docx_parser.go +++ b/internal/ingestion/parser/docx_parser.go @@ -1,3 +1,5 @@ +//go:build cgo + // // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // diff --git a/internal/ingestion/parser/office_parsers_no_cgo.go b/internal/ingestion/parser/office_parsers_no_cgo.go new file mode 100644 index 0000000000..fbdadadd9f --- /dev/null +++ b/internal/ingestion/parser/office_parsers_no_cgo.go @@ -0,0 +1,104 @@ +//go:build !cgo + +package parser + +import "fmt" + +// OfficeOxide is the lib_type identifier for office_oxide backend. +const OfficeOxide = "office_oxide" + +type DOCParser struct { + libType string +} + +func NewDOCParser(libType string) (*DOCParser, error) { + return nil, fmt.Errorf("DOC parser requires CGO (office_oxide)") +} + +func (p *DOCParser) Parse(_ string, _ []byte) error { + return fmt.Errorf("DOC parser requires CGO (office_oxide)") +} + +func (p *DOCParser) String() string { + return "DOCParser(no-cgo)" +} + +type DOCXParser struct { + libType string +} + +func NewDOCXParser(libType string) (*DOCXParser, error) { + return nil, fmt.Errorf("DOCX parser requires CGO (office_oxide)") +} + +func (p *DOCXParser) Parse(_ string, _ []byte) error { + return fmt.Errorf("DOCX parser requires CGO (office_oxide)") +} + +func (p *DOCXParser) String() string { + return "DOCXParser(no-cgo)" +} + +type XLSParser struct { + libType string +} + +func NewXLSParser(libType string) (*XLSParser, error) { + return nil, fmt.Errorf("XLS parser requires CGO (office_oxide)") +} + +func (p *XLSParser) Parse(_ string, _ []byte) error { + return fmt.Errorf("XLS parser requires CGO (office_oxide)") +} + +func (p *XLSParser) String() string { + return "XLSParser(no-cgo)" +} + +type XLSXParser struct { + libType string +} + +func NewXLSXParser(libType string) (*XLSXParser, error) { + return nil, fmt.Errorf("XLSX parser requires CGO (office_oxide)") +} + +func (p *XLSXParser) Parse(_ string, _ []byte) error { + return fmt.Errorf("XLSX parser requires CGO (office_oxide)") +} + +func (p *XLSXParser) String() string { + return "XLSXParser(no-cgo)" +} + +type PPTParser struct { + libType string +} + +func NewPPTParser(libType string) (*PPTParser, error) { + return nil, fmt.Errorf("PPT parser requires CGO (office_oxide)") +} + +func (p *PPTParser) Parse(_ string, _ []byte) error { + return fmt.Errorf("PPT parser requires CGO (office_oxide)") +} + +func (p *PPTParser) String() string { + return "PPTParser(no-cgo)" +} + +type PPTXParser struct { + libType string +} + +func NewPPTXParser(libType string) (*PPTXParser, error) { + return nil, fmt.Errorf("PPTX parser requires CGO (office_oxide)") +} + +func (p *PPTXParser) Parse(_ string, _ []byte) error { + return fmt.Errorf("PPTX parser requires CGO (office_oxide)") +} + +func (p *PPTXParser) String() string { + return "PPTXParser(no-cgo)" +} diff --git a/internal/ingestion/parser/ppt_parser.go b/internal/ingestion/parser/ppt_parser.go index 018e8f7a70..93061abb7a 100644 --- a/internal/ingestion/parser/ppt_parser.go +++ b/internal/ingestion/parser/ppt_parser.go @@ -1,3 +1,5 @@ +//go:build cgo + // // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // diff --git a/internal/ingestion/parser/pptx_parser.go b/internal/ingestion/parser/pptx_parser.go index 9dcfea5515..589bd010ee 100644 --- a/internal/ingestion/parser/pptx_parser.go +++ b/internal/ingestion/parser/pptx_parser.go @@ -1,3 +1,5 @@ +//go:build cgo + // // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // diff --git a/internal/ingestion/parser/xls_parser.go b/internal/ingestion/parser/xls_parser.go index ba78584639..031945a73b 100644 --- a/internal/ingestion/parser/xls_parser.go +++ b/internal/ingestion/parser/xls_parser.go @@ -1,3 +1,5 @@ +//go:build cgo + // // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // diff --git a/internal/ingestion/parser/xlsx_parser.go b/internal/ingestion/parser/xlsx_parser.go index 3b46e8716e..5c6cadf03f 100644 --- a/internal/ingestion/parser/xlsx_parser.go +++ b/internal/ingestion/parser/xlsx_parser.go @@ -1,3 +1,5 @@ +//go:build cgo + // // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. //