Files
ragflow/internal/parser/parser/pdf_postprocess.go
Zhichang Yu 12787996d1 feat(agent): Go ingestion pipeline progress mirroring and DeepDOC parser hardening (#16795)
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.
2026-07-10 10:36:10 +08:00

209 lines
5.3 KiB
Go

package parser
import (
"math"
"regexp"
"sort"
"strings"
deepdoctype "ragflow/internal/deepdoc/parser/type"
)
var pdfHeaderFooterPattern = regexp.MustCompile(`(?i)^(header|footer|number)$`)
var pdfTOCTitlePattern = regexp.MustCompile(`(?i)^(contents|目录|目次|table of contents|致谢|acknowledge)$`)
type pdfPostProcessOptions struct {
outputFormat string
pageWidth float64
zoom float64
enableMultiColumn bool
flattenMediaToText bool
removeTOC bool
removeHeaderFooter bool
}
func applyPDFPostProcess(result *deepdoctype.ParseResult, opts pdfPostProcessOptions) {
if result == nil {
return
}
if opts.enableMultiColumn && opts.pageWidth > 0 {
reorderPDFMultiColumn(result, opts.pageWidth, opts.zoom)
}
if opts.removeTOC {
removePDFTOCByOutlines(result, result.Outlines)
}
normalizePDFLayoutTypes(result)
if opts.removeHeaderFooter {
filterPDFHeaderFooter(result)
}
assignPDFDocTypeKeywords(result, opts.flattenMediaToText)
}
func normalizePDFLayoutTypes(result *deepdoctype.ParseResult) {
for i := range result.Sections {
layoutType := strings.TrimSpace(result.Sections[i].LayoutType)
if layoutType == "" {
layoutType = deepdoctype.LayoutTypeText
}
result.Sections[i].LayoutType = layoutType
}
}
func filterPDFHeaderFooter(result *deepdoctype.ParseResult) {
filtered := result.Sections[:0]
for _, s := range result.Sections {
if pdfHeaderFooterPattern.MatchString(strings.TrimSpace(s.LayoutType)) {
continue
}
filtered = append(filtered, s)
}
result.Sections = filtered
}
func assignPDFDocTypeKeywords(result *deepdoctype.ParseResult, flatten bool) {
for i := range result.Sections {
section := &result.Sections[i]
if flatten {
section.DocTypeKwd = "text"
continue
}
switch strings.TrimSpace(section.LayoutType) {
case deepdoctype.LayoutTypeTable:
section.DocTypeKwd = "table"
case deepdoctype.LayoutTypeFigure:
section.DocTypeKwd = "image"
default:
// doc_type_kwd is derived from layout, not from whether a
// section image was cropped. Cropping happens lazily at
// markdown serialization / chunk time, so it must not
// influence classification here (otherwise every positioned
// text box would be mislabeled "image").
section.DocTypeKwd = "text"
}
}
}
func removePDFTOCByOutlines(result *deepdoctype.ParseResult, outlines []deepdoctype.Outline) {
if result == nil || len(outlines) == 0 {
return
}
tocPage, contentPage := findPDFTOCPageRange(outlines)
if contentPage <= tocPage {
return
}
filtered := result.Sections[:0]
for _, s := range result.Sections {
page := firstSectionPage(s)
if page >= tocPage && page < contentPage {
continue
}
filtered = append(filtered, s)
}
result.Sections = filtered
}
func findPDFTOCPageRange(outlines []deepdoctype.Outline) (tocPage, contentPage int) {
outer:
for i, o := range outlines {
title := strings.TrimSpace(o.Title)
if idx := strings.Index(title, "@@"); idx >= 0 {
title = strings.TrimSpace(title[:idx])
}
if !pdfTOCTitlePattern.MatchString(strings.ToLower(title)) {
continue
}
tocPage = o.PageNumber
for _, next := range outlines[i+1:] {
if next.Level != o.Level {
continue
}
nextTitle := strings.TrimSpace(next.Title)
if idx := strings.Index(nextTitle, "@@"); idx >= 0 {
nextTitle = strings.TrimSpace(nextTitle[:idx])
}
if pdfTOCTitlePattern.MatchString(strings.ToLower(nextTitle)) {
continue
}
contentPage = next.PageNumber
break outer
}
break
}
return
}
func reorderPDFMultiColumn(result *deepdoctype.ParseResult, pageWidth, _ float64) {
if result == nil || len(result.Sections) < 2 {
return
}
var widths []float64
for _, s := range result.Sections {
if strings.TrimSpace(s.LayoutType) != deepdoctype.LayoutTypeText || len(s.Positions) == 0 {
continue
}
width := s.Positions[0].Right - s.Positions[0].Left
if width > 0 {
widths = append(widths, width)
}
}
if len(widths) == 0 {
return
}
sort.Float64s(widths)
medianWidth := widths[len(widths)/2]
if medianWidth >= pageWidth/2 {
return
}
sort.Slice(result.Sections, func(i, j int) bool {
pi, pj := firstSectionPage(result.Sections[i]), firstSectionPage(result.Sections[j])
if pi != pj {
return pi < pj
}
xi, xj := firstSectionLeft(result.Sections[i]), firstSectionLeft(result.Sections[j])
if math.Abs(xi-xj) > 1e-6 {
return xi < xj
}
return firstSectionTop(result.Sections[i]) < firstSectionTop(result.Sections[j])
})
threshold := medianWidth / 2
for i := len(result.Sections) - 1; i >= 1; i-- {
for j := i - 1; j >= 0; j-- {
if firstSectionPage(result.Sections[j]) != firstSectionPage(result.Sections[j+1]) {
continue
}
if math.Abs(firstSectionLeft(result.Sections[j])-firstSectionLeft(result.Sections[j+1])) >= threshold {
continue
}
if firstSectionTop(result.Sections[j+1]) < firstSectionTop(result.Sections[j]) {
result.Sections[j], result.Sections[j+1] = result.Sections[j+1], result.Sections[j]
}
}
}
}
func firstSectionPage(s deepdoctype.Section) int {
for _, p := range s.Positions {
for _, pn := range p.PageNumbers {
return pn
}
}
return 0
}
func firstSectionLeft(s deepdoctype.Section) float64 {
for _, p := range s.Positions {
return p.Left
}
return 0
}
func firstSectionTop(s deepdoctype.Section) float64 {
for _, p := range s.Positions {
return p.Top
}
return 0
}