mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
fix(deepdoc): stop PDF caption merge from dropping text and figures (#20650)
This commit is contained in:
1 parent
fc215f299d
commit
829aeb2f29
12 files changed
+961
-259
No files matched your search
@@ -21,6 +21,7 @@ package canvas
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -696,13 +697,8 @@ func BuildWorkflow(ctx context.Context, c *Canvas) (*compose.Workflow[map[string
|
||||
// tracks start/end membership by these explicit wirings — without
|
||||
// them, Compile() returns "start node not set" / "end node not set".
|
||||
//
|
||||
// Multi-terminal case: eino's END node is stricter than regular
|
||||
// workflow nodes about repeated output mappings. Instead of wiring
|
||||
// multiple terminals directly into END, route them through one
|
||||
// synthetic merge node. The merge node consumes one terminal as its
|
||||
// data input and treats the rest as exec-only dependencies, mirroring
|
||||
// the same "first input carries data; the rest are dependencies"
|
||||
// policy used in Pass 2.
|
||||
// Multiple terminals use distinct field mappings into a gather node.
|
||||
// Branch pruning lets it collect only the leaves selected in this run.
|
||||
//
|
||||
// A "start" node with no upstream gets an empty input from START so
|
||||
// eino registers it as a workflow entry point. FieldMapping is nil
|
||||
@@ -824,48 +820,29 @@ func wireWorkflowTerminals(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sub-workflows wire END without field mappings. These multi-terminal
|
||||
// shapes commonly come from mutually exclusive branches (for example a
|
||||
// loop body Switch choosing either continue or exit). We therefore
|
||||
// create a small field-mapped gather node that forwards whichever
|
||||
// branch actually produced output, instead of the outer workflow's
|
||||
// dependency-based merge node that would incorrectly wait for every
|
||||
// terminal to execute in the same run.
|
||||
if !useFieldMapping {
|
||||
gatherNode := wf.AddLambdaNode(
|
||||
terminalMergeNodeID,
|
||||
compose.InvokableLambda[map[string]any, map[string]any](
|
||||
func(_ context.Context, in map[string]any) (map[string]any, error) {
|
||||
for _, terminalID := range terminals {
|
||||
if v, ok := in[terminalID].(map[string]any); ok && v != nil {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
),
|
||||
compose.WithNodeName(terminalMergeNodeID),
|
||||
)
|
||||
for _, terminalID := range terminals {
|
||||
gatherNode.AddInput(terminalID, compose.ToField(terminalID))
|
||||
}
|
||||
addEndInput(terminalMergeNodeID)
|
||||
return nil
|
||||
}
|
||||
|
||||
mergeNode := wf.AddLambdaNode(
|
||||
// Keep checkpoint mappings stable across builds. Every terminal has the
|
||||
// same data/control role, regardless of component map enumeration order.
|
||||
terminals = append([]string(nil), terminals...)
|
||||
sort.Strings(terminals)
|
||||
gatherNode := wf.AddLambdaNode(
|
||||
terminalMergeNodeID,
|
||||
compose.InvokableLambda[map[string]any, map[string]any](
|
||||
func(_ context.Context, in map[string]any) (map[string]any, error) {
|
||||
if !useFieldMapping {
|
||||
for _, terminalID := range terminals {
|
||||
if output, ok := in[terminalID].(map[string]any); ok && output != nil {
|
||||
return output, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
},
|
||||
),
|
||||
compose.WithNodeName(terminalMergeNodeID),
|
||||
)
|
||||
mergeNode.AddInput(terminals[0])
|
||||
for _, terminalID := range terminals[1:] {
|
||||
mergeNode.AddDependency(terminalID)
|
||||
for _, terminalID := range terminals {
|
||||
gatherNode.AddInput(terminalID, compose.ToField(terminalID))
|
||||
}
|
||||
addEndInput(terminalMergeNodeID)
|
||||
wf.End().AddInput(terminalMergeNodeID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package canvas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// Rebuilding a paused workflow must preserve the terminal data mappings even
|
||||
// when the component map enumerates its mutually exclusive leaves differently.
|
||||
func TestWorkflowTerminalsResumeAcrossEnumerationOrder(t *testing.T) {
|
||||
for _, active := range []string{"a", "b"} {
|
||||
t.Run(active, func(t *testing.T) {
|
||||
store, _ := newTestStore(t, time.Hour)
|
||||
build := func(terminals []string) compose.Runnable[map[string]any, map[string]any] {
|
||||
wf := compose.NewWorkflow[map[string]any, map[string]any]()
|
||||
wf.AddLambdaNode("route", compose.InvokableLambda(func(_ context.Context, in map[string]any) (map[string]any, error) {
|
||||
return in, nil
|
||||
})).AddInput(compose.START)
|
||||
for _, id := range []string{"a", "b"} {
|
||||
wf.AddLambdaNode(id, compose.InvokableLambda(func(_ context.Context, _ map[string]any) (map[string]any, error) {
|
||||
return map[string]any{"content": "finished " + id}, nil
|
||||
})).AddInput("route")
|
||||
}
|
||||
wf.AddBranch("route", compose.NewGraphMultiBranch(func(_ context.Context, _ map[string]any) (map[string]bool, error) {
|
||||
return map[string]bool{active: true}, nil
|
||||
}, map[string]bool{"a": true, "b": true}))
|
||||
if err := wireWorkflowTerminals(wf, terminals, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, err := wf.Compile(t.Context(), compose.WithCheckPointStore(store), compose.WithInterruptBeforeNodes([]string{active}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return run
|
||||
}
|
||||
first := build([]string{"a", "b"})
|
||||
_, err := first.Invoke(t.Context(), map[string]any{}, compose.WithCheckPointID("terminal-order"))
|
||||
if !IsInterruptError(err) {
|
||||
t.Fatalf("first run: want interrupt, got %v", err)
|
||||
}
|
||||
resumed := build([]string{"b", "a"})
|
||||
out, err := resumed.Invoke(t.Context(), nil, compose.WithCheckPointID("terminal-order"))
|
||||
if err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
want := map[string]any{active: map[string]any{"content": "finished " + active}}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("output = %#v, want %#v", out, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -615,7 +615,7 @@ func (p *Parser) buildLayout(ctx context.Context,
|
||||
// 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())
|
||||
result.Sections = tbl.MergeCaptions(result.Sections)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -492,8 +492,7 @@ func TestTableSectionCaptionInHTML(t *testing.T) {
|
||||
})
|
||||
|
||||
// Step 2: mergeCaptions injects caption as a <caption> element inside <table>
|
||||
figures := pdf.CollectFigures(sections)
|
||||
sections = tbl.MergeCaptions(sections, figures)
|
||||
sections = tbl.MergeCaptions(sections)
|
||||
|
||||
got := sections[0].Text
|
||||
if !strings.Contains(got, "<caption>表1: 交通工具等级</caption>") {
|
||||
|
||||
@@ -62,8 +62,7 @@ func TestMergeCaptions_KeepsInterleavedParagraph(t *testing.T) {
|
||||
{Text: "<table><tr><td >Category</td></tr></table>", LayoutType: pdf.LayoutTypeTable,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 159, Right: 393, Top: 130, Bottom: 336}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 sections (title + paragraph + table), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
@@ -78,6 +77,54 @@ func TestMergeCaptions_KeepsInterleavedParagraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptionKind_CJKBodyParagraphNotCaption locks the fix for go_bug
|
||||
// cjk-caption-false-positive: Python's is_caption pattern
|
||||
// `[图表]+[ 0-9::]{2,}` requires ≥2 marker chars after the 图/表 run, so a
|
||||
// body paragraph that merely STARTS with 表/图 (Chinese or Japanese) is not a
|
||||
// caption. The bare `^表`/`^图` used previously misclassified such paragraphs
|
||||
// as captions, and MergeCaptions then either dropped them (no table nearby)
|
||||
// or swallowed them into <caption> — content loss Python never has.
|
||||
func TestCaptionKind_CJKBodyParagraphNotCaption(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"表格是一种常见的数据组织形式,本文对其进行对比。", // Chinese: 表格…
|
||||
"图中所示的方法在实践中得到广泛应用。", // Chinese: 图中…
|
||||
"表現方法の比較について述べる。", // Japanese: 表現…
|
||||
} {
|
||||
if got := CaptionKind(pdf.Section{Text: text, LayoutType: pdf.LayoutTypeText}); got != "" {
|
||||
t.Errorf("CaptionKind(%q) = %q, want \"\" (body paragraph is not a caption)", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptionKind_CJKCaptionMarkersStillMatch pins the other side of the
|
||||
// cjk-caption-false-positive fix: real CJK captions (图/表 + number +
|
||||
// separator) must still classify — the suffix requirement must not
|
||||
// over-tighten detection. The kind follows the run's first character.
|
||||
func TestCaptionKind_CJKCaptionMarkersStillMatch(t *testing.T) {
|
||||
for _, tt := range []struct{ text, want string }{
|
||||
{"表1:交通工具等级", pdf.LayoutTypeTable},
|
||||
{"表 1 出差标准", pdf.LayoutTypeTable},
|
||||
{"图1 系统架构总览", pdf.LayoutTypeFigure},
|
||||
{"图表1 全球市场规模", pdf.LayoutTypeFigure},
|
||||
} {
|
||||
if got := CaptionKind(pdf.Section{Text: tt.text, LayoutType: pdf.LayoutTypeText}); got != tt.want {
|
||||
t.Errorf("CaptionKind(%q) = %q, want %q", tt.text, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasBox reports whether positions contains a box with the given top and
|
||||
// bottom edges. Tests use it to assert that a merged caption's box reached the
|
||||
// section it was merged into.
|
||||
func hasBox(positions []pdf.Position, top, bottom float64) bool {
|
||||
for _, p := range positions {
|
||||
if p.Top == top && p.Bottom == bottom {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func textsOf(secs []pdf.Section) []string {
|
||||
out := make([]string, 0, len(secs))
|
||||
for _, s := range secs {
|
||||
|
||||
@@ -8,14 +8,26 @@ import (
|
||||
pdf "ragflow/internal/deepdoc/parser/pdf/type"
|
||||
)
|
||||
|
||||
// captionText is a caption box's text plus its top edge, used to order
|
||||
// multiple captions of one table in READING order (top→bottom) before
|
||||
// concatenation. Section order is not guaranteed to match the PDF layout
|
||||
// (e.g. 06's lower caption box precedes the upper one in sections), so the
|
||||
// top coordinate is carried explicitly.
|
||||
// captionText is one caption box: its text plus the box itself.
|
||||
type captionText struct {
|
||||
top float64
|
||||
text string
|
||||
// pos is the caption box. Two things read it: the reading-order sort below,
|
||||
// and the target's highlight geometry — merging a caption moves its TEXT
|
||||
// into the target's chunk, so its boxes have to move with it, or the UI
|
||||
// highlights the figure region only and never the caption line beside it.
|
||||
pos []pdf.Position
|
||||
}
|
||||
|
||||
// top is the caption's top edge, used to order multiple captions of one target
|
||||
// in READING order (top→bottom) before concatenation. Section order is not
|
||||
// guaranteed to match the PDF layout (e.g. 06's lower caption box precedes the
|
||||
// upper one in sections), so it is read from the box. A caption with no
|
||||
// positions sorts last.
|
||||
func (c captionText) top() float64 {
|
||||
if len(c.pos) == 0 {
|
||||
return 1e9
|
||||
}
|
||||
return c.pos[0].Top
|
||||
}
|
||||
|
||||
// captionSep returns the separator inserted before a caption whose text is
|
||||
@@ -35,7 +47,7 @@ func captionSep(text string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func MergeCaptions(sections []pdf.Section, figures []pdf.Section) []pdf.Section {
|
||||
func MergeCaptions(sections []pdf.Section) []pdf.Section {
|
||||
captions := make([]int, 0, 4)
|
||||
// Group caption texts by the target section index they attach to, so
|
||||
// multiple caption boxes for the SAME table/figure collapse into a single
|
||||
@@ -44,32 +56,44 @@ func MergeCaptions(sections []pdf.Section, figures []pdf.Section) []pdf.Section
|
||||
// (browsers, HTML→Markdown converters) silently drop — a real content loss.
|
||||
byTarget := make(map[int][]captionText)
|
||||
for i, s := range sections {
|
||||
// An extracted figure/table box is already a parent candidate, never a
|
||||
// caption: Python pops table/figure boxes out of self.boxes BEFORE its
|
||||
// caption scan (pdf_parser.py _extract_table_figure), so they are never
|
||||
// classified there. Without this skip, CaptionKind's text patterns would
|
||||
// classify a figure whose embedded text starts with a caption marker
|
||||
// (图1 …, Figure 1: …) as its own caption, and findNearestParent would
|
||||
// match it to ITSELF (CollectFigures includes it — center distance 0) —
|
||||
// the figure would become its own merge target and be removed with the
|
||||
// caption, deleting the image from the output (go_bug
|
||||
// figure-self-caption-deleted).
|
||||
if s.LayoutType == pdf.LayoutTypeFigure || s.LayoutType == pdf.LayoutTypeTable {
|
||||
continue
|
||||
}
|
||||
captionType := CaptionKind(s)
|
||||
if captionType == "" {
|
||||
continue
|
||||
}
|
||||
target := findNearestParent(i, s, sections, figures, captionType)
|
||||
target := findNearestParent(s, sections, captionType)
|
||||
if target >= 0 {
|
||||
// Emit the caption inside the target table's HTML as a <caption>
|
||||
// element (matching Python's __html_table) and drop the standalone
|
||||
// caption section. Retaining the caption text closes the previous
|
||||
// content-loss go_bug table-html-emission-format; it is NOT a
|
||||
// table-assembly change (cell content/structure are untouched).
|
||||
top := 1e9
|
||||
if len(s.Positions) > 0 {
|
||||
top = s.Positions[0].Top
|
||||
}
|
||||
byTarget[target] = append(byTarget[target], captionText{top: top, text: s.Text})
|
||||
byTarget[target] = append(byTarget[target], captionText{text: s.Text, pos: s.Positions})
|
||||
captions = append(captions, i)
|
||||
continue
|
||||
}
|
||||
// No merge target. A FIGURE caption is kept as its own section: a pure
|
||||
// image figure has no text section (BoxesToSections skips empty figure
|
||||
// boxes), so removing it would drop caption text that Python keeps
|
||||
// (07_mixed_content 'Figure 1/2'). A TABLE caption without a table
|
||||
// section is a DLA mislabel (e.g. rotate_270's rotated text labeled
|
||||
// "table") — keep the historical removal so rotated-page text is not
|
||||
// duplicated.
|
||||
// (07_mixed_content 'Figure 1/2'). A TABLE caption with neither a table
|
||||
// nor a figure parent — findNearestParent already fell back to figures,
|
||||
// mirroring Python's nearest(tables)/nearest(figures) double search — is
|
||||
// a DLA mislabel (e.g. rotate_270's rotated text labeled "table"); keep
|
||||
// the historical removal so rotated-page text is not duplicated. Python
|
||||
// likewise drops a caption only when BOTH searches fail, so the removal
|
||||
// branch now fires only when no parent exists at all.
|
||||
if captionType != pdf.LayoutTypeFigure {
|
||||
captions = append(captions, i)
|
||||
}
|
||||
@@ -77,21 +101,24 @@ func MergeCaptions(sections []pdf.Section, figures []pdf.Section) []pdf.Section
|
||||
// Inject one combined <caption> per target. Captions of the same table are
|
||||
// ordered by top edge (reading order, top→bottom) before concatenation.
|
||||
for idx, entries := range byTarget {
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].top < entries[j].top })
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].top() < entries[j].top() })
|
||||
texts := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
texts[i] = e.text
|
||||
}
|
||||
if sections[idx].LayoutType == pdf.LayoutTypeTable {
|
||||
injectCaption(§ions[idx], texts)
|
||||
continue
|
||||
} else {
|
||||
// Non-table target (figure): keep the historical raw-text
|
||||
// concatenation. The <caption> element is table-specific; wrapping a
|
||||
// figure section's text in it would emit meaningless HTML in a figure
|
||||
// section (which carries an image, not a table). Figure captions are
|
||||
// out of this PR's scope, so preserve their pre-existing behavior.
|
||||
appendRawCaptions(§ions[idx], texts)
|
||||
}
|
||||
// Non-table target (figure): keep the historical raw-text
|
||||
// concatenation. The <caption> element is table-specific; wrapping a
|
||||
// figure section's text in it would emit meaningless HTML in a figure
|
||||
// section (which carries an image, not a table). Figure captions are
|
||||
// out of this PR's scope, so preserve their pre-existing behavior.
|
||||
appendRawCaptions(§ions[idx], texts)
|
||||
// Either way the caption's text now lives in this section's chunk, so its
|
||||
// boxes have to move with it (see extendCaptionPositions).
|
||||
extendCaptionPositions(§ions[idx], entries)
|
||||
}
|
||||
// Remove caption sections in reverse order.
|
||||
n := len(sections)
|
||||
@@ -108,40 +135,111 @@ func MergeCaptions(sections []pdf.Section, figures []pdf.Section) []pdf.Section
|
||||
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
|
||||
// maxCaptionGap is the squared centre distance (PDF points²) beyond which a
|
||||
// caption is not attached to a candidate parent: 40000 — about 200pt (~7cm).
|
||||
const maxCaptionGap = 40000.0
|
||||
|
||||
// occupiesPage reports whether a section's recorded positions place it on
|
||||
// `page`, and whether the section carries page metadata at all. Page-local
|
||||
// coordinates repeat on every page (every page has a caption band at roughly
|
||||
// the same y), so proximity alone cannot decide which page a caption belongs
|
||||
// to — both parent searches must consult this.
|
||||
//
|
||||
// hasPages==false means the section has NO page metadata (a section built by a
|
||||
// caller that never set PageNumbers). The two callers treat that differently on
|
||||
// purpose: findTables rejects it (its pre-existing behavior), while the figure
|
||||
// search keeps it, because that search is also the fallback target for
|
||||
// orphaned table captions — rejecting on missing metadata there would delete
|
||||
// their text.
|
||||
func occupiesPage(s pdf.Section, page int) (onPage, hasPages bool) {
|
||||
for _, p := range s.Positions {
|
||||
for _, pn := range p.PageNumbers {
|
||||
hasPages = true
|
||||
if pn == page {
|
||||
onPage = true
|
||||
}
|
||||
}
|
||||
return bestIdx, bestDist
|
||||
}
|
||||
return onPage, hasPages
|
||||
}
|
||||
|
||||
const maxCaptionGap = 40000.0 // PDF points (~7cm) — beyond this, don't attach.
|
||||
// nearestFigureIn returns the index in `sections` of the figure closest to
|
||||
// caption by page-local centre distance, or -1 when none is within
|
||||
// maxCaptionGap.
|
||||
//
|
||||
// Candidates are read straight off `sections` and the section index is tracked
|
||||
// directly. A position box is NOT a figure's identity: page-local coordinates
|
||||
// repeat on every page, so two figures on different pages can carry the same
|
||||
// box — the very repetition the page filter exists to resolve — and resolving a
|
||||
// hit back by box would land on the wrong page's figure.
|
||||
//
|
||||
// requirePage restricts candidates to figures that occupy the caption's page.
|
||||
// Without it the search is page-blind, and the nearest figure by page-local
|
||||
// centre is usually on a DIFFERENT page. A figure with no page metadata stays a
|
||||
// candidate in both modes, so a caller that must not lose text (the
|
||||
// table-caption fallback) cannot be defeated by missing metadata — see
|
||||
// occupiesPage.
|
||||
func nearestFigureIn(sections []pdf.Section, caption pdf.Section, requirePage bool) int {
|
||||
if len(caption.Positions) == 0 {
|
||||
return -1
|
||||
}
|
||||
capKnown, capPage := false, 0
|
||||
if requirePage {
|
||||
if pn := caption.Positions[0].PageNumbers; len(pn) > 0 {
|
||||
capKnown, capPage = true, pn[0]
|
||||
}
|
||||
}
|
||||
cp := caption.Positions[0]
|
||||
ccx := (cp.Left + cp.Right) / 2
|
||||
ccy := (cp.Top + cp.Bottom) / 2
|
||||
|
||||
// Candidates are ranked in two tiers so a figure recorded on the caption's
|
||||
// own page always beats a page-less one: tier 0 is occupied-by-capPage,
|
||||
// tier 1 is "no page metadata". A figure known to sit on a DIFFERENT page is
|
||||
// skipped outright. Keeping tier 1 means a figure whose caller never set
|
||||
// PageNumbers can still take a caption the table fallback must not drop;
|
||||
// ordering the tiers means it cannot outbid a figure we can actually place.
|
||||
//
|
||||
bestIdx, bestDist, bestTier := -1, 1e9, 2
|
||||
for i, t := range sections {
|
||||
if t.LayoutType != pdf.LayoutTypeFigure || len(t.Positions) == 0 {
|
||||
continue
|
||||
}
|
||||
tier := 0
|
||||
if capKnown {
|
||||
switch onPage, hasPages := occupiesPage(t, capPage); {
|
||||
case !hasPages:
|
||||
tier = 1
|
||||
case onPage:
|
||||
tier = 0
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
tp := t.Positions[0]
|
||||
cx := (tp.Left + tp.Right) / 2
|
||||
cy := (tp.Top + tp.Bottom) / 2
|
||||
dist := (cx-ccx)*(cx-ccx) + (cy-ccy)*(cy-ccy)
|
||||
if tier < bestTier || (tier == bestTier && dist < bestDist) {
|
||||
bestTier, bestDist, bestIdx = tier, dist, i
|
||||
}
|
||||
}
|
||||
if bestIdx < 0 || bestDist >= maxCaptionGap {
|
||||
return -1
|
||||
}
|
||||
return bestIdx
|
||||
}
|
||||
|
||||
// findNearestParent finds the nearest figure (for figure captions) or table
|
||||
// (for table captions) section by position proximity. captionType is "table"
|
||||
// or "figure" (from CaptionKind). A table caption first searches tables and,
|
||||
// when none is in range, falls through to the figure search — mirroring
|
||||
// Python's nearest(tables)/nearest(figures) double search and its `elif fk`
|
||||
// fallback (pdf_parser.py _extract_table_figure), so a caption whose table is
|
||||
// unreachable (wrong page, out of range, or absent) lands on the nearest
|
||||
// figure instead of being dropped. Returns the index in `sections`, or -1
|
||||
// when no parent is in range.
|
||||
func findNearestParent(caption pdf.Section, sections []pdf.Section, captionType string) int {
|
||||
// maxCaptionVGap is the vertical band within which a caption attaches to a
|
||||
// table regardless of its horizontal offset. A narrow caption (e.g. a short
|
||||
// Chinese label) sitting directly above a much wider table has a large dx to
|
||||
@@ -152,23 +250,6 @@ func findNearestParent(captionIdx int, caption pdf.Section, sections []pdf.Secti
|
||||
// via min-distance. Keep this in line with the vertical tolerance implied by
|
||||
// maxCaptionGap when dx≈0 (~200pt).
|
||||
const maxCaptionVGap = 200.0
|
||||
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, gapY := findTables(sections, caption)
|
||||
// Attach a vertically-adjacent caption even when it is horizontally
|
||||
@@ -178,6 +259,29 @@ func findNearestParent(captionIdx int, caption pdf.Section, sections []pdf.Secti
|
||||
if idx >= 0 && (dist < maxCaptionGap || gapY <= maxCaptionVGap) {
|
||||
return idx
|
||||
}
|
||||
// No table in range: fall through to the figure search below —
|
||||
// Python's `elif fk` — rather than leaving the caller to delete a
|
||||
// caption whose text Python preserves on a nearby figure.
|
||||
}
|
||||
// Two-pass figure search (see nearestFigureIn). Page-local coordinates
|
||||
// repeat on every page, so proximity alone picks a figure on the wrong
|
||||
// page: Figure 1's caption on page 0, page-local centre (415,474), matched
|
||||
// a formula figure on page 13 at (417,480) — dist²=45, ~7pt.
|
||||
//
|
||||
// A genuine FIGURE caption is restricted to its own page: when no figure
|
||||
// shares that page it stays a standalone section, which MergeCaptions keeps
|
||||
// for figure captions — no text is lost and the caption is never glued to
|
||||
// an unrelated figure. The TABLE-caption fallback must not lose text
|
||||
// either, so it retries without the page filter and takes the nearest
|
||||
// figure anywhere, mirroring Python's nearest(figures), which has no page
|
||||
// scope at all: there, placement is best-effort but preservation is not.
|
||||
if idx := nearestFigureIn(sections, caption, true); idx >= 0 {
|
||||
return idx
|
||||
}
|
||||
if captionType == pdf.LayoutTypeTable {
|
||||
if idx := nearestFigureIn(sections, caption, false); idx >= 0 {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -249,19 +353,9 @@ func findTables(sections []pdf.Section, caption pdf.Section) (int, float64, floa
|
||||
// icbccs bug where a page-3 caption wrongly attached to a page-5
|
||||
// table and was concatenated into the <caption>, duplicating it
|
||||
// ("请求参数 请求参数").
|
||||
onPage := false
|
||||
for _, pp := range t.Positions {
|
||||
for _, pn := range pp.PageNumbers {
|
||||
if pn == capPage {
|
||||
onPage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if onPage {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !onPage {
|
||||
// A table whose positions carry no page metadata is rejected,
|
||||
// matching the pre-existing behavior (see occupiesPage).
|
||||
if onPage, _ := occupiesPage(t, capPage); !onPage {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -306,6 +400,68 @@ func appendRawCaptions(target *pdf.Section, captions []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// extendCaptionPositions appends the attached captions' boxes to the target's
|
||||
// own, so the target chunk's highlight geometry covers the caption as well as
|
||||
// the table or figure region it was merged into. The chunk TEXT already
|
||||
// contains the caption (appendRawCaptions / injectCaption); without this the
|
||||
// caption is never highlighted with the parent it belongs to. A table's
|
||||
// caption box routinely sits a couple of points ABOVE the table box rather
|
||||
// than overlapping it, so this bites tables too.
|
||||
//
|
||||
// Only the pages the target itself occupies are merged. A cross-page
|
||||
// attachment (the table-caption fallback can land on a figure pages away), or
|
||||
// a caption box that itself spans pages, must not make the target claim a page
|
||||
// it does not occupy: Position pages drive the section's page set, its
|
||||
// render/eviction window and its crop plan. So a caption position is trimmed
|
||||
// to the pages it shares with the target, and dropped when it shares none. A
|
||||
// caption with no page metadata at all carries no page claim and is merged
|
||||
// as-is.
|
||||
//
|
||||
// Eligibility is anchored to the target's ORIGINAL pages, so a caption merged
|
||||
// earlier in the loop cannot widen what a later one is allowed to claim.
|
||||
//
|
||||
// Appending (not prepending) keeps Positions[0] as the primary box, which
|
||||
// reading-order sorting and the proximity searches elsewhere depend on.
|
||||
func extendCaptionPositions(target *pdf.Section, entries []captionText) {
|
||||
base := make([]pdf.Position, len(target.Positions))
|
||||
copy(base, target.Positions)
|
||||
for _, e := range entries {
|
||||
for _, p := range e.pos {
|
||||
shared, constrained := sharedPageNumbers(p, base)
|
||||
if constrained {
|
||||
if len(shared) == 0 {
|
||||
continue
|
||||
}
|
||||
p.PageNumbers = shared
|
||||
}
|
||||
target.Positions = append(target.Positions, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sharedPageNumbers returns the page numbers p and have have in common.
|
||||
// constrained is false when p carries no page numbers at all — there is then
|
||||
// nothing to intersect and the position is safe to merge unchanged.
|
||||
func sharedPageNumbers(p pdf.Position, have []pdf.Position) (shared []int, constrained bool) {
|
||||
if len(p.PageNumbers) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
seen := make(map[int]struct{}, len(p.PageNumbers))
|
||||
for _, b := range p.PageNumbers {
|
||||
for _, h := range have {
|
||||
for _, a := range h.PageNumbers {
|
||||
if a == b {
|
||||
if _, dup := seen[b]; !dup {
|
||||
seen[b] = struct{}{}
|
||||
shared = append(shared, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return shared, true
|
||||
}
|
||||
|
||||
// injectCaption concatenates the given caption texts (already grouped per
|
||||
// target by MergeCaptions) into a SINGLE <caption> element inserted
|
||||
// immediately after the table's opening <table> tag (matching Python's
|
||||
|
||||
@@ -20,8 +20,7 @@ func TestMergeCaptions_EmitsCaptionTag(t *testing.T) {
|
||||
{Text: "Table 1: Revenue", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 61, Right: 144, Top: 219, Bottom: 231}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table with caption, standalone caption removed), got %d: %v", len(result), textsOf(result))
|
||||
@@ -56,8 +55,7 @@ func TestMergeCaptions_LeftMarginCaptionAttaches(t *testing.T) {
|
||||
{Text: "The following table summarizes the quarterly sales performance", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 61, Right: 144, Top: 150, Bottom: 170}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 sections (title + table-with-caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
@@ -90,8 +88,7 @@ func TestMergeCaptions_SingleCaptionPerTable(t *testing.T) {
|
||||
{Text: "The following table summarizes the quarterly sales performance", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 200, Right: 380, Top: 350, Bottom: 370}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table with combined caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
@@ -129,8 +126,7 @@ func TestMergeCaptions_ReadingOrderByTop(t *testing.T) {
|
||||
{Text: "The following table summarizes the quarterly sales performance", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 107, Right: 501, Top: 105, Bottom: 118}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table with combined caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
@@ -175,8 +171,7 @@ func TestMergeCaptions_TallTableCaptionNearEdgeAttaches(t *testing.T) {
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
sections := []pdf.Section{table, c.caption}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table with caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
@@ -208,8 +203,7 @@ func TestMergeCaptions_CaptionOtherPageDoesNotAttach(t *testing.T) {
|
||||
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 183, Right: 412, Top: 64, Bottom: 82}},
|
||||
}
|
||||
sections := []pdf.Section{table, caption}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
// No table on the caption's page -> orphaned caption (dropped), table unchanged.
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table unchanged, orphaned caption dropped), got %d: %v", len(result), textsOf(result))
|
||||
@@ -233,8 +227,7 @@ func TestMergeCaptions_FigureCaptionRawText(t *testing.T) {
|
||||
{Text: "Figure 1: Revenue trend by quarter", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 400, Top: 510, Bottom: 525}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (figure with caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
@@ -266,8 +259,7 @@ func TestMergeCaptions_NarrowCaptionAttachesWideTable(t *testing.T) {
|
||||
{Text: "请求参数", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{2}, Left: 28, Right: 100, Top: 157, Bottom: 178}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table with caption, standalone caption removed), got %d: %v", len(result), textsOf(result))
|
||||
@@ -280,3 +272,450 @@ func TestMergeCaptions_NarrowCaptionAttachesWideTable(t *testing.T) {
|
||||
t.Errorf("caption text should appear exactly once (inside <caption>), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_CJKBodyParagraphKept locks go_bug
|
||||
// cjk-caption-false-positive end-to-end: a Chinese/Japanese body paragraph
|
||||
// starting with 表/图 must survive MergeCaptions as its own section (Python
|
||||
// keeps it as body text) — neither dropped when no table is nearby, nor
|
||||
// swallowed into a table's <caption> when one is.
|
||||
func TestMergeCaptions_CJKBodyParagraphKept(t *testing.T) {
|
||||
para := pdf.Section{Text: "表格是一种常见的数据组织形式,本文对其进行对比。", LayoutType: pdf.LayoutTypeText,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 170, Bottom: 195}}}
|
||||
t.Run("no table nearby", func(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "产品分析报告", LayoutType: pdf.LayoutTypeTitle,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 50, Bottom: 80}}},
|
||||
para,
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 2 {
|
||||
t.Errorf("body paragraph dropped; got %d sections: %v", len(result), textsOf(result))
|
||||
}
|
||||
})
|
||||
t.Run("table nearby", func(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "<table><tr><td>data</td></tr></table>", LayoutType: pdf.LayoutTypeTable,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 200, Bottom: 400}}},
|
||||
para,
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
kept, swallowed := false, false
|
||||
for _, s := range result {
|
||||
if strings.Contains(s.Text, "表格是一种") {
|
||||
kept = true
|
||||
}
|
||||
if s.LayoutType == pdf.LayoutTypeTable && strings.Contains(s.Text, "<caption>表格是") {
|
||||
swallowed = true
|
||||
}
|
||||
}
|
||||
if !kept || swallowed {
|
||||
t.Errorf("body paragraph must stay a standalone section (not enter <caption>); kept=%v swallowed=%v sections=%v",
|
||||
kept, swallowed, textsOf(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMergeCaptions_FigureWithCaptionMarkerTextSurvives locks go_bug
|
||||
// figure-self-caption-deleted: a figure section whose OWN text starts with a
|
||||
// caption marker (embedded chart title, OCR'd caption inside the figure box)
|
||||
// must not be classified as its own caption and deleted. Python pops figure
|
||||
// boxes before its caption scan so this cannot happen there; before the fix,
|
||||
// findNearestParent matched the figure to ITSELF (CollectFigures includes it,
|
||||
// distance 0) and the whole image section vanished from the output. Covers
|
||||
// the figure-kind (图1/Figure 1) and table-kind (Table 2) classification
|
||||
// paths — the latter would also steal the figure's text into a nearby
|
||||
// table's <caption>.
|
||||
func TestMergeCaptions_FigureWithCaptionMarkerTextSurvives(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"Figure 1: system architecture overview",
|
||||
"图1 系统架构总览",
|
||||
"Table 2: embedded chart title inside the figure region",
|
||||
} {
|
||||
sections := []pdf.Section{
|
||||
{Text: "Introduction", LayoutType: pdf.LayoutTypeTitle,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 50, Bottom: 80}}},
|
||||
{Text: text, LayoutType: pdf.LayoutTypeFigure, Image: "img",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 200, Bottom: 400}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
figureAlive := false
|
||||
for _, s := range result {
|
||||
if s.LayoutType == pdf.LayoutTypeFigure {
|
||||
figureAlive = true
|
||||
if s.Text != text {
|
||||
t.Errorf("figure text mutated: got %q, want %q", s.Text, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !figureAlive {
|
||||
t.Errorf("figure %q removed from output (self-caption deletion); sections = %v", text, textsOf(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_FigureTextNotStolenIntoTable: same figure, but with a
|
||||
// real table in range — before the fix, the figure's "Table 2: …" text was
|
||||
// injected into that table's <caption> and the figure section deleted.
|
||||
func TestMergeCaptions_FigureTextNotStolenIntoTable(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "<table><tr><td>data</td></tr></table>", LayoutType: pdf.LayoutTypeTable,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 200, Bottom: 400}}},
|
||||
{Text: "Table 2: embedded chart title inside the figure region", LayoutType: pdf.LayoutTypeFigure, Image: "chartimg",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 120, Right: 480, Top: 420, Bottom: 600}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
figureAlive, stolen := false, false
|
||||
for _, s := range result {
|
||||
if s.LayoutType == pdf.LayoutTypeFigure {
|
||||
figureAlive = true
|
||||
}
|
||||
if s.LayoutType == pdf.LayoutTypeTable && strings.Contains(s.Text, "embedded chart title") {
|
||||
stolen = true
|
||||
}
|
||||
}
|
||||
if !figureAlive {
|
||||
t.Errorf("figure removed; sections = %v", textsOf(result))
|
||||
}
|
||||
if stolen {
|
||||
t.Errorf("figure text stolen into the table's <caption>: %v", textsOf(result))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_FigureCaptionKeptWithMarkerFigure locks the cascade form
|
||||
// of figure-self-caption-deleted: a genuine DLA figure caption attaching to a
|
||||
// figure whose own text starts with a caption marker must keep BOTH the
|
||||
// image and the real caption text (before the fix, the figure became its own
|
||||
// target, was deleted, and the attached caption text vanished with it).
|
||||
func TestMergeCaptions_FigureCaptionKeptWithMarkerFigure(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "Figure 4: deployment topology", LayoutType: pdf.LayoutTypeFigure, Image: "img4",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 200, Bottom: 400}}},
|
||||
{Text: "图4 部署拓扑结构", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 410, Bottom: 430}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 || result[0].LayoutType != pdf.LayoutTypeFigure {
|
||||
t.Fatalf("want the single figure section, got %d sections: %v", len(result), textsOf(result))
|
||||
}
|
||||
if !strings.Contains(result[0].Text, "部署拓扑") {
|
||||
t.Errorf("real figure caption text lost with the deleted figure: %q", result[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_TableCaptionFallsBackToFigure locks go_bug
|
||||
// table-caption-orphan-dropped: Python attaches a caption to the nearest
|
||||
// table OR figure (nearest(tables)/nearest(figures), `elif fk`) and drops it
|
||||
// only when BOTH searches fail. Go used to search tables only and delete the
|
||||
// caption when none was reachable — losing text Python keeps, for English
|
||||
// body text AND for correctly-DLA-labeled "table caption" sections alike
|
||||
// (language-independent; the figure here is deliberately neutral-text so the
|
||||
// figure-self-deletion bug cannot mask this path).
|
||||
func TestMergeCaptions_TableCaptionFallsBackToFigure(t *testing.T) {
|
||||
for _, caption := range []pdf.Section{
|
||||
{Text: "Table 1 shows revenue by category.", LayoutType: pdf.LayoutTypeText,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 510, Bottom: 530}}},
|
||||
{Text: "Quarterly revenue breakdown", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 510, Bottom: 530}}},
|
||||
} {
|
||||
sections := []pdf.Section{
|
||||
{Text: "market overview chart", LayoutType: pdf.LayoutTypeFigure, Image: "img",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 300, Bottom: 500}}},
|
||||
caption,
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
found := false
|
||||
for _, s := range result {
|
||||
if strings.Contains(s.Text, "revenue") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("table caption %q dropped although a figure exists (Python attaches it); sections = %v",
|
||||
caption.Text, textsOf(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_EnglishTable1OpeningParagraphSwallowed pins the SHARED
|
||||
// (Python-identical) heuristic boundary of the CJK fix: a body paragraph that
|
||||
// STARTS with "Table N" is classified a caption by BOTH implementations
|
||||
// (Python's re.match is start-anchored too) and consumed into the table's
|
||||
// <caption>. The text is retained — misplacement parity with Python, not a
|
||||
// Go regression; only the CJK false-positive class was fixed.
|
||||
func TestMergeCaptions_EnglishTable1OpeningParagraphSwallowed(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "<table><tr><td>data</td></tr></table>", LayoutType: pdf.LayoutTypeTable,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 200, Bottom: 400}}},
|
||||
{Text: "Table 1 shows revenue by category for the fiscal year.", LayoutType: pdf.LayoutTypeText,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 500, Top: 170, Bottom: 195}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
for _, s := range result {
|
||||
if s.LayoutType == pdf.LayoutTypeTable && strings.Contains(s.Text, "<caption>Table 1 shows revenue") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected the start-anchored English paragraph consumed into <caption> (Python parity); sections = %v",
|
||||
textsOf(result))
|
||||
}
|
||||
|
||||
// TestMergeCaptions_FigureCaptionOtherPageDoesNotAttach locks the figure-side
|
||||
// page-scope guard (go_bug figure-caption-cross-page-attached). Page-local
|
||||
// coordinates repeat on every page, so the figure search used to match on
|
||||
// page-local distance alone: here the page-2 caption's page-local centre is
|
||||
// CLOSER to the page-0 figure (dist²=13225) than to the page-2 figure it
|
||||
// belongs to (dist²=21025). The caption was therefore glued to the page-0
|
||||
// figure — pages away — and its own figure was left with no caption at all.
|
||||
func TestMergeCaptions_FigureCaptionOtherPageDoesNotAttach(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "page zero chart", LayoutType: pdf.LayoutTypeFigure, Image: "img0",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 250, Right: 350, Top: 250, Bottom: 350}}},
|
||||
{Text: "page two chart", LayoutType: pdf.LayoutTypeFigure, Image: "img2",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{2}, Left: 250, Right: 350, Top: 510, Bottom: 610}}},
|
||||
{Text: "Figure 9: latency by shard count", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{2}, Left: 250, Right: 350, Top: 400, Bottom: 430}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
|
||||
var onZero, onTwo bool
|
||||
for _, s := range result {
|
||||
if s.LayoutType != pdf.LayoutTypeFigure {
|
||||
continue
|
||||
}
|
||||
hasCap := strings.Contains(s.Text, "latency by shard count")
|
||||
switch s.Positions[0].PageNumbers[0] {
|
||||
case 0:
|
||||
onZero = hasCap
|
||||
case 2:
|
||||
onTwo = hasCap
|
||||
}
|
||||
}
|
||||
if !onTwo {
|
||||
t.Errorf("caption did not attach to its own (page 2) figure; sections = %v", textsOf(result))
|
||||
}
|
||||
if onZero {
|
||||
t.Errorf("caption wrongly attached to the page-0 figure (page-local distance only): %v", textsOf(result))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_FigureCaptionUnknownPageStillAttaches pins the deliberate
|
||||
// asymmetry with findTables: a figure whose positions carry NO page metadata is
|
||||
// kept as a candidate. This search is also the fallback target for orphaned
|
||||
// table captions, so rejecting on missing metadata would DELETE their text.
|
||||
func TestMergeCaptions_FigureCaptionUnknownPageStillAttaches(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "metadata-less chart", LayoutType: pdf.LayoutTypeFigure, Image: "imgnp",
|
||||
Positions: []pdf.Position{{Left: 100, Right: 500, Top: 200, Bottom: 400}}},
|
||||
{Text: "Table 3: yearly totals", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{4}, Left: 100, Right: 500, Top: 410, Bottom: 430}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
for _, s := range result {
|
||||
if s.LayoutType == pdf.LayoutTypeFigure && strings.Contains(s.Text, "yearly totals") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("caption dropped instead of attaching to the page-less figure: %v", textsOf(result))
|
||||
}
|
||||
|
||||
// TestMergeCaptions_TableFallbackSurvivesPageGuard locks the interaction
|
||||
// between the figure-side page guard and the orphaned-table-caption fallback
|
||||
// (go_bug table-caption-orphan-dropped). A table caption with no table in
|
||||
// range falls back to the figure search; the page guard must not then reject
|
||||
// every candidate and hand the caller a section to delete. Here the only
|
||||
// figure is on another page, so the page-scoped pass finds nothing — the
|
||||
// fallback must retry without the page filter and keep the text. Before that
|
||||
// retry existed, this caption was deleted outright.
|
||||
func TestMergeCaptions_TableFallbackSurvivesPageGuard(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "far away chart", LayoutType: pdf.LayoutTypeFigure, Image: "img0",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 400, Top: 300, Bottom: 500}}},
|
||||
{Text: "Table 5 shows the answer quality and retrieval performance across benchmarks.",
|
||||
LayoutType: pdf.LayoutTypeText,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{7}, Left: 100, Right: 400, Top: 320, Bottom: 340}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
for _, s := range result {
|
||||
if strings.Contains(s.Text, "answer quality and retrieval performance") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("table caption text deleted instead of preserved on the fallback figure: %v", textsOf(result))
|
||||
}
|
||||
|
||||
// TestMergeCaptions_FigureCaptionPositionMerged locks the highlight geometry:
|
||||
// a caption merged into a figure carries its TEXT into the figure's chunk, so
|
||||
// its box must be merged into the figure's Positions too — otherwise the UI
|
||||
// highlights the figure region only and the caption line is never highlighted
|
||||
// with the image. Positions[0] must stay the figure's own box (reading-order
|
||||
// sorting and the proximity searches use it).
|
||||
func TestMergeCaptions_FigureCaptionPositionMerged(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "revenue chart", LayoutType: pdf.LayoutTypeFigure, Image: "img",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 400, Top: 300, Bottom: 500}}},
|
||||
{Text: "Figure 1: revenue by quarter", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 400, Top: 510, Bottom: 525}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (figure with caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
got := result[0]
|
||||
if len(got.Positions) != 2 {
|
||||
t.Fatalf("caption box not merged into the figure's positions: %+v", got.Positions)
|
||||
}
|
||||
if got.Positions[0].Top != 300 || got.Positions[0].Bottom != 500 {
|
||||
t.Errorf("primary box must stay first, got %+v", got.Positions[0])
|
||||
}
|
||||
if !hasBox(got.Positions, 510, 525) {
|
||||
t.Errorf("caption box missing from the merged positions: %+v", got.Positions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_CrossPageFallbackDoesNotClaimPage pins the guard on the
|
||||
// above: a table caption rescued by the page-blind fallback can land on a
|
||||
// figure pages away. Its text is still preserved, but its box must NOT be
|
||||
// merged into the figure's positions — Position pages drive the section's page
|
||||
// set, its render/eviction window and its crop plan, so claiming a page the
|
||||
// figure does not occupy would corrupt all three.
|
||||
func TestMergeCaptions_CrossPageFallbackDoesNotClaimPage(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "far chart", LayoutType: pdf.LayoutTypeFigure, Image: "img0",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 400, Top: 300, Bottom: 500}}},
|
||||
{Text: "Table 9: yearly totals", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{5}, Left: 100, Right: 400, Top: 320, Bottom: 340}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
for _, s := range result {
|
||||
if s.LayoutType != pdf.LayoutTypeFigure {
|
||||
continue
|
||||
}
|
||||
if len(s.Positions) != 1 {
|
||||
t.Errorf("cross-page caption must not extend the figure's positions: %+v", s.Positions)
|
||||
}
|
||||
if !strings.Contains(s.Text, "yearly totals") {
|
||||
t.Errorf("caption text must still be preserved: %q", s.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_IdenticalBoxOtherPageNotChosen locks the page identity of
|
||||
// the figure the caption lands on. Two figures on DIFFERENT pages can share a
|
||||
// page-local box (page-local coordinates repeat per page — the very reason the
|
||||
// page filter exists). The search used to map its hit back to a section by
|
||||
// comparing position boxes alone, which returned the earliest section with an
|
||||
// equal box: the caption was then attached to the other page's figure.
|
||||
func TestMergeCaptions_IdenticalBoxOtherPageNotChosen(t *testing.T) {
|
||||
shared := func(pg int) []pdf.Position {
|
||||
return []pdf.Position{{PageNumbers: []int{pg}, Left: 100, Right: 400, Top: 200, Bottom: 400}}
|
||||
}
|
||||
sections := []pdf.Section{
|
||||
{Text: "page zero chart", LayoutType: pdf.LayoutTypeFigure, Image: "i0", Positions: shared(0)},
|
||||
{Text: "page two chart", LayoutType: pdf.LayoutTypeFigure, Image: "i2", Positions: shared(2)},
|
||||
{Text: "Figure 7: numbers", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{2}, Left: 100, Right: 400, Top: 410, Bottom: 425}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
var zero, two bool
|
||||
for _, s := range result {
|
||||
has := strings.Contains(s.Text, "numbers")
|
||||
switch s.Positions[0].PageNumbers[0] {
|
||||
case 0:
|
||||
zero = has
|
||||
case 2:
|
||||
two = has
|
||||
}
|
||||
}
|
||||
if !two {
|
||||
t.Errorf("caption did not attach to the page-2 figure; sections = %v", textsOf(result))
|
||||
}
|
||||
if zero {
|
||||
t.Errorf("caption attached to the page-0 figure that merely shares a box: %v", textsOf(result))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_SamePageFigureBeatsPageLessNearer locks the candidate
|
||||
// ordering: a figure with no page metadata stays eligible so that a caption the
|
||||
// table fallback must not drop can still land somewhere, but it must never
|
||||
// outbid a figure we can actually place on the caption's page.
|
||||
func TestMergeCaptions_SamePageFigureBeatsPageLessNearer(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
// page-less figure, much CLOSER to the caption than the same-page one:
|
||||
// dist² 56 against the same-page figure's 6006, so distance alone would
|
||||
// pick it.
|
||||
{Text: "page-less chart", LayoutType: pdf.LayoutTypeFigure, Image: "np",
|
||||
Positions: []pdf.Position{{Left: 100, Right: 400, Top: 500, Bottom: 540}}},
|
||||
{Text: "same-page chart", LayoutType: pdf.LayoutTypeFigure, Image: "sp",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 100, Right: 400, Top: 400, Bottom: 500}}},
|
||||
{Text: "Figure 3: detail", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{1}, Left: 100, Right: 400, Top: 520, Bottom: 535}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
for _, s := range result {
|
||||
if !strings.Contains(s.Text, "detail") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(s.Text, "same-page chart") {
|
||||
t.Errorf("closer page-less figure outbid the caption's own-page figure: %v", textsOf(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_TableCaptionPositionMerged is the table half of the
|
||||
// highlight-geometry fix: injectCaption puts the caption text inside the
|
||||
// table's HTML, but a table's caption box routinely sits a few points ABOVE the
|
||||
// table box rather than overlapping it, so without merging the boxes the UI
|
||||
// highlights the table and never the caption line above it.
|
||||
func TestMergeCaptions_TableCaptionPositionMerged(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "<table><tr><td>x</td></tr></table>", LayoutType: pdf.LayoutTypeTable,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 113, Right: 484, Top: 161, Bottom: 236}}},
|
||||
{Text: "Table 1: results", LayoutType: pdf.DLALabelTableCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 210, Right: 385, Top: 138, Bottom: 149}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (table with caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
got := result[0]
|
||||
if !strings.Contains(got.Text, "<caption>") {
|
||||
t.Fatalf("caption not injected into the table HTML: %q", got.Text)
|
||||
}
|
||||
if len(got.Positions) != 2 {
|
||||
t.Fatalf("caption box not merged into the table's positions: %+v", got.Positions)
|
||||
}
|
||||
if got.Positions[0].Top != 161 || got.Positions[0].Bottom != 236 {
|
||||
t.Errorf("primary box must stay first, got %+v", got.Positions[0])
|
||||
}
|
||||
if !hasBox(got.Positions, 138, 149) {
|
||||
t.Errorf("caption box missing from the merged positions: %+v", got.Positions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeCaptions_MergedCaptionKeepsOnlySharedPages locks the page trimming
|
||||
// on the highlight merge: a caption box that itself spans pages must not make
|
||||
// the target claim a page it does not occupy. Position pages drive the
|
||||
// section's page set, its render/eviction window and its crop plan, so merging
|
||||
// a {0,5} caption box into a page-0 figure would corrupt all three.
|
||||
func TestMergeCaptions_MergedCaptionKeepsOnlySharedPages(t *testing.T) {
|
||||
sections := []pdf.Section{
|
||||
{Text: "spread chart", LayoutType: pdf.LayoutTypeFigure, Image: "img",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0}, Left: 100, Right: 400, Top: 300, Bottom: 500}}},
|
||||
{Text: "Figure 5: spread", LayoutType: pdf.DLALabelFigureCaption,
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0, 5}, Left: 100, Right: 400, Top: 510, Bottom: 525}}},
|
||||
}
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section (figure with caption), got %d: %v", len(result), textsOf(result))
|
||||
}
|
||||
got := result[0]
|
||||
if len(got.Positions) != 2 {
|
||||
t.Fatalf("caption box not merged: %+v", got.Positions)
|
||||
}
|
||||
if pn := got.Positions[1].PageNumbers; len(pn) != 1 || pn[0] != 0 {
|
||||
t.Errorf("merged caption must keep only the pages the figure occupies, got %v", pn)
|
||||
}
|
||||
if pn := got.Positions[0].PageNumbers; len(pn) != 1 || pn[0] != 0 {
|
||||
t.Errorf("primary box changed: %v", pn)
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,8 @@ func TestMergeCaptions_Unit(t *testing.T) {
|
||||
{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)
|
||||
result := MergeCaptions(sections)
|
||||
|
||||
// Caption removed.
|
||||
if len(result) != 1 {
|
||||
@@ -36,9 +35,8 @@ func TestMergeCaptions_TableCaption(t *testing.T) {
|
||||
{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)
|
||||
result := MergeCaptions(sections)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 section after merge, got %d", len(result))
|
||||
@@ -62,8 +60,7 @@ func TestMergeCaptions_EuclideanDistance(t *testing.T) {
|
||||
{PageNumbers: []int{0, 0}, Left: 0, Right: 100, Top: 70, Bottom: 80},
|
||||
}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
// 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))
|
||||
@@ -83,8 +80,7 @@ func TestMergeCaptions_FigureCaptionNoTargetKept(t *testing.T) {
|
||||
{Text: "Figure 2: system architecture overview", LayoutType: "figure caption",
|
||||
Positions: []pdf.Position{{PageNumbers: []int{0, 0}, Left: 40, Right: 160, Top: 300, Bottom: 315}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections) // empty: no "figure" section present
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("figure caption with no parent must be kept, got %d sections", len(result))
|
||||
}
|
||||
|
||||
@@ -436,10 +436,21 @@ func IsCaptionBox(text string, layoutType string) bool {
|
||||
// start-anchored with ^: an unanchored alternative wrongly classifies such a
|
||||
// paragraph as a caption and MergeCaptions then drops it (go_bug
|
||||
// table-text-interleaved-paragraph-dropped).
|
||||
var reTableCaptionText = regexp.MustCompile(`^表|(?i)^Table\s+\d+`)
|
||||
//
|
||||
// The CJK branch mirrors Python's `[图表]+[ 0-9::]{2,}`: the 图/表 run must be
|
||||
// followed by ≥2 chars from [ 0-9::], so a body paragraph that merely STARTS
|
||||
// with 表/图 (表格是一种…, 表現方法…, 图中所示…) is not a caption. The bare
|
||||
// `^表` previously classified any 表-prefixed CJK/Japanese body paragraph as a
|
||||
// caption that MergeCaptions either swallowed into <caption> or dropped —
|
||||
// content loss Python never has (go_bug cjk-caption-false-positive). Kind is
|
||||
// split by the run's first character because findNearestParent needs a
|
||||
// table/figure kind to pick which parent to search; Python has a single
|
||||
// pattern and picks the nearest parent of either kind instead.
|
||||
var reTableCaptionText = regexp.MustCompile(`^表[图表]*[ 0-9::]{2,}|(?i)^Table\s+\d+`)
|
||||
|
||||
// reFigureCaptionText matches text patterns that indicate a figure caption.
|
||||
var reFigureCaptionText = regexp.MustCompile(`^图|(?i)^Fig\.?\s*\d+|(?i)^Figure\s+\d+`)
|
||||
// reFigureCaptionText matches text patterns that indicate a figure caption —
|
||||
// the figure-kind split of the same Python pattern (see reTableCaptionText).
|
||||
var reFigureCaptionText = regexp.MustCompile(`^图[图表]*[ 0-9::]{2,}|(?i)^Fig\.?\s*\d+|(?i)^Figure\s+\d+`)
|
||||
|
||||
// captionKind returns "table" if the section is a table caption,
|
||||
// "figure" if a figure caption, or "" if not a caption.
|
||||
@@ -460,10 +471,6 @@ func CaptionKind(s pdf.Section) string {
|
||||
if reFigureCaptionText.MatchString(t) {
|
||||
return pdf.LayoutTypeFigure
|
||||
}
|
||||
// The chart/figure pattern is ambiguous (matches both) — fall back to isCaptionBox.
|
||||
if IsCaptionBox(t, "") {
|
||||
return pdf.LayoutTypeTable
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -730,8 +730,7 @@ func TestMergeCaptions_NeedsCaptionLayoutType(t *testing.T) {
|
||||
{LayoutType: "text", Text: "公司领导班子成员、出差地",
|
||||
Positions: []pdf.Position{{Left: 100, Right: 500, Top: 180, Bottom: 198}}},
|
||||
}
|
||||
figures := pdf.CollectFigures(sections)
|
||||
result := MergeCaptions(sections, figures)
|
||||
result := MergeCaptions(sections)
|
||||
// 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 {
|
||||
|
||||
+41
-95
@@ -2242,6 +2242,7 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
||||
if isResume && resumeID != "" {
|
||||
wfInput = map[string]any{"query": ""}
|
||||
}
|
||||
runStartedAt := time.Now()
|
||||
workflowOutput, invokeErr := cc.Workflow.Invoke(ctx2, wfInput, invokeOpts...)
|
||||
err = invokeErr
|
||||
if errors.Is(err, context.Canceled) || errors.Is(ctx2.Err(), context.Canceled) {
|
||||
@@ -2255,56 +2256,27 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
||||
_ = s.runTracker.AttachCheckpoint(ctx2, runID, cpID)
|
||||
}
|
||||
|
||||
// Collect answer and references from the state snapshot.
|
||||
// Collect thinking and references from the state snapshot.
|
||||
// node_finished events are already emitted per-node by the
|
||||
// statePost wrappers in scheduler.go.
|
||||
var answer string
|
||||
var thinking string
|
||||
var legacyReference []interface{}
|
||||
var downloads any
|
||||
var attachment map[string]any
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
for _, bucket := range state.Snapshot() {
|
||||
if v, ok := bucket["answer"].(string); ok && v != "" {
|
||||
if answer == "" {
|
||||
answer = v
|
||||
}
|
||||
}
|
||||
if v, ok := bucket["content"].(string); ok && v != "" && answer == "" {
|
||||
answer = v
|
||||
}
|
||||
if v, ok := bucket["result"].(string); ok && v != "" && answer == "" {
|
||||
answer = v
|
||||
}
|
||||
if v, ok := bucket["thinking"].(string); ok && v != "" && thinking == "" {
|
||||
thinking = v
|
||||
}
|
||||
if v, ok := bucket["reference"].([]interface{}); ok {
|
||||
legacyReference = append(legacyReference, v...)
|
||||
}
|
||||
if v, ok := bucket["downloads"]; ok && !emptyDownloadValue(v) {
|
||||
downloads = v
|
||||
}
|
||||
if v, ok := bucket["attachment"].(map[string]any); ok && len(v) > 0 {
|
||||
attachment = v
|
||||
}
|
||||
}
|
||||
referencePayload := agentRunReferencePayload(c, state, legacyReference)
|
||||
assistantOutput := terminalCanvasOutput(c, state, workflowOutput, answer, downloads, attachment)
|
||||
// The terminal Message output is authoritative. Do not let a result or
|
||||
// content field from a non-terminal upstream node become the answer when
|
||||
// the Message intentionally selected attachments only.
|
||||
if terminalContent, ok := assistantOutput["content"].(string); ok {
|
||||
answer = terminalContent
|
||||
} else {
|
||||
answer = ""
|
||||
}
|
||||
downloads = assistantOutput["downloads"]
|
||||
if terminalAttachment, ok := assistantOutput["attachment"].(map[string]any); ok {
|
||||
attachment = terminalAttachment
|
||||
} else {
|
||||
attachment = nil
|
||||
}
|
||||
assistantOutput := terminalCanvasOutput(c, state, workflowOutput, runStartedAt)
|
||||
// The selected Message owns the answer, including empty text when it
|
||||
// intentionally selected attachments only.
|
||||
answer, _ := assistantOutput["content"].(string)
|
||||
downloads := assistantOutput["downloads"]
|
||||
attachment, _ := assistantOutput["attachment"].(map[string]any)
|
||||
// Release any deferred Agent node that was not consumed because the
|
||||
// downstream Message was skipped by an exception/branch path.
|
||||
runtime.CompleteAllDeferredNodes(ctx2)
|
||||
@@ -2347,41 +2319,6 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
|
||||
}
|
||||
return state, err
|
||||
}
|
||||
if shouldTreatAsCompletedLoopRun(err, answer) {
|
||||
appendAssistantHistory(state, partialAssistantOutput(answer, downloads, attachment))
|
||||
if persistErr := s.persistAgentRunSession(ctx, canvasID, userID, sessionID, messageID, userInput, answer, thinking, referencePayload, dsl, state, true); persistErr != nil {
|
||||
s.markRunFailed(ctx2, runID, "persist session: "+persistErr.Error())
|
||||
return nil, canvas.NewInternalRunError(
|
||||
fmt.Errorf("persist agent session: %w: %w", persistErr, ErrAgentStorageError),
|
||||
)
|
||||
}
|
||||
if !messageEventsEmitted && shouldEmitMessage {
|
||||
emitAgentMessageEvents(emit, answer, thinking, referencePayload)
|
||||
}
|
||||
|
||||
if shouldEmitMessage {
|
||||
meData, _ := json.Marshal(canvas.MessageEndEvent{
|
||||
Attachment: attachment,
|
||||
Reference: referencePayload,
|
||||
})
|
||||
emit("message_end", string(meData))
|
||||
}
|
||||
|
||||
wfPayload := map[string]interface{}{
|
||||
"inputs": map[string]any{"query": userInput},
|
||||
"outputs": workflowOutputsFromTerminal(assistantOutput),
|
||||
"elapsed_time": now - startedAt,
|
||||
"created_at": now,
|
||||
}
|
||||
if u := usagePayload(); u != nil {
|
||||
wfPayload["usage"] = u
|
||||
}
|
||||
wfData, _ := json.Marshal(wfPayload)
|
||||
emit("workflow_finished", string(wfData))
|
||||
|
||||
s.markRunSucceeded(ctx2, runID)
|
||||
return state, nil
|
||||
}
|
||||
if failureText := deferredAgentStreamFailureText(err); failureText != "" {
|
||||
visibleAnswer := answer
|
||||
if visibleAnswer == "" && !messageEventsEmitted && shouldEmitMessage {
|
||||
@@ -2760,19 +2697,27 @@ func terminalCanvasOutput(
|
||||
c *canvas.Canvas,
|
||||
state *canvas.CanvasState,
|
||||
workflowOutput map[string]any,
|
||||
answer string,
|
||||
downloads any,
|
||||
attachment any,
|
||||
runStartedAt time.Time,
|
||||
) map[string]any {
|
||||
terminalIDs := make([]string, 0)
|
||||
messageIDs := make([]string, 0)
|
||||
if c != nil {
|
||||
for cpnID, component := range c.Components {
|
||||
if len(component.Downstream) == 0 {
|
||||
if strings.EqualFold(component.Obj.ComponentName, "Message") {
|
||||
messageIDs = append(messageIDs, cpnID)
|
||||
}
|
||||
if c.NodeParents[cpnID] != "" || strings.EqualFold(component.Obj.ComponentName, "ExitLoop") {
|
||||
continue
|
||||
}
|
||||
// Successful workflow output names the actual outer terminals,
|
||||
// including macros whose declared downstream belongs to their body.
|
||||
if _, ok := workflowOutput[cpnID]; ok || len(component.Downstream) == 0 {
|
||||
terminalIDs = append(terminalIDs, cpnID)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(terminalIDs)
|
||||
sort.Strings(messageIDs)
|
||||
for _, cpnID := range terminalIDs {
|
||||
if output, ok := workflowOutput[cpnID].(map[string]any); ok && len(output) > 0 {
|
||||
return cloneCanvasOutput(output)
|
||||
@@ -2782,21 +2727,30 @@ func terminalCanvasOutput(
|
||||
snapshot := state.Snapshot()
|
||||
for _, cpnID := range terminalIDs {
|
||||
if output := snapshot[cpnID]; len(output) > 0 {
|
||||
return cloneCanvasOutput(output)
|
||||
created, _ := output["_created_time"].(string)
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err == nil && !createdAt.Before(runStartedAt) {
|
||||
return cloneCanvasOutput(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Message may have emitted a prompt before the next node paused.
|
||||
// Persist that partial answer, but never replay an earlier turn.
|
||||
var partial map[string]any
|
||||
latest := runStartedAt
|
||||
for _, cpnID := range messageIDs {
|
||||
output := snapshot[cpnID]
|
||||
created, _ := output["_created_time"].(string)
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err == nil && !createdAt.Before(latest) {
|
||||
partial, latest = output, createdAt
|
||||
}
|
||||
}
|
||||
if partial != nil {
|
||||
return cloneCanvasOutput(partial)
|
||||
}
|
||||
}
|
||||
if len(workflowOutput) > 0 {
|
||||
return cloneCanvasOutput(workflowOutput)
|
||||
}
|
||||
fallback := map[string]any{"content": answer}
|
||||
if !emptyDownloadValue(downloads) {
|
||||
fallback["downloads"] = downloads
|
||||
}
|
||||
if !emptyAttachmentValue(attachment) {
|
||||
fallback["attachment"] = attachment
|
||||
}
|
||||
return fallback
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func cloneCanvasOutput(input map[string]any) map[string]any {
|
||||
@@ -2959,14 +2913,6 @@ func deferredAgentStreamFailureText(err error) string {
|
||||
return strings.TrimSpace(deferred.FailureText())
|
||||
}
|
||||
|
||||
func shouldTreatAsCompletedLoopRun(err error, answer string) bool {
|
||||
if err == nil || answer == "" {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "[GraphRunError] no tasks to execute")
|
||||
}
|
||||
|
||||
func canvasInvokeError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ragflow/internal/agent/canvas"
|
||||
)
|
||||
|
||||
func TestTerminalCanvasOutputExcludesLoopBody(t *testing.T) {
|
||||
c := &canvas.Canvas{
|
||||
Components: map[string]canvas.CanvasComponent{
|
||||
"Loop:x": {Obj: canvas.CanvasComponentObj{ComponentName: "Loop"}, Downstream: []string{"Message:done"}},
|
||||
"ExitLoop:x": {Obj: canvas.CanvasComponentObj{ComponentName: "ExitLoop"}},
|
||||
"Message:body": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}},
|
||||
"Message:done": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}},
|
||||
},
|
||||
NodeParents: map[string]string{"ExitLoop:x": "Loop:x", "Message:body": "Loop:x"},
|
||||
}
|
||||
state := canvas.NewCanvasState("run", "session")
|
||||
state.SetVar("ExitLoop:x", "_next", []string{"ExitLoop:x"})
|
||||
state.SetVar("Message:body", "content", "continue")
|
||||
state.SetVar("Message:body", "_created_time", "2026-10-10T00:00:02Z")
|
||||
state.SetVar("Message:done", "content", "finished")
|
||||
state.SetVar("Message:done", "_created_time", "2026-10-10T00:00:01Z")
|
||||
got := terminalCanvasOutput(c, state, nil, time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC))
|
||||
if got["content"] != "finished" {
|
||||
t.Fatalf("output = %#v, want the outer terminal message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalCanvasOutputKeepsCurrentPartialMessage(t *testing.T) {
|
||||
c := &canvas.Canvas{Components: map[string]canvas.CanvasComponent{
|
||||
"Message:old": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}, Downstream: []string{"input"}},
|
||||
"Message:current": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}, Downstream: []string{"input"}},
|
||||
"input": {Obj: canvas.CanvasComponentObj{ComponentName: "UserFillUp"}},
|
||||
}}
|
||||
state := canvas.NewCanvasState("run", "session")
|
||||
state.SetVar("Message:old", "content", "previous prompt")
|
||||
state.SetVar("Message:old", "_created_time", "2026-10-09T00:00:00Z")
|
||||
state.SetVar("Message:current", "content", "current prompt")
|
||||
state.SetVar("Message:current", "_created_time", "2026-10-10T00:00:01Z")
|
||||
got := terminalCanvasOutput(c, state, nil, time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC))
|
||||
if got["content"] != "current prompt" {
|
||||
t.Fatalf("output = %#v, want the current partial message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalCanvasOutputUsesCurrentWorkflowBranch(t *testing.T) {
|
||||
c := &canvas.Canvas{Components: map[string]canvas.CanvasComponent{
|
||||
"Message:a": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}},
|
||||
"Message:b": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}},
|
||||
}}
|
||||
state := canvas.NewCanvasState("run", "session")
|
||||
state.SetVar("Message:a", "content", "old branch")
|
||||
for _, output := range []map[string]any{
|
||||
{"content": "current branch"},
|
||||
{"content": "", "downloads": []any{map[string]any{"url": "/file"}}},
|
||||
{"content": ""},
|
||||
} {
|
||||
got := terminalCanvasOutput(c, state, map[string]any{"Message:b": output}, time.Now())
|
||||
if !reflect.DeepEqual(got, output) {
|
||||
t.Fatalf("output = %#v, want %#v", got, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalCanvasOutputDoesNotReplayPreviousTurn(t *testing.T) {
|
||||
c := &canvas.Canvas{Components: map[string]canvas.CanvasComponent{
|
||||
"Message:a": {Obj: canvas.CanvasComponentObj{ComponentName: "Message"}},
|
||||
}}
|
||||
state := canvas.NewCanvasState("run", "session")
|
||||
state.SetVar("Message:a", "content", "previous answer")
|
||||
state.SetVar("Message:a", "_created_time", "2026-10-09T00:00:00Z")
|
||||
got := terminalCanvasOutput(c, state, nil, time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC))
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("output = %#v, want no output from the previous turn", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user