Files
ragflow/internal/parser/parser/epub_parser.go
qinling0210 d549194562 Implement builtin chunk method as ingestion pipeline in GO (#16822)
### Summary

Implement builtin chunk mehtod as ingestion pipeline in GO
2026-07-13 13:51:40 +08:00

312 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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.
//
// EPUBParser extracts text content from EPUB files. EPUB is a ZIP
// container with XHTML spine items. The parser:
//
// 1. Opens the EPUB as a ZIP archive.
// 2. Reads META-INF/container.xml to locate the OPF manifest.
// 3. Walks the OPF spine to collect content documents in order.
// 4. Strips HTML tags from each content document to produce plain
// text items, emitting one JSON item per spine entry.
//
// The output format is "json", matching the epub default in
// ParserParam.Defaults().
package parser
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"regexp"
"strings"
)
// EPUBParser extracts text from EPUB archives.
type EPUBParser struct{}
func NewEPUBParser() *EPUBParser {
return &EPUBParser{}
}
func (p *EPUBParser) String() string {
return "EPUBParser"
}
// ParseWithResult implements ParseResultProducer. It extracts XHTML
// content from the EPUB spine and emits one JSON item per spine entry
// with {text, doc_type_kwd:"text"}.
func (p *EPUBParser) ParseWithResult(filename string, data []byte) ParseResult {
if len(data) == 0 {
return ParseResult{
OutputFormat: "json",
File: map[string]any{
"name": filename,
"size": 0,
"encoding": "utf-8",
},
JSON: []map[string]any{{"text": "", "doc_type_kwd": "text"}},
}
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return ParseResult{Err: fmt.Errorf("epub: open zip: %w", err)}
}
opfPath, err := readContainerXML(reader)
if err != nil {
return ParseResult{Err: fmt.Errorf("epub: container.xml: %w", err)}
}
spineItems, err := readOPFSpine(reader, opfPath)
if err != nil {
return ParseResult{Err: fmt.Errorf("epub: opf: %w", err)}
}
items := extractEPUBTextItems(reader, opfPath, spineItems)
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,
}
}
// --- container.xml parsing ---
type containerXML struct {
RootFiles []struct {
FullPath string `xml:"full-path,attr"`
} `xml:"rootfiles>rootfile"`
}
func readContainerXML(r *zip.Reader) (string, error) {
f, err := r.Open("META-INF/container.xml")
if err != nil {
return "", fmt.Errorf("open container.xml: %w", err)
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("read container.xml: %w", err)
}
var c containerXML
if err := xml.Unmarshal(raw, &c); err != nil {
return "", fmt.Errorf("parse container.xml: %w", err)
}
if len(c.RootFiles) == 0 {
return "", fmt.Errorf("no rootfile in container.xml")
}
return c.RootFiles[0].FullPath, nil
}
// --- OPF parsing ---
type opfPackage struct {
Manifest struct {
Items []struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
Type string `xml:"media-type,attr"`
} `xml:"item"`
} `xml:"manifest"`
Spine struct {
ItemRefs []struct {
IDRef string `xml:"idref,attr"`
} `xml:"itemref"`
} `xml:"spine"`
}
func readOPFSpine(r *zip.Reader, opfPath string) ([]string, error) {
f, err := r.Open(opfPath)
if err != nil {
// Some EPUBs use a path with a leading slash stripped.
clean := strings.TrimPrefix(opfPath, "/")
if clean != opfPath {
f, err = r.Open(clean)
if err != nil {
return nil, fmt.Errorf("open opf %q: %w", opfPath, err)
}
} else {
return nil, fmt.Errorf("open opf %q: %w", opfPath, err)
}
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read opf: %w", err)
}
var pkg opfPackage
if err := xml.Unmarshal(raw, &pkg); err != nil {
return nil, fmt.Errorf("parse opf: %w", err)
}
// Build id → href lookup.
byID := make(map[string]string, len(pkg.Manifest.Items))
for _, it := range pkg.Manifest.Items {
byID[it.ID] = it.Href
}
// Build spine item list (ordered hrefs).
var hrefs []string
for _, ref := range pkg.Spine.ItemRefs {
if href, ok := byID[ref.IDRef]; ok {
hrefs = append(hrefs, href)
}
}
return hrefs, nil
}
// --- text extraction ---
var (
epubScriptRe = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
epubStyleRe = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
epubTagRe = regexp.MustCompile(`<[^>]+>`)
epubEntityRe = regexp.MustCompile(`&[a-zA-Z]+;`)
epubWSRe = regexp.MustCompile(`\s+`)
)
func extractEPUBTextItems(r *zip.Reader, opfDir string, spineHrefs []string) []map[string]any {
var items []map[string]any
for _, href := range spineHrefs {
text := readEPUBContentFile(r, opfDir, href)
if strings.TrimSpace(text) == "" {
continue
}
items = append(items, map[string]any{
"text": text,
"doc_type_kwd": "text",
})
}
return items
}
// readEPUBContentFile resolves a spine href (relative to the OPF
// directory) inside the ZIP, reads the raw bytes, and strips HTML to
// return clean text.
func readEPUBContentFile(r *zip.Reader, opfDir, href string) string {
// Resolve href relative to the directory containing the OPF.
// Use path.Join/Dir (slash separator) because ZIP entry paths are always POSIX.
resolved := path.Join(path.Dir(opfDir), href)
var f io.ReadCloser
var err error
// Try the resolved path first, then raw href.
for _, name := range []string{resolved, href} {
if strings.TrimSpace(name) == "" {
continue
}
f, err = r.Open(name)
if err == nil {
break
}
}
if err != nil {
return ""
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return ""
}
return stripHTMLTags(string(raw))
}
// stripHTMLTags removes HTML markup and returns normalized plain text.
func stripHTMLTags(s string) string {
// Remove script/style blocks first (including their content).
s = epubScriptRe.ReplaceAllString(s, "")
s = epubStyleRe.ReplaceAllString(s, "")
// Replace <br>, <p>, </p>, </div>, </tr>, </li> etc. with newlines.
s = epubTagRe.ReplaceAllStringFunc(s, func(tag string) string {
lower := strings.ToLower(tag)
switch {
case strings.HasPrefix(lower, "<br"),
strings.HasPrefix(lower, "</p"),
strings.HasPrefix(lower, "</div"),
strings.HasPrefix(lower, "</h1"),
strings.HasPrefix(lower, "</h2"),
strings.HasPrefix(lower, "</h3"),
strings.HasPrefix(lower, "</h4"),
strings.HasPrefix(lower, "</h5"),
strings.HasPrefix(lower, "</h6"),
strings.HasPrefix(lower, "</tr"),
strings.HasPrefix(lower, "</li"),
strings.HasPrefix(lower, "</blockquote"):
return "\n"
case strings.HasPrefix(lower, "</td"),
strings.HasPrefix(lower, "</th"):
return " "
}
return ""
})
// Decode common XML entities.
s = epubEntityRe.ReplaceAllStringFunc(s, xmlEntityValue)
// Normalize whitespace.
s = epubWSRe.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
// xmlEntityValue decodes a few common XML named entities. A full
// entity table isn't needed for the typical EPUB vocabulary.
func xmlEntityValue(entity string) string {
switch entity {
case "&amp;":
return "&"
case "&lt;":
return "<"
case "&gt;":
return ">"
case "&quot;":
return "\""
case "&apos;":
return "'"
case "&nbsp;":
return " "
case "&ndash;":
return ""
case "&mdash;":
return "—"
case "&lsquo;":
return "'"
case "&rsquo;":
return "'"
case "&ldquo;":
return "\""
case "&rdquo;":
return "\""
case "&hellip;":
return "…"
default:
return entity
}
}