diff --git a/internal/ingestion/component/media_dispatch.go b/internal/ingestion/component/media_dispatch.go index 94c0c40654..1fce20f46d 100644 --- a/internal/ingestion/component/media_dispatch.go +++ b/internal/ingestion/component/media_dispatch.go @@ -160,8 +160,14 @@ func decodeDispatchImage(data []byte, decodeRaster bool) (image.Image, error) { if err != nil { return nil, fmt.Errorf("parser: decode image: %w", err) } - if config.Width <= 0 || config.Height <= 0 || config.Width > parser.MaxImageEdge || config.Height > parser.MaxImageEdge || int64(config.Width)*int64(config.Height) > parser.MaxImagePixels { - return nil, fmt.Errorf("parser: image dimensions %dx%d exceed limits", config.Width, config.Height) + if config.Width <= 0 || config.Height <= 0 { + return nil, fmt.Errorf("parser: image has invalid dimensions %dx%d", config.Width, config.Height) + } + // Length/width are unbounded (mirrors Python's picture.py); the decoded + // raster byte ceiling is opt-in via RAGFLOW_IMAGE_RASTER_MAX_BYTES (0 = + // unlimited). + if err := parser.CheckImageRasterLimit(config.Width, config.Height); err != nil { + return nil, fmt.Errorf("parser: %w", err) } // VLM consumes the original bytes; only local OCR needs a decoded raster. if !decodeRaster { diff --git a/internal/ingestion/component/vision_enhancement_cgo.go b/internal/ingestion/component/vision_enhancement_cgo.go index c7551421c9..95354da940 100644 --- a/internal/ingestion/component/vision_enhancement_cgo.go +++ b/internal/ingestion/component/vision_enhancement_cgo.go @@ -159,7 +159,7 @@ func (c *visionPDFCropper) Crop(ctx context.Context, item map[string]any) (*visi if len(single) == 0 { return nil, nil } - raster := util.CropSectionPositionsRasterLimited(positions, single, deepdoctype.DlaScale, maxVisionImagePixels) + raster := util.CropSectionPositionsRasterLimited(positions, single, deepdoctype.DlaScale, maxVLMCropPixels) if raster == nil { return nil, nil } @@ -182,12 +182,12 @@ func pdfPagesRasterWithinVisionLimits(engine deepdoctype.PDFEngine, pageNums map if math.IsNaN(widthPixels) || math.IsInf(widthPixels, 0) || math.IsNaN(heightPixels) || math.IsInf(heightPixels, 0) || widthPixels <= 0 || heightPixels <= 0 || - widthPixels > maxVisionImageEdge || heightPixels > maxVisionImageEdge || - widthPixels*heightPixels > float64(maxVisionImagePixels) { + widthPixels > maxVLMCropEdge || heightPixels > maxVLMCropEdge || + widthPixels*heightPixels > float64(maxVLMCropPixels) { return false } pagePixels := int64(widthPixels * heightPixels) - if pagePixels > maxVisionImagePixels-totalPixels { + if pagePixels > maxVLMCropPixels-totalPixels { return false } totalPixels += pagePixels diff --git a/internal/ingestion/component/vision_image.go b/internal/ingestion/component/vision_image.go index 6a09a45cdd..dd14c0a2a1 100644 --- a/internal/ingestion/component/vision_image.go +++ b/internal/ingestion/component/vision_image.go @@ -35,10 +35,17 @@ import ( ) const ( - maxVisionImageBytes = parserlib.MaxImagePayloadBytes - maxVisionImagePixels = parserlib.MaxImagePixels - maxVisionImageEdge = parserlib.MaxImageEdge - maxVLMEncodedBytes = (maxVisionImageBytes+2)/3*4 + 256 + maxVisionImageBytes = parserlib.MaxImagePayloadBytes + maxVLMEncodedBytes = (maxVisionImageBytes+2)/3*4 + 256 + // maxVLMCropPixels and maxVLMCropEdge bound the raster of PDF page crops + // rendered for the vision model (see vision_enhancement_cgo.go). They are a + // pixel budget for the VLM crop path and are intentionally separate from + // the file/inline image parser, which now uses the opt-in raster-byte limit + // (RAGFLOW_IMAGE_RASTER_MAX_BYTES, default 0 = unlimited). These keep their + // historical values because a 0-byte ceiling would map to a 0-pixel crop + // budget and reject every VLM crop. + maxVLMCropPixels = 40_000_000 + maxVLMCropEdge = 12_000 ) func materializeInlineVisionImage(raw string) (*visionImage, error) { @@ -96,8 +103,11 @@ func inspectVisionImagePayload(payload string) (string, error) { if err != nil { continue } - if config.Width <= 0 || config.Height <= 0 || config.Width > maxVisionImageEdge || config.Height > maxVisionImageEdge || int64(config.Width)*int64(config.Height) > maxVisionImagePixels { - return "", fmt.Errorf("vision image: image dimensions %dx%d exceed limits", config.Width, config.Height) + if config.Width <= 0 || config.Height <= 0 { + return "", fmt.Errorf("vision image: image has invalid dimensions %dx%d", config.Width, config.Height) + } + if err := parserlib.CheckImageRasterLimit(config.Width, config.Height); err != nil { + return "", fmt.Errorf("vision image: %w", err) } return format, nil } @@ -165,7 +175,11 @@ func imageWithinVisionLimits(img image.Image) bool { } bounds := img.Bounds() width, height := bounds.Dx(), bounds.Dy() - return width > 0 && height > 0 && width <= maxVisionImageEdge && height <= maxVisionImageEdge && int64(width)*int64(height) <= maxVisionImagePixels + if width <= 0 || height <= 0 { + return false + } + // Edge is unbounded (mirrors Python); only the opt-in raster-byte ceiling applies. + return parserlib.CheckImageRasterLimit(width, height) == nil } func imageMIMEForFormat(format string) string { diff --git a/internal/ingestion/component/vision_image_test.go b/internal/ingestion/component/vision_image_test.go index 450ac8ac0b..09fd048001 100644 --- a/internal/ingestion/component/vision_image_test.go +++ b/internal/ingestion/component/vision_image_test.go @@ -25,11 +25,14 @@ import ( "testing" ) -func TestMaterializeInlineVisionImageKeepsVLMForImageDimensionLimit(t *testing.T) { +func TestMaterializeInlineVisionImageNoEdgeLimitKeepsVLM(t *testing.T) { var encoded bytes.Buffer - large := image.NewRGBA(image.Rect(0, 0, maxVisionImageEdge+1, 1)) - if err := png.Encode(&encoded, large); err != nil { - t.Fatalf("encode oversized image: %v", err) + // Width exceeds the former 12000-edge guard. With the edge limit removed + // (aligned to Python's picture.py, which imposes none) the inline image is + // still materialized and its VLM payload preserved for the vision model. + wide := image.NewRGBA(image.Rect(0, 0, 12001, 1)) + if err := png.Encode(&encoded, wide); err != nil { + t.Fatalf("encode wide image: %v", err) } dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()) @@ -37,11 +40,8 @@ func TestMaterializeInlineVisionImageKeepsVLMForImageDimensionLimit(t *testing.T if err != nil { t.Fatalf("materializeInlineVisionImage: %v", err) } - if materialized == nil || materialized.Raster != nil { - t.Fatalf("raster = %#v, want OCR to skip the oversized image", materialized) - } - if materialized.VLMData != dataURI || !isUsableVisionImage(materialized.VLMData) { - t.Fatal("oversized OCR input should preserve its valid VLM payload") + if materialized == nil || materialized.VLMData != dataURI || !isUsableVisionImage(materialized.VLMData) { + t.Fatalf("wide inline image must preserve its valid VLM payload (no edge limit): %#v", materialized) } } diff --git a/internal/parser/parser/image_media.go b/internal/parser/parser/image_media.go index cce226f896..90fb9e4528 100644 --- a/internal/parser/parser/image_media.go +++ b/internal/parser/parser/image_media.go @@ -18,6 +18,10 @@ package parser import ( "context" + "fmt" + "os" + "strconv" + "strings" "sync" deepdocpdf "ragflow/internal/deepdoc/parser/pdf" @@ -26,12 +30,71 @@ import ( const ( // MaxImagePayloadBytes bounds compressed image data accepted by media paths. MaxImagePayloadBytes = 32 << 20 - // MaxImagePixels bounds decoded image dimensions before allocating a raster. - MaxImagePixels = 40_000_000 - // MaxImageEdge bounds either decoded image dimension before raster allocation. - MaxImageEdge = 12_000 ) +// EnvImageRasterMaxBytes names the environment variable that sets the +// decoded-raster byte ceiling for image parsing (media dispatch and inline +// vision images). It mirrors Python's picture.py, which imposes no image size +// guard at all: length/width are unbounded and the raster-byte ceiling is opt +// in. A value of 0 (the default when unset, empty, or invalid/negative) means +// unlimited. When set to N > 0 (bytes), a decoded raster larger than N bytes +// (modelled as width * height * 4 for an 8-bit RGBA raster) is rejected. +// +// Operator guidance: with the default (unlimited) ceiling, a highly +// compressible image can still exhaust the ingestion worker's heap during +// decode. For untrusted input sources, set RAGFLOW_IMAGE_RASTER_MAX_BYTES to a +// budget the deployment can absorb (bounded by DeepDocConcurrency). The 4 +// bytes/pixel model is an 8-bit RGBA estimate; 16-bit formats (e.g. NRGBA64) +// allocate roughly twice that, so size the ceiling with headroom for such +// input. +const EnvImageRasterMaxBytes = "RAGFLOW_IMAGE_RASTER_MAX_BYTES" + +var ( + imageRasterMaxBytesOnce sync.Once + imageRasterMaxBytes int64 +) + +// ImageRasterMaxBytes returns the decoded-raster byte ceiling. It is resolved +// once from EnvImageRasterMaxBytes; negative or unparseable values fall back to +// 0 (unlimited) so a misconfiguration never silently blocks every image. +func ImageRasterMaxBytes() int64 { + imageRasterMaxBytesOnce.Do(func() { + imageRasterMaxBytes = resolveImageRasterMaxBytes() + }) + return imageRasterMaxBytes +} + +func resolveImageRasterMaxBytes() int64 { + v := strings.TrimSpace(os.Getenv(EnvImageRasterMaxBytes)) + if v == "" { + return 0 + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return 0 + } + return n +} + +// CheckImageRasterLimit returns an error if a w-by-h image's decoded RGBA +// raster would exceed the opt-in byte ceiling (ImageRasterMaxBytes). Edge +// (length/width) limits are intentionally absent to match Python's picture.py. +// A zero ceiling means unlimited and always passes. +// +// The raster is modelled as 4 bytes/pixel (8-bit RGBA); see EnvImageRasterMaxBytes +// for the 16-bit headroom note. The product is computed in uint64 so extreme +// dimensions cannot silently wrap to 0 under int64 signed overflow and bypass +// a positive ceiling. +func CheckImageRasterLimit(w, h int) error { + if max := ImageRasterMaxBytes(); max > 0 { + const bytesPerPixel = 4 + if uint64(w)*uint64(h)*bytesPerPixel > uint64(max) { + return fmt.Errorf("image raster %dx%d exceeds %d-byte limit", w, h, max) + } + } + return nil +} + // AcquireImageMedia limits concurrent image materialization across Parser // payload encoding and ingestion VLM crop paths. The native analyzer keeps // its own inference limit. diff --git a/internal/parser/parser/image_media_test.go b/internal/parser/parser/image_media_test.go new file mode 100644 index 0000000000..c1a436adeb --- /dev/null +++ b/internal/parser/parser/image_media_test.go @@ -0,0 +1,91 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package parser + +import ( + "sync" + "testing" +) + +// resetImageRasterMaxBytesForTest re-reads EnvImageRasterMaxBytes between +// subtests by resetting the once guard. It lives in the test file so it is not +// compiled into production; the parser package's unexported vars are reachable +// because this is a same-package (white-box) test. +func resetImageRasterMaxBytesForTest() { + imageRasterMaxBytesOnce = sync.Once{} + imageRasterMaxBytes = 0 +} + +// TestImageRasterMaxBytesResolution covers env parsing: unset/zero/invalid/negative +// all mean unlimited (0), while a positive integer is honoured. +func TestImageRasterMaxBytesResolution(t *testing.T) { + cases := []struct { + name string + env string + want int64 + }{ + {"unset means unlimited", "", 0}, + {"explicit zero means unlimited", "0", 0}, + {"positive value honoured", "123456789", 123456789}, + {"invalid falls back to unlimited", "not-a-number", 0}, + {"negative falls back to unlimited", "-5", 0}, + {"whitespace-trimmed zero", " 0 ", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv(EnvImageRasterMaxBytes, c.env) + resetImageRasterMaxBytesForTest() + t.Cleanup(resetImageRasterMaxBytesForTest) + if got := ImageRasterMaxBytes(); got != c.want { + t.Errorf("ImageRasterMaxBytes() with env %q = %d, want %d", c.env, got, c.want) + } + }) + } +} + +// TestCheckImageRasterLimit covers the opt-in raster-byte ceiling against the +// real 2692x16384 image that previously tripped the hard dimension guard. +// raster bytes = 2692 * 16384 * 4 = 176,422,912. +func TestCheckImageRasterLimit(t *testing.T) { + cases := []struct { + name string + w, h int + env string + wantErr bool + }{ + {"default unlimited passes huge image", 2692, 16384, "", false}, + {"explicit zero passes huge image", 2692, 16384, "0", false}, + {"negative treated unlimited passes", 2692, 16384, "-1", false}, + {"invalid treated unlimited passes", 2692, 16384, "garbage", false}, + {"ceiling below raster blocks", 2692, 16384, "100000000", true}, + {"ceiling above raster passes", 2692, 16384, "200000000", false}, + {"ceiling exactly equal passes (not strictly over)", 2692, 16384, "176422912", false}, + {"ceiling one byte below blocks", 2692, 16384, "176422911", true}, + {"small image under generous ceiling passes", 100, 100, "100000", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Setenv(EnvImageRasterMaxBytes, c.env) + resetImageRasterMaxBytesForTest() + t.Cleanup(resetImageRasterMaxBytesForTest) + err := CheckImageRasterLimit(c.w, c.h) + if (err != nil) != c.wantErr { + t.Errorf("CheckImageRasterLimit(%d,%d) env=%q: err=%v, wantErr=%v", c.w, c.h, c.env, err, c.wantErr) + } + }) + } +}