feat(parser): hand off EPUB images as media refs (#21677)

This commit is contained in:
jay77721 authored and GitHub committed 2026-10-29 17:20:44 +08:00
1 parent ac4a166d4c
commit 5b397cecd2
12 files changed
+945 -122

No files matched your search

+3
View File
@@ -3030,6 +3030,9 @@ func (c *CLI) APIParseLocalFileCommand(commandCount int, cmd *Command) (Response
if fileType == utility.FileTypeHTML {
fileParser = parser.NewHTMLTextParser()
}
if fileType == utility.FileTypeEPUB {
fileParser = parser.NewEPUBTextParser()
}
fileContent, err := os.ReadFile(filename)
if err != nil {
+2 -2
View File
@@ -45,8 +45,8 @@ func WithCache(ctx context.Context, cache Cache) context.Context {
}
// CacheFromContext returns the run-scoped media cache, or nil when the caller
// did not attach one. HTML images must fail rather than silently drop when it
// is absent.
// did not attach one. Parser paths that emit references must fail rather than
// silently drop images when it is absent.
func CacheFromContext(ctx context.Context) Cache {
cache, _ := ctx.Value(cacheContextKey{}).(Cache)
return cache
+11 -23
View File
@@ -16,7 +16,6 @@ package media
import (
"crypto/sha256"
"encoding/base64"
"os"
"strconv"
"strings"
@@ -46,32 +45,21 @@ func HTMLImageMaxBytes() int {
return defaultLimit
}
// HTMLImageExceedsLimit identifies oversized, syntactically valid data URIs.
func HTMLImageExceedsLimit(src string) bool {
// HTMLImageDecodedSize validates an image data URI and returns the decoded
// payload size without allocating the decoded bytes. Malformed payloads are
// rejected; a syntactically valid payload above HTMLImageMaxBytes is still
// reported by its size so callers can apply their own limit.
func HTMLImageDecodedSize(src string) (int, bool) {
_, payload, ok := splitHTMLImageSource(src)
if !ok {
return false
return 0, false
}
payload = strings.ReplaceAll(strings.ReplaceAll(payload, "\r", ""), "\n", "")
n := len(payload)
if n == 0 {
return false
encoded, padding, ok := scanBase64ImagePayload(payload, len(payload))
if !ok {
return 0, false
}
padding := 0
for padding < n && payload[n-1-padding] == '=' {
padding++
}
if padding > 2 {
return false
}
if n%4 == 1 {
return false
}
decoded := base64.StdEncoding.DecodedLen((n+3)/4*4) - padding
if n%4 != 0 {
decoded -= 4 - n%4
}
return decoded > HTMLImageMaxBytes()
padded := (encoded + 3) / 4 * 4
return padded/4*3 - (padded - encoded) - padding, true
}
// splitHTMLImageSource validates the data-URI header and returns the normalized
+47 -11
View File
@@ -67,20 +67,56 @@ func TestNormalizeHTMLImageSourceMatchesDecode(t *testing.T) {
}
}
func TestHTMLImageExceedsConfiguredLimit(t *testing.T) {
t.Setenv("RAGFLOW_HTML_IMAGE_MAX_BYTES", "5")
for _, tc := range []struct {
// TestHTMLImageDecodedSizeMatchesDecode checks that the allocation-free
// preflight accepts exactly what DecodeHTMLImageSource accepts and reports the
// same decoded size, so budget admission never diverges from the actual write.
func TestHTMLImageDecodedSizeMatchesDecode(t *testing.T) {
cases := []struct {
src string
want bool
want int // -1 means the source is malformed
}{
{"data:image/png;base64,aGVsbG8=", false},
{"data:image/png;base64,aGVsbG8", false},
{"data:image/png;base64,aGVsbG8h", true},
{"data:image/png;base64,YQ==", false},
} {
if got := HTMLImageExceedsLimit(tc.src); got != tc.want {
t.Errorf("%q: got %v, want %v", tc.src, got, tc.want)
{"data:image/png;base64,aGVsbG8=", 5},
{"data:image/png;base64,aGVsbG8", 5},
{"data:image/png;base64,aGVs\nbG8", 5},
{"DATA:IMAGE/PNG;BASE64,aGVsbG8=", 5},
{"data:image/png;base64,YQ==", 1},
{"data:image/png;base64,YWI=", 2},
{"data:image/png;base64,YQ", 1},
{"data:image/png;base64,", -1},
{"data:image/png;base64,YQ=", -1},
{"data:image/png;base64,YQ===", -1},
{"data:image/png;base64,G0==JbjQ", -1},
{"data:image/png;base64,Y?==", -1},
{"data:image/png;" + strings.Repeat("x", 200) + ",aGVsbG8=", -1},
}
for _, tc := range cases {
size, ok := HTMLImageDecodedSize(tc.src)
_, data, decodeOK := DecodeHTMLImageSource(tc.src)
if ok != (tc.want >= 0) {
t.Errorf("%q: HTMLImageDecodedSize ok = %v, want %v", tc.src, ok, tc.want >= 0)
continue
}
if ok != decodeOK {
t.Errorf("%q: preflight ok = %v, decode ok = %v", tc.src, ok, decodeOK)
continue
}
if ok && (size != tc.want || size != len(data)) {
t.Errorf("%q: size = %d, want %d (decoded %d)", tc.src, size, tc.want, len(data))
}
}
}
// TestHTMLImageDecodedSizeIgnoresPerImageLimit checks that the preflight
// reports the size of a syntactically valid payload even when it exceeds the
// configured per-image limit, leaving the limit decision to the caller.
func TestHTMLImageDecodedSizeIgnoresPerImageLimit(t *testing.T) {
t.Setenv("RAGFLOW_HTML_IMAGE_MAX_BYTES", "2")
size, ok := HTMLImageDecodedSize("data:image/png;base64,aGVsbG8=")
if !ok || size != 5 {
t.Fatalf("size, ok = %d, %v, want 5, true", size, ok)
}
if size <= HTMLImageMaxBytes() {
t.Fatalf("size %d does not exceed the configured limit %d", size, HTMLImageMaxBytes())
}
}
+23 -14
View File
@@ -81,7 +81,7 @@ func cleanBase64ImagePayload(payload string, maxDecodedBytes int) (string, bool)
if maxDecodedBytes < 0 {
return "", false
}
encodedBytes, ok := scanBase64ImagePayload(payload, base64.StdEncoding.EncodedLen(maxDecodedBytes))
encodedBytes, _, ok := scanBase64ImagePayload(payload, base64.StdEncoding.EncodedLen(maxDecodedBytes))
if !ok {
return "", false
}
@@ -94,42 +94,51 @@ func cleanBase64ImagePayload(payload string, maxDecodedBytes int) (string, bool)
}
// scanBase64ImagePayload counts the base64 characters of an image payload
// (CR/LF excluded) and rejects a payload whose padding is malformed: '=' may
// (CR/LF excluded) and returns the padding count. It rejects bytes outside the
// standard base64 alphabet and a payload whose padding is malformed: '=' may
// only appear in the final quantum, at most twice, and the character count
// must form whole quanta.
//
// The padding rule is what rejects data that follows a padded quantum. Go's
// streaming decoder only reports that when the whole payload lands in one
// decode call, so it cannot be left to the decoder.
func scanBase64ImagePayload(payload string, maxEncodedBytes int) (int, bool) {
encodedBytes := 0
padding := 0
func scanBase64ImagePayload(payload string, maxEncodedBytes int) (encodedBytes, padding int, ok bool) {
sawPadding := false
for i := 0; i < len(payload); i++ {
if payload[i] == '\r' || payload[i] == '\n' {
c := payload[i]
if c == '\r' || c == '\n' {
continue
}
encodedBytes++
if encodedBytes > maxEncodedBytes {
return 0, false
return 0, 0, false
}
if payload[i] == '=' {
if c == '=' {
sawPadding = true
padding++
if padding > 2 {
return 0, false
return 0, 0, false
}
continue
}
if sawPadding {
return 0, false
if sawPadding || !isBase64Alphabet(c) {
return 0, 0, false
}
}
if encodedBytes == 0 {
return 0, false
return 0, 0, false
}
if encodedBytes%4 == 1 || (padding > 0 && encodedBytes%4 != 0) {
return 0, false
return 0, 0, false
}
return encodedBytes, true
return encodedBytes, padding, true
}
// isBase64Alphabet reports whether c is a standard base64 alphabet byte.
func isBase64Alphabet(c byte) bool {
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '+', c == '/':
return true
}
return false
}
+3
View File
@@ -686,6 +686,9 @@ func (p *EmailParser) rechunkEmailAttachments(ctx context.Context, content map[s
if ft == utility.FileTypeHTML {
np = NewHTMLTextParser()
}
if ft == utility.FileTypeEPUB {
np = NewEPUBTextParser()
}
res, panicked = recoverParse(func() ParseResult {
return np.ParseWithResult(ctx, fn, []byte(payload))
})
+223 -40
View File
@@ -20,7 +20,11 @@
// 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. Emits text, tables and inline images from each chapter in document order.
// 4. Emits text, tables and image references from each chapter in document order.
//
// Chapter images are read from their ZIP entries and written to the run media
// cache; the result carries only a MediaRef. Inline data URIs take the same
// ref-only path as the HTML parser.
//
// The output format is "json", matching the epub default in
// ParserParam.Defaults().
@@ -41,16 +45,27 @@ import (
"golang.org/x/net/html"
"ragflow/internal/entity"
"ragflow/internal/parser/charset"
"ragflow/internal/parser/media"
)
// EPUBParser extracts text from EPUB archives.
type EPUBParser struct{}
type EPUBParser struct {
textOnly bool
}
func NewEPUBParser() *EPUBParser {
return &EPUBParser{}
}
// NewEPUBTextParser extracts EPUB text without reading or caching images.
// Callers without a run media cache use it to keep the previous text-only
// behavior.
func NewEPUBTextParser() *EPUBParser {
return &EPUBParser{textOnly: true}
}
func (p *EPUBParser) String() string {
return "EPUBParser"
}
@@ -79,12 +94,12 @@ func (p *EPUBParser) ParseWithResult(ctx context.Context, filename string, data
return ParseResult{Err: fmt.Errorf("epub: container.xml: %w", err)}
}
spineItems, err := readOPFSpine(reader, opfPath)
opf, err := readOPFManifest(reader, opfPath)
if err != nil {
return ParseResult{Err: fmt.Errorf("epub: opf: %w", err)}
}
items, detectedEnc, warnings, err := extractEPUBTextItems(reader, opfPath, spineItems)
items, detectedEnc, warnings, err := extractEPUBTextItems(ctx, reader, opf, p.textOnly)
if err != nil {
return ParseResult{Err: fmt.Errorf("epub: %w", err)}
}
@@ -148,25 +163,22 @@ type opfPackage struct {
} `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()
// opfManifest is the parsed OPF package document.
type opfManifest struct {
// Dir is the ZIP directory that contains the package document. Spine and
// manifest hrefs resolve against it.
Dir string
// SpineHrefs lists content-document hrefs in reading order.
SpineHrefs []string
// Images maps a resolved ZIP entry path to the media type the package
// declares for it.
Images map[string]string
}
raw, err := io.ReadAll(f)
func readOPFManifest(r *zip.Reader, opfPath string) (*opfManifest, error) {
raw, dir, err := readOPFBytes(r, opfPath)
if err != nil {
return nil, fmt.Errorf("read opf: %w", err)
return nil, err
}
var pkg opfPackage
if err := xml.Unmarshal(raw, &pkg); err != nil {
@@ -175,8 +187,16 @@ func readOPFSpine(r *zip.Reader, opfPath string) ([]string, error) {
// Build id → href lookup.
byID := make(map[string]string, len(pkg.Manifest.Items))
images := make(map[string]string)
for _, it := range pkg.Manifest.Items {
byID[it.ID] = it.Href
mime := strings.ToLower(strings.TrimSpace(it.Type))
if !strings.HasPrefix(mime, "image/") {
continue
}
if entry, ok := resolveEPUBEntry(dir, it.Href); ok {
images[entry] = mime
}
}
// Build spine item list (ordered hrefs).
@@ -186,7 +206,62 @@ func readOPFSpine(r *zip.Reader, opfPath string) ([]string, error) {
hrefs = append(hrefs, href)
}
}
return hrefs, nil
return &opfManifest{Dir: dir, SpineHrefs: hrefs, Images: images}, nil
}
// readOPFBytes reads the package document and returns its ZIP directory. Some
// EPUBs declare the OPF path with a leading slash, so the trimmed name is
// tried as well.
func readOPFBytes(r *zip.Reader, opfPath string) ([]byte, string, error) {
names := []string{strings.TrimSpace(opfPath)}
if trimmed := strings.TrimPrefix(names[0], "/"); trimmed != names[0] {
names = append(names, trimmed)
}
var lastErr error
for _, name := range names {
f, err := r.Open(name)
if err != nil {
lastErr = err
continue
}
raw, err := io.ReadAll(f)
f.Close()
if err != nil {
return nil, "", fmt.Errorf("read opf: %w", err)
}
return raw, path.Dir(name), nil
}
return nil, "", fmt.Errorf("open opf %q: %w", opfPath, lastErr)
}
// resolveEPUBEntry resolves an EPUB href or image source to a ZIP entry path.
// baseDir is the ZIP directory of the document that references it. Remote
// URLs, absolute paths, and references that escape the archive root are
// rejected.
func resolveEPUBEntry(baseDir, ref string) (string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", false
}
if i := strings.IndexByte(ref, '#'); i >= 0 {
ref = ref[:i]
}
unescaped, err := url.PathUnescape(ref)
if err != nil {
return "", false
}
ref = unescaped
if ref == "" || strings.HasPrefix(ref, "/") {
return "", false
}
if u, err := url.Parse(ref); err == nil && u.Scheme != "" {
return "", false
}
resolved := path.Join(baseDir, ref)
if resolved == "" || resolved == "." || resolved == ".." || strings.HasPrefix(resolved, "../") {
return "", false
}
return resolved, true
}
// --- text extraction ---
@@ -208,14 +283,41 @@ func normalizeEPUBSelfClosing(source string) string {
// skipped and no other item could be read, it returns an error instead of an
// empty result. An item that holds no text (an image-only chapter) still counts
// as read, and an empty item counts as neither.
func extractEPUBTextItems(r *zip.Reader, opfDir string, spineHrefs []string) ([]map[string]any, string, []string, error) {
func extractEPUBTextItems(ctx context.Context, r *zip.Reader, opf *opfManifest, textOnly bool) ([]map[string]any, string, []string, error) {
var items []map[string]any
var failures []string
read := 0
state := &htmlWalkState{}
budget := newEmbeddedMediaBudget()
// mediacache.Put enforces the same per-image limit, so keep the parser
// budget aligned with it instead of the legacy fixed constant.
budget.maxImageBytes = media.HTMLImageMaxBytes()
state := &htmlWalkState{
refOnly: true,
ctx: ctx,
mediaCache: media.CacheFromContext(ctx),
mediaBudget: budget,
mediaFormat: "epub",
skipImages: textOnly,
}
if textOnly {
// Preserve alt text without resolving or caching image bytes, matching
// NewHTMLTextParser.
state.resolveImage = func(string) (string, bool) { return "", false }
}
entries := make(map[string]*zip.File, len(r.File))
for _, f := range r.File {
// Match zip.Reader.Open, which resolves duplicate names to the first
// entry.
if _, ok := entries[f.Name]; !ok {
entries[f.Name] = f
}
}
detectedEncoding := "utf-8"
for _, href := range spineHrefs {
raw, err := readEPUBContentFile(r, opfDir, href)
for _, href := range opf.SpineHrefs {
if state.err != nil {
break
}
raw, chapterEntry, err := readEPUBContentFile(r, opf.Dir, href)
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", href, err))
continue
@@ -237,14 +339,89 @@ func extractEPUBTextItems(r *zip.Reader, opfDir string, spineHrefs []string) ([]
failures = append(failures, fmt.Sprintf("%s: %v", href, err))
continue
}
// Image sources are relative to the chapter, not to the OPF.
state.resolveMedia = epubMediaResolver(entries, state, chapterEntry, opf.Images)
items = appendEPUBChapter(items, doc, state)
}
if state.err != nil {
return nil, detectedEncoding, state.warnings(), state.err
}
if len(failures) > 0 && read == 0 {
return nil, detectedEncoding, state.warnings(), fmt.Errorf("no readable content: %d of %d content items could not be read (%s)", len(failures), len(spineHrefs), failures[0])
return nil, detectedEncoding, state.warnings(), fmt.Errorf("no readable content: %d of %d content items could not be read (%s)", len(failures), len(opf.SpineHrefs), failures[0])
}
return items, detectedEncoding, state.warnings(), nil
}
// epubMediaResolver returns the image resolver for one chapter. src is an image
// reference relative to the chapter's ZIP entry. The entry bytes are stored in
// the run media cache and the caller receives only the reference.
//
// A source that cannot be mapped to a readable manifest image is skipped and
// counted in the shared warning. Cache failures fail the task.
func epubMediaResolver(entries map[string]*zip.File, state *htmlWalkState, chapterEntry string, images map[string]string) func(string) (*entity.MediaRef, bool) {
chapterDir := path.Dir(chapterEntry)
return func(src string) (*entity.MediaRef, bool) {
if state.err != nil {
return nil, false
}
// Inline data URIs keep the shared HTML data-URI path.
if media.IsHTMLDataImageSource(src) {
return nil, false
}
if state.mediaCache == nil {
state.err = fmt.Errorf("%s media cache is not configured", state.cacheFormat())
return nil, false
}
name, ok := resolveEPUBEntry(chapterDir, src)
if !ok {
state.unresolvedImages++
return nil, false
}
mime, ok := images[name]
if !ok {
state.unresolvedImages++
return nil, false
}
entry, ok := entries[name]
if !ok {
state.unresolvedImages++
return nil, false
}
maxBytes := state.mediaBudget.maxImageBytes
if entry.UncompressedSize64 > uint64(maxBytes) {
// Charge the per-image limit so the skip is reported through the
// shared budget warning instead of being silent.
state.mediaBudget.includeReservedSize(maxBytes + 1)
return nil, false
}
// Reserve only for an actual ZIP read: entries skipped above need no
// bytes and must not exhaust the attempt budget.
if !state.mediaBudget.reserveItem() {
return nil, false
}
f, err := entry.Open()
if err != nil {
state.unresolvedImages++
return nil, false
}
data, err := io.ReadAll(io.LimitReader(f, int64(maxBytes)+1))
f.Close()
if err != nil || len(data) == 0 {
state.unresolvedImages++
return nil, false
}
if !state.mediaBudget.includeReservedSize(len(data)) {
return nil, false
}
ref, err := state.mediaCache.Put(state.ctx, state.cacheFormat(), mime, data)
if err != nil {
state.err = fmt.Errorf("%s media cache write: %w", state.cacheFormat(), err)
return nil, false
}
return &ref, true
}
}
// appendEPUBChapter keeps continuous chapter prose together, splitting only
// at tables and images. The shared leaf walker preserves their source order.
func appendEPUBChapter(items []map[string]any, doc *html.Node, state *htmlWalkState) []map[string]any {
@@ -276,9 +453,10 @@ func appendEPUBChapter(items []map[string]any, doc *html.Node, state *htmlWalkSt
return items
}
// readEPUBContentFile resolves a spine href (relative to the OPF
// directory) inside the ZIP and returns the item's raw bytes.
func readEPUBContentFile(r *zip.Reader, opfDir, href string) ([]byte, error) {
// readEPUBContentFile resolves a spine href (relative to the OPF directory)
// inside the ZIP and returns the item's raw bytes together with the entry name
// it actually opened.
func readEPUBContentFile(r *zip.Reader, opfDir, href string) ([]byte, string, error) {
// Manifest hrefs are URLs: drop the fragment and decode %-escapes to get
// the ZIP entry name.
if i := strings.IndexByte(href, '#'); i >= 0 {
@@ -289,23 +467,28 @@ func readEPUBContentFile(r *zip.Reader, opfDir, href string) ([]byte, error) {
}
// 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)
resolved := path.Join(opfDir, href)
var f io.ReadCloser
var err error
var lastErr 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
f, err := r.Open(name)
if err != nil {
lastErr = err
continue
}
raw, err := io.ReadAll(f)
f.Close()
if err != nil {
return nil, "", err
}
return raw, name, nil
}
if err != nil {
return nil, err
if lastErr == nil {
lastErr = fmt.Errorf("empty href")
}
defer f.Close()
return io.ReadAll(f)
return nil, "", lastErr
}
+524 -17
View File
@@ -20,11 +20,17 @@ import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
"ragflow/internal/entity"
"ragflow/internal/parser/media"
)
// epubTestChapter is one spine item of a test EPUB. A missing chapter is
@@ -38,17 +44,40 @@ type epubTestChapter struct {
damaged bool
}
// epubTestImage is one manifest image of a test EPUB.
type epubTestImage struct {
name string // manifest href, also the ZIP entry under OEBPS/ unless entry is set
entry string // ZIP entry under OEBPS/ when it differs from the manifest href
mime string // manifest media type; defaults to image/png
content string
missing bool // listed in the manifest but absent from the ZIP
damaged bool // raw Deflate entry whose data is not a valid Deflate stream
}
func epubTestHTML(body string) string {
return `<?xml version="1.0" encoding="utf-8"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Test</title></head><body>` + body + `</body></html>`
}
func buildTestEPUB(t *testing.T, chapters []epubTestChapter) []byte {
t.Helper()
return buildTestEPUBWithImages(t, chapters, nil)
}
func buildTestEPUBWithImages(t *testing.T, chapters []epubTestChapter, images []epubTestImage) []byte {
t.Helper()
var manifest, spine strings.Builder
for i, ch := range chapters {
fmt.Fprintf(&manifest, `<item id="c%d" href="%s" media-type="application/xhtml+xml"/>`, i, ch.name)
fmt.Fprintf(&spine, `<itemref idref="c%d"/>`, i)
}
for i, img := range images {
href := img.name
mime := img.mime
if mime == "" {
mime = "image/png"
}
fmt.Fprintf(&manifest, `<item id="m%d" href="%s" media-type="%s"/>`, i, href, mime)
}
containerXML := `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
contentOPF := `<?xml version="1.0" encoding="utf-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0"><manifest>` + manifest.String() + `</manifest><spine>` + spine.String() + `</spine></package>`
@@ -63,25 +92,41 @@ func buildTestEPUB(t *testing.T, chapters []epubTestChapter) []byte {
t.Fatalf("write %s: %v", name, err)
}
}
writeRaw := func(name string) {
w, err := zw.CreateRaw(&zip.FileHeader{Name: "OEBPS/" + name, Method: zip.Deflate, CompressedSize64: 4, UncompressedSize64: 64})
if err != nil {
t.Fatalf("create raw %s: %v", name, err)
}
if _, err := w.Write([]byte{0xff, 0xff, 0xff, 0xff}); err != nil {
t.Fatalf("write raw %s: %v", name, err)
}
}
write("META-INF/container.xml", containerXML)
write("OEBPS/content.opf", contentOPF)
for _, ch := range chapters {
switch {
case ch.missing:
case ch.damaged:
w, err := zw.CreateRaw(&zip.FileHeader{Name: "OEBPS/" + ch.name, Method: zip.Deflate, CompressedSize64: 4, UncompressedSize64: 64})
if err != nil {
t.Fatalf("create raw %s: %v", ch.name, err)
}
if _, err := w.Write([]byte{0xff, 0xff, 0xff, 0xff}); err != nil {
t.Fatalf("write raw %s: %v", ch.name, err)
}
writeRaw(ch.name)
case ch.entry != "":
write("OEBPS/"+ch.entry, ch.content)
default:
write("OEBPS/"+ch.name, ch.content)
}
}
for _, img := range images {
entry := img.entry
if entry == "" {
entry = img.name
}
switch {
case img.missing:
case img.damaged:
writeRaw(entry)
default:
write("OEBPS/"+entry, img.content)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
@@ -90,27 +135,38 @@ func buildTestEPUB(t *testing.T, chapters []epubTestChapter) []byte {
func parseTestEPUB(t *testing.T, chapters []epubTestChapter) ParseResult {
t.Helper()
return NewEPUBParser().ParseWithResult(context.Background(), "book.epub", buildTestEPUB(t, chapters))
return parseTestEPUBContext(t, context.Background(), chapters, nil)
}
func TestEPUBParserReportsDocumentImageLimitAcrossChapters(t *testing.T) {
image := `<img src="data:image/png;base64,aGVsbG8=">`
res := parseTestEPUB(t, []epubTestChapter{
func parseTestEPUBContext(t *testing.T, ctx context.Context, chapters []epubTestChapter, images []epubTestImage) ParseResult {
t.Helper()
return NewEPUBParser().ParseWithResult(ctx, "book.epub", buildTestEPUBWithImages(t, chapters, images))
}
// The parser-side embedded budget temporarily bounds how many ZIP images one
// EPUB extracts, until the shared media budget replaces it.
func TestEPUBParserCapsZipImageExtractionAcrossChapters(t *testing.T) {
image := `<img src="Images/plate.png">`
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{
{name: "one.xhtml", content: epubTestHTML(strings.Repeat(image, 128))},
{name: "two.xhtml", content: epubTestHTML(strings.Repeat(image, 129) + "<p>After images.</p>")},
})
}, []epubTestImage{{name: "Images/plate.png", content: "plate-bytes"}})
if res.Err != nil {
t.Fatal(res.Err)
}
images := 0
for _, item := range res.JSON {
if item.ToMap()["doc_type_kwd"] == "image" {
for _, item := range ItemsToMaps(res.JSON) {
if item["doc_type_kwd"] == DocTypeImage {
images++
}
}
if images != 256 || !strings.Contains(epubTestText(res), "After images.") {
if images != maxEmbeddedMediaItems || !strings.Contains(epubTestText(res), "After images.") {
t.Fatalf("images=%d text=%q", images, epubTestText(res))
}
if cache.putCalls != maxEmbeddedMediaItems {
t.Fatalf("cache writes = %d, want %d", cache.putCalls, maxEmbeddedMediaItems)
}
if !strings.Contains(strings.Join(res.Warnings, "\n"), "256-item document limit") {
t.Fatalf("missing document image limit warning: %v", res.Warnings)
}
@@ -195,10 +251,11 @@ func TestEPUBParser_ImageOnlyChapterCountsAsRead(t *testing.T) {
// The image-only chapter was read and has no text; the book is not unreadable.
// The chapter has no body text.
imageOnly := `<?xml version="1.0" encoding="utf-8"?><html xmlns="http://www.w3.org/1999/xhtml"><body><img src="plate.png" alt=""/></body></html>`
res := parseTestEPUB(t, []epubTestChapter{
ctx, _ := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{
{name: "ch1.xhtml", content: imageOnly},
{name: "ch2.xhtml", damaged: true},
})
}, []epubTestImage{{name: "plate.png", content: "plate-bytes"}})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
@@ -352,3 +409,453 @@ func TestEPUBParser_GB2312(t *testing.T) {
t.Errorf("EPUB File.encoding = %q, want gb2312/gbk/gb18030", enc)
}
}
func epubTestImageItems(t *testing.T, res ParseResult) []map[string]any {
t.Helper()
var images []map[string]any
for _, item := range ItemsToMaps(res.JSON) {
if item["doc_type_kwd"] == DocTypeImage {
images = append(images, item)
}
}
return images
}
func epubTestWarningText(res ParseResult) string {
return strings.Join(res.Warnings, "\n")
}
func TestHTMLWalkStateResolveMediaPrecedesResolveImageAndDataURI(t *testing.T) {
zipRef := &entity.MediaRef{Format: "epub", Kind: entity.MediaKindKV, SHA256: strings.Repeat("a", sha256.Size*2)}
ctx, cache := recordingMediaContext()
state := &htmlWalkState{
refOnly: true,
ctx: ctx,
mediaCache: cache,
mediaFormat: "epub",
resolveMedia: func(src string) (*entity.MediaRef, bool) {
if src == "zip.png" || src == htmlMediaDataURI {
return zipRef, true
}
return nil, false
},
resolveImage: func(src string) (string, bool) { return "inline:" + src, true },
}
if src, ref, ok := state.imageSource("zip.png"); !ok || ref != zipRef || src != "" {
t.Fatalf("resolveMedia did not win over resolveImage: src=%q ref=%+v ok=%v", src, ref, ok)
}
if src, ref, ok := state.imageSource(htmlMediaDataURI); !ok || ref != zipRef || src != "" {
t.Fatalf("resolveMedia did not win over the data URI branch: src=%q ref=%+v ok=%v", src, ref, ok)
}
if cache.putCalls != 0 {
t.Fatalf("cache was written although resolveMedia handled the source: %+v", cache.puts)
}
if src, ref, ok := state.imageSource("remote.png"); !ok || ref != nil || src != "inline:remote.png" {
t.Fatalf("declined source did not fall through to resolveImage: src=%q ref=%+v ok=%v", src, ref, ok)
}
}
func TestHTMLWalkStateDataURIWriteUsesMediaFormat(t *testing.T) {
for _, tc := range []struct {
name string
format string
want string
}{
{name: "default html", format: "", want: "html"},
{name: "explicit epub", format: "epub", want: "epub"},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cache := recordingMediaContext()
state := &htmlWalkState{refOnly: true, ctx: ctx, mediaCache: cache, mediaFormat: tc.format}
_, ref, ok := state.imageSource(htmlMediaDataURI)
if !ok || ref == nil {
t.Fatalf("data URI image was not cached: ok=%v ref=%+v", ok, ref)
}
if len(cache.puts) != 1 || cache.puts[0].format != tc.want || ref.Format != tc.want {
t.Fatalf("cache puts = %+v, ref = %+v, want format %q", cache.puts, ref, tc.want)
}
})
}
}
// HTML does not install a media resolver: a non-data image source stays
// unresolved and is neither cached nor reported as an EPUB-style skip.
func TestHTMLParserIgnoresNonDataImageSourcesWithoutResolveMedia(t *testing.T) {
ctx, cache := recordingMediaContext()
result := NewHTMLParser().ParseWithResult(ctx, "plain.html", []byte(`<p>before</p><img src="figure.png" alt="figure"><p>after</p>`))
if result.Err != nil {
t.Fatalf("ParseWithResult: %v", result.Err)
}
if cache.putCalls != 0 {
t.Fatalf("cache writes = %+v, want none", cache.puts)
}
if images := epubTestImageItems(t, result); len(images) != 0 {
t.Fatalf("image items = %#v, want none", images)
}
if strings.Contains(epubTestWarningText(result), "unresolved") {
t.Fatalf("HTML reported an unresolved image warning: %v", result.Warnings)
}
}
func TestEPUBParserCachesZipImageBytes(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "Text/ch1.xhtml",
content: epubTestHTML(`<p>Before.</p><img src="../Images/cover.png" alt="cover art"><p>After.</p>`),
}}, []epubTestImage{{name: "Images/cover.png", content: "cover-bytes"}})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(res.Warnings) != 0 {
t.Fatalf("warnings = %v, want none", res.Warnings)
}
if len(cache.puts) != 1 {
t.Fatalf("cache puts = %+v, want one", cache.puts)
}
put := cache.puts[0]
if put.format != "epub" || put.mime != "image/png" || string(put.data) != "cover-bytes" {
t.Fatalf("cache put = %+v", put)
}
items := ItemsToMaps(res.JSON)
if len(items) != 3 {
t.Fatalf("items = %#v, want text/image/text", items)
}
if items[0]["text"] != "Test Before." || items[0]["doc_type_kwd"] != DocTypeText {
t.Fatalf("before item = %#v", items[0])
}
image := items[1]
if image["doc_type_kwd"] != DocTypeImage || image["text"] != "cover art" || image["media_order"] != 1 {
t.Fatalf("image item = %#v", image)
}
if _, ok := image["image"]; ok {
t.Fatalf("inline image retained: %#v", image)
}
ref := entity.MediaRefFromAny(image["media_ref"])
if ref == nil || ref.Format != "epub" || ref.MIME != "image/png" || ref.Size != int64(len("cover-bytes")) {
t.Fatalf("media_ref = %+v", ref)
}
sum := sha256.Sum256([]byte("cover-bytes"))
if want := hex.EncodeToString(sum[:]); ref.SHA256 != want {
t.Fatalf("sha256 = %s, want %s", ref.SHA256, want)
}
if items[2]["text"] != "After." || items[2]["doc_type_kwd"] != DocTypeText {
t.Fatalf("after item = %#v", items[2])
}
}
func TestEPUBParserResolvesPercentEncodedAndFragmentImageHrefs(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "Text/ch1.xhtml",
content: epubTestHTML(`<img src="../Images/cover%20art.png#frag" alt="cover">`),
}}, []epubTestImage{{name: "Images/cover%20art.png", entry: "Images/cover art.png", content: "art-bytes"}})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(cache.puts) != 1 || string(cache.puts[0].data) != "art-bytes" {
t.Fatalf("cache puts = %+v, want the decoded ZIP entry", cache.puts)
}
if images := epubTestImageItems(t, res); len(images) != 1 {
t.Fatalf("image items = %#v, want one", images)
}
}
func TestEPUBParserSkipsImagesOutsideTheZipRoot(t *testing.T) {
sources := map[string]string{
"root escape": "../../../escape.png",
"absolute path": "/Images/cover.png",
"remote URL": "https://example.com/cover.png",
"protocol relative": "//example.com/cover.png",
}
for name, src := range sources {
t.Run(name, func(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "Text/ch1.xhtml",
content: epubTestHTML(`<img src="` + src + `" alt="">`),
}}, []epubTestImage{{name: "Images/cover.png", content: "cover-bytes"}})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if cache.putCalls != 0 {
t.Fatalf("cache writes = %+v, want none", cache.puts)
}
if images := epubTestImageItems(t, res); len(images) != 0 {
t.Fatalf("image items = %#v, want none", images)
}
if !strings.Contains(epubTestWarningText(res), "unresolved image reference") {
t.Fatalf("missing unresolved-image warning: %v", res.Warnings)
}
})
}
}
func TestEPUBParserSkipsUnreadableImageEntries(t *testing.T) {
cases := map[string]epubTestImage{
"missing entry": {name: "Images/cover.png", missing: true},
"damaged entry": {name: "Images/cover.png", damaged: true},
"empty entry": {name: "Images/cover.png"},
}
for name, image := range cases {
t.Run(name, func(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(`<img src="Images/cover.png" alt="cover">`),
}}, []epubTestImage{image})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if cache.putCalls != 0 {
t.Fatalf("cache writes = %+v, want none", cache.puts)
}
if images := epubTestImageItems(t, res); len(images) != 0 {
t.Fatalf("image items = %#v, want none", images)
}
if !strings.Contains(epubTestWarningText(res), "unresolved image reference") {
t.Fatalf("missing unresolved-image warning: %v", res.Warnings)
}
})
}
}
// A manifest image missing from the ZIP is skipped without reading bytes, so
// it must not consume an image attempt and hide a later readable image.
func TestEPUBParserMissingZipEntryDoesNotConsumeImageBudget(t *testing.T) {
ctx, cache := recordingMediaContext()
content := epubTestHTML(strings.Repeat(`<img src="Images/gone.png">`, maxEmbeddedMediaItems) +
`<img src="Images/keep.png" alt="keep">`)
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: content,
}}, []epubTestImage{
{name: "Images/gone.png", missing: true},
{name: "Images/keep.png", content: "keep-bytes"},
})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if cache.putCalls != 1 || len(cache.puts) != 1 || string(cache.puts[0].data) != "keep-bytes" {
t.Fatalf("cache puts = %+v, want only the readable image", cache.puts)
}
images := epubTestImageItems(t, res)
if len(images) != 1 || images[0]["text"] != "keep" {
t.Fatalf("image items = %#v, want the readable image", images)
}
if !strings.Contains(epubTestWarningText(res), "unresolved image reference") {
t.Fatalf("missing unresolved-image warning: %v", res.Warnings)
}
}
// An image above the per-image limit is reported through the budget warning
// without reading bytes, so it must not consume an image attempt either.
func TestEPUBParserOversizedZipImageDoesNotConsumeImageBudget(t *testing.T) {
t.Setenv("RAGFLOW_HTML_IMAGE_MAX_BYTES", "8")
ctx, cache := recordingMediaContext()
content := epubTestHTML(strings.Repeat(`<img src="Images/huge.png">`, maxEmbeddedMediaItems) +
`<img src="Images/ok.png" alt="ok">`)
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: content,
}}, []epubTestImage{
{name: "Images/huge.png", content: strings.Repeat("x", 9)},
{name: "Images/ok.png", content: "y"},
})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if cache.putCalls != 1 || len(cache.puts) != 1 || string(cache.puts[0].data) != "y" {
t.Fatalf("cache puts = %+v, want only the small image", cache.puts)
}
if !strings.Contains(epubTestWarningText(res), "larger than the 8-byte per-image limit") {
t.Fatalf("missing oversized-image warning: %v", res.Warnings)
}
}
// A read failure is still a fetch attempt, so the item budget keeps bounding
// the reads a document full of unreadable entries can force.
func TestEPUBParserUnreadableImageAttemptsCountAgainstBudget(t *testing.T) {
ctx, cache := recordingMediaContext()
content := epubTestHTML(strings.Repeat(`<img src="Images/bad.png">`, maxEmbeddedMediaItems) +
`<img src="Images/keep.png" alt="keep">`)
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: content,
}}, []epubTestImage{
{name: "Images/bad.png", damaged: true},
{name: "Images/keep.png", content: "keep-bytes"},
})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if cache.putCalls != 0 {
t.Fatalf("cache puts = %+v, want the readable image dropped after the attempt cap", cache.puts)
}
if !strings.Contains(epubTestWarningText(res), "256-item document limit") {
t.Fatalf("missing document image limit warning: %v", res.Warnings)
}
}
func TestEPUBParserMediaCacheWriteFailureFailsTask(t *testing.T) {
writeErr := errors.New("kvrocks unavailable")
cache := &recordingMediaCache{putErr: writeErr}
ctx := media.WithCache(context.Background(), cache)
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(`<img src="Images/a.png"><img src="Images/b.png">`),
}}, []epubTestImage{{name: "Images/a.png", content: "a"}, {name: "Images/b.png", content: "b"}})
if !errors.Is(res.Err, writeErr) {
t.Fatalf("ParseWithResult error = %v, want %v", res.Err, writeErr)
}
if cache.putCalls != 1 {
t.Fatalf("cache writes = %d, want traversal to stop after the first failure", cache.putCalls)
}
}
func TestEPUBParserImageWithoutCacheFails(t *testing.T) {
cases := map[string]string{
"zip image": `<img src="Images/cover.png">`,
"data URI": `<img src="` + htmlMediaDataURI + `">`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
res := parseTestEPUBContext(t, context.Background(), []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(body),
}}, []epubTestImage{{name: "Images/cover.png", content: "cover-bytes"}})
if res.Err == nil || !strings.Contains(res.Err.Error(), "epub media cache is not configured") {
t.Fatalf("error = %v, want a missing media cache error", res.Err)
}
})
}
}
func TestEPUBTextParserSkipsImagesWithoutCache(t *testing.T) {
res := NewEPUBTextParser().ParseWithResult(context.Background(), "book.epub", buildTestEPUBWithImages(t, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(`<p>Before.</p><img src="Images/cover.png" alt="cover"><img src="` + htmlMediaDataURI + `" alt="inline"><p>After.</p>`),
}}, []epubTestImage{{name: "Images/cover.png", content: "cover-bytes"}}))
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if images := epubTestImageItems(t, res); len(images) != 0 {
t.Fatalf("image items = %#v, want none", images)
}
if text := epubTestText(res); !strings.Contains(text, "Before.") || !strings.Contains(text, "After.") {
t.Fatalf("text = %q, want surrounding prose", text)
}
if len(res.Warnings) != 0 {
t.Fatalf("warnings = %v, want none for a text-only parse", res.Warnings)
}
}
func TestEPUBParserDataURIImagesShareEmbeddedMediaBudget(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(strings.Repeat(`<img src="`+htmlMediaDataURI+`">`, maxEmbeddedMediaItems+1) + "<p>After images.</p>"),
}}, nil)
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if cache.putCalls != maxEmbeddedMediaItems {
t.Fatalf("cache writes = %d, want %d", cache.putCalls, maxEmbeddedMediaItems)
}
images := 0
for _, item := range ItemsToMaps(res.JSON) {
if item["doc_type_kwd"] == DocTypeImage {
images++
}
}
if images != maxEmbeddedMediaItems || !strings.Contains(epubTestText(res), "After images.") {
t.Fatalf("images=%d text=%q", images, epubTestText(res))
}
if !strings.Contains(strings.Join(res.Warnings, "\n"), "256-item document limit") {
t.Fatalf("missing document image limit warning: %v", res.Warnings)
}
}
func TestEPUBParserEmitsTableCellImages(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(`<table><tr><td>Owner</td><td><img src="Images/cell.png" alt="cell"></td></tr></table>`),
}}, []epubTestImage{{name: "Images/cell.png", content: "cell-bytes"}})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(cache.puts) != 1 || string(cache.puts[0].data) != "cell-bytes" {
t.Fatalf("cache puts = %+v, want the table image", cache.puts)
}
items := ItemsToMaps(res.JSON)
if len(items) != 3 {
t.Fatalf("items = %#v, want text/table/image", items)
}
if items[1]["doc_type_kwd"] != DocTypeTable || items[1]["source_table_id"] != "html-table-1" {
t.Fatalf("table item = %#v", items[1])
}
image := items[2]
if image["doc_type_kwd"] != DocTypeImage || image["text"] != "cell" ||
image["parent_table_id"] != "html-table-1" || image["row_index"] != 1 || image["column_index"] != 2 {
t.Fatalf("table image item = %#v", image)
}
if _, ok := image["image"]; ok {
t.Fatalf("inline image retained: %#v", image)
}
if entity.MediaRefFromAny(image["media_ref"]) == nil {
t.Fatalf("table image without media_ref: %#v", image)
}
}
func TestEPUBParserDataURIImagesUseEPUBFormat(t *testing.T) {
ctx, cache := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(`<img src="` + htmlMediaDataURI + `" alt="inline">`),
}}, nil)
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
if len(cache.puts) != 1 || cache.puts[0].format != "epub" || cache.puts[0].mime != "image/png" || string(cache.puts[0].data) != "hello" {
t.Fatalf("cache puts = %+v", cache.puts)
}
images := epubTestImageItems(t, res)
if len(images) != 1 {
t.Fatalf("image items = %#v, want one", images)
}
if _, ok := images[0]["image"]; ok {
t.Fatalf("inline image retained: %#v", images[0])
}
ref := entity.MediaRefFromAny(images[0]["media_ref"])
if ref == nil || ref.Format != "epub" {
t.Fatalf("media_ref = %+v, want an epub reference", ref)
}
}
func TestEPUBParserPreservesImageOrderAndAlt(t *testing.T) {
ctx, _ := recordingMediaContext()
res := parseTestEPUBContext(t, ctx, []epubTestChapter{{
name: "ch1.xhtml",
content: epubTestHTML(`<p>one</p><img src="Images/a.png" alt="A"><p>two</p><img src="Images/b.png" alt="B">`),
}}, []epubTestImage{{name: "Images/a.png", content: "a"}, {name: "Images/b.png", content: "b"}})
if res.Err != nil {
t.Fatalf("ParseWithResult: %v", res.Err)
}
items := ItemsToMaps(res.JSON)
if len(items) != 4 {
t.Fatalf("items = %#v, want text/image/text/image", items)
}
if items[0]["text"] != "Test one" || items[0]["doc_type_kwd"] != DocTypeText {
t.Fatalf("first item = %#v", items[0])
}
if items[1]["doc_type_kwd"] != DocTypeImage || items[1]["text"] != "A" || items[1]["media_order"] != 1 {
t.Fatalf("first image = %#v", items[1])
}
if items[2]["text"] != "two" || items[2]["doc_type_kwd"] != DocTypeText {
t.Fatalf("middle item = %#v", items[2])
}
if items[3]["doc_type_kwd"] != DocTypeImage || items[3]["text"] != "B" || items[3]["media_order"] != 2 {
t.Fatalf("second image = %#v", items[3])
}
}
+51 -12
View File
@@ -130,13 +130,21 @@ type htmlWalkState struct {
tableSequence int
mediaOrder int
skippedInlineImages int
unresolvedImages int
mediaBudget *embeddedMediaBudget
// refOnly defers image bytes to content-addressed references instead of
// carrying the normalized data URI on the item. HTML sets it; formats
// that still inline image bytes leave it false.
refOnly bool
ctx context.Context
mediaCache media.Cache
// carrying the normalized data URI on the item. HTML and EPUB set it;
// formats that still inline image bytes leave it false.
refOnly bool
ctx context.Context
mediaCache media.Cache
// mediaFormat names the parser family recorded on cache writes. An empty
// value means the HTML parser.
mediaFormat string
// skipImages drops every image source before resolution. Text-only
// callers use it when no run media cache is available.
skipImages bool
resolveMedia func(string) (*entity.MediaRef, bool)
resolveImage func(string) (string, bool)
err error
}
@@ -146,13 +154,32 @@ func (s *htmlWalkState) warnings() []string {
if s.skippedInlineImages > 0 {
warnings = append(warnings, fmt.Sprintf("document parser skipped %d invalid or oversized inline image(s)", s.skippedInlineImages))
}
if s.unresolvedImages > 0 {
warnings = append(warnings, fmt.Sprintf("document parser skipped %d unresolved image reference(s)", s.unresolvedImages))
}
return warnings
}
// cacheFormat reports the parser family recorded on media-cache writes.
func (s *htmlWalkState) cacheFormat() string {
if s.mediaFormat != "" {
return s.mediaFormat
}
return "html"
}
func (s *htmlWalkState) imageSource(src string) (string, *entity.MediaRef, bool) {
if s.err != nil {
return "", nil, false
}
if s.skipImages {
return "", nil, false
}
if s.resolveMedia != nil {
if ref, ok := s.resolveMedia(src); ok {
return "", ref, true
}
}
if s.resolveImage != nil {
normalized, ok := s.resolveImage(src)
return normalized, nil, ok
@@ -161,20 +188,32 @@ func (s *htmlWalkState) imageSource(src string) (string, *entity.MediaRef, bool)
return "", nil, false
}
if s.refOnly {
if media.HTMLImageExceedsLimit(src) {
// Validate and size the payload without allocating so budget admission
// happens before the decode. Malformed sources are never charged
// against the item budget.
size, ok := media.HTMLImageDecodedSize(src)
if !ok || size > media.HTMLImageMaxBytes() {
return "", nil, false
}
if s.mediaCache == nil {
s.err = fmt.Errorf("%s media cache is not configured", s.cacheFormat())
return "", nil, false
}
if s.mediaBudget != nil {
if !s.mediaBudget.reserveItem() {
return "", nil, false
}
if !s.mediaBudget.includeReservedSize(size) {
return "", nil, false
}
}
mime, data, ok := media.DecodeHTMLImageSource(src)
if !ok {
return "", nil, false
}
if s.mediaCache == nil {
s.err = fmt.Errorf("html media cache is not configured")
return "", nil, false
}
ref, err := s.mediaCache.Put(s.ctx, "html", mime, data)
ref, err := s.mediaCache.Put(s.ctx, s.cacheFormat(), mime, data)
if err != nil {
s.err = fmt.Errorf("html media cache write: %w", err)
s.err = fmt.Errorf("%s media cache write: %w", s.cacheFormat(), err)
return "", nil, false
}
return "", &ref, true
+4 -3
View File
@@ -59,7 +59,8 @@ func (b *embeddedMediaBudget) include(data []byte) (bool, bool) {
return b.includeReservedSize(len(data)), true
}
// reserveItem lets a parser cap image source attempts before fetching bytes.
// reserveItem caps the image fetch attempts for one document. Callers reserve
// immediately before the I/O; skips that read no bytes are not charged.
func (b *embeddedMediaBudget) reserveItem() bool {
if b.countExhausted {
return false
@@ -72,8 +73,8 @@ func (b *embeddedMediaBudget) reserveItem() bool {
return true
}
// includeReservedSize charges a previously reserved image without retaining
// or decoding its payload just to measure its size.
// includeReservedSize charges a payload against the per-image and document
// byte limits without retaining or decoding it just to measure its size.
func (b *embeddedMediaBudget) includeReservedSize(size int) bool {
if size > b.maxImageBytes {
b.oversizedImages++
+6
View File
@@ -224,6 +224,9 @@ func parseAgentUploadContent(ctx context.Context, filename string, data []byte,
if fileType == utility.FileTypeHTML {
fp = parser.NewHTMLTextParser()
}
if fileType == utility.FileTypeEPUB {
fp = parser.NewEPUBTextParser()
}
if configurable, ok := fp.(interface{ ConfigureFromSetup(map[string]any) }); ok {
configurable.ConfigureFromSetup(map[string]any{"layout_recognize": layoutRecognize})
}
@@ -279,6 +282,9 @@ func parseFileContent(ctx context.Context, filename string, data []byte) string
if fileType == utility.FileTypeHTML {
fp = parser.NewHTMLTextParser()
}
if fileType == utility.FileTypeEPUB {
fp = parser.NewEPUBTextParser()
}
res := fp.ParseWithResult(ctx, filename, data)
if res.Err != nil {
return string(data)
@@ -1,6 +1,8 @@
package file
import (
"archive/zip"
"bytes"
"strings"
"ragflow/internal/parser/parser"
@@ -166,3 +168,49 @@ func TestHTMLContentExtractionWithoutMediaCache(t *testing.T) {
})
}
}
func buildTestEPUBForContent(t *testing.T) []byte {
t.Helper()
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
write := func(name, content string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
write("META-INF/container.xml", `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`)
write("OEBPS/content.opf", `<?xml version="1.0" encoding="utf-8"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0"><manifest><item id="c1" href="ch1.xhtml" media-type="application/xhtml+xml"/><item id="img" href="Images/cover.png" media-type="image/png"/></manifest><spine><itemref idref="c1"/></spine></package>`)
write("OEBPS/ch1.xhtml", `<?xml version="1.0" encoding="utf-8"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Test</title></head><body><p>before</p><img src="Images/cover.png" alt="figure"><img src="data:image/png;base64,aGVsbG8=" alt="inline"><p>after</p></body></html>`)
write("OEBPS/Images/cover.png", "cover-bytes")
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}
func TestEPUBContentExtractionWithoutMediaCache(t *testing.T) {
input := buildTestEPUBForContent(t)
for name, parse := range map[string]func() (string, error){
"file": func() (string, error) { return parseFileContent(t.Context(), "book.epub", input), nil },
"agent": func() (string, error) { return parseAgentUploadContent(t.Context(), "book.epub", input, "") },
} {
t.Run(name, func(t *testing.T) {
got, err := parse()
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"before", "figure", "inline", "after"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q: %q", want, got)
}
}
if strings.HasPrefix(got, "PK") || strings.Contains(got, "cover-bytes") || strings.Contains(got, "base64,") {
t.Fatalf("raw EPUB bytes leaked: %q", got)
}
})
}
}