mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-15 01:18:26 +08:00
312 lines
7.9 KiB
Go
312 lines
7.9 KiB
Go
//
|
||
// 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 "&":
|
||
return "&"
|
||
case "<":
|
||
return "<"
|
||
case ">":
|
||
return ">"
|
||
case """:
|
||
return "\""
|
||
case "'":
|
||
return "'"
|
||
case " ":
|
||
return " "
|
||
case "–":
|
||
return "–"
|
||
case "—":
|
||
return "—"
|
||
case "‘":
|
||
return "'"
|
||
case "’":
|
||
return "'"
|
||
case "“":
|
||
return "\""
|
||
case "”":
|
||
return "\""
|
||
case "…":
|
||
return "…"
|
||
default:
|
||
return entity
|
||
}
|
||
}
|