Align Go ingestion boundaries with Python (#16647)

Moves doc_id blob resolution into Parser, tightens chunker/tokenizer to
Python output_format semantics, updates extractor list handling, and
fixes real-template integration tests.
This commit is contained in:
Zhichang Yu
2026-07-05 20:43:52 +08:00
committed by GitHub
parent 0fcfb38365
commit 014c3f634f
119 changed files with 18083 additions and 4712 deletions

View File

@@ -0,0 +1,68 @@
//go:build cgo
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"fmt"
officeOxide "github.com/yfedoseev/office_oxide/go"
)
type DOCParser struct {
libType string
}
func NewDOCParser(libType string) (*DOCParser, error) {
switch libType {
case OfficeOxide:
return &DOCParser{
libType: OfficeOxide,
}, nil
default:
return nil, fmt.Errorf("unsupported DOC library type: %s", libType)
}
}
func (p *DOCParser) String() string {
return "DOCParser"
}
// ParseWithResult captures the office_oxide PlainText output for
// the DOC family. Python parser.py routes .doc through tika;
// the Go side uses office_oxide which supports DOC via PlainText.
// OutputFormat="text" — the python side falls back to text for
// legacy DOC files since structured extraction is unreliable.
func (p *DOCParser) ParseWithResult(filename string, data []byte) ParseResult {
doc, err := officeOxide.OpenFromBytes(data, "doc")
if err != nil {
return ParseResult{Err: fmt.Errorf("doc open: %w", err)}
}
defer doc.Close()
text, err := doc.PlainText()
if err != nil {
return ParseResult{Err: fmt.Errorf("doc plain-text: %w", err)}
}
return ParseResult{
OutputFormat: "text",
File: map[string]any{"name": filename, "format": "doc"},
Text: text,
}
}

View File

@@ -0,0 +1,69 @@
//go:build cgo
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"fmt"
officeOxide "github.com/yfedoseev/office_oxide/go"
)
type DOCXParser struct {
libType string
}
func NewDOCXParser(libType string) (*DOCXParser, error) {
switch libType {
case OfficeOxide:
return &DOCXParser{
libType: OfficeOxide,
}, nil
default:
return nil, fmt.Errorf("unsupported DOCX library type: %s", libType)
}
}
// ParseWithResult captures the office_oxide ToMarkdown output
// instead of discarding it. Returns OutputFormat="markdown" with
// the rendered Markdown on the matching key. Mirrors the python
// parser.py:Docx branch which uses rag.app.naive.Docx to render
// markdown; the Go side delegates to office_oxide for now and
// will switch to a native Markdown renderer in a follow-up.
func (p *DOCXParser) ParseWithResult(filename string, data []byte) ParseResult {
doc, err := officeOxide.OpenFromBytes(data, "docx")
if err != nil {
return ParseResult{Err: fmt.Errorf("docx open: %w", err)}
}
defer doc.Close()
md, err := doc.ToMarkdown()
if err != nil {
return ParseResult{Err: fmt.Errorf("docx to-markdown: %w", err)}
}
return ParseResult{
OutputFormat: "markdown",
File: map[string]any{"name": filename, "format": "docx"},
Markdown: md,
}
}
func (p *DOCXParser) String() string {
return "DOCXParser"
}

View File

@@ -0,0 +1,185 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"bytes"
"fmt"
"strings"
"golang.org/x/net/html"
)
const (
Official string = "official"
)
type HTMLParser struct {
libType string
}
func NewHTMLParser(libType string) (*HTMLParser, error) {
switch libType {
case Official:
return &HTMLParser{
libType: Official,
}, nil
default:
return nil, fmt.Errorf("unsupported HTML library type: %s", libType)
}
}
func (p *HTMLParser) String() string {
return "HTMLParser"
}
// ParseWithResult emits one item per block-level HTML element
// (headings, paragraphs, lists, pre blocks). The walker is a
// pure-Go replacement for the previous `fmt.Printf` debug output:
// it descends the html.Parse tree, joins the leaf text of each
// block-level element, and emits the python-compatible
// `{text, doc_type_kwd:"text"}` shape.
//
// Phase 2.5 (Slice 1) of port-rag-flow-pipeline-to-go.md makes
// HTMLParser a ParseResultProducer so the dispatch seam routes
// the html family through the structured path. Inline formatting
// (bold / links / images) is intentionally NOT surfaced as a
// separate ck_type — the python HtmlParser collapses inline
// formatting into the parent block's text.
func (p *HTMLParser) ParseWithResult(filename string, data []byte) ParseResult {
if p.libType != Official {
return ParseResult{Err: fmt.Errorf("unsupported HTML library type: %s", p.libType)}
}
doc, err := html.Parse(bytes.NewReader(data))
if err != nil {
return ParseResult{Err: fmt.Errorf("html parse: %w", err)}
}
var items []map[string]any
walkHTMLBlocks(doc, &items)
if items == nil {
items = []map[string]any{{"text": "", "doc_type_kwd": "text"}}
}
return ParseResult{
OutputFormat: "json",
File: map[string]any{
"name": filename,
"encoding": "utf-8",
},
JSON: items,
}
}
// walkHTMLBlocks emits one normalized item per block-level
// descendant of root. Inline elements (b, i, a, span, …) are
// collapsed into the parent's text via leafText. <script> and
// <style> blocks are skipped entirely so they don't pollute the
// downstream chunker input.
func walkHTMLBlocks(root *html.Node, out *[]map[string]any) {
for child := root.FirstChild; child != nil; child = child.NextSibling {
if child.Type != html.ElementNode {
continue
}
tag := child.Data
switch tag {
case "script", "style", "noscript":
// Skip executable / stylistic blocks entirely.
continue
case "html", "head", "body":
// Wrapper elements: descend into their children.
walkHTMLBlocks(child, out)
continue
}
text := htmlLeafText(child)
if strings.TrimSpace(text) == "" {
continue
}
*out = append(*out, map[string]any{
"text": strings.TrimSpace(text),
"doc_type_kwd": "text",
"ck_type": htmlTagToCkType(tag),
})
}
}
// htmlTagToCkType maps HTML block tags to the python `ck_type`
// vocabulary used downstream by TitleChunker and similar
// components. Tags not in the map fall back to "text".
func htmlTagToCkType(tag string) string {
switch tag {
case "h1", "h2", "h3", "h4", "h5", "h6":
return "heading"
case "p":
return "paragraph"
case "ul", "ol", "li":
return "list"
case "pre", "code":
return "code"
case "table", "tr", "td", "th":
return "table"
case "blockquote":
return "quote"
case "img":
return "image"
}
return "text"
}
// htmlLeafText joins the visible text of an HTML node and its
// descendants. <script>/<style> subtrees are skipped (mirrors
// the python html.parser behaviour). The output preserves
// whitespace runs so headings like "<h1>Hello world</h1>"
// round-trip with their spacing intact.
func htmlLeafText(n *html.Node) string {
var b strings.Builder
walkHTMLLeaf(n, &b)
return b.String()
}
func walkHTMLLeaf(n *html.Node, b *strings.Builder) {
switch n.Type {
case html.TextNode:
b.WriteString(n.Data)
case html.ElementNode:
if n.Data == "script" || n.Data == "style" {
return
}
// Add a line break between block children so headings,
// paragraphs, and list items don't run together.
switch n.Data {
case "h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "pre",
"tr", "blockquote":
if b.Len() > 0 && !strings.HasSuffix(b.String(), "\n") {
b.WriteString("\n")
}
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walkHTMLLeaf(child, b)
}
if isBlockTag(n.Data) && b.Len() > 0 && !strings.HasSuffix(b.String(), "\n") {
b.WriteString("\n")
}
}
}
func isBlockTag(tag string) bool {
switch tag {
case "h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "pre",
"tr", "blockquote", "div", "section", "article", "header", "footer":
return true
}
return false
}

View File

@@ -0,0 +1,167 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"bytes"
"fmt"
"strings"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
)
const (
GoMarkdown = "go_markdown"
)
type MarkdownParser struct {
libType string
}
func NewMarkdownParser(libType string) (*MarkdownParser, error) {
switch libType {
case GoMarkdown:
return &MarkdownParser{
libType: GoMarkdown,
}, nil
default:
return nil, fmt.Errorf("unsupported Markdown library type: %s", libType)
}
}
// ParseWithResult implements ParseResultProducer (plan §6.5) and
// returns a structured markdown payload that mirrors the Python
// parser's `output_format == "json"` shape. Each top-level block
// emits one item with `text` + `doc_type_kwd: "text"`. The legacy
// debug-print path has been removed; callers consume ParseResult directly.
func (p *MarkdownParser) ParseWithResult(filename string, data []byte) ParseResult {
if p.libType != GoMarkdown {
return ParseResult{Err: fmt.Errorf("unsupported Markdown library type: %s", p.libType)}
}
doc := markdownNew().Parse(data)
var items []map[string]any
walkMarkdownBlocks(doc, &items)
if items == nil {
// No blocks emitted — surface a single empty item so the
// downstream chunker sees a non-nil JSON slice (mirrors the
// Python contract of always producing at least one item).
items = []map[string]any{{"text": "", "doc_type_kwd": "text"}}
}
return ParseResult{
OutputFormat: "json",
File: map[string]any{
"name": filename,
},
JSON: items,
}
}
func (p *MarkdownParser) String() string {
return "MarkdownParser"
}
// markdownNew is a thin constructor so the extension set is owned
// in one place (both Parse and ParseWithResult consume it).
func markdownNew() *parser.Parser {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
return parser.NewWithExtensions(extensions)
}
// walkMarkdownBlocks emits one normalized item per top-level block.
// Headings (LeafBlock / H*) are emitted with the heading text so a
// downstream title chunker can find them; paragraphs (Paragraph)
// emit the leaf-node text. The walker is intentionally a
// best-effort pass — full TOC / outline handling lands with the
// deepdoc/parser port — but it satisfies the per-block "emit
// text+doc_type_kwd" contract enough for a Phase-1a migration
// test to verify wire-shape parity.
func walkMarkdownBlocks(doc ast.Node, out *[]map[string]any) {
for _, child := range doc.GetChildren() {
switch n := child.(type) {
case *ast.Heading:
*out = append(*out, map[string]any{
"text": headingText(n),
"doc_type_kwd": "text",
"ck_type": "heading",
})
case *ast.Paragraph:
*out = append(*out, map[string]any{
"text": leafText(n),
"doc_type_kwd": "text",
"ck_type": "text",
})
case *ast.List:
*out = append(*out, map[string]any{
"text": leafText(n),
"doc_type_kwd": "text",
"ck_type": "list",
})
case *ast.CodeBlock:
*out = append(*out, map[string]any{
"text": leafText(n),
"doc_type_kwd": "text",
"ck_type": "code",
})
default:
// Block types we don't yet normalize (HTML, tables,
// images, definitions) are best-effort: emit the leaf
// text without a ck_type so downstream components can
// still treat them as text chunks.
txt := leafText(n)
if strings.TrimSpace(txt) != "" {
*out = append(*out, map[string]any{
"text": txt,
"doc_type_kwd": "text",
})
}
}
}
}
// headingText returns the inline-text of a heading node by
// concatenating every Leaf / Text child. Empty headings emit "".
func headingText(h *ast.Heading) string {
var buf bytes.Buffer
for _, c := range h.GetChildren() {
buf.WriteString(leafText(c))
}
return strings.TrimSpace(buf.String())
}
// leafText mirrors gomarkdown's leaf walker: walks every descendant
// leaf (Text or Inline content) and returns the concatenated UTF-8.
// Non-text containers that have no leaf descendants return "".
func leafText(n ast.Node) string {
var buf bytes.Buffer
walkLeaf(n, &buf)
return strings.TrimSpace(buf.String())
}
func walkLeaf(n ast.Node, buf *bytes.Buffer) {
switch t := n.(type) {
case *ast.Text:
buf.Write(t.Literal)
case *ast.Code:
buf.Write(t.Literal)
default:
for _, c := range n.GetChildren() {
walkLeaf(c, buf)
}
}
}

View File

@@ -0,0 +1,12 @@
//go:build cgo
package parser
import "html"
// OfficeOxide is the lib_type identifier for office_oxide backend.
const OfficeOxide = "office_oxide"
func htmlEscape(s string) string {
return html.EscapeString(s)
}

View File

@@ -0,0 +1,136 @@
//go:build !cgo
package parser
import (
"errors"
"fmt"
)
// OfficeOxide is the lib_type identifier for office_oxide backend.
const OfficeOxide = "office_oxide"
// ErrOfficeCGORequired is returned by ParseWithResult on every
// office-parser family (DOC / DOCX / PPT / PPTX / XLS / XLSX)
// when the build is not CGO-enabled. The CGO build's
// implementation captures the office_oxide PlainText / ToMarkdown
// output; this stub mirrors that surface so the package compiles
// and existing tests pass.
var ErrOfficeCGORequired = errors.New("parser: office family requires CGO (office_oxide)")
// docxParseWithResultNoCGO is the no-CGO stub for the DOCX
// family. The CGO build's implementation lives in docx_parser.go
// under //go:build cgo.
func (p *DOCXParser) ParseWithResult(filename string, _ []byte) ParseResult {
return ParseResult{
File: map[string]any{"name": filename},
Err: fmt.Errorf("%w: docx", ErrOfficeCGORequired),
}
}
func (p *DOCParser) ParseWithResult(filename string, _ []byte) ParseResult {
return ParseResult{
File: map[string]any{"name": filename},
Err: fmt.Errorf("%w: doc", ErrOfficeCGORequired),
}
}
func (p *XLSParser) ParseWithResult(filename string, _ []byte) ParseResult {
return ParseResult{
File: map[string]any{"name": filename},
Err: fmt.Errorf("%w: xls", ErrOfficeCGORequired),
}
}
func (p *XLSXParser) ParseWithResult(filename string, _ []byte) ParseResult {
return ParseResult{
File: map[string]any{"name": filename},
Err: fmt.Errorf("%w: xlsx", ErrOfficeCGORequired),
}
}
func (p *PPTParser) ParseWithResult(filename string, _ []byte) ParseResult {
return ParseResult{
File: map[string]any{"name": filename},
Err: fmt.Errorf("%w: ppt", ErrOfficeCGORequired),
}
}
func (p *PPTXParser) ParseWithResult(filename string, _ []byte) ParseResult {
return ParseResult{
File: map[string]any{"name": filename},
Err: fmt.Errorf("%w: pptx", ErrOfficeCGORequired),
}
}
type DOCParser struct {
libType string
}
func NewDOCParser(libType string) (*DOCParser, error) {
return nil, fmt.Errorf("DOC parser requires CGO (office_oxide)")
}
func (p *DOCParser) String() string {
return "DOCParser(no-cgo)"
}
type DOCXParser struct {
libType string
}
func NewDOCXParser(libType string) (*DOCXParser, error) {
return nil, fmt.Errorf("DOCX parser requires CGO (office_oxide)")
}
func (p *DOCXParser) String() string {
return "DOCXParser(no-cgo)"
}
type XLSParser struct {
libType string
}
func NewXLSParser(libType string) (*XLSParser, error) {
return nil, fmt.Errorf("XLS parser requires CGO (office_oxide)")
}
func (p *XLSParser) String() string {
return "XLSParser(no-cgo)"
}
type XLSXParser struct {
libType string
}
func NewXLSXParser(libType string) (*XLSXParser, error) {
return nil, fmt.Errorf("XLSX parser requires CGO (office_oxide)")
}
func (p *XLSXParser) String() string {
return "XLSXParser(no-cgo)"
}
type PPTParser struct {
libType string
}
func NewPPTParser(libType string) (*PPTParser, error) {
return nil, fmt.Errorf("PPT parser requires CGO (office_oxide)")
}
func (p *PPTParser) String() string {
return "PPTParser(no-cgo)"
}
type PPTXParser struct {
libType string
}
func NewPPTXParser(libType string) (*PPTXParser, error) {
return nil, fmt.Errorf("PPTX parser requires CGO (office_oxide)")
}
func (p *PPTXParser) String() string {
return "PPTXParser(no-cgo)"
}

View File

@@ -0,0 +1,88 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// ParseResult is the structured output contract for the Go parser
// library. Port-rag-flow-pipeline-to-go.md §6.5 mandates that
// parsers surface enough data to reconstruct a Python-compatible
// stage-boundary payload:
//
// output_format ∈ {"json","markdown","text","html"}
// file (enriched metadata)
// exactly one payload family populated (matching output_format)
// err
//
// Go parser callers now consume only the structured ParseResult
// contract. The legacy `Parse(filename, []byte) error` interface has
// been removed so parser dispatch, ingestion, and service paths all
// share the same typed payload contract.
package parser
// ParseResult is the structured return value of a successful parse.
// Exactly one of the payload fields (JSON / Markdown / Text / HTML)
// is populated on success, matching the Python contract — see
// port-rag-flow-pipeline-to-go.md §4.2:
//
// - OutputFormat = "json" → JSON populated
// - OutputFormat = "markdown" → Markdown populated
// - OutputFormat = "text" → Text populated
// - OutputFormat = "html" → HTML populated
//
// On failure (Err != nil), all payload fields are zero values and
// OutputFormat is empty.
type ParseResult struct {
// OutputFormat is the wire-compatible format the parser
// chose. Empty when Err is non-nil.
OutputFormat string
// File is the enriched file metadata the parser emits. In
// Python this is the dict form of the original `file`
// descriptor, augmented with format-specific keys (e.g.
// `outline` on the PDF path, `page_count` for paginated
// formats). Nil when the parser did not enrich.
File map[string]any
// JSON is the structured payload when OutputFormat == "json".
// Shape depends on the parser family: PDF emits
// `[]map[string]any` with `text` + `doc_type_kwd` keys (and
// optional `image` / `layout` / `positions` fields);
// markdown / html / text emit normalized
// `{text, doc_type_kwd}` items; image emits OCR/VLM result
// items. Exactly one payload family is populated on success.
JSON []map[string]any
// Markdown is the string payload when OutputFormat ==
// "markdown". Empty otherwise.
Markdown string
// Text is the string payload when OutputFormat == "text".
// Empty otherwise.
Text string
// HTML is the string payload when OutputFormat == "html".
// Empty otherwise.
HTML string
// Err is the failure reason. On non-nil Err, all payload
// fields are zero values.
Err error
}
// ParseResultProducer is the parser package's single structured-output
// contract. Every parser returned by GetParser must implement it.
type ParseResultProducer interface {
ParseWithResult(filename string, data []byte) ParseResult
}

View File

@@ -0,0 +1,212 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"strings"
"testing"
)
// TestParseResult_Contract pins the wire-shape guarantees
// port-rag-flow-pipeline-to-go.md §6.5 requires:
//
// - exactly one payload family populated on success
// - empty payload fields on failure (Err != nil)
// - OutputFormat is "" on failure
//
// These are the public-key invariants a component-level caller
// (component/parser.go) relies on. The test does not exercise any
// specific parser implementation — it pins the contract that the
// ParseResult type itself enforces, so future parser additions
// inherit the same guarantees.
func TestParseResult_Contract(t *testing.T) {
cases := []struct {
name string
in ParseResult
wantErr bool
wantFmt string // expected OutputFormat on success
}{
{
name: "json family only",
in: ParseResult{
OutputFormat: "json",
JSON: []map[string]any{
{"text": "hello", "doc_type_kwd": "text"},
},
},
wantFmt: "json",
},
{
name: "markdown family only",
in: ParseResult{
OutputFormat: "markdown",
Markdown: "# Title\n\nbody",
},
wantFmt: "markdown",
},
{
name: "text family only",
in: ParseResult{
OutputFormat: "text",
Text: "raw text",
},
wantFmt: "text",
},
{
name: "html family only",
in: ParseResult{
OutputFormat: "html",
HTML: "<p>x</p>",
},
wantFmt: "html",
},
{
name: "failure clears payload",
in: ParseResult{Err: errSentinel},
wantErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.wantErr {
if tc.in.Err == nil {
t.Fatal("Err: want non-nil, got nil")
}
if tc.in.OutputFormat != "" {
t.Errorf("OutputFormat on failure = %q, want empty", tc.in.OutputFormat)
}
if tc.in.JSON != nil || tc.in.Markdown != "" || tc.in.Text != "" || tc.in.HTML != "" {
t.Errorf("payload fields must be zero on failure; got %+v", tc.in)
}
return
}
if tc.in.Err != nil {
t.Errorf("Err on success: want nil, got %v", tc.in.Err)
}
if tc.in.OutputFormat != tc.wantFmt {
t.Errorf("OutputFormat = %q, want %q", tc.in.OutputFormat, tc.wantFmt)
}
// Exactly-one-payload-family: pick the family declared
// by OutputFormat; every other field must be zero.
active := payloadFamily(tc.in)
for _, other := range allPayloadFamilies {
if other == active {
continue
}
if !isPayloadZero(tc.in, other) {
t.Errorf("OutputFormat=%s: secondary family %s should be zero", tc.in.OutputFormat, other)
}
}
})
}
}
// errSentinel is a tiny stand-in so the test does not need to
// import errors just to declare one.
var errSentinel = sentinelErr("sentinel")
type sentinelErr string
func (s sentinelErr) Error() string { return string(s) }
// TestMarkdownParser_ParseWithResult pins the migration exemplar
// — port-rag-flow-pipeline-to-go.md §6.5 marks MarkdownParser as
// the first ported format whose ParseWithResult surface emulates
// the Python "json" output_format. The fixture is intentionally
// tiny: 1 heading, 1 paragraph, 1 unordered list item, no nested
// formatting.
func TestMarkdownParser_ParseWithResult(t *testing.T) {
p, err := NewMarkdownParser(GoMarkdown)
if err != nil {
t.Fatalf("NewMarkdownParser: %v", err)
}
src := []byte("# Title\n\nFirst paragraph.\n\n- Item one\n")
res := p.ParseWithResult("doc.md", src)
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if res.OutputFormat != "json" {
t.Errorf("OutputFormat = %q, want json", res.OutputFormat)
}
if res.File == nil || res.File["name"] != "doc.md" {
t.Errorf("File.name = %v, want doc.md", res.File)
}
if res.JSON == nil {
t.Fatal("JSON: want non-nil slice, got nil")
}
// 1 heading + 1 paragraph + 1 list = 3 items.
if len(res.JSON) != 3 {
t.Fatalf("JSON len = %d, want 3 (heading, paragraph, list)", len(res.JSON))
}
heading := res.JSON[0]
if txt, _ := heading["text"].(string); !strings.Contains(txt, "Title") {
t.Errorf("JSON[0].text = %q, want contains 'Title'", txt)
}
if got, _ := heading["ck_type"].(string); got != "heading" {
t.Errorf("JSON[0].ck_type = %q, want heading", got)
}
for i, it := range res.JSON {
if _, ok := it["doc_type_kwd"]; !ok {
t.Errorf("JSON[%d] missing doc_type_kwd: %+v", i, it)
}
}
}
// TestParseResultProducer_PDFIsProducer pins the explicit
// PDFParser ParseResultProducer wiring. The dispatch seam routes
// PDFs through ParseWithResult regardless of whether the current
// build has the cgo-backed DeepDOC engine enabled.
func TestParseResultProducer_PDFIsProducer(t *testing.T) {
pdf := &PDFParser{}
if _, ok := any(pdf).(ParseResultProducer); !ok {
t.Error("PDFParser must implement ParseResultProducer so the " +
"dispatch seam routes PDFs through ParseWithResult")
}
}
// payloadFamily returns the field name that should be populated
// given the declared OutputFormat. "" when OutputFormat is
// unrecognized.
func payloadFamily(r ParseResult) string {
switch r.OutputFormat {
case "json":
return "json"
case "markdown":
return "markdown"
case "text":
return "text"
case "html":
return "html"
}
return ""
}
var allPayloadFamilies = []string{"json", "markdown", "text", "html"}
func isPayloadZero(r ParseResult, family string) bool {
switch family {
case "json":
return r.JSON == nil
case "markdown":
return r.Markdown == ""
case "text":
return r.Text == ""
case "html":
return r.HTML == ""
}
return false
}

View File

@@ -0,0 +1,199 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Slice 1 tests for port-rag-flow-pipeline-to-go.md Phase 2.5.
// These pin the new ParseWithResult contracts for the parsers
// that did not previously satisfy ParseResultProducer:
//
// - HTMLParser — block-level walker that emits the python-compatible
// {text, doc_type_kwd, ck_type} shape.
// - TextParser — paragraph-splitting for the text&code family
// (.txt / .py / .js / .java / .c / .cpp / .h / .php / .go / .ts
// / .sh / .cs / .kt / .sql).
//
// MarkdownParser's ParseWithResult is already pinned by
// parse_result_test.go (prior slice). PDFParser and the office
// variants remain deferred to a follow-up slice that wires
// them to the existing internal/deepdoc/parser/pdf pipeline and
// office_oxide libraries.
package parser
import (
"strings"
"testing"
"ragflow/internal/utility"
)
// TestTextParser_ParseWithResult_ParaSplit pins the paragraph-split
// rule. A blank-line-separated input yields one item per
// paragraph; the python TxtParser does the same.
func TestTextParser_ParseWithResult_ParaSplit(t *testing.T) {
p, err := NewTextParser("")
if err != nil {
t.Fatalf("NewTextParser: %v", err)
}
src := []byte("First paragraph.\n\nSecond paragraph.\n\nThird.")
res := p.ParseWithResult("doc.txt", src)
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if res.OutputFormat != "json" {
t.Errorf("OutputFormat = %q, want json", res.OutputFormat)
}
if got, want := res.File["name"], "doc.txt"; got != want {
t.Errorf("File.name = %v, want %v", got, want)
}
if len(res.JSON) != 3 {
t.Fatalf("JSON len = %d, want 3 (one per paragraph)", len(res.JSON))
}
if got, want := res.JSON[0]["text"], "First paragraph."; got != want {
t.Errorf("JSON[0].text = %v, want %v", got, want)
}
if got, want := res.JSON[2]["text"], "Third."; got != want {
t.Errorf("JSON[2].text = %v, want %v", got, want)
}
}
// TestTextParser_ParseWithResult_Empty pins the empty-input
// fallback (one empty item, not nil) so the downstream chunker
// sees a non-nil JSON slice. Mirrors the MarkdownParser convention
// at markdown_parser.go:71-76.
func TestTextParser_ParseWithResult_Empty(t *testing.T) {
p, _ := NewTextParser("")
res := p.ParseWithResult("empty.txt", []byte{})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(res.JSON) != 1 {
t.Errorf("JSON len = %d, want 1 (empty-input fallback)", len(res.JSON))
}
}
// TestTextParser_ParseWithResult_LongParagraphSlicing pins the
// maxItemBytes boundary behaviour. A single paragraph longer
// than 8192 bytes is sliced at the nearest line boundary.
func TestTextParser_ParseWithResult_LongParagraphSlicing(t *testing.T) {
p, _ := NewTextParser("")
long := strings.Repeat("a", 9000)
res := p.ParseWithResult("long.txt", []byte(long))
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(res.JSON) < 2 {
t.Errorf("JSON len = %d, want >=2 (sliced at maxItemBytes)", len(res.JSON))
}
for i, it := range res.JSON {
if txt, _ := it["text"].(string); len(txt) > 8192 {
t.Errorf("JSON[%d].text len = %d, exceeds maxItemBytes=8192", i, len(txt))
}
}
}
// TestTextParser_ParseWithResult_InvalidUTF8 pins the UTF-8
// validation rule. Invalid bytes produce an error in the result
// (matching the python TxtParser's behaviour).
func TestTextParser_ParseWithResult_InvalidUTF8(t *testing.T) {
p, _ := NewTextParser("")
bad := []byte{0xff, 0xfe, 0xfd}
res := p.ParseWithResult("bad.txt", bad)
if res.Err == nil {
t.Fatal("want error for invalid UTF-8, got nil")
}
}
// TestHTMLParser_ParseWithResult_BlockSplit pins the HTML walker.
// Three block elements (heading, paragraph, list) yield three
// items with the python-compatible ck_type vocabulary.
func TestHTMLParser_ParseWithResult_BlockSplit(t *testing.T) {
p, err := NewHTMLParser(Official)
if err != nil {
t.Fatalf("NewHTMLParser: %v", err)
}
src := []byte(`<!DOCTYPE html><html><body>
<h1>Title</h1>
<p>First paragraph.</p>
<ul><li>Item one</li></ul>
</body></html>`)
res := p.ParseWithResult("doc.html", src)
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if res.OutputFormat != "json" {
t.Errorf("OutputFormat = %q, want json", res.OutputFormat)
}
if len(res.JSON) != 3 {
t.Fatalf("JSON len = %d, want 3 (h1, p, ul)", len(res.JSON))
}
if got, want := res.JSON[0]["ck_type"], "heading"; got != want {
t.Errorf("JSON[0].ck_type = %v, want %v", got, want)
}
if got, want := res.JSON[0]["text"], "Title"; got != want {
t.Errorf("JSON[0].text = %v, want %v", got, want)
}
if got, want := res.JSON[1]["ck_type"], "paragraph"; got != want {
t.Errorf("JSON[1].ck_type = %v, want %v", got, want)
}
if got, want := res.JSON[1]["text"], "First paragraph."; got != want {
t.Errorf("JSON[1].text = %v, want %v", got, want)
}
if got, want := res.JSON[2]["ck_type"], "list"; got != want {
t.Errorf("JSON[2].ck_type = %v, want %v", got, want)
}
if got, want := res.JSON[2]["text"], "Item one"; got != want {
t.Errorf("JSON[2].text = %v, want %v", got, want)
}
}
// TestHTMLParser_ParseWithResult_SkipsScriptAndStyle pins the
// rule that <script> / <style> subtrees are skipped entirely so
// they don't pollute the downstream chunker input.
func TestHTMLParser_ParseWithResult_SkipsScriptAndStyle(t *testing.T) {
p, _ := NewHTMLParser(Official)
src := []byte(`<html><body>
<p>Visible.</p>
<script>alert("x")</script>
<style>body { color: red; }</style>
<p>Also visible.</p>
</body></html>`)
res := p.ParseWithResult("doc.html", src)
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(res.JSON) != 2 {
t.Errorf("JSON len = %d, want 2 (script+style skipped)", len(res.JSON))
}
for _, it := range res.JSON {
if txt, _ := it["text"].(string); strings.Contains(txt, "alert") || strings.Contains(txt, "color") {
t.Errorf("item text leaks script/style content: %q", txt)
}
}
}
// TestGetParser_RoutesTextAndCode pins the parser-type switch
// routing for the text&code family. After the Slice 1 additions
// `utility.FileTypeTXT` resolves to a TextParser that satisfies
// ParseResultProducer.
func TestGetParser_RoutesTextAndCode(t *testing.T) {
p, err := GetParser(utility.FileTypeTXT, map[string]string{"lib_type": ""})
if err != nil {
t.Fatalf("GetParser(FileTypeTXT): %v", err)
}
if _, ok := p.(ParseResultProducer); !ok {
t.Fatal("TextParser does not implement ParseResultProducer")
}
}

View File

@@ -0,0 +1,53 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"fmt"
"ragflow/internal/utility"
)
func GetParser(fileType utility.FileType, config map[string]string) (ParseResultProducer, error) {
libType, ok := config["lib_type"]
if !ok {
return nil, fmt.Errorf("missing lib_type config")
}
switch fileType {
case utility.FileTypePPTX:
return NewPPTXParser(libType)
case utility.FileTypePPT:
return NewPPTParser(libType)
case utility.FileTypeXLSX:
return NewXLSXParser(libType)
case utility.FileTypeXLS:
return NewXLSParser(libType)
case utility.FileTypeDOCX:
return NewDOCXParser(libType)
case utility.FileTypeDOC:
return NewDOCParser(libType)
case utility.FileTypePDF:
return NewPDFParser(), nil
case utility.FileTypeHTML:
return NewHTMLParser(Official)
case utility.FileTypeMarkdown:
return NewMarkdownParser(GoMarkdown)
case utility.FileTypeTXT:
return NewTextParser(libType)
default:
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
}

View File

@@ -0,0 +1,26 @@
//go:build cgo
package parser
import (
"context"
"errors"
"fmt"
deepdocpdf "ragflow/internal/deepdoc/parser/pdf"
deepdoctype "ragflow/internal/deepdoc/parser/type"
)
func (p *PDFParser) ParseWithResult(filename string, data []byte) ParseResult {
cfg := deepdoctype.DefaultParserConfig()
cfg.SkipOCR = false
parser := deepdocpdf.NewParser(cfg)
res := parsePDFWithDeepDoc(context.Background(), filename, data, parser.Parse)
if res.Err != nil && errors.Is(res.Err, deepdocpdf.ErrNoPDFData) {
return ParseResult{Err: fmt.Errorf("%w: %s", ErrPDFEngineUnavailable, filename)}
}
if res.Err != nil && res.Err.Error() == "deepdoc/pdf: cgo required" {
return ParseResult{Err: fmt.Errorf("%w: %s", ErrPDFEngineUnavailable, filename)}
}
return res
}

View File

@@ -0,0 +1,49 @@
//go:build cgo
package parser
import (
"strings"
"testing"
)
func TestPDFParser_ParseWithResult_CGOInvalidPDF(t *testing.T) {
t.Setenv("DEEPDOC_URL", "")
t.Setenv("OSSDEEPDOC_URL", "")
pdf := NewPDFParser()
res := pdf.ParseWithResult("bad.pdf", []byte("not a valid pdf"))
if res.Err == nil {
t.Fatal("want parse error for invalid PDF bytes, got nil")
}
}
func TestPDFParser_ParseWithResult_CGOEmpty(t *testing.T) {
pdf := NewPDFParser()
res := pdf.ParseWithResult("empty.pdf", nil)
if res.Err != nil {
t.Fatalf("empty input: want nil err, got %v", res.Err)
}
if res.OutputFormat != "json" {
t.Fatalf("OutputFormat = %q, want json", res.OutputFormat)
}
if got, want := res.File["name"], "empty.pdf"; got != want {
t.Fatalf("File.name = %v, want %v", got, want)
}
if got := res.File["page_count"]; got != 0 {
t.Fatalf("File.page_count = %v, want 0", got)
}
outline, ok := res.File["outline"].([]map[string]any)
if !ok {
t.Fatalf("File.outline type = %T, want []map[string]any", res.File["outline"])
}
if len(outline) != 0 {
t.Fatalf("File.outline len = %d, want 0", len(outline))
}
if len(res.JSON) != 1 {
t.Fatalf("JSON len = %d, want 1", len(res.JSON))
}
if text, _ := res.JSON[0]["text"].(string); strings.TrimSpace(text) != "" {
t.Fatalf("JSON[0].text = %q, want empty", text)
}
}

View File

@@ -0,0 +1,181 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
deepdocpdf "ragflow/internal/deepdoc/parser/pdf"
"ragflow/internal/deepdoc/parser/pdf/inference"
pdflayout "ragflow/internal/deepdoc/parser/pdf/layout"
deepdoctype "ragflow/internal/deepdoc/parser/type"
)
// ErrPDFEngineUnavailable is returned by PDFParser.ParseWithResult
// when the current build cannot construct the DeepDOC PDF backend.
// The normal reason is a non-cgo build, because the pdfoxide bridge
// is compiled behind `//go:build cgo`.
var ErrPDFEngineUnavailable = errors.New("parser: PDF backend unavailable in this build")
type PDFParser struct {
ParserType string // DeepDoc, PaddleOCR, MinerU
Model string // DeepDoc@buildin@ragflow
LibType string // pdf_oxide, used by DeepDoc
}
func NewPDFParser() *PDFParser {
return &PDFParser{
ParserType: "DeepDoc",
Model: "DeepDoc@buildin@ragflow",
LibType: "pdf_oxide",
}
}
func (p *PDFParser) String() string {
return "PDFParser"
}
func emptyPDFResult(filename string) ParseResult {
return ParseResult{
OutputFormat: "json",
File: map[string]any{
"name": filename,
"page_count": 0,
"outline": []map[string]any{},
},
JSON: []map[string]any{{"text": "", "doc_type_kwd": "text"}},
}
}
func deepDocAnalyzerFromEnv() deepdoctype.DocAnalyzer {
baseURL := strings.TrimSpace(os.Getenv("DEEPDOC_URL"))
if baseURL == "" {
baseURL = strings.TrimSpace(os.Getenv("OSSDEEPDOC_URL"))
}
if baseURL == "" {
return &deepdocpdf.MockDocAnalyzer{Healthy: true}
}
client, err := inference.NewClient(baseURL)
if err != nil {
return &deepdocpdf.MockDocAnalyzer{Healthy: true}
}
if !client.Health() {
return &deepdocpdf.MockDocAnalyzer{Healthy: true}
}
return client
}
func pdfParseResultToJSON(filename string, parsed *deepdoctype.ParseResult) ParseResult {
if parsed == nil {
return ParseResult{Err: fmt.Errorf("parser: nil DeepDOC PDF result for %s", filename)}
}
items := pdflayout.SectionsToJSON(parsed.Sections)
if len(items) == 0 {
items = []map[string]any{{"text": "", "doc_type_kwd": "text"}}
}
for i := range items {
if layoutType, _ := items[i]["layout_type"].(string); layoutType != "" {
items[i]["layout"] = layoutType
}
if _, ok := items[i]["page_number"]; !ok {
items[i]["page_number"] = firstPageNumber(items[i]["_pdf_positions"])
}
if img, _ := items[i]["image"].(string); img != "" {
items[i]["image"] = "data:image/png;base64," + img
}
}
return ParseResult{
OutputFormat: "json",
File: map[string]any{
"name": filename,
"page_count": len(parsed.PageImages),
"outline": outlinesToFileMeta(parsed.Outlines),
},
JSON: items,
}
}
func outlinesToFileMeta(outlines []deepdoctype.Outline) []map[string]any {
if len(outlines) == 0 {
return []map[string]any{}
}
result := make([]map[string]any, 0, len(outlines))
for _, o := range outlines {
result = append(result, map[string]any{
"title": o.Title,
"level": o.Level,
"page_number": o.PageNumber,
})
}
return result
}
func firstPageNumber(raw any) int {
positions, ok := raw.([][]any)
if !ok || len(positions) == 0 || len(positions[0]) == 0 {
return 0
}
pages, ok := positions[0][0].([]any)
if !ok || len(pages) == 0 {
return 0
}
switch v := pages[0].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
default:
return 0
}
}
func inlinePNGDataURL(raw string) string {
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "data:image/") {
return raw
}
if _, err := base64.StdEncoding.DecodeString(raw); err != nil {
return raw
}
return "data:image/png;base64," + raw
}
func parsePDFWithDeepDoc(ctx context.Context, filename string, data []byte, parseFn func(context.Context, []byte, deepdoctype.DocAnalyzer) (*deepdoctype.ParseResult, error)) ParseResult {
if len(data) == 0 {
return emptyPDFResult(filename)
}
parsed, err := parseFn(ctx, data, deepDocAnalyzerFromEnv())
if err != nil {
return ParseResult{Err: err}
}
res := pdfParseResultToJSON(filename, parsed)
for i := range res.JSON {
if img, _ := res.JSON[i]["image"].(string); img != "" {
res.JSON[i]["image"] = inlinePNGDataURL(img)
}
}
return res
}

View File

@@ -0,0 +1,16 @@
//go:build !cgo
package parser
import (
"fmt"
)
func (p *PDFParser) ParseWithResult(filename string, data []byte) ParseResult {
if len(data) == 0 {
return emptyPDFResult(filename)
}
return ParseResult{
Err: fmt.Errorf("%w: %s", ErrPDFEngineUnavailable, filename),
}
}

View File

@@ -0,0 +1,31 @@
//go:build !cgo
package parser
import (
"errors"
"testing"
)
func TestPDFParser_ParseWithResult_NoCGO(t *testing.T) {
pdf := NewPDFParser()
empty := pdf.ParseWithResult("empty.pdf", nil)
if empty.Err != nil {
t.Fatalf("empty input: want nil err, got %v", empty.Err)
}
if empty.OutputFormat != "json" {
t.Fatalf("empty input OutputFormat = %q, want json", empty.OutputFormat)
}
if len(empty.JSON) != 1 {
t.Fatalf("empty input JSON len = %d, want 1", len(empty.JSON))
}
res := pdf.ParseWithResult("a.pdf", []byte("%PDF-1.4"))
if res.Err == nil {
t.Fatal("want ErrPDFEngineUnavailable, got nil")
}
if !errors.Is(res.Err, ErrPDFEngineUnavailable) {
t.Fatalf("err = %v, want wraps ErrPDFEngineUnavailable", res.Err)
}
}

View File

@@ -0,0 +1,58 @@
//go:build cgo
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"fmt"
)
type PPTParser struct {
libType string
}
func NewPPTParser(libType string) (*PPTParser, error) {
switch libType {
case OfficeOxide:
return &PPTParser{
libType: OfficeOxide,
}, nil
default:
return nil, fmt.Errorf("unsupported PPT library type: %s", libType)
}
}
func (p *PPTParser) String() string {
return "PPTParser"
}
// ParseWithResult delegates to PPTXParser's structured output
// for the legacy PPT format. The two file families differ only
// in the binary container; the python parser.py:slides branch
// treats them uniformly.
func (p *PPTParser) ParseWithResult(filename string, data []byte) ParseResult {
delegate, err := NewPPTXParser(OfficeOxide)
if err != nil {
return ParseResult{Err: fmt.Errorf("ppt delegate: %w", err)}
}
res := delegate.ParseWithResult(filename, data)
if res.File != nil {
res.File["format"] = "ppt"
}
return res
}

View File

@@ -0,0 +1,85 @@
//go:build cgo
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"fmt"
"strings"
officeOxide "github.com/yfedoseev/office_oxide/go"
)
type PPTXParser struct {
libType string
}
func NewPPTXParser(libType string) (*PPTXParser, error) {
switch libType {
case OfficeOxide:
return &PPTXParser{
libType: OfficeOxide,
}, nil
default:
return nil, fmt.Errorf("unsupported PPTX library type: %s", libType)
}
}
func (p *PPTXParser) String() string {
return "PPTXParser"
}
// ParseWithResult emits one JSON item per slide with the slide's
// plain text. Mirrors the python parser.py:slides branch which
// forces output_format="json" for the slide family.
func (p *PPTXParser) ParseWithResult(filename string, data []byte) ParseResult {
doc, err := officeOxide.OpenFromBytes(data, "pptx")
if err != nil {
return ParseResult{Err: fmt.Errorf("pptx open: %w", err)}
}
defer doc.Close()
text, err := doc.PlainText()
if err != nil {
return ParseResult{Err: fmt.Errorf("pptx plain-text: %w", err)}
}
// Split on form-feed (the python TxtParser convention used by
// ragflow's slide parser) — each block becomes a JSON item.
var items []map[string]any
for i, raw := range strings.Split(text, "\f") {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
items = append(items, map[string]any{
"text": trimmed,
"doc_type_kwd": "text",
"slide_number": i + 1,
})
}
if items == nil {
items = []map[string]any{{"text": strings.TrimSpace(text), "doc_type_kwd": "text"}}
}
return ParseResult{
OutputFormat: "json",
File: map[string]any{"name": filename, "format": "pptx"},
JSON: items,
}
}

View File

@@ -0,0 +1,178 @@
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// TextParser (port-rag-flow-pipeline-to-go.md Phase 2.5 Slice 1).
//
// The python rag/flow/parser/parser.py:_code path (L1066) routes
// .txt / .py / .js / .java / .c / .cpp / .h / .php / .go / .ts / .sh
// / .cs / .kt / .sql files through deepdoc.parser.TxtParser. The Go
// side needs a parser for these families so `text&code` resolves to a
// real ParseResultProducer.
//
// TextParser fills that gap with a minimal but real implementation:
// it splits the input into paragraph-sized items and emits the
// python-compatible `{text, doc_type_kwd:"text"}` shape. The
// python TxtParser additionally does layout-aware section
// detection; the Go version is intentionally simpler because (a)
// no production template currently relies on text&code for richer
// structure than paragraph items.
package parser
import (
"bytes"
"strings"
)
const TextParserLibType = "text"
// TextParser is the text&code family parser. It implements the
// structured ParseResultProducer contract directly.
type TextParser struct {
// maxItemBytes caps each emitted item's text length. The
// python TxtParser uses similar paragraph-style chunking;
// 8192 bytes is a conservative ceiling that prevents the
// downstream chunker from receiving oversized inputs.
maxItemBytes int
}
// NewTextParser constructs a TextParser. The libType argument
// preserves the parser-library constructor signature for
// consistency with the other family parsers; the value is ignored
// (TextParser has no alternative backend).
func NewTextParser(_ string) (*TextParser, error) {
return &TextParser{maxItemBytes: 8192}, nil
}
// ParseWithResult emits one item per non-empty paragraph. The
// output format is "json" to mirror the python TxtParser's
// behaviour (it emits a list of items with text + doc_type_kwd).
//
// The items slice is always non-nil so downstream chunkers see a
// non-empty JSON payload even for an empty input (mirrors the
// MarkdownParser convention at markdown_parser.go:71-76).
func (p *TextParser) ParseWithResult(filename string, data []byte) ParseResult {
if !utf8Valid(data) {
return ParseResult{Err: errInvalidUTF8}
}
items := textParserItems(data, p.maxItemBytes)
if items == nil {
items = []map[string]any{{"text": "", "doc_type_kwd": "text"}}
}
return ParseResult{
OutputFormat: "json",
File: map[string]any{
"name": filename,
"size": len(data),
"encoding": "utf-8",
},
JSON: items,
}
}
func (p *TextParser) String() string {
return "TextParser"
}
// errInvalidUTF8 is returned when the input bytes fail UTF-8
// validation. Matches the python TxtParser's behaviour of
// surfacing a clear error rather than emitting replacement bytes.
var errInvalidUTF8 = errInvalidUTF8Sentinel("parser: text input is not valid UTF-8")
type errInvalidUTF8Sentinel string
func (e errInvalidUTF8Sentinel) Error() string { return string(e) }
// utf8Valid is a tiny stdlib-free validator. We avoid
// unicode/utf8.Valid to keep this file dependency-light; the
// validation rule is the same (decode without rejecting bytes).
func utf8Valid(data []byte) bool {
for i := 0; i < len(data); {
r, size := decodeRune(data[i:])
if r == 0xFFFD && size == 1 {
return false
}
i += size
}
return true
}
// decodeRune is a minimal UTF-8 decoder that mirrors
// utf8.DecodeRune's signature: returns the rune and its byte
// width. Returns (RuneError, 1) on invalid sequences, matching
// the stdlib contract.
func decodeRune(p []byte) (rune, int) {
if len(p) == 0 {
return 0xFFFD, 0
}
c := p[0]
switch {
case c < 0x80:
return rune(c), 1
case c < 0xC2:
return 0xFFFD, 1
case c < 0xE0:
if len(p) < 2 || p[1]&0xC0 != 0x80 {
return 0xFFFD, 1
}
return rune(c&0x1F)<<6 | rune(p[1]&0x3F), 2
case c < 0xF0:
if len(p) < 3 || p[1]&0xC0 != 0x80 || p[2]&0xC0 != 0x80 {
return 0xFFFD, 1
}
return rune(c&0x0F)<<12 | rune(p[1]&0x3F)<<6 | rune(p[2]&0x3F), 3
case c < 0xF5:
if len(p) < 4 || p[1]&0xC0 != 0x80 || p[2]&0xC0 != 0x80 || p[3]&0xC0 != 0x80 {
return 0xFFFD, 1
}
return rune(c&0x07)<<18 | rune(p[1]&0x3F)<<12 | rune(p[2]&0x3F)<<6 | rune(p[3]&0x3F), 4
}
return 0xFFFD, 1
}
// textParserItems splits `data` into paragraph-sized chunks. The
// split rule mirrors the python TxtParser: blank lines separate
// paragraphs; long paragraphs are sliced at maxItemBytes boundaries.
func textParserItems(data []byte, maxItemBytes int) []map[string]any {
var items []map[string]any
for _, raw := range bytes.Split(data, []byte("\n\n")) {
text := strings.TrimSpace(string(raw))
if text == "" {
continue
}
if maxItemBytes > 0 && len(text) > maxItemBytes {
// Slice at the nearest newline below maxItemBytes;
// falls back to a hard slice when no newline exists.
cut := strings.LastIndex(text[:maxItemBytes], "\n")
if cut <= 0 {
cut = maxItemBytes
}
items = append(items, map[string]any{
"text": strings.TrimSpace(text[:cut]),
"doc_type_kwd": "text",
})
text = strings.TrimSpace(text[cut:])
if text == "" {
continue
}
}
items = append(items, map[string]any{
"text": text,
"doc_type_kwd": "text",
})
}
return items
}

View File

@@ -0,0 +1,85 @@
//go:build cgo
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"bytes"
"fmt"
"strings"
"github.com/xuri/excelize/v2"
)
type XLSParser struct {
libType string
}
func NewXLSParser(libType string) (*XLSParser, error) {
switch libType {
case OfficeOxide:
return &XLSParser{
libType: OfficeOxide,
}, nil
default:
return nil, fmt.Errorf("unsupported XLS library type: %s", libType)
}
}
func (p *XLSParser) String() string {
return "XLSParser"
}
// ParseWithResult delegates to excelize which handles both .xls
// and .xlsx through the same API. The python ExcelParser falls
// back to a similar delegation; on the Go side excelize is the
// single library for both extensions.
func (p *XLSParser) ParseWithResult(filename string, data []byte) ParseResult {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return ParseResult{Err: fmt.Errorf("xls open: %w", err)}
}
defer f.Close()
var html strings.Builder
html.WriteString("<html><body>")
for _, sheet := range f.GetSheetList() {
html.WriteString("<h3>")
html.WriteString(sheet)
html.WriteString("</h3>")
rows, _ := f.GetRows(sheet)
html.WriteString("<table>")
for _, row := range rows {
html.WriteString("<tr>")
for _, cell := range row {
html.WriteString("<td>")
html.WriteString(htmlEscape(cell))
html.WriteString("</td>")
}
html.WriteString("</tr>")
}
html.WriteString("</table>")
}
html.WriteString("</body></html>")
return ParseResult{
OutputFormat: "html",
File: map[string]any{"name": filename, "format": "xls"},
HTML: html.String(),
}
}

View File

@@ -0,0 +1,91 @@
//go:build cgo
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"bytes"
"fmt"
"strings"
"github.com/xuri/excelize/v2"
)
type XLSXParser struct {
libType string
}
func NewXLSXParser(libType string) (*XLSXParser, error) {
switch libType {
case OfficeOxide:
return &XLSXParser{
libType: OfficeOxide,
}, nil
default:
return nil, fmt.Errorf("unsupported XLSX library type: %s", libType)
}
}
func (p *XLSXParser) String() string {
return "XLSXParser"
}
// ParseWithResult renders the spreadsheet as HTML — the python
// ExcelParser shape. Each sheet becomes a <table> with row /
// column structure preserved; sheet names become <h3> headings
// so a downstream title chunker can pick them up. Implementation
// uses excelize (already in go.mod) instead of office_oxide's
// PlainText/ToMarkdown so cell boundaries survive the round-trip.
func (p *XLSXParser) ParseWithResult(filename string, data []byte) ParseResult {
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
return ParseResult{Err: fmt.Errorf("xlsx open: %w", err)}
}
defer f.Close()
sheets := f.GetSheetList()
var html strings.Builder
html.WriteString("<html><body>")
for _, sheet := range sheets {
html.WriteString("<h3>")
html.WriteString(sheet)
html.WriteString("</h3>")
rows, err := f.GetRows(sheet)
if err != nil {
continue
}
html.WriteString("<table>")
for _, row := range rows {
html.WriteString("<tr>")
for _, cell := range row {
html.WriteString("<td>")
html.WriteString(htmlEscape(cell))
html.WriteString("</td>")
}
html.WriteString("</tr>")
}
html.WriteString("</table>")
}
html.WriteString("</body></html>")
return ParseResult{
OutputFormat: "html",
File: map[string]any{"name": filename, "format": "xlsx", "sheets": len(sheets)},
HTML: html.String(),
}
}