mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-10 21:55:42 +08:00
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:
68
internal/parser/parser/doc_parser.go
Normal file
68
internal/parser/parser/doc_parser.go
Normal 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,
|
||||
}
|
||||
}
|
||||
69
internal/parser/parser/docx_parser.go
Normal file
69
internal/parser/parser/docx_parser.go
Normal 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"
|
||||
}
|
||||
185
internal/parser/parser/html_parser.go
Normal file
185
internal/parser/parser/html_parser.go
Normal 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
|
||||
}
|
||||
167
internal/parser/parser/markdown_parser.go
Normal file
167
internal/parser/parser/markdown_parser.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
internal/parser/parser/office_parsers_cgo_shared.go
Normal file
12
internal/parser/parser/office_parsers_cgo_shared.go
Normal 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)
|
||||
}
|
||||
136
internal/parser/parser/office_parsers_no_cgo.go
Normal file
136
internal/parser/parser/office_parsers_no_cgo.go
Normal 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)"
|
||||
}
|
||||
88
internal/parser/parser/parse_result.go
Normal file
88
internal/parser/parser/parse_result.go
Normal 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
|
||||
}
|
||||
212
internal/parser/parser/parse_result_test.go
Normal file
212
internal/parser/parser/parse_result_test.go
Normal 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
|
||||
}
|
||||
199
internal/parser/parser/parse_with_result_test.go
Normal file
199
internal/parser/parser/parse_with_result_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
53
internal/parser/parser/parser_type.go
Normal file
53
internal/parser/parser/parser_type.go
Normal 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)
|
||||
}
|
||||
}
|
||||
26
internal/parser/parser/pdf_parser_cgo.go
Normal file
26
internal/parser/parser/pdf_parser_cgo.go
Normal 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
|
||||
}
|
||||
49
internal/parser/parser/pdf_parser_cgo_test.go
Normal file
49
internal/parser/parser/pdf_parser_cgo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
181
internal/parser/parser/pdf_parser_common.go
Normal file
181
internal/parser/parser/pdf_parser_common.go
Normal 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
|
||||
}
|
||||
16
internal/parser/parser/pdf_parser_nocgo.go
Normal file
16
internal/parser/parser/pdf_parser_nocgo.go
Normal 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),
|
||||
}
|
||||
}
|
||||
31
internal/parser/parser/pdf_parser_nocgo_test.go
Normal file
31
internal/parser/parser/pdf_parser_nocgo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
58
internal/parser/parser/ppt_parser.go
Normal file
58
internal/parser/parser/ppt_parser.go
Normal 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
|
||||
}
|
||||
85
internal/parser/parser/pptx_parser.go
Normal file
85
internal/parser/parser/pptx_parser.go
Normal 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,
|
||||
}
|
||||
}
|
||||
178
internal/parser/parser/text_parser.go
Normal file
178
internal/parser/parser/text_parser.go
Normal 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
|
||||
}
|
||||
85
internal/parser/parser/xls_parser.go
Normal file
85
internal/parser/parser/xls_parser.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
91
internal/parser/parser/xlsx_parser.go
Normal file
91
internal/parser/parser/xlsx_parser.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user