mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-11 22:25:41 +08:00
feat(ingestion): mirror Go pipeline progress into the document table;
harden resume guards
- pipeline: bind the owning document via WithDocumentID; after each
TrackProgress event aggregate ingestion_task_log progress and mirror
progress/run/progress_msg back into the document table, so GET
/api/v1/datasets/{dataset_id}/documents reflects live Go pipeline
progress without a bespoke endpoint.
- canvas: extend the S3 resume guard to reject legacy no-op nodes (e.g.
ExitLoop) so component_total equals the count of progress-reporting
components and the aggregate percent can reach 100%.
- runtime/canvas: route progress through TrackProgress; add interrupt
test coverage (r3_interrupt_test.go).
- dao/entity: add IngestionTask.DocumentID column and AggregateProgress
support used by the mirror; IngestionTaskLog keeps a Checkpoint column
alongside the progress fields.
feat(deepdoc): cache DocAnalyzer inference results in Redis (1h TTL)
- Redis-backed DocAnalyzerCache decorator over inference.Client; cache
key = "ddoc:cache:<method>:" + sha256 of the JPEG-encoded image bytes
(deterministic).
- TTL = 1h; hits skip the inner HTTP call and return cached JSON; inner
errors are not cached.
refactor(deepdoc): align figure cropping with Python cropout + bounded
page caches
- CropSectionByDLA mirrors Python cropout: best-overlap DLA
figure/equation region, fallback to section bbox per page, vertical
concat on gray background.
- sliding-window page-image cache bounds peak memory to the recent
window instead of the whole PDF.
- rename DLADebug -> DLARegions across parser/chunker/tests.
refactor(parser): drop lib_type selector; align NewXxxParser with
NewPDFParser
- remove config["lib_type"] lookup and the libType param/field/switch
from all nine constructors; surface the CGO-required error at
ParseWithResult time instead of construction time; drop resolveLibType,
its test, and the four lib_type constants.
feat(utility): add a reusable workerpool for bounded concurrent
execution
- internal/utility/workerpool.go (+ tests).
refactor: translate Chinese prose comments to English in non-harness Go
files.
chore: upgrade github.com/cloudwego/eino from v0.9.9 to v0.9.12.
525 lines
19 KiB
Go
525 lines
19 KiB
Go
package pdf
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"image"
|
||
"log/slog"
|
||
"math"
|
||
"sync"
|
||
|
||
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"
|
||
"ragflow/internal/utility"
|
||
)
|
||
|
||
// Parser is the core PDF text/layout extraction pipeline.
|
||
// It corresponds to RAGFlowPdfParser in pdf_parser.py.
|
||
// Stateless after construction — safe to reuse across documents.
|
||
type Parser struct {
|
||
Config pdf.ParserConfig
|
||
// deepInfOnce lazily initializes deepInf exactly once, so a Parser shared
|
||
// across goroutines does not race on the DeepDoc inference slot channel.
|
||
deepInfOnce sync.Once
|
||
// deepInf bounds concurrent DeepDoc (deepdoc) inference calls. Created
|
||
// on first use via limiters(); see parser_concurrency.go.
|
||
deepInf *deepInfLimiter
|
||
}
|
||
|
||
// pageResult holds per-page worker-local artifacts produced by
|
||
// processAllPagesParallel. The struct is keyed by page number in a
|
||
// map so worker completion order does not affect collection order;
|
||
// final assembly sorts by page number before merging into ParseResult.
|
||
//
|
||
// Fatal vs recoverable policy:
|
||
// - RenderPageToImage failure → recoverable: page may still produce
|
||
// boxes from charsToBoxes fallback. The Err field carries the
|
||
// render error for logging visibility.
|
||
// - OCR detect/recognize failure → recoverable: page falls back to
|
||
// embedded chars; Err carries the underlying failure if any.
|
||
// - DLA/TSR failure → recoverable: page produces no tables for that
|
||
// page; tables remain empty in the worker artifact.
|
||
// - Worker-pool orchestration bugs → fatal: panic surfaces immediately.
|
||
type pageResult struct {
|
||
PageNumber int
|
||
Boxes []pdf.TextBox
|
||
Chars []pdf.TextChar
|
||
MedianH float64
|
||
MedianW float64
|
||
IsEnglish bool
|
||
IsScanNoise bool
|
||
OCRUsed bool
|
||
// PageHeight and PageWidth record the PDF-point dimensions of the
|
||
// winning page render (pixel dimensions divided by the per-page
|
||
// zoom). They are used by buildLayout without needing a zoom map.
|
||
PageHeight float64
|
||
PageWidth float64
|
||
Tables []pdf.TableItem
|
||
DLARegions []pdf.DLAPageRegions
|
||
Err error
|
||
}
|
||
|
||
// New creates a new Parser with the given config.
|
||
func NewParser(cfg pdf.ParserConfig) *Parser {
|
||
return &Parser{Config: cfg}
|
||
}
|
||
|
||
// ── TableBuilder factory ───────────────────────────────────────────────────
|
||
|
||
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
|
||
}
|
||
|
||
func NewTableBuilderFor(doc pdf.DocAnalyzer) pdf.TableBuilder {
|
||
if tableBuilderFactory != nil {
|
||
return tableBuilderFactory(doc)
|
||
}
|
||
return tbl.NewDeepDocTableBuilder(doc)
|
||
}
|
||
|
||
// ── Public API ─────────────────────────────────────────────────────────────
|
||
|
||
// ParseRaw is the internal entry point: runs the core pipeline on an
|
||
// already-opened engine. Exported for tests that inject mock engines.
|
||
func (p *Parser) ParseRaw(ctx context.Context, engine pdf.PDFEngine, docAnalyzer pdf.DocAnalyzer) (*pdf.ParseResult, error) {
|
||
outlines := p.extractOutlines(engine)
|
||
|
||
result, err := p.processPages(ctx, engine, docAnalyzer)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result.Outlines = outlines
|
||
return result, nil
|
||
}
|
||
|
||
// ── ParseRaw helper functions ───────────────────────────────────────────────
|
||
|
||
func documentPages(pageCount int) []int {
|
||
pages := make([]int, pageCount)
|
||
for pg := range pages {
|
||
pages[pg] = pg
|
||
}
|
||
return pages
|
||
}
|
||
|
||
// extractOutlines extracts the PDF outlines, returning nil on error.
|
||
func (p *Parser) extractOutlines(engine pdf.PDFEngine) []pdf.Outline {
|
||
outlines, outlineErr := engine.Outlines()
|
||
if outlineErr != nil {
|
||
slog.Warn("Failed to extract PDF outlines; continuing without them", "err", outlineErr)
|
||
outlines = nil
|
||
}
|
||
return outlines
|
||
}
|
||
|
||
// ── Page worker pool ─────────────────────────────────────────────────────────
|
||
|
||
// processPage executes a single page through the unified page-local path.
|
||
// Both clean and garbled/empty pages flow through this function; the OCR
|
||
// strategy is selected page-locally based on character quality. Worker
|
||
// artifacts are returned in pageResult for the global assembly phase.
|
||
//
|
||
// Zoom retry is per-page: if the default zoom produces no boxes and
|
||
// conditions allow (Zoom < 9, !SkipOCR, render succeeded), the page is
|
||
// re-rendered at Config.Zoom × DlaScale and OCR/DLA are re-run. This
|
||
// replaces the old document-wide retryZoom pass and ensures only pages
|
||
// that actually need a higher zoom pay the memory cost.
|
||
//
|
||
// Cancellation: workers honor ctx.Err() before issuing expensive work.
|
||
// Page-local OCR detection / recognition failures are recoverable; the
|
||
// page falls back to charsToBoxes. RenderPageToImage failure is also
|
||
// recoverable — the page may still emit chars-derived boxes.
|
||
func (p *Parser) processPage(ctx context.Context, engine pdf.PDFEngine, pg int,
|
||
docAnalyzer pdf.DocAnalyzer, tb pdf.TableBuilder,
|
||
) pageResult {
|
||
chars, extractErr := engine.ExtractChars(pg)
|
||
if extractErr != nil {
|
||
slog.Warn("processPage: ExtractChars failed", "page", pg, "err", extractErr)
|
||
chars = nil
|
||
}
|
||
medianH := util.MedianCharHeight(chars)
|
||
medianW := util.MedianCharWidth(chars)
|
||
isEnglish := util.DetectEnglishPage(chars, nil)
|
||
isScanNoise := util.IsScanNoise(util.FullTextFromChars(map[int][]pdf.TextChar{pg: chars}))
|
||
|
||
if err := ctx.Err(); err != nil {
|
||
return pageResult{
|
||
PageNumber: pg,
|
||
Chars: chars,
|
||
MedianH: medianH,
|
||
MedianW: medianW,
|
||
IsEnglish: isEnglish,
|
||
IsScanNoise: isScanNoise,
|
||
Err: err,
|
||
}
|
||
}
|
||
|
||
// First pass: render at the default DLA DPI (216 DPI).
|
||
pageImg, renderErr := p.renderPageToImage(ctx, engine, pg)
|
||
pageZoom := pdf.DlaScale
|
||
var ocrBoxes []pdf.TextBox
|
||
var updatedChars []pdf.TextChar
|
||
var ocrUsed bool
|
||
var annotated []pdf.TextBox
|
||
var pageTables []pdf.TableItem
|
||
var dlaRegions []pdf.DLAPageRegions
|
||
|
||
if pageImg != nil && renderErr == nil {
|
||
ocrBoxes, updatedChars, ocrUsed = p.processPageBoxes(ctx, pageImg, chars, pg, renderErr, isScanNoise, docAnalyzer)
|
||
annotated, pageTables, dlaRegions = p.enrichOnePageWithDeepDoc(
|
||
ctx, pageImg, ocrBoxes, pg, renderErr, docAnalyzer, tb, pageZoom)
|
||
}
|
||
|
||
if renderErr != nil {
|
||
slog.Warn("processPage: RenderPageToImage failed", "page", pg, "err", renderErr)
|
||
}
|
||
|
||
// Per-page zoom retry: if no boxes were produced at the default zoom
|
||
// and conditions allow, re-render at a higher zoom and re-run OCR/DLA.
|
||
if len(annotated) == 0 && p.Config.Zoom >= 1.0 && !p.Config.SkipOCR && renderErr == nil {
|
||
// Cap the retry zoom so a large Config.Zoom cannot drive the retry
|
||
// render to an unsafe DPI and spike memory on large pages.
|
||
const maxRetryZoom = 9.0
|
||
retryZoom := math.Min(p.Config.Zoom*pdf.DlaScale, maxRetryZoom)
|
||
slog.Info("per-page zoom retry", "page", pg, "zoom", retryZoom)
|
||
retryImg, retryRenderErr := p.renderAtDPI(ctx, engine, pg, retryZoom*72)
|
||
if retryRenderErr == nil && retryImg != nil {
|
||
ocrBoxes, updatedChars, ocrUsed = p.processPageBoxes(ctx, retryImg, chars, pg, retryRenderErr, isScanNoise, docAnalyzer)
|
||
annotated, pageTables, dlaRegions = p.enrichOnePageWithDeepDoc(
|
||
ctx, retryImg, ocrBoxes, pg, retryRenderErr, docAnalyzer, tb, retryZoom)
|
||
pageImg = retryImg
|
||
pageZoom = retryZoom
|
||
} else if retryRenderErr != nil {
|
||
slog.Warn("processPage: retry-zoom render failed", "page", pg, "err", retryRenderErr)
|
||
}
|
||
}
|
||
|
||
// Compute PDF-point page dimensions from the winning render so
|
||
// buildLayout can use them without needing a per-page zoom map.
|
||
var pageHeight, pageWidth float64
|
||
if pageImg != nil && pageZoom > 0 {
|
||
pageHeight = float64(pageImg.Bounds().Dy()) / pageZoom
|
||
pageWidth = float64(pageImg.Bounds().Dx()) / pageZoom
|
||
}
|
||
|
||
return pageResult{
|
||
PageNumber: pg,
|
||
Boxes: annotated,
|
||
Chars: updatedChars,
|
||
MedianH: medianH,
|
||
MedianW: medianW,
|
||
IsEnglish: isEnglish,
|
||
IsScanNoise: isScanNoise,
|
||
OCRUsed: ocrUsed,
|
||
PageHeight: pageHeight,
|
||
PageWidth: pageWidth,
|
||
Tables: pageTables,
|
||
DLARegions: dlaRegions,
|
||
Err: renderErr,
|
||
}
|
||
}
|
||
|
||
// processPageBoxes picks the OCR strategy for a single page based on
|
||
// character quality. There is no async/sync split: the decision is
|
||
// page-local and the OCR call path is selected here.
|
||
//
|
||
// - Clean embedded chars (count > 0 and not garbled):
|
||
// use ocrMergeChars, which detect-merges chars into boxes and falls
|
||
// back to single-image OCR recognize for empty boxes.
|
||
// - Garbled or empty chars: use ocrDetectAndRecognize for full OCR,
|
||
// then fall back to ocrMergeChars if detect succeeds but recognize
|
||
// produced no useful output. Synthetic chars are appended to support
|
||
// subsequent median calculations.
|
||
//
|
||
// updatedChars includes synthetic OCR chars when detect+recognize was
|
||
// used; callers must use the returned slice instead of the original.
|
||
// processPageBoxes builds the page's text boxes — line/word-level, NOT
|
||
// per-rune — either from embedded chars or via OCR, chosen by page quality.
|
||
//
|
||
// Parameters:
|
||
// - pageImg: the page bitmap rendered at the DLA DPI (216 by default),
|
||
// consumed by OCR detection/recognition.
|
||
// - chars: per-glyph characters from ExtractChars (0-based page pg); the
|
||
// finest-grained text unit. nil when the page has no embedded text.
|
||
// - pg: page number (0-based), stamped onto every produced box.
|
||
// - renderErr: non-nil skips OCR entirely and falls back to chars.
|
||
// - isScanNoise: true marks a scanned/noisy page → prefer full OCR
|
||
// detect+recognize over the embedded-char merge path.
|
||
// - docAnalyzer: the DLA/OCR/Tensor backend (DeepDOC model) used for
|
||
// detection and recognition.
|
||
//
|
||
// Returns:
|
||
// - ocrBoxes: line/word-level []pdf.TextBox in PDF-point space; this is
|
||
// the granularity fed into enrichOnePageWithDeepDoc as pageBoxes
|
||
// (one box per line or per column-subline, never per rune).
|
||
// - updatedChars: chars, possibly augmented with synthetic OCR-derived
|
||
// glyphs so downstream median-height/width stats stay meaningful.
|
||
// - bool: whether OCR was actually used to produce ocrBoxes.
|
||
func (p *Parser) processPageBoxes(ctx context.Context, pageImg image.Image, chars []pdf.TextChar, pg int,
|
||
renderErr error, isScanNoise bool, docAnalyzer pdf.DocAnalyzer,
|
||
) ([]pdf.TextBox, []pdf.TextChar, bool) {
|
||
var ocrBoxes []pdf.TextBox
|
||
ocrUsed := false
|
||
|
||
if !p.Config.SkipOCR && renderErr == nil && pageImg != nil {
|
||
hasCleanChars := len(chars) > 0 && !isScanNoise && !util.IsGarbledPage(chars)
|
||
if hasCleanChars {
|
||
ocrBoxes = p.ocrMergeChars(ctx, pageImg, chars, docAnalyzer, pg)
|
||
ocrUsed = ocrBoxes != nil
|
||
} else {
|
||
label := "scan page"
|
||
if len(chars) > 0 && !isScanNoise {
|
||
label = "garbled page"
|
||
}
|
||
ocrBoxes = p.ocrDetectAndRecognize(ctx, pageImg, docAnalyzer, pg, label)
|
||
ocrUsed = ocrBoxes != nil
|
||
if ocrUsed {
|
||
// Synthetic OCR chars feed downstream median calculations.
|
||
for j := range ocrBoxes {
|
||
for _, r := range ocrBoxes[j].Text {
|
||
chars = append(chars, pdf.TextChar{Text: string(r), PageNumber: pg})
|
||
break
|
||
}
|
||
}
|
||
} else if len(chars) > 0 {
|
||
// Detect failed but chars exist: try the merge path.
|
||
ocrBoxes = p.ocrMergeChars(ctx, pageImg, chars, docAnalyzer, pg)
|
||
ocrUsed = ocrBoxes != nil
|
||
}
|
||
}
|
||
}
|
||
|
||
if !ocrUsed && len(chars) > 0 {
|
||
if ocrBoxes == nil {
|
||
ocrBoxes = lyt.CharsToBoxes(chars, pg, p.Config.SortByTop)
|
||
}
|
||
}
|
||
|
||
return ocrBoxes, chars, ocrUsed
|
||
}
|
||
|
||
// runPageWorkers executes pages through the single process-wide worker
|
||
// pool (parserPageWorkerPool). Page concurrency is bounded by that pool's
|
||
// worker count — there is no per-Parser parallelism knob; callers tune
|
||
// throughput via SetPageWorkerPoolSize. Each page produces one pageResult
|
||
// stored by page number so worker completion order does not affect
|
||
// collection order. Workers record the first page-level error for caller
|
||
// visibility, while context cancellation still stops new dispatch and lets
|
||
// in-flight work observe ctx.Err().
|
||
//
|
||
// Pages are returned sorted by page number so callers can stream them
|
||
// directly into downstream assembly without re-sorting.
|
||
func (p *Parser) runPageWorkers(ctx context.Context, engine pdf.PDFEngine,
|
||
pages []int,
|
||
docAnalyzer pdf.DocAnalyzer, tb pdf.TableBuilder,
|
||
) ([]*pageResult, error) {
|
||
if len(pages) == 0 {
|
||
return nil, nil
|
||
}
|
||
if err := ctx.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
resultMap := make(map[int]*pageResult, len(pages))
|
||
var firstErr error
|
||
recordErr := func(e error) {
|
||
if e != nil && firstErr == nil {
|
||
firstErr = e
|
||
}
|
||
}
|
||
|
||
type pageTaskResult = utility.WorkerPoolResult[pageTask, pageResult]
|
||
resultCh := make(chan pageTaskResult, len(pages))
|
||
|
||
submitted := 0
|
||
for _, pg := range pages {
|
||
task := pageTask{
|
||
parser: p,
|
||
engine: engine,
|
||
pageNumber: pg,
|
||
docAnalyzer: docAnalyzer,
|
||
tb: tb,
|
||
}
|
||
if err := parserPageWorkerPool().SubmitTo(ctx, task, resultCh); err != nil {
|
||
recordErr(err)
|
||
break
|
||
}
|
||
submitted++
|
||
}
|
||
|
||
for i := 0; i < submitted; i++ {
|
||
taskResult := <-resultCh
|
||
r := taskResult.Value
|
||
r.PageNumber = taskResult.Input.pageNumber
|
||
if taskResult.Err != nil && r.Err == nil {
|
||
r.Err = taskResult.Err
|
||
}
|
||
if r.Err != nil {
|
||
recordErr(r.Err)
|
||
}
|
||
resultMap[r.PageNumber] = &r
|
||
}
|
||
|
||
results := make([]*pageResult, 0, len(pages))
|
||
for _, pg := range pages {
|
||
if r, ok := resultMap[pg]; ok {
|
||
results = append(results, r)
|
||
}
|
||
}
|
||
return results, firstErr
|
||
}
|
||
|
||
// ── Internal pipeline steps ────────────────────────────────────────────────
|
||
|
||
// runAssembly merges per-page worker artifacts into the final document-wide
|
||
// state. Document-wide layout, table merge/replace, cross-page figures, and
|
||
// metrics aggregation happen here so page workers never mutate shared state.
|
||
// pageResults are expected to be sorted by page number.
|
||
func (p *Parser) assembleDocument(ctx context.Context, pages []int, pageResults []*pageResult) (*pdf.ParseResult, error) {
|
||
result := &pdf.ParseResult{
|
||
PageHeight: make(map[int]float64),
|
||
PageWidth: make(map[int]float64),
|
||
}
|
||
|
||
var boxes []pdf.TextBox
|
||
pageChars := make(map[int][]pdf.TextChar)
|
||
medianHeights := make(map[int]float64, len(pageResults))
|
||
medianWidths := make(map[int]float64, len(pageResults))
|
||
pageEnglish := make(map[int]bool, len(pageResults))
|
||
|
||
for _, r := range pageResults {
|
||
if r == nil {
|
||
continue
|
||
}
|
||
if r.Err != nil {
|
||
slog.Warn("page worker failed", "page", r.PageNumber, "err", r.Err)
|
||
}
|
||
// Store per-page PDF-point dimensions for buildLayout.
|
||
if r.PageHeight > 0 {
|
||
result.PageHeight[r.PageNumber] = r.PageHeight
|
||
}
|
||
if r.PageWidth > 0 {
|
||
result.PageWidth[r.PageNumber] = r.PageWidth
|
||
}
|
||
// Worker Boxes already carry DLA/TSR annotations (worker-local
|
||
// write-back happened inside processPage). Concatenate in page
|
||
// order to rebuild the document-wide boxes slice.
|
||
boxes = append(boxes, r.Boxes...)
|
||
pageChars[r.PageNumber] = r.Chars
|
||
medianHeights[r.PageNumber] = r.MedianH
|
||
medianWidths[r.PageNumber] = r.MedianW
|
||
pageEnglish[r.PageNumber] = r.IsEnglish
|
||
if r.OCRUsed {
|
||
medianHeights[r.PageNumber] = util.MedianCharHeight(r.Chars)
|
||
medianWidths[r.PageNumber] = util.MedianCharWidth(r.Chars)
|
||
pageEnglish[r.PageNumber] = util.DetectEnglishPage(r.Chars, nil)
|
||
}
|
||
// Tables and DLARegions are accumulated across pages in order.
|
||
if len(r.Tables) > 0 {
|
||
result.Tables = append(result.Tables, r.Tables...)
|
||
}
|
||
if len(r.DLARegions) > 0 {
|
||
result.DLARegions = append(result.DLARegions, r.DLARegions...)
|
||
}
|
||
}
|
||
|
||
if len(boxes) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
if err := p.buildLayout(ctx, result, boxes, pageChars,
|
||
medianHeights, medianWidths, pageEnglish); err != nil {
|
||
return nil, fmt.Errorf("buildLayout: %w", err)
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// buildLayout consumes the assembled boxes + page-level artifacts and runs
|
||
// the global layout/table/figure pipeline. It is the only step that runs
|
||
// AssignColumn, TextMerge, FinalReadingOrderMerge, NaiveVerticalMerge, table
|
||
// merge, figure consolidation, BoxesToSections, and caption merge.
|
||
func (p *Parser) buildLayout(ctx context.Context,
|
||
result *pdf.ParseResult,
|
||
boxes []pdf.TextBox, pageChars map[int][]pdf.TextChar,
|
||
medianHeights, medianWidths map[int]float64,
|
||
pageEnglish map[int]bool,
|
||
) error {
|
||
result.Metrics.BoxesInitial = len(boxes)
|
||
|
||
boxes = lyt.AssignColumn(boxes)
|
||
boxes = lyt.TextMerge(boxes, medianHeights)
|
||
result.Metrics.BoxesTextMerge = len(boxes)
|
||
|
||
// FinalReadingOrderMerge sorts page → column (ColID) → top → x0, which
|
||
// is the correct multi-column reading order. NaiveVerticalMerge preserves
|
||
// this column-major order (it buckets by ColID before merging).
|
||
boxes = lyt.FinalReadingOrderMerge(boxes)
|
||
|
||
boxes = lyt.NaiveVerticalMerge(boxes, medianHeights, medianWidths, pageEnglish)
|
||
result.Metrics.BoxesVertMerge = len(boxes)
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(result.Tables) > 0 {
|
||
result.Tables = tbl.MergeTablesAcrossPages(result.Tables, nil)
|
||
}
|
||
|
||
boxes = tbl.ExtractTableAndReplace(boxes, result.Tables)
|
||
result.Metrics.TablesCount = len(result.Tables)
|
||
boxes = tbl.ConsolidateFigures(boxes)
|
||
|
||
// PageHeight is already in PDF-point space — computed in processPage.
|
||
result.Sections = lyt.BoxesToSections(boxes, result.PageHeight)
|
||
result.Metrics.BoxesFinal = len(result.Sections)
|
||
result.Sections = tbl.MergeCaptions(result.Sections, result.Figures())
|
||
return nil
|
||
}
|
||
|
||
// processPages drives the page worker pool and the global assembly step.
|
||
// Page-local work (including per-page zoom retry) happens inside processPage;
|
||
// document-wide work happens in assembleDocument.
|
||
func (p *Parser) processPages(ctx context.Context, engine pdf.PDFEngine, docAnalyzer pdf.DocAnalyzer) (*pdf.ParseResult, error) {
|
||
pageCount, err := engine.PageCount()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("page count: %w", err)
|
||
}
|
||
if pageCount == 0 {
|
||
return &pdf.ParseResult{
|
||
PageHeight: make(map[int]float64),
|
||
PageWidth: make(map[int]float64),
|
||
}, nil
|
||
}
|
||
|
||
tb := NewTableBuilderFor(docAnalyzer)
|
||
pages := documentPages(pageCount)
|
||
|
||
pageResults, pageErr := p.runPageWorkers(ctx, engine, pages, docAnalyzer, tb)
|
||
if pageErr != nil {
|
||
slog.Warn("runPageWorkers: some pages failed", "err", pageErr)
|
||
}
|
||
|
||
result, err := p.assembleDocument(ctx, pages, pageResults)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Preserve a hard failure from the page workers (e.g. context
|
||
// cancellation) — assembleDocument may still succeed on an empty
|
||
// result, which would otherwise swallow the error.
|
||
if pageErr != nil {
|
||
return result, pageErr
|
||
}
|
||
// Carry the engine on the result so the JSON/markdown serialization
|
||
// step can crop section images on demand, then release it. This also
|
||
// fixes the previous engine leak (the engine was discarded here and
|
||
// re-created unnecessarily downstream).
|
||
result.Engine = engine
|
||
return result, nil
|
||
}
|