@@ -22,6 +22,10 @@ const (
2222 // MaxImageBytes is the maximum file size for images sent to LLM providers (4.5MB,
2323 // below Anthropic's 5MB limit).
2424 MaxImageBytes = 4_500_000
25+ // maxDecodedDimension is the absolute upper bound on decoded image dimensions.
26+ // Images exceeding this are rejected before processing to guard against
27+ // decompression bombs (small files that expand to huge pixel buffers).
28+ maxDecodedDimension = 20_000
2529 // jpegQuality is the default JPEG encoding quality.
2630 jpegQuality = 80
2731)
@@ -75,6 +79,13 @@ func ResizeImage(data []byte, mimeType string) (*ImageResizeResult, error) {
7579 bounds := img .Bounds ()
7680 origW , origH := bounds .Dx (), bounds .Dy ()
7781
82+ // Guard against decompression bombs: reject images whose decoded
83+ // dimensions are absurdly large. A small compressed file can expand
84+ // to hundreds of megabytes in memory (e.g. 20000×20000×4 ≈ 1.6 GB).
85+ if origW > maxDecodedDimension || origH > maxDecodedDimension {
86+ return nil , fmt .Errorf ("image dimensions too large: %dx%d (max %d)" , origW , origH , maxDecodedDimension )
87+ }
88+
7889 // If the image already fits within all limits, return unchanged.
7990 if origW <= MaxImageDimension && origH <= MaxImageDimension && len (data ) <= MaxImageBytes {
8091 return & ImageResizeResult {
@@ -103,6 +114,7 @@ func ResizeImage(data []byte, mimeType string) (*ImageResizeResult, error) {
103114 for _ , q := range []int {70 , 55 , 40 } {
104115 encoded , err := encodeJPEG (resized , q )
105116 if err != nil {
117+ slog .Debug ("JPEG encoding failed" , "quality" , q , "error" , err )
106118 continue
107119 }
108120
@@ -131,6 +143,7 @@ func ResizeImage(data []byte, mimeType string) (*ImageResizeResult, error) {
131143 for _ , q := range []int {80 , 55 , 40 } {
132144 encoded , err := encodeJPEG (smaller , q )
133145 if err != nil {
146+ slog .Debug ("JPEG encoding failed" , "quality" , q , "scale" , scale , "error" , err )
134147 continue
135148 }
136149
@@ -150,8 +163,7 @@ func ResizeImage(data []byte, mimeType string) (*ImageResizeResult, error) {
150163 }
151164
152165 if len (best ) > MaxImageBytes {
153- slog .Warn ("Image still exceeds size limit after all resize attempts" ,
154- "original_size" , len (data ), "final_size" , len (best ), "limit" , MaxImageBytes )
166+ return nil , fmt .Errorf ("image exceeds size limit after all resize attempts: %d bytes (limit %d)" , len (best ), MaxImageBytes )
155167 }
156168
157169 return & ImageResizeResult {
@@ -166,34 +178,51 @@ func ResizeImage(data []byte, mimeType string) (*ImageResizeResult, error) {
166178}
167179
168180// ResizeImageBase64 is a convenience wrapper around ResizeImage that accepts
169- // and returns base64-encoded image data.
170- func ResizeImageBase64 (b64Data , mimeType string ) (* ImageResizeResult , error ) {
181+ // and returns base64-encoded image data. The base64-encoded result is returned
182+ // separately to avoid mutating the ImageResizeResult.Data field.
183+ func ResizeImageBase64 (b64Data , mimeType string ) (b64Result string , metadata * ImageResizeResult , err error ) {
171184 raw , err := base64 .StdEncoding .DecodeString (b64Data )
172185 if err != nil {
173- return nil , fmt .Errorf ("decode base64: %w" , err )
186+ return "" , nil , fmt .Errorf ("decode base64: %w" , err )
174187 }
175188 result , err := ResizeImage (raw , mimeType )
176189 if err != nil {
177- return nil , err
190+ return "" , nil , err
178191 }
179- result .Data = []byte (base64 .StdEncoding .EncodeToString (result .Data ))
180- return result , nil
192+ return base64 .StdEncoding .EncodeToString (result .Data ), result , nil
181193}
182194
183195// FormatDimensionNote produces a human-readable note describing the resize mapping.
184196// This helps the model translate coordinates from the resized image back to the original.
197+ //
198+ // Because ResizeImage uses fitDimensions (which preserves aspect ratio), the X and
199+ // Y scale factors are always equal in practice. If they ever differ (e.g. because
200+ // the function is called with a manually constructed ImageResizeResult), we emit
201+ // separate per-axis factors so that coordinate mapping remains correct.
185202func FormatDimensionNote (r * ImageResizeResult ) string {
186203 if ! r .Resized {
187204 return ""
188205 }
189206 scaleX := float64 (r .OriginalWidth ) / float64 (r .Width )
190207 scaleY := float64 (r .OriginalHeight ) / float64 (r .Height )
191- scale := scaleX
192- if scaleY > scaleX {
193- scale = scaleY
208+
209+ // Uniform scaling (the normal path): a single factor suffices.
210+ const epsilon = 0.01
211+ if abs (scaleX - scaleY ) < epsilon {
212+ return fmt .Sprintf ("[Image: original %dx%d, displayed at %dx%d. Multiply coordinates by %.2f to map to original image.]" ,
213+ r .OriginalWidth , r .OriginalHeight , r .Width , r .Height , scaleX )
214+ }
215+
216+ // Non-uniform scaling: provide separate X and Y factors.
217+ return fmt .Sprintf ("[Image: original %dx%d, displayed at %dx%d. Multiply X coordinates by %.2f and Y coordinates by %.2f to map to original image.]" ,
218+ r .OriginalWidth , r .OriginalHeight , r .Width , r .Height , scaleX , scaleY )
219+ }
220+
221+ func abs (x float64 ) float64 {
222+ if x < 0 {
223+ return - x
194224 }
195- return fmt .Sprintf ("[Image: original %dx%d, displayed at %dx%d. Multiply coordinates by %.2f to map to original image.]" ,
196- r .OriginalWidth , r .OriginalHeight , r .Width , r .Height , scale )
225+ return x
197226}
198227
199228// fitDimensions returns new dimensions that fit within maxW×maxH while
0 commit comments