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,55 @@
//
// 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 chunk
// Operator defines the interface for all chunking pipeline stages.
type Operator interface {
// Prepare configures the operator from a DSL stage config map.
Prepare(ctx *ChunkContext) error
// Execute runs the operator on the shared context.
Execute(ctx *ChunkContext) error
// Finish performs any cleanup.
Finish(ctx *ChunkContext) error
String() string
}
// ChunkData represents a single chunk produced by the pipeline.
type ChunkData struct {
Content string `json:"content"`
Size int `json:"size"`
Index int `json:"index,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (c *ChunkData) GetContent() string {
if c == nil {
return ""
}
return c.Content
}
// ChunkContext flows through the pipeline, carrying text and chunks.
type ChunkContext struct {
Origin string // raw text
TextAfterPreprocess string // text after preprocess operator
SplitChunks []ChunkData // chunks after split operator
ResultChunks []ChunkData // final or intermediate chunks
}

View File

@@ -0,0 +1,95 @@
//
// 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.
//
// Internal chunk execution entrypoint used by production callers.
package chunk
import "fmt"
// Run executes the internal chunk steps against `text` using typed
// options. The sequence is preprocess -> split -> postprocess.
func Run(text string, opts ChunkOptions) (*ChunkContext, error) {
if err := opts.validate(); err != nil {
return nil, err
}
ctx := &ChunkContext{
Origin: text,
TextAfterPreprocess: text,
}
var stages []Operator
var stageNames []string
if opts.NormalizeNewlines || opts.StripWhitespace || opts.RemoveEmptyLines {
pre, err := NewPreprocessOperator(map[string]interface{}{
"normalize_newlines": opts.NormalizeNewlines,
"strip_whitespace": opts.StripWhitespace,
"remove_empty_lines": opts.RemoveEmptyLines,
})
if err != nil {
return nil, fmt.Errorf("chunk: build preprocess: %w", err)
}
stages = append(stages, pre)
stageNames = append(stageNames, "preprocess")
}
split, err := NewSplitOperator(map[string]interface{}{
"strategy": opts.SplitStrategy,
})
if err != nil {
return nil, fmt.Errorf("chunk: build split: %w", err)
}
stages = append(stages, split)
stageNames = append(stageNames, "split")
postCfg := map[string]interface{}{}
if opts.MergeTargetSize > 0 {
postCfg["merge"] = map[string]interface{}{
"target_size": float64(opts.MergeTargetSize),
"strategy": "greedy",
}
}
if opts.FilterMinLength > 0 {
postCfg["filter"] = map[string]interface{}{
"min_length": float64(opts.FilterMinLength),
}
}
if len(postCfg) > 0 {
post, err := NewPostprocessOperator(postCfg)
if err != nil {
return nil, fmt.Errorf("chunk: build postprocess: %w", err)
}
stages = append(stages, post)
stageNames = append(stageNames, "postprocess")
}
for i, op := range stages {
if err := op.Prepare(ctx); err != nil {
return ctx, fmt.Errorf("%s: prepare: %w", stageNames[i], err)
}
if err := op.Execute(ctx); err != nil {
return ctx, fmt.Errorf("%s: execute: %w", stageNames[i], err)
}
if err := op.Finish(ctx); err != nil {
return ctx, fmt.Errorf("%s: finish: %w", stageNames[i], err)
}
}
if len(ctx.ResultChunks) == 0 && len(ctx.SplitChunks) > 0 {
ctx.ResultChunks = ctx.SplitChunks
}
return ctx, nil
}

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 chunk
import "testing"
func TestRun_SentenceSplitHandlesASCIIBoundaries(t *testing.T) {
ctx, err := Run("One. Two! Three? Four;", ChunkOptions{SplitStrategy: "sentence"})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(ctx.ResultChunks) != 4 {
t.Fatalf("got %d chunks, want 4", len(ctx.ResultChunks))
}
want := []string{"One", "Two", "Three", "Four"}
for i, chunk := range ctx.ResultChunks {
if chunk.Content != want[i] {
t.Errorf("chunk[%d] = %q, want %q", i, chunk.Content, want[i])
}
}
}
func TestRun_PostprocessHonorsTypedNumericOptions(t *testing.T) {
ctx, err := Run("a\nbb\nccc", ChunkOptions{
SplitStrategy: "paragraph",
MergeTargetSize: 100,
FilterMinLength: 2,
RemoveEmptyLines: true,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if len(ctx.ResultChunks) != 1 {
t.Fatalf("got %d chunks, want 1", len(ctx.ResultChunks))
}
if ctx.ResultChunks[0].Content != "a bb ccc" {
t.Errorf("merged content = %q, want %q", ctx.ResultChunks[0].Content, "a bb ccc")
}
}

View File

@@ -0,0 +1,43 @@
//
// 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 chunk
import "unicode"
// DetectLanguage returns a best-effort language code based on the
// proportion of CJK characters.
func DetectLanguage(text string) string {
cjk := 0
total := 0
for _, r := range text {
if unicode.Is(unicode.Han, r) {
cjk++
}
if unicode.IsLetter(r) {
total++
}
}
if total > 0 && float64(cjk)/float64(total) > 0.3 {
return "zh"
}
return "en"
}
// RuneCount returns the number of runes in text.
func RuneCount(text string) int {
return len([]rune(text))
}

View File

@@ -0,0 +1,59 @@
//
// 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.
//
// Typed options for the internal chunk execution path.
package chunk
import (
"fmt"
)
// ChunkOptions is the typed configuration for production callers.
// It intentionally models only the option subset used by the current
// production call sites.
type ChunkOptions struct {
// Preprocess flags. Any combination of the three is honoured; all
// false means "no preprocess stage" (the engine skips the stage
// rather than running an identity preprocess).
NormalizeNewlines bool
StripWhitespace bool
RemoveEmptyLines bool
// Split configuration. Callers must select a strategy; an unset
// strategy degrades to "sentence" inside the operator.
SplitStrategy string
// Postprocess configuration. Zero values mean "do not run that
// step"; non-zero values enable it.
MergeTargetSize int
// FilterMinLength > 0 drops chunks shorter than that (rune count).
FilterMinLength int
}
// validate ensures the typed option set is internally consistent.
// The check is cheap; an option set that fails validation will not
// produce meaningful results at run time.
func (o ChunkOptions) validate() error {
if o.MergeTargetSize < 0 {
return fmt.Errorf("chunk: MergeTargetSize must be >= 0 (got %d)", o.MergeTargetSize)
}
if o.FilterMinLength < 0 {
return fmt.Errorf("chunk: FilterMinLength must be >= 0 (got %d)", o.FilterMinLength)
}
return nil
}

View File

@@ -0,0 +1,200 @@
//
// 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 chunk
import (
"fmt"
"strings"
)
type mergeConfig struct {
TargetSize int `json:"target_size"`
}
type filterConfig struct {
MinLength int `json:"min_length"`
}
// ---------------------------------------------------------------------------
// PostprocessOperator
// ---------------------------------------------------------------------------
type PostprocessOperator struct {
merge *mergeConfig
filter *filterConfig
}
func NewPostprocessOperator(config map[string]interface{}) (*PostprocessOperator, error) {
op := &PostprocessOperator{}
// Merge
if m, ok := config["merge"].(map[string]interface{}); ok {
op.merge = &mergeConfig{}
if ts, ok := m["target_size"].(float64); ok {
op.merge.TargetSize = int(ts)
} else {
op.merge.TargetSize = 500
}
}
// Filter
if f, ok := config["filter"].(map[string]interface{}); ok {
op.filter = &filterConfig{}
if v, ok := f["min_length"].(float64); ok {
op.filter.MinLength = int(v)
}
}
return op, nil
}
func (o *PostprocessOperator) Prepare(chunkCtx *ChunkContext) error {
return nil
}
func (o *PostprocessOperator) Execute(chunkCtx *ChunkContext) error {
chunks := chunkCtx.SplitChunks
if len(chunks) == 0 {
return nil
}
// 1. Merge
if o.merge != nil {
chunks = o.mergeChunks(chunks)
}
// 2. Filter
if o.filter != nil {
chunks = o.filterChunks(chunks)
}
// Re-index
for i := range chunks {
chunks[i].Index = i
chunks[i].Size = len(chunks[i].GetContent())
}
chunkCtx.ResultChunks = chunks
return nil
}
func (o *PostprocessOperator) Finish(chunkCtx *ChunkContext) error {
return nil
}
func (o *PostprocessOperator) String() string {
var buf strings.Builder
buf.WriteString("postprocess:\n")
if o.merge != nil {
fmt.Fprintf(&buf, " merge:\n")
fmt.Fprintf(&buf, " target_size: %d\n", o.merge.TargetSize)
}
if o.filter != nil {
fmt.Fprintf(&buf, " filter:\n")
fmt.Fprintf(&buf, " min_length: %d\n", o.filter.MinLength)
}
return buf.String()
}
// mergeChunks greedily merges small chunks into larger ones up to target_size.
func (o *PostprocessOperator) mergeChunks(chunks []ChunkData) []ChunkData {
target := o.merge.TargetSize
if target <= 0 {
target = 500
}
var merged []ChunkData
var buf strings.Builder
var bufMeta map[string]interface{}
firstIndex := 0
for i, c := range chunks {
// If this single chunk already exceeds target, flush first then add
if len([]rune(c.Content)) >= target {
if buf.Len() > 0 {
merged = append(merged, ChunkData{
Content: buf.String(),
Index: firstIndex,
Metadata: bufMeta,
})
buf.Reset()
bufMeta = nil
}
merged = append(merged, c)
firstIndex = i + 1
continue
}
if buf.Len() == 0 {
buf.WriteString(c.Content)
bufMeta = c.Metadata
firstIndex = c.Index
} else {
nextLen := len([]rune(c.Content))
// If adding this chunk would exceed target, flush current and start new
if buf.Len()+nextLen+1 > target {
merged = append(merged, ChunkData{
Content: buf.String(),
Index: firstIndex,
Metadata: bufMeta,
})
buf.Reset()
buf.WriteString(c.Content)
bufMeta = c.Metadata
firstIndex = c.Index
} else {
buf.WriteString(" ")
buf.WriteString(c.Content)
// Merge metadata (last wins for overlapping keys)
if c.Metadata != nil && bufMeta == nil {
bufMeta = make(map[string]interface{})
}
for k, v := range c.Metadata {
bufMeta[k] = v
}
}
}
}
// Flush remaining
if buf.Len() > 0 {
merged = append(merged, ChunkData{
Content: buf.String(),
Index: firstIndex,
Metadata: bufMeta,
})
}
return merged
}
// filterChunks removes chunks outside the length bounds.
func (o *PostprocessOperator) filterChunks(chunks []ChunkData) []ChunkData {
filtered := make([]ChunkData, 0, len(chunks))
for _, c := range chunks {
l := len([]rune(c.Content))
if o.filter.MinLength > 0 && l < o.filter.MinLength {
continue
}
filtered = append(filtered, c)
}
return filtered
}

View File

@@ -0,0 +1,133 @@
//
// 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 chunk
import (
"fmt"
"regexp"
"strings"
)
type PreprocessOperator struct {
normalizeNewlines bool
stripWhitespace bool
removeEmptyLines bool
softLineBreakMerging bool
}
func NewPreprocessOperator(config map[string]interface{}) (*PreprocessOperator, error) {
operator := &PreprocessOperator{}
if v, ok := config["normalize_newlines"]; ok {
operator.normalizeNewlines, ok = v.(bool)
if !ok {
return nil, fmt.Errorf("preprocess: normalize_newlines must be bool")
}
}
if v, ok := config["strip_whitespace"]; ok {
operator.stripWhitespace, ok = v.(bool)
if !ok {
return nil, fmt.Errorf("preprocess: strip_whitespace must be bool")
}
}
if v, ok := config["remove_empty_lines"]; ok {
operator.removeEmptyLines, ok = v.(bool)
if !ok {
return nil, fmt.Errorf("preprocess: remove_empty_lines must be bool")
}
}
if v, ok := config["soft_line_break_merging"]; ok {
operator.softLineBreakMerging, ok = v.(bool)
if !ok {
return nil, fmt.Errorf("preprocess: soft_line_break_merging must be bool")
}
}
return operator, nil
}
func (o *PreprocessOperator) Prepare(chunkCtx *ChunkContext) error {
return nil
}
func (o *PreprocessOperator) Execute(chunkCtx *ChunkContext) error {
text := chunkCtx.Origin
if o.normalizeNewlines {
// \r\n → \n, \r → \n
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
// Collapse multiple \n into one
re := regexp.MustCompile(`\n{2,}`)
text = re.ReplaceAllString(text, "\n")
}
if o.stripWhitespace {
// Trim leading/trailing whitespace on each line
lines := strings.Split(text, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(line)
}
text = strings.Join(lines, "\n")
}
if o.removeEmptyLines {
lines := strings.Split(text, "\n")
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(line) != "" {
filtered = append(filtered, line)
}
}
text = strings.Join(filtered, "\n")
}
if o.softLineBreakMerging {
lines := strings.Split(text, "\n")
var merged []string
var current strings.Builder
sentenceEnd := regexp.MustCompile(`[.!?][\s]*$`)
for i, line := range lines {
if current.Len() > 0 {
current.WriteString(" ")
}
current.WriteString(line)
if i == len(lines)-1 || sentenceEnd.MatchString(line) {
merged = append(merged, current.String())
current.Reset()
}
}
text = strings.Join(merged, "\n")
}
chunkCtx.TextAfterPreprocess = text
return nil
}
func (o *PreprocessOperator) Finish(chunkCtx *ChunkContext) error {
return nil
}
func (o *PreprocessOperator) String() string {
var buf strings.Builder
buf.WriteString("preprocess:\n")
fmt.Fprintf(&buf, " normalize_newlines: %t\n", o.normalizeNewlines)
fmt.Fprintf(&buf, " strip_whitespace: %t\n", o.stripWhitespace)
fmt.Fprintf(&buf, " remove_empty_lines: %t\n", o.removeEmptyLines)
return buf.String()
}

View File

@@ -0,0 +1,163 @@
//
// 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 chunk
import (
"fmt"
"strings"
"unicode/utf8"
)
type SplitOperator struct {
strategy string
}
func NewSplitOperator(config map[string]interface{}) (*SplitOperator, error) {
op := &SplitOperator{}
if v, ok := config["strategy"]; ok {
if s, ok := v.(string); ok {
op.strategy = s
}
}
return op, nil
}
func (o *SplitOperator) Prepare(ctx *ChunkContext) error {
return nil
}
func (o *SplitOperator) Execute(ctx *ChunkContext) error {
text := ctx.TextAfterPreprocess
if o.strategy == "" {
o.strategy = "sentence"
}
switch o.strategy {
case "sentence":
ctx.SplitChunks = o.splitSentences(text)
case "char":
ctx.SplitChunks = o.splitByChar(text)
case "paragraph":
ctx.SplitChunks = o.splitByParagraph(text)
default:
ctx.SplitChunks = o.splitSentences(text)
}
return nil
}
func (o *SplitOperator) Finish(ctx *ChunkContext) error {
return nil
}
func (o *SplitOperator) String() string {
var buf strings.Builder
buf.WriteString("split:\n")
fmt.Fprintf(&buf, " strategy: %q\n", o.strategy)
return buf.String()
}
var sentenceBoundaries = []string{"。", "", "", ".", "!", "?", ";", "\n"}
// splitSentences splits text at the built-in sentence boundaries.
func (o *SplitOperator) splitSentences(text string) []ChunkData {
var chunks []ChunkData
var buf strings.Builder
i := 0
for i < len(text) {
// Try to match any boundary at current position (first match wins)
matchedBound := ""
for _, bound := range sentenceBoundaries {
if bound != "" && i+len(bound) <= len(text) && text[i:i+len(bound)] == bound {
matchedBound = bound
break
}
}
if matchedBound != "" {
if buf.Len() > 0 {
content := strings.TrimSpace(buf.String())
if content != "" {
chunks = append(chunks, ChunkData{
Content: content,
Index: len(chunks),
Metadata: map[string]interface{}{
"language": DetectLanguage(content),
},
})
}
buf.Reset()
}
i += len(matchedBound)
} else {
r, size := utf8.DecodeRuneInString(text[i:])
buf.WriteRune(r)
i += size
}
}
// flush remaining text
if buf.Len() > 0 {
content := strings.TrimSpace(buf.String())
if content != "" {
chunks = append(chunks, ChunkData{
Content: content,
Index: len(chunks),
Metadata: map[string]interface{}{
"language": DetectLanguage(content),
},
})
}
}
return chunks
}
func (o *SplitOperator) splitByChar(text string) []ChunkData {
var chunks []ChunkData
for len(text) > 0 {
r, size := utf8.DecodeRuneInString(text)
chunks = append(chunks, ChunkData{
Content: string(r),
Index: len(chunks),
Metadata: map[string]interface{}{"language": DetectLanguage(text)},
})
text = text[size:]
}
return chunks
}
func (o *SplitOperator) splitByParagraph(text string) []ChunkData {
paragraphs := strings.Split(text, "\n")
chunks := make([]ChunkData, 0, len(paragraphs))
for i, p := range paragraphs {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
chunks = append(chunks, ChunkData{
Content: trimmed,
Index: i,
Metadata: map[string]interface{}{"language": DetectLanguage(trimmed)},
})
}
return chunks
}

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(),
}
}