mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
feat(parser): hand off EPUB images as media refs (#21677)
This commit is contained in:
1 parent
ac4a166d4c
commit
5b397cecd2
12 files changed
+945
-122
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user