diff --git a/internal/parser/parser/align_test.go b/internal/parser/parser/align_test.go
new file mode 100644
index 0000000000..d031f4f738
--- /dev/null
+++ b/internal/parser/parser/align_test.go
@@ -0,0 +1,237 @@
+//
+// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// Warranties, INCLUDING THE WARRANTIES OF MERCHANTABILITY AND
+// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+//
+
+package parser
+
+import (
+ "encoding/json"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// Normalizer transforms a single item's text before comparison. Normalizers
+// are composed per parser type so the same comparison core is reused across
+// every format (sessions A–E of the Go↔Python parser alignment).
+type Normalizer func(string) string
+
+// WithDelimiterStrip returns a Normalizer that replaces every rune present in
+// delims with a single space. This normalizes the delimiter-split difference:
+// Python splits the text at delimiters into separate items (so the delimiter
+// becomes an item boundary, i.e. whitespace), while Go keeps the delimiter
+// inline. Replacing with a space — rather than deleting — preserves the token
+// separation on the Go side, so after CollapseWhitespace both sides yield the
+// same space-joined text.
+func WithDelimiterStrip(delims string) Normalizer {
+ return func(s string) string {
+ return strings.Map(func(r rune) rune {
+ if strings.ContainsRune(delims, r) {
+ return ' '
+ }
+ return r
+ }, s)
+ }
+}
+
+// CollapseWhitespace returns a Normalizer that trims and collapses runs of
+// whitespace into a single space. Universal normalizer for tolerant compare.
+func CollapseWhitespace() Normalizer {
+ return func(s string) string {
+ return strings.Join(strings.Fields(s), " ")
+ }
+}
+
+// htmlTagRE matches an HTML tag so table/HTML markup can be ignored when
+// comparing content across the two markdown libraries (goldmark vs
+// Python-Markdown serialize tables differently but the cell text is the same).
+var htmlTagRE = regexp.MustCompile(`(?is)<[^>]+>`)
+
+// StripHTMLTags returns a Normalizer that removes HTML tags, leaving the
+// visible text. Tags are replaced with a single space (not deleted) so
+// adjacent cell text does not fuse — e.g. "
A | B | " becomes
+// "A B" rather than "AB". CollapseWhitespace then folds the extra space.
+// Used so table-markup differences between markdown libraries don't mask the
+// underlying content equivalence.
+func StripHTMLTags() Normalizer {
+ return func(s string) string {
+ return htmlTagRE.ReplaceAllString(s, " ")
+ }
+}
+
+// Markdown-syntax regexes removed by StripMarkdownSyntax. The Python flow
+// parser keeps raw markdown in its section text ("# Title", "- item",
+// ``` fenced ```); the Go parser emits clean per-block text. These are
+// representation differences (PARSER_ALIGNMENT_HANDOFF.md §3.1), not content
+// divergences, so the markdown alignment strips them before comparing.
+var (
+ mdHeaderRE = regexp.MustCompile(`(?m)^#{1,6}\s+`)
+ mdListRE = regexp.MustCompile(`(?m)^\s*[-*+]\s+`)
+ mdFenceRE = regexp.MustCompile("(?s)```[^\n]*\n(.*?)```")
+)
+
+// StripMarkdownSyntax returns a Normalizer that removes markdown presentation
+// characters (ATX headings, list bullets, fenced-code fences) from a section,
+// leaving the bare text. It must run before CollapseWhitespace because the
+// fence regex relies on the surrounding newlines.
+func StripMarkdownSyntax() Normalizer {
+ return func(s string) string {
+ s = mdHeaderRE.ReplaceAllString(s, "")
+ s = mdListRE.ReplaceAllString(s, "")
+ s = mdFenceRE.ReplaceAllString(s, "$1")
+ return s
+ }
+}
+
+// FilterByDocType returns only the items whose doc_type_kwd equals kwd.
+// Python emits duplicate table items (separate_tables=False still appends
+// them) — excluding doc_type_kwd:"table" lets the comparison focus on the
+// inlined textual content, which is what Go produces.
+func FilterByDocType(items []map[string]any, kwd string) []map[string]any {
+ out := make([]map[string]any, 0, len(items))
+ for _, it := range items {
+ if v, _ := it["doc_type_kwd"].(string); v == kwd {
+ out = append(out, it)
+ }
+ }
+ return out
+}
+
+// AlignOptions configures NormalizeConcat / CompareAlignment.
+type AlignOptions struct {
+ // Normalizers applied (in order) to each item's text before concat.
+ Normalizers []Normalizer
+ // ItemKey is the field holding the compared text (default "text").
+ ItemKey string
+}
+
+func alignItemText(item map[string]any, key string) string {
+ if key == "" {
+ key = "text"
+ }
+ if v, ok := item[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+// NormalizeConcat extracts the text field from each item, applies the
+// normalizers in order, and concatenates into one string. Format-agnostic.
+//
+// Items are joined with a single space (after whitespace is collapsed by the
+// normalizers) rather than by newlines: Go emits one item per top-level block
+// while Python splits the same text on delimiters into many smaller items, so
+// the item *boundaries* legitimately differ. Joining on whitespace makes the
+// comparison boundary-agnostic — only the concatenated content (order
+// preserved) is compared, which is exactly the alignment guarantee we want.
+// Empty items are skipped so Python's trailing/duplicate segments don't mask a
+// real content difference.
+func NormalizeConcat(items []map[string]any, opts AlignOptions) string {
+ key := opts.ItemKey
+ if key == "" {
+ key = "text"
+ }
+ parts := make([]string, 0, len(items))
+ for _, it := range items {
+ t := alignItemText(it, key)
+ for _, n := range opts.Normalizers {
+ t = n(t)
+ }
+ if strings.TrimSpace(t) == "" {
+ continue
+ }
+ // Trim so a delimiter turned into a trailing space (WithDelimiterStrip)
+ // doesn't combine with the join space into a double gap.
+ t = strings.TrimSpace(t)
+ parts = append(parts, t)
+ }
+ return strings.Join(parts, " ")
+}
+
+// CompareAlignment reports whether two parser outputs are aligned after
+// normalization. goItems come from Go's ParseResult.JSON; pyItems come from the
+// Python golden JSON. Returns (equal, diffReport).
+func CompareAlignment(goItems, pyItems []map[string]any, opts AlignOptions) (bool, string) {
+ g := NormalizeConcat(goItems, opts)
+ p := NormalizeConcat(pyItems, opts)
+ if g == p {
+ return true, ""
+ }
+ return false, diffReport(g, p)
+}
+
+func diffReport(g, p string) string {
+ const max = 2000
+ if len(g) > max {
+ g = g[:max] + "...(truncated)"
+ }
+ if len(p) > max {
+ p = p[:max] + "...(truncated)"
+ }
+ return "alignment mismatch after normalization:\n--- GO ---\n" + g + "\n--- PY ---\n" + p
+}
+
+// LoadGolden reads a Python golden JSON file (a JSON list of item objects)
+// produced by the Python flow parser for the same input.
+func LoadGolden(t *testing.T, path string) []map[string]any {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("load golden %s: %v", path, err)
+ }
+ var items []map[string]any
+ if err := json.Unmarshal(data, &items); err != nil {
+ t.Fatalf("parse golden %s: %v", path, err)
+ }
+ return items
+}
+
+// MarkdownAlignOptions returns the normalizer preset for markdown. The order
+// matters:
+// - StripMarkdownSyntax first: drops "#"/"-"/fenced-code markup that Python
+// keeps inline but Go parses out (relies on the surrounding newlines, so it
+// must run before CollapseWhitespace).
+// - StripHTMLTags next: replace table/HTML tags with a space (not delete) so
+// adjacent cell text does not fuse, e.g. "A | B | " → "A B".
+// - WithDelimiterStrip: replace the delimiter set Python consumes at split
+// points with a space while Go keeps it inline, so both sides keep the same
+// token separation. Runs before CollapseWhitespace so the introduced space
+// is folded normally.
+// - CollapseWhitespace last: folds all remaining internal whitespace (the
+// inter-tag gaps of an HTML table, the space from delimiter replacement,
+// the newlines inside a fenced code block) into single spaces.
+//
+// Reused by every markdown alignment test; other formats define their own
+// preset and share CompareAlignment.
+func MarkdownAlignOptions(delimiter string) AlignOptions {
+ return AlignOptions{
+ Normalizers: []Normalizer{
+ StripMarkdownSyntax(),
+ StripHTMLTags(),
+ WithDelimiterStrip(delimiter),
+ CollapseWhitespace(),
+ },
+ ItemKey: "text",
+ }
+}
+
+// DefaultMarkdownDelimiter is the flow parser's default markdown delimiter
+// set, used when generating/loading the golden baseline.
+const DefaultMarkdownDelimiter = "\n!?;。;!?"
diff --git a/internal/parser/parser/markdown_parser.go b/internal/parser/parser/markdown_parser.go
index 54f9f838f0..e41897d152 100644
--- a/internal/parser/parser/markdown_parser.go
+++ b/internal/parser/parser/markdown_parser.go
@@ -25,7 +25,6 @@ import (
"net"
"net/http"
"net/url"
- "regexp"
"strings"
"sync"
"time"
@@ -35,9 +34,6 @@ import (
mdparser "github.com/gomarkdown/markdown/parser"
)
-// mdImagePattern matches markdown inline image syntax: .
-var mdImagePattern = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`)
-
// dataURIPrefix is the MIME prefix for data URI images.
const dataURIPrefix = "data:image/"
@@ -96,20 +92,18 @@ func (p *MarkdownParser) ConfigureFromSetup(setup map[string]any) {
// been removed; callers consume ParseResult directly.
func (p *MarkdownParser) ParseWithResult(ctx context.Context, filename string, data []byte) ParseResult {
rawText := string(data)
- if rendered, ok := renderMarkdownTablesInline(rawText); ok {
- return ParseResult{
- OutputFormat: "json",
- File: map[string]any{
- "name": filename,
- },
- JSON: []map[string]any{{"text": rendered, "doc_type_kwd": "text"}},
- }
- }
+ // Render any GFM/HTML table inline as an HTML block before parsing. This
+ // keeps the document as one item per top-level block (the table becomes a
+ // normal text item) instead of collapsing the whole document into a single
+ // item. The result mirrors Python's `_markdown` (separate_tables=False),
+ // which also inlines tables into the surrounding text. When no table is
+ // present renderMarkdownTablesInlineText returns the input unchanged.
+ rendered := renderMarkdownTablesInlineText(rawText)
- doc := markdownNew().Parse(data)
+ doc := markdownNew().Parse([]byte(rendered))
var items []map[string]any
- walkMarkdownBlocksWithImages(doc, rawText, &items, p.FlattenMediaToText)
+ walkMarkdownBlocksWithImages(doc, &items, p.FlattenMediaToText)
if items == nil {
items = []map[string]any{{"text": "", "doc_type_kwd": "text"}}
}
@@ -165,8 +159,13 @@ func renderMarkdownTablesInline(text string) (string, bool) {
i++
}
tableHTML := markdownlib.ToHTML([]byte(strings.Join(lines[start:i], "")), markdownNew(), nil)
+ // Wrap the inlined HTML in blank lines so gomarkdown
+ // keeps it as a single HTML block (one item) instead of
+ // re-parsing it into scattered cell text. See
+ // PARSER_ALIGNMENT_HANDOFF.md §3.1 (markdown session A, 方案 Y).
+ ensureTrailingBlankLine(&buf)
buf.WriteString(strings.TrimRight(string(tableHTML), "\r\n"))
- buf.WriteByte('\n')
+ buf.WriteString("\n\n")
changed = true
continue
}
@@ -176,6 +175,32 @@ func renderMarkdownTablesInline(text string) (string, bool) {
return buf.String(), changed
}
+// renderMarkdownTablesInlineText renders every GFM/HTML table inline as an
+// HTML block and returns the rewritten text. When no table is present the
+// input is returned unchanged. Unlike renderMarkdownTablesInline it always
+// returns the full text (ignoring the changed flag) so callers can parse the
+// result uniformly and emit one item per top-level block.
+func renderMarkdownTablesInlineText(text string) string {
+ out, _ := renderMarkdownTablesInline(text)
+ return out
+}
+
+// ensureTrailingBlankLine makes sure b ends with a blank line (two
+// newlines) so the next block is separated from what precedes it. gomarkdown
+// only treats a as a standalone HTML block (rather than re-parsing it
+// into scattered cell nodes) when it is surrounded by blank lines.
+func ensureTrailingBlankLine(b *strings.Builder) {
+ s := b.String()
+ switch {
+ case strings.HasSuffix(s, "\n\n"):
+ // already separated.
+ case strings.HasSuffix(s, "\n"):
+ b.WriteByte('\n')
+ default:
+ b.WriteString("\n\n")
+ }
+}
+
func markdownFenceMarker(line string) (byte, int, bool) {
trimmed := strings.TrimLeft(line, " \t")
if len(line)-len(trimmed) > 3 || len(trimmed) < 3 {
@@ -236,11 +261,25 @@ func markdownTableCells(line string) []string {
// top-level block. Headings, paragraphs, lists, and code blocks are
// emitted with their text. When a block contains a markdown image
// reference (), the image data is resolved via
-// resolveMarkdownImage and the item carries `doc_type_kwd: "image"`
-// together with the base64-encoded image payload. When flatten is
-// true, all items are forced to doc_type_kwd="text" (mirrors Python
-// parser.py:1034 flatten_media_to_text).
-func walkMarkdownBlocksWithImages(doc ast.Node, rawText string, out *[]map[string]any, flatten bool) {
+// findBlockImage (per-block AST walk) and the item carries
+// `doc_type_kwd: "image"` together with the base64-encoded image
+// payload. When flatten is true, all items are forced to
+// doc_type_kwd="text" (mirrors Python parser.py:1034
+// flatten_media_to_text).
+//
+// Tables: a GFM/HTML table is rendered inline as a single HTML
+// block by renderMarkdownTablesInlineText and kept as one HTML block.
+// It is emitted as TWO items, mirroring Python's _markdown
+// (separate_tables=False): an inlined copy in the text flow
+// (doc_type_kwd:"text") and a separate structured table item
+// (doc_type_kwd:"table", ck_type:"table"). The downstream chunker
+// consumes doc_type_kwd:"table" to keep the table whole and attach
+// table context to neighbouring chunks (chunker/token.go). Non-table
+// HTML blocks (, \n\nAfter.\n"
+ res := p.ParseWithResult(ctx, "test.md", []byte(md))
+ if res.Err != nil {
+ t.Fatalf("ParseWithResult: %v", res.Err)
+ }
+ for _, item := range res.JSON {
+ text, _ := item["text"].(string)
+ if strings.Contains(text, "
HTML, corrupting the code. renderMarkdownTablesInline
+// tracks fence state (inFence) so the table detector must skip lines inside a fence.
+func TestMarkdownParser_TableInCodeFenceNotRendered(t *testing.T) {
+ ctx := t.Context()
+ p, _ := NewMarkdownParser(GoMarkdown)
+ md := "# Title\n\n```\n| A | B |\n| --- | --- |\n| x | y |\n```\n\nAfter fence.\n"
+ res := p.ParseWithResult(ctx, "test.md", []byte(md))
+ if res.Err != nil {
+ t.Fatalf("ParseWithResult: %v", res.Err)
+ }
+ // No table item may be emitted — the pipe rows live inside a code block.
+ for _, item := range res.JSON {
+ if kd, _ := item["doc_type_kwd"].(string); kd == "table" {
+ t.Fatalf("code-fence pipe rows wrongly emitted as table item: %q", item["text"])
+ }
+ }
+ // The code block item must retain the raw pipe text and must NOT contain
+ // any
markup.
+ sawCode := false
+ for _, item := range res.JSON {
+ text, _ := item["text"].(string)
+ if strings.Contains(text, "| A | B |") {
+ sawCode = true
+ if strings.Contains(text, " HTML
+// block (not a GFM pipe table). renderMarkdownTablesInline only rewrites GFM
+// pipe tables, so the raw passes through and is caught by isTableHTML
+// as an HTMLBlock, producing the same inlined copy + separate doc_type_kwd:"table"
+// item shape as a GFM table.
+func TestMarkdownParser_RawHTMLTableHandled(t *testing.T) {
+ ctx := t.Context()
+ p, _ := NewMarkdownParser(GoMarkdown)
+ md := "Before.\n\n\n\nAfter.\n"
+ res := p.ParseWithResult(ctx, "test.md", []byte(md))
+ if res.Err != nil {
+ t.Fatalf("ParseWithResult: %v", res.Err)
+ }
+ sawInlined, sawTableItem := false, false
+ for _, item := range res.JSON {
+ text, _ := item["text"].(string)
+ switch kd, _ := item["doc_type_kwd"].(string); kd {
+ case "text":
+ if strings.Contains(text, " HTML: %q", text)
+ }
+ if ck, _ := item["ck_type"].(string); ck != "table" {
+ t.Fatalf("raw table item ck_type = %q, want \"table\"", ck)
+ }
+ }
+ }
+ if !sawInlined {
+ t.Fatal("raw HTML block not emitted as inlined copy in text flow")
+ }
+ if !sawTableItem {
+ t.Fatal("raw HTML block not emitted as separate doc_type_kwd:\"table\" item")
+ }
+}
+
+// TestMarkdownParser_MultipleTablesOrdering guards the ordering contract: each
+// GFM table emits an inlined copy in its original document position (among the
+// surrounding text blocks) and a separate doc_type_kwd:"table" item appended at
+// the end of the stream (mirroring Python's _markdown, which appends tables
+// after all sections). Both tables' cell text must be present and in source order.
+func TestMarkdownParser_MultipleTablesOrdering(t *testing.T) {
+ ctx := t.Context()
+ p, _ := NewMarkdownParser(GoMarkdown)
+ md := "# Title\n\n| A | B |\n| --- | --- |\n| x | y |\n\nMiddle.\n\n| C | D |\n| --- | --- |\n| p | q |\n\nEnd.\n"
+ res := p.ParseWithResult(ctx, "test.md", []byte(md))
+ if res.Err != nil {
+ t.Fatalf("ParseWithResult: %v", res.Err)
+ }
+
+ var inlinedTableIdx []int
+ var tableItemIdx []int
+ var titleIdx, middleIdx, endIdx = -1, -1, -1
+ for i, item := range res.JSON {
+ text, _ := item["text"].(string)
+ switch kd, _ := item["doc_type_kwd"].(string); kd {
+ case "text":
+ switch text {
+ case "Title":
+ titleIdx = i
+ case "Middle.":
+ middleIdx = i
+ case "End.":
+ endIdx = i
+ }
+ if strings.Contains(text, " endIdx && tableItemIdx[0] < tableItemIdx[1]) {
+ t.Fatalf("table items not appended after text items in source order: %v end=%d", tableItemIdx, endIdx)
+ }
+ t1, _ := res.JSON[tableItemIdx[0]]["text"].(string)
+ t2, _ := res.JSON[tableItemIdx[1]]["text"].(string)
+ if !strings.Contains(t1, "x") || !strings.Contains(t1, "y") {
+ t.Fatalf("first table item missing x/y cells: %q", t1)
+ }
+ if !strings.Contains(t2, "p") || !strings.Contains(t2, "q") {
+ t.Fatalf("second table item missing p/q cells: %q", t2)
+ }
+}
+
+// TestMarkdownParser_AlignmentGolden verifies Go's ParseWithResult output is
+// content-equivalent to Python's _markdown on the shared sample, using the
+// shared concatenation-normalization alignment tool (align_test.go). Python
+// keeps raw markdown and splits on the delimiter set; Go emits clean per-block
+// text. The comparison normalizes both (markdown syntax, html tags, delimiters
+// stripped; whitespace collapsed) and ignores "table"/"image" items, which are
+// accepted representation differences (PARSER_ALIGNMENT_HANDOFF.md §3.1).
+//
+// Regenerate the baseline with:
+//
+// .venv/bin/python internal/parser/parser/testdata/gen_markdown_golden.py
+func TestMarkdownParser_AlignmentGolden(t *testing.T) {
+ ctx := t.Context()
+ p, _ := NewMarkdownParser(GoMarkdown)
+
+ sample, err := os.ReadFile("testdata/markdown.sample.md")
+ if err != nil {
+ t.Fatalf("read sample: %v", err)
+ }
+ res := p.ParseWithResult(ctx, "markdown.sample.md", sample)
+ if res.Err != nil {
+ t.Fatalf("ParseWithResult: %v", res.Err)
+ }
+
+ golden := LoadGolden(t, "testdata/markdown.python.golden.json")
+
+ // Ignore "table"/"image" items on both sides (accepted divergences).
+ goText := FilterByDocType(res.JSON, "text")
+ pyText := FilterByDocType(golden, "text")
+
+ if ok, diff := CompareAlignment(goText, pyText, MarkdownAlignOptions(DefaultMarkdownDelimiter)); !ok {
+ t.Fatalf("markdown parser not aligned with Python golden:%s", diff)
+ }
+}
diff --git a/internal/parser/parser/testdata/gen_markdown_golden.py b/internal/parser/parser/testdata/gen_markdown_golden.py
new file mode 100644
index 0000000000..fc0c113812
--- /dev/null
+++ b/internal/parser/parser/testdata/gen_markdown_golden.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Regenerate internal/parser/parser/testdata/markdown.python.golden.json.
+
+Drives the REAL Python markdown parser (deepdoc.parser.markdown_parser, the
+same engine rag/flow/parser/parser.py:_markdown delegates to via
+rag/app/naive.Markdown) so the golden is a faithful baseline rather than a
+hand approximation.
+
+Requires the project virtualenv (uv) because deepdoc needs markdown /
+beartype / etc.:
+
+ .venv/bin/python internal/parser/parser/testdata/gen_markdown_golden.py
+
+It mirrors _markdown with separate_tables=False and the default delimiter
+set, then assembles json items exactly as _markdown does:
+
+ * each extracted section -> {"text": , "doc_type_kwd": "text"}
+ * each standalone table -> {"text": , "doc_type_kwd": "table"}
+ * an image section -> {"text": , "doc_type_kwd": "image"}
+
+The Go alignment test then strips markdown syntax, html tags, and delimiters
+before comparing, and ignores "table"/"image" items (those representations are
+accepted divergences per PARSER_ALIGNMENT_HANDOFF.md §3.1).
+"""
+
+import json
+import os
+import re
+import sys
+
+# Make the repo root importable when run as a standalone script from testdata.
+# Script lives at /internal/parser/parser/testdata/, so five dirname hops.
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))))
+
+SAMPLE = "internal/parser/parser/testdata/markdown.sample.md"
+OUT = "internal/parser/parser/testdata/markdown.python.golden.json"
+DELIM = "\n!?;。;!?"
+IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
+SENTINEL = "@@IMAGE@@"
+
+
+def main():
+ from deepdoc.parser.markdown_parser import RAGFlowMarkdownParser, MarkdownElementExtractor
+
+ with open(SAMPLE, encoding="utf-8") as f:
+ raw = f.read()
+
+ # Model _markdown's return_section_images: the image is extracted as its
+ # own item (alt text only). Replace the markdown with a delimiter-free
+ # sentinel so the extractor does not split it.
+ alts = []
+
+ def _repl(m):
+ alts.append(m.group(1))
+ return SENTINEL
+
+ prepared = IMG_RE.sub(_repl, raw)
+
+ parser = RAGFlowMarkdownParser()
+ remainder, tables = parser.extract_tables_and_remainder(prepared + "\n", separate_tables=False)
+ extractor = MarkdownElementExtractor(remainder)
+ sections = extractor.extract_elements(DELIM, include_meta=True)
+
+ items = []
+ for s in sections:
+ content = s["content"]
+ if SENTINEL in content:
+ items.append({"text": alts.pop(0), "doc_type_kwd": "image"})
+ continue
+ items.append({"text": content, "doc_type_kwd": "text"})
+
+ for tbl in tables:
+ # _markdown (rag/flow/parser/parser.py:1103-1111) appends each
+ # extracted table as a duplicate "table" item even when inlined,
+ # carrying the table's raw text (GFM source or raw HTML).
+ items.append({"text": tbl, "doc_type_kwd": "table"})
+
+ with open(OUT, "w", encoding="utf-8") as f:
+ json.dump(items, f, ensure_ascii=False, indent=2)
+ print("wrote %d items to %s" % (len(items), OUT))
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/internal/parser/parser/testdata/markdown.python.golden.json b/internal/parser/parser/testdata/markdown.python.golden.json
new file mode 100644
index 0000000000..73f5168110
--- /dev/null
+++ b/internal/parser/parser/testdata/markdown.python.golden.json
@@ -0,0 +1,50 @@
+[
+ {
+ "text": "# 健康检查套餐对比\n本文比较两种体检套餐,包含表格、列表与代码块",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "## 套餐明细",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "\n\n\n| 检查项目 | \n基础版 699 元 | \n进阶版 1299 元 | \n
\n\n\n\n| 血常规 / 尿常规 | \n包含 | \n包含 | \n
\n\n| 心电图 | \n不包含 | \n包含 | \n
\n\n
",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "注意:所有套餐均需空腹",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "## 注意事项",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "- 体检前三天清淡饮食",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "- 避免剧烈运动",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "下面是示例配置:",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "```yaml\nname: health-check\nversion: 1\n```",
+ "doc_type_kwd": "text"
+ },
+ {
+ "text": "示意图",
+ "doc_type_kwd": "image"
+ },
+ {
+ "text": "\n| 检查项目 | 基础版 699 元 | 进阶版 1299 元 |\n| --- | --- | --- |\n| 血常规 / 尿常规 | 包含 | 包含 |\n| 心电图 | 不包含 | 包含 |\n",
+ "doc_type_kwd": "table"
+ },
+ {
+ "text": "\n\n\n\n| 检查项目 | \n基础版 699 元 | \n进阶版 1299 元 | \n
\n\n\n\n| 血常规 / 尿常规 | \n包含 | \n包含 | \n
\n\n| 心电图 | \n不包含 | \n包含 | \n
\n\n
\n",
+ "doc_type_kwd": "table"
+ }
+]
diff --git a/internal/parser/parser/testdata/markdown.sample.md b/internal/parser/parser/testdata/markdown.sample.md
new file mode 100644
index 0000000000..7aec22ee26
--- /dev/null
+++ b/internal/parser/parser/testdata/markdown.sample.md
@@ -0,0 +1,26 @@
+# 健康检查套餐对比
+
+本文比较两种体检套餐,包含表格、列表与代码块。
+
+## 套餐明细
+
+| 检查项目 | 基础版 699 元 | 进阶版 1299 元 |
+| --- | --- | --- |
+| 血常规 / 尿常规 | 包含 | 包含 |
+| 心电图 | 不包含 | 包含 |
+
+注意:所有套餐均需空腹。
+
+## 注意事项
+
+- 体检前三天清淡饮食。
+- 避免剧烈运动!
+
+下面是示例配置:
+
+```yaml
+name: health-check
+version: 1
+```
+
+