// // 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)]*>.*?`) epubStyleRe = regexp.MustCompile(`(?is)]*>.*?`) 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
,

,

, , , etc. with newlines. s = epubTagRe.ReplaceAllStringFunc(s, func(tag string) string { lower := strings.ToLower(tag) switch { case strings.HasPrefix(lower, "" case """: return "\"" case "'": return "'" case " ": return " " case "–": return "–" case "—": return "—" case "‘": return "'" case "’": return "'" case "“": return "\"" case "”": return "\"" case "…": return "…" default: return entity } }