diff --git a/internal/agent/canvas/scheduler.go b/internal/agent/canvas/scheduler.go
index 8fa38d6c1d..af5a58d06d 100644
--- a/internal/agent/canvas/scheduler.go
+++ b/internal/agent/canvas/scheduler.go
@@ -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
}
diff --git a/internal/agent/canvas/terminal_resume_test.go b/internal/agent/canvas/terminal_resume_test.go
new file mode 100644
index 0000000000..0b0e4fa770
--- /dev/null
+++ b/internal/agent/canvas/terminal_resume_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/deepdoc/parser/pdf/parser.go b/internal/deepdoc/parser/pdf/parser.go
index 663a25c5a1..23e84df68d 100644
--- a/internal/deepdoc/parser/pdf/parser.go
+++ b/internal/deepdoc/parser/pdf/parser.go
@@ -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
}
diff --git a/internal/deepdoc/parser/pdf/parser_test.go b/internal/deepdoc/parser/pdf/parser_test.go
index 78545b1afc..6b72953107 100644
--- a/internal/deepdoc/parser/pdf/parser_test.go
+++ b/internal/deepdoc/parser/pdf/parser_test.go
@@ -492,8 +492,7 @@ func TestTableSectionCaptionInHTML(t *testing.T) {
})
// Step 2: mergeCaptions injects caption as a
element inside
- figures := pdf.CollectFigures(sections)
- sections = tbl.MergeCaptions(sections, figures)
+ sections = tbl.MergeCaptions(sections)
got := sections[0].Text
if !strings.Contains(got, "表1: 交通工具等级") {
diff --git a/internal/deepdoc/parser/pdf/table/caption_kind_test.go b/internal/deepdoc/parser/pdf/table/caption_kind_test.go
index 59dc97a0a8..54d1b72e7a 100644
--- a/internal/deepdoc/parser/pdf/table/caption_kind_test.go
+++ b/internal/deepdoc/parser/pdf/table/caption_kind_test.go
@@ -62,8 +62,7 @@ func TestMergeCaptions_KeepsInterleavedParagraph(t *testing.T) {
{Text: "", 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 — 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 {
diff --git a/internal/deepdoc/parser/pdf/table/merge_captions.go b/internal/deepdoc/parser/pdf/table/merge_captions.go
index aba8b986f3..70913420cf 100644
--- a/internal/deepdoc/parser/pdf/table/merge_captions.go
+++ b/internal/deepdoc/parser/pdf/table/merge_captions.go
@@ -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
// 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 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 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 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 , 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 element inserted
// immediately after the table's opening tag (matching Python's
diff --git a/internal/deepdoc/parser/pdf/table/merge_captions_caption_test.go b/internal/deepdoc/parser/pdf/table/merge_captions_caption_test.go
index cc4f6966df..325d046061 100644
--- a/internal/deepdoc/parser/pdf/table/merge_captions_caption_test.go
+++ b/internal/deepdoc/parser/pdf/table/merge_captions_caption_test.go
@@ -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 ), 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 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: "", 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, "表格是") {
+ swallowed = true
+ }
+ }
+ if !kept || swallowed {
+ t.Errorf("body paragraph must stay a standalone section (not enter ); 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 .
+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 and the figure section deleted.
+func TestMergeCaptions_FigureTextNotStolenIntoTable(t *testing.T) {
+ sections := []pdf.Section{
+ {Text: "", 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 : %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
+// . 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: "", 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, "Table 1 shows revenue") {
+ return
+ }
+ }
+ t.Errorf("expected the start-anchored English paragraph consumed into (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: "", 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, "") {
+ 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)
+ }
+}
diff --git a/internal/deepdoc/parser/pdf/table/merge_captions_test.go b/internal/deepdoc/parser/pdf/table/merge_captions_test.go
index d818498779..4f3b55e5f8 100644
--- a/internal/deepdoc/parser/pdf/table/merge_captions_test.go
+++ b/internal/deepdoc/parser/pdf/table/merge_captions_test.go
@@ -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))
}
diff --git a/internal/deepdoc/parser/pdf/table/table_cells.go b/internal/deepdoc/parser/pdf/table/table_cells.go
index cffdb4acbe..9b85b18d4d 100644
--- a/internal/deepdoc/parser/pdf/table/table_cells.go
+++ b/internal/deepdoc/parser/pdf/table/table_cells.go
@@ -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 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 ""
}
diff --git a/internal/deepdoc/parser/pdf/table/table_construct_test.go b/internal/deepdoc/parser/pdf/table/table_construct_test.go
index e14b97cd1c..8dcb82dfc0 100644
--- a/internal/deepdoc/parser/pdf/table/table_construct_test.go
+++ b/internal/deepdoc/parser/pdf/table/table_construct_test.go
@@ -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 {
diff --git a/internal/service/agent.go b/internal/service/agent.go
index 0ef19bed7e..fba67b5ea5 100644
--- a/internal/service/agent.go
+++ b/internal/service/agent.go
@@ -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
diff --git a/internal/service/agent_terminal_output_test.go b/internal/service/agent_terminal_output_test.go
new file mode 100644
index 0000000000..b860bd35e7
--- /dev/null
+++ b/internal/service/agent_terminal_output_test.go
@@ -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)
+ }
+}