fix: correct image MIME by content and re-encode for kitty
read tool and Anthropic builder derived an image's media type from its file extension, so a .png file containing JPEG bytes was declared image/png. Anthropic sniffs the real bytes and 400s the whole request on a mismatch, breaking the session and making it impossible to resume. - read tool now sniffs the real format from magic bytes (sniffImageMIME) - Anthropic request builder reconciles declared MIME against the actual bytes on every outbound image, repairing already-persisted sessions on continue/resume - kitty renderer re-encodes non-PNG images to PNG (f=100 is PNG-only), fixing empty image boxes for JPEG/GIF screenshots
- commit
- 1a24a204b48f74e266247474adc42863929daeae
- author
- patriceckhart · · 2026-06-20 16:18 UTC
- parent
- 0250ce1
- signature
- unsigned
- refs
- tag: v0.2.41
6 files · +216 -1
M packages/agent/tools/read.go +31 -0
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | package tools | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 6 | 7 | "encoding/json" | |
| 7 | 8 | "fmt" | |
| @@ -69,6 +70,16 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 69 | 70 | if err != nil { | |
| 70 | 71 | return core.ToolResult{}, err | |
| 71 | 72 | } | |
| 73 | + | // The extension is only a hint. Files are routinely mislabeled | |
| 74 | + | // (a .png that is actually JPEG bytes, a renamed download, an | |
| 75 | + | // editor that re-encoded on save). Anthropic and other providers | |
| 76 | + | // sniff the real bytes and reject the whole request when the | |
| 77 | + | // declared media type disagrees, which would break the session. | |
| 78 | + | // Always derive the MIME from the actual content; fall back to | |
| 79 | + | // the extension-based guess only when the bytes are unrecognized. | |
| 80 | + | if sniffed := sniffImageMIME(data); sniffed != "" { | |
| 81 | + | mime = sniffed | |
| 82 | + | } | |
| 72 | 83 | return core.ToolResult{ | |
| 73 | 84 | Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}}, | |
| 74 | 85 | }, nil | |
| @@ -180,6 +191,26 @@ func imageMIME(path string) string { | |||
| 180 | 191 | return "" | |
| 181 | 192 | } | |
| 182 | 193 | ||
| 194 | + | // sniffImageMIME inspects the leading bytes of an image file and | |
| 195 | + | // returns the real media type, independent of the file's extension. | |
| 196 | + | // Providers validate the declared media type against the actual bytes | |
| 197 | + | // and 400 the whole request on a mismatch, so the extension can never | |
| 198 | + | // be trusted. Returns "" when the format is not one zot ships images | |
| 199 | + | // for, leaving the caller's extension-based guess in place. | |
| 200 | + | func sniffImageMIME(data []byte) string { | |
| 201 | + | switch { | |
| 202 | + | case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): | |
| 203 | + | return "image/png" | |
| 204 | + | case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: | |
| 205 | + | return "image/jpeg" | |
| 206 | + | case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))): | |
| 207 | + | return "image/gif" | |
| 208 | + | case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): | |
| 209 | + | return "image/webp" | |
| 210 | + | } | |
| 211 | + | return "" | |
| 212 | + | } | |
| 213 | + | ||
| 183 | 214 | // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB. | |
| 184 | 215 | func looksBinary(b []byte) bool { | |
| 185 | 216 | n := len(b) | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package tools | 2 | package tools | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "bytes" | |||
| 5 | "context" | 6 | "context" | ||
| 6 | "encoding/json" | 7 | "encoding/json" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| @@ -69,6 +70,16 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 69 | if err != nil { | 70 | if err != nil { | ||
| 70 | return core.ToolResult{}, err | 71 | return core.ToolResult{}, err | ||
| 71 | } | 72 | } | ||
| 73 | + | // The extension is only a hint. Files are routinely mislabeled | |||
| 74 | + | // (a .png that is actually JPEG bytes, a renamed download, an | |||
| 75 | + | // editor that re-encoded on save). Anthropic and other providers | |||
| 76 | + | // sniff the real bytes and reject the whole request when the | |||
| 77 | + | // declared media type disagrees, which would break the session. | |||
| 78 | + | // Always derive the MIME from the actual content; fall back to | |||
| 79 | + | // the extension-based guess only when the bytes are unrecognized. | |||
| 80 | + | if sniffed := sniffImageMIME(data); sniffed != "" { | |||
| 81 | + | mime = sniffed | |||
| 82 | + | } | |||
| 72 | return core.ToolResult{ | 83 | return core.ToolResult{ | ||
| 73 | Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}}, | 84 | Content: []provider.Content{provider.ImageBlock{MimeType: mime, Data: data}}, | ||
| 74 | }, nil | 85 | }, nil | ||
| @@ -180,6 +191,26 @@ func imageMIME(path string) string { | |||||
| 180 | return "" | 191 | return "" | ||
| 181 | } | 192 | } | ||
| 182 | 193 | ||||
| 194 | + | // sniffImageMIME inspects the leading bytes of an image file and | |||
| 195 | + | // returns the real media type, independent of the file's extension. | |||
| 196 | + | // Providers validate the declared media type against the actual bytes | |||
| 197 | + | // and 400 the whole request on a mismatch, so the extension can never | |||
| 198 | + | // be trusted. Returns "" when the format is not one zot ships images | |||
| 199 | + | // for, leaving the caller's extension-based guess in place. | |||
| 200 | + | func sniffImageMIME(data []byte) string { | |||
| 201 | + | switch { | |||
| 202 | + | case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): | |||
| 203 | + | return "image/png" | |||
| 204 | + | case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: | |||
| 205 | + | return "image/jpeg" | |||
| 206 | + | case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))): | |||
| 207 | + | return "image/gif" | |||
| 208 | + | case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): | |||
| 209 | + | return "image/webp" | |||
| 210 | + | } | |||
| 211 | + | return "" | |||
| 212 | + | } | |||
| 213 | + | ||||
| 183 | // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB. | 214 | // looksBinary returns true if the buffer contains a NUL byte in its first 8 KiB. | ||
| 184 | func looksBinary(b []byte) bool { | 215 | func looksBinary(b []byte) bool { | ||
| 185 | n := len(b) | 216 | n := len(b) | ||
M packages/agent/tools/tools_test.go +24 -0
| @@ -38,6 +38,30 @@ func TestReadText(t *testing.T) { | |||
| 38 | 38 | } | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | func TestReadImageMimeFromContentNotExtension(t *testing.T) { | |
| 42 | + | // A file named .png whose bytes are actually JPEG. The MIME must be | |
| 43 | + | // sniffed from the content (image/jpeg), not the extension, or | |
| 44 | + | // providers that validate the declared media type reject the request. | |
| 45 | + | dir := t.TempDir() | |
| 46 | + | p := filepath.Join(dir, "shot.png") | |
| 47 | + | jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} | |
| 48 | + | if err := os.WriteFile(p, jpegBytes, 0o644); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | tool := &ReadTool{CWD: dir} | |
| 52 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil) | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | img, ok := res.Content[0].(provider.ImageBlock) | |
| 57 | + | if !ok { | |
| 58 | + | t.Fatalf("expected ImageBlock, got %T", res.Content[0]) | |
| 59 | + | } | |
| 60 | + | if img.MimeType != "image/jpeg" { | |
| 61 | + | t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 41 | 65 | func TestReadOffsetLimit(t *testing.T) { | |
| 42 | 66 | dir := t.TempDir() | |
| 43 | 67 | p := filepath.Join(dir, "a.txt") | |
| @@ -38,6 +38,30 @@ func TestReadText(t *testing.T) { | |||||
| 38 | } | 38 | } | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | func TestReadImageMimeFromContentNotExtension(t *testing.T) { | |||
| 42 | + | // A file named .png whose bytes are actually JPEG. The MIME must be | |||
| 43 | + | // sniffed from the content (image/jpeg), not the extension, or | |||
| 44 | + | // providers that validate the declared media type reject the request. | |||
| 45 | + | dir := t.TempDir() | |||
| 46 | + | p := filepath.Join(dir, "shot.png") | |||
| 47 | + | jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} | |||
| 48 | + | if err := os.WriteFile(p, jpegBytes, 0o644); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | tool := &ReadTool{CWD: dir} | |||
| 52 | + | res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil) | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | img, ok := res.Content[0].(provider.ImageBlock) | |||
| 57 | + | if !ok { | |||
| 58 | + | t.Fatalf("expected ImageBlock, got %T", res.Content[0]) | |||
| 59 | + | } | |||
| 60 | + | if img.MimeType != "image/jpeg" { | |||
| 61 | + | t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 41 | func TestReadOffsetLimit(t *testing.T) { | 65 | func TestReadOffsetLimit(t *testing.T) { | ||
| 42 | dir := t.TempDir() | 66 | dir := t.TempDir() | ||
| 43 | p := filepath.Join(dir, "a.txt") | 67 | p := filepath.Join(dir, "a.txt") | ||
M packages/provider/anthropic_image.go +31 -0
| @@ -24,6 +24,24 @@ import ( | |||
| 24 | 24 | // kinds of screenshots / charts the model usually consumes. | |
| 25 | 25 | const anthMaxImageSide = 2000 | |
| 26 | 26 | ||
| 27 | + | // anthSniffImageMIME returns the media type implied by an image's | |
| 28 | + | // leading magic bytes, independent of any declared type or stdlib | |
| 29 | + | // decoder registration. Returns "" when the signature is unrecognized, | |
| 30 | + | // leaving the caller's declared MIME untouched. | |
| 31 | + | func anthSniffImageMIME(data []byte) string { | |
| 32 | + | switch { | |
| 33 | + | case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): | |
| 34 | + | return "image/png" | |
| 35 | + | case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: | |
| 36 | + | return "image/jpeg" | |
| 37 | + | case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))): | |
| 38 | + | return "image/gif" | |
| 39 | + | case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): | |
| 40 | + | return "image/webp" | |
| 41 | + | } | |
| 42 | + | return "" | |
| 43 | + | } | |
| 44 | + | ||
| 27 | 45 | // anthShrinkImageBytesIfTooBig returns data unchanged when the image | |
| 28 | 46 | // already fits within Anthropic's per-image dimension cap. When it | |
| 29 | 47 | // doesn't, the image is decoded, resampled with Catmull-Rom (a good | |
| @@ -43,6 +61,19 @@ func anthShrinkImageBytesIfTooBig(data []byte, mime string) ([]byte, string) { | |||
| 43 | 61 | if len(data) == 0 { | |
| 44 | 62 | return data, mime | |
| 45 | 63 | } | |
| 64 | + | // Reconcile the declared media type with the actual bytes before | |
| 65 | + | // anything else. Callers can mislabel images (a .png that is really | |
| 66 | + | // JPEG, an extension that hardcodes a type), and so can already- | |
| 67 | + | // persisted session transcripts created before this fix existed. | |
| 68 | + | // Anthropic rejects the whole request when the declared type | |
| 69 | + | // disagrees with the bytes it sniffs, which would make such a | |
| 70 | + | // session impossible to continue. Sniffing the magic bytes here | |
| 71 | + | // (independent of stdlib decoder registration, and resilient to a | |
| 72 | + | // DecodeConfig failure below) makes the mismatch impossible to ship | |
| 73 | + | // and lets a previously-broken session resume cleanly. | |
| 74 | + | if real := anthSniffImageMIME(data); real != "" { | |
| 75 | + | mime = real | |
| 76 | + | } | |
| 46 | 77 | cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) | |
| 47 | 78 | if err != nil { | |
| 48 | 79 | return data, mime | |
| @@ -24,6 +24,24 @@ import ( | |||||
| 24 | // kinds of screenshots / charts the model usually consumes. | 24 | // kinds of screenshots / charts the model usually consumes. | ||
| 25 | const anthMaxImageSide = 2000 | 25 | const anthMaxImageSide = 2000 | ||
| 26 | 26 | ||||
| 27 | + | // anthSniffImageMIME returns the media type implied by an image's | |||
| 28 | + | // leading magic bytes, independent of any declared type or stdlib | |||
| 29 | + | // decoder registration. Returns "" when the signature is unrecognized, | |||
| 30 | + | // leaving the caller's declared MIME untouched. | |||
| 31 | + | func anthSniffImageMIME(data []byte) string { | |||
| 32 | + | switch { | |||
| 33 | + | case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}): | |||
| 34 | + | return "image/png" | |||
| 35 | + | case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: | |||
| 36 | + | return "image/jpeg" | |||
| 37 | + | case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))): | |||
| 38 | + | return "image/gif" | |||
| 39 | + | case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): | |||
| 40 | + | return "image/webp" | |||
| 41 | + | } | |||
| 42 | + | return "" | |||
| 43 | + | } | |||
| 44 | + | ||||
| 27 | // anthShrinkImageBytesIfTooBig returns data unchanged when the image | 45 | // anthShrinkImageBytesIfTooBig returns data unchanged when the image | ||
| 28 | // already fits within Anthropic's per-image dimension cap. When it | 46 | // already fits within Anthropic's per-image dimension cap. When it | ||
| 29 | // doesn't, the image is decoded, resampled with Catmull-Rom (a good | 47 | // doesn't, the image is decoded, resampled with Catmull-Rom (a good | ||
| @@ -43,6 +61,19 @@ func anthShrinkImageBytesIfTooBig(data []byte, mime string) ([]byte, string) { | |||||
| 43 | if len(data) == 0 { | 61 | if len(data) == 0 { | ||
| 44 | return data, mime | 62 | return data, mime | ||
| 45 | } | 63 | } | ||
| 64 | + | // Reconcile the declared media type with the actual bytes before | |||
| 65 | + | // anything else. Callers can mislabel images (a .png that is really | |||
| 66 | + | // JPEG, an extension that hardcodes a type), and so can already- | |||
| 67 | + | // persisted session transcripts created before this fix existed. | |||
| 68 | + | // Anthropic rejects the whole request when the declared type | |||
| 69 | + | // disagrees with the bytes it sniffs, which would make such a | |||
| 70 | + | // session impossible to continue. Sniffing the magic bytes here | |||
| 71 | + | // (independent of stdlib decoder registration, and resilient to a | |||
| 72 | + | // DecodeConfig failure below) makes the mismatch impossible to ship | |||
| 73 | + | // and lets a previously-broken session resume cleanly. | |||
| 74 | + | if real := anthSniffImageMIME(data); real != "" { | |||
| 75 | + | mime = real | |||
| 76 | + | } | |||
| 46 | cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) | 77 | cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) | ||
| 47 | if err != nil { | 78 | if err != nil { | ||
| 48 | return data, mime | 79 | return data, mime | ||
M packages/provider/anthropic_image_test.go +36 -0
| @@ -60,6 +60,42 @@ func TestAnthShrinkImage_PassesThroughWhenSmall(t *testing.T) { | |||
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | func TestAnthShrinkImage_CorrectsMislabeledMimeWhenSmall(t *testing.T) { | |
| 64 | + | // A JPEG that fits within the cap but is wrongly declared as PNG. | |
| 65 | + | // Anthropic 400s on the mismatch, so the builder must rewrite the | |
| 66 | + | // declared media type to match the bytes even without resizing. | |
| 67 | + | src := encodeJPEG(t, makeRect(800, 600)) | |
| 68 | + | out, mime := anthShrinkImageBytesIfTooBig(src, "image/png") | |
| 69 | + | if !bytes.Equal(out, src) { | |
| 70 | + | t.Errorf("small image bytes were rewritten; expected pass-through") | |
| 71 | + | } | |
| 72 | + | if mime != "image/jpeg" { | |
| 73 | + | t.Errorf("mislabeled mime not corrected: got %s want image/jpeg", mime) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestAnthBuildToolResultContent_RepairsMislabeledImageOnResume(t *testing.T) { | |
| 78 | + | // Simulates continuing a session whose transcript already carries a | |
| 79 | + | // tool_result image with the wrong declared media type (.png name, | |
| 80 | + | // JPEG bytes). The outbound request builder must rewrite the media | |
| 81 | + | // type to match the bytes so Anthropic accepts the resumed request. | |
| 82 | + | jpegBytes := encodeJPEG(t, makeRect(64, 64)) | |
| 83 | + | blocks := []Content{ | |
| 84 | + | TextBlock{Text: "screenshot"}, | |
| 85 | + | ImageBlock{MimeType: "image/png", Data: jpegBytes}, | |
| 86 | + | } | |
| 87 | + | raw, err := anthBuildToolResultContent(blocks) | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatalf("build tool result: %v", err) | |
| 90 | + | } | |
| 91 | + | if !bytes.Contains(raw, []byte(`"media_type":"image/jpeg"`)) { | |
| 92 | + | t.Fatalf("media type not repaired in outbound request: %s", raw) | |
| 93 | + | } | |
| 94 | + | if bytes.Contains(raw, []byte(`"media_type":"image/png"`)) { | |
| 95 | + | t.Fatalf("stale image/png media type still present: %s", raw) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 63 | 99 | func TestAnthShrinkImage_DownscalesWhenTooWide(t *testing.T) { | |
| 64 | 100 | src := encodePNG(t, makeRect(4000, 1000)) | |
| 65 | 101 | out, mime := anthShrinkImageBytesIfTooBig(src, "image/png") | |
| @@ -60,6 +60,42 @@ func TestAnthShrinkImage_PassesThroughWhenSmall(t *testing.T) { | |||||
| 60 | } | 60 | } | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | + | func TestAnthShrinkImage_CorrectsMislabeledMimeWhenSmall(t *testing.T) { | |||
| 64 | + | // A JPEG that fits within the cap but is wrongly declared as PNG. | |||
| 65 | + | // Anthropic 400s on the mismatch, so the builder must rewrite the | |||
| 66 | + | // declared media type to match the bytes even without resizing. | |||
| 67 | + | src := encodeJPEG(t, makeRect(800, 600)) | |||
| 68 | + | out, mime := anthShrinkImageBytesIfTooBig(src, "image/png") | |||
| 69 | + | if !bytes.Equal(out, src) { | |||
| 70 | + | t.Errorf("small image bytes were rewritten; expected pass-through") | |||
| 71 | + | } | |||
| 72 | + | if mime != "image/jpeg" { | |||
| 73 | + | t.Errorf("mislabeled mime not corrected: got %s want image/jpeg", mime) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestAnthBuildToolResultContent_RepairsMislabeledImageOnResume(t *testing.T) { | |||
| 78 | + | // Simulates continuing a session whose transcript already carries a | |||
| 79 | + | // tool_result image with the wrong declared media type (.png name, | |||
| 80 | + | // JPEG bytes). The outbound request builder must rewrite the media | |||
| 81 | + | // type to match the bytes so Anthropic accepts the resumed request. | |||
| 82 | + | jpegBytes := encodeJPEG(t, makeRect(64, 64)) | |||
| 83 | + | blocks := []Content{ | |||
| 84 | + | TextBlock{Text: "screenshot"}, | |||
| 85 | + | ImageBlock{MimeType: "image/png", Data: jpegBytes}, | |||
| 86 | + | } | |||
| 87 | + | raw, err := anthBuildToolResultContent(blocks) | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatalf("build tool result: %v", err) | |||
| 90 | + | } | |||
| 91 | + | if !bytes.Contains(raw, []byte(`"media_type":"image/jpeg"`)) { | |||
| 92 | + | t.Fatalf("media type not repaired in outbound request: %s", raw) | |||
| 93 | + | } | |||
| 94 | + | if bytes.Contains(raw, []byte(`"media_type":"image/png"`)) { | |||
| 95 | + | t.Fatalf("stale image/png media type still present: %s", raw) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 63 | func TestAnthShrinkImage_DownscalesWhenTooWide(t *testing.T) { | 99 | func TestAnthShrinkImage_DownscalesWhenTooWide(t *testing.T) { | ||
| 64 | src := encodePNG(t, makeRect(4000, 1000)) | 100 | src := encodePNG(t, makeRect(4000, 1000)) | ||
| 65 | out, mime := anthShrinkImageBytesIfTooBig(src, "image/png") | 101 | out, mime := anthShrinkImageBytesIfTooBig(src, "image/png") | ||
M packages/tui/image.go +28 -1
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "image" | |
| 8 | 8 | _ "image/gif" | |
| 9 | 9 | _ "image/jpeg" | |
| 10 | - | _ "image/png" | |
| 10 | + | "image/png" | |
| 11 | 11 | "math" | |
| 12 | 12 | "os" | |
| 13 | 13 | "strconv" | |
| @@ -134,6 +134,27 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |||
| 134 | 134 | return sb.String() | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | + | // kittyEnsurePNG returns data unchanged when it is already a PNG, and | |
| 138 | + | // otherwise decodes and re-encodes it to PNG so the Kitty graphics | |
| 139 | + | // protocol (f=100, PNG-only) can render it. On any decode/encode error | |
| 140 | + | // the original bytes are returned untouched: the worst case is the | |
| 141 | + | // pre-existing empty-box behaviour rather than a panic or a broken | |
| 142 | + | // escape, and ImageDimensions still works off the original bytes. | |
| 143 | + | func kittyEnsurePNG(data []byte) []byte { | |
| 144 | + | if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) { | |
| 145 | + | return data | |
| 146 | + | } | |
| 147 | + | img, _, err := image.Decode(bytes.NewReader(data)) | |
| 148 | + | if err != nil { | |
| 149 | + | return data | |
| 150 | + | } | |
| 151 | + | var buf bytes.Buffer | |
| 152 | + | if err := png.Encode(&buf, img); err != nil { | |
| 153 | + | return data | |
| 154 | + | } | |
| 155 | + | return buf.Bytes() | |
| 156 | + | } | |
| 157 | + | ||
| 137 | 158 | // renderKitty builds a Kitty graphics protocol sequence. Supports chunked | |
| 138 | 159 | // data via the "m" continuation flag; chunk size is 4096 to stay under | |
| 139 | 160 | // terminal escape-buffer limits. | |
| @@ -145,6 +166,12 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |||
| 145 | 166 | // | |
| 146 | 167 | // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/ | |
| 147 | 168 | func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | |
| 169 | + | // The Kitty graphics protocol with f=100 expects a PNG payload; it | |
| 170 | + | // has no JPEG/GIF decoder. Feeding it non-PNG bytes makes kitty/ | |
| 171 | + | // ghostty reserve the cell rectangle but paint nothing, leaving an | |
| 172 | + | // empty box. Re-encode anything that is not already PNG to PNG so | |
| 173 | + | // the image actually renders regardless of its source format. | |
| 174 | + | data = kittyEnsurePNG(data) | |
| 148 | 175 | b64 := base64.StdEncoding.EncodeToString(data) | |
| 149 | 176 | const chunk = 4096 | |
| 150 | 177 | var sb strings.Builder | |
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | "image" | 7 | "image" | ||
| 8 | _ "image/gif" | 8 | _ "image/gif" | ||
| 9 | _ "image/jpeg" | 9 | _ "image/jpeg" | ||
| 10 | - | _ "image/png" | 10 | + | "image/png" |
| 11 | "math" | 11 | "math" | ||
| 12 | "os" | 12 | "os" | ||
| 13 | "strconv" | 13 | "strconv" | ||
| @@ -134,6 +134,27 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |||||
| 134 | return sb.String() | 134 | return sb.String() | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | + | // kittyEnsurePNG returns data unchanged when it is already a PNG, and | |||
| 138 | + | // otherwise decodes and re-encodes it to PNG so the Kitty graphics | |||
| 139 | + | // protocol (f=100, PNG-only) can render it. On any decode/encode error | |||
| 140 | + | // the original bytes are returned untouched: the worst case is the | |||
| 141 | + | // pre-existing empty-box behaviour rather than a panic or a broken | |||
| 142 | + | // escape, and ImageDimensions still works off the original bytes. | |||
| 143 | + | func kittyEnsurePNG(data []byte) []byte { | |||
| 144 | + | if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) { | |||
| 145 | + | return data | |||
| 146 | + | } | |||
| 147 | + | img, _, err := image.Decode(bytes.NewReader(data)) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return data | |||
| 150 | + | } | |||
| 151 | + | var buf bytes.Buffer | |||
| 152 | + | if err := png.Encode(&buf, img); err != nil { | |||
| 153 | + | return data | |||
| 154 | + | } | |||
| 155 | + | return buf.Bytes() | |||
| 156 | + | } | |||
| 157 | + | ||||
| 137 | // renderKitty builds a Kitty graphics protocol sequence. Supports chunked | 158 | // renderKitty builds a Kitty graphics protocol sequence. Supports chunked | ||
| 138 | // data via the "m" continuation flag; chunk size is 4096 to stay under | 159 | // data via the "m" continuation flag; chunk size is 4096 to stay under | ||
| 139 | // terminal escape-buffer limits. | 160 | // terminal escape-buffer limits. | ||
| @@ -145,6 +166,12 @@ func renderITerm2(data []byte, maxCellsWide, maxCellsHigh int) string { | |||||
| 145 | // | 166 | // | ||
| 146 | // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/ | 167 | // Reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/ | ||
| 147 | func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | 168 | func renderKitty(data []byte, maxCellsWide, maxCellsHigh int) string { | ||
| 169 | + | // The Kitty graphics protocol with f=100 expects a PNG payload; it | |||
| 170 | + | // has no JPEG/GIF decoder. Feeding it non-PNG bytes makes kitty/ | |||
| 171 | + | // ghostty reserve the cell rectangle but paint nothing, leaving an | |||
| 172 | + | // empty box. Re-encode anything that is not already PNG to PNG so | |||
| 173 | + | // the image actually renders regardless of its source format. | |||
| 174 | + | data = kittyEnsurePNG(data) | |||
| 148 | b64 := base64.StdEncoding.EncodeToString(data) | 175 | b64 := base64.StdEncoding.EncodeToString(data) | ||
| 149 | const chunk = 4096 | 176 | const chunk = 4096 | ||
| 150 | var sb strings.Builder | 177 | var sb strings.Builder | ||
M packages/tui/image_test.go +66 -0
| @@ -2,9 +2,13 @@ package tui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "image" | |
| 6 | 7 | "image/color" | |
| 8 | + | "image/jpeg" | |
| 7 | 9 | "image/png" | |
| 10 | + | "regexp" | |
| 11 | + | "strings" | |
| 8 | 12 | "testing" | |
| 9 | 13 | ) | |
| 10 | 14 | ||
| @@ -23,6 +27,68 @@ func testPNG(t *testing.T, w, h int) []byte { | |||
| 23 | 27 | return buf.Bytes() | |
| 24 | 28 | } | |
| 25 | 29 | ||
| 30 | + | func testJPEG(t *testing.T, w, h int) []byte { | |
| 31 | + | t.Helper() | |
| 32 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |
| 33 | + | for y := 0; y < h; y++ { | |
| 34 | + | for x := 0; x < w; x++ { | |
| 35 | + | img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255}) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | var buf bytes.Buffer | |
| 39 | + | if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | return buf.Bytes() | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | var kittyPayloadRE = regexp.MustCompile(`\x1b_G[^;]*;([^\x1b]*)\x1b\\`) | |
| 46 | + | ||
| 47 | + | // kittyDecodePayload reassembles the base64 chunks from a kitty | |
| 48 | + | // graphics escape sequence and returns the decoded raw bytes. | |
| 49 | + | func kittyDecodePayload(t *testing.T, seq string) []byte { | |
| 50 | + | t.Helper() | |
| 51 | + | ms := kittyPayloadRE.FindAllStringSubmatch(seq, -1) | |
| 52 | + | if len(ms) == 0 { | |
| 53 | + | t.Fatalf("no kitty payload chunks found in sequence") | |
| 54 | + | } | |
| 55 | + | var b64 strings.Builder | |
| 56 | + | for _, m := range ms { | |
| 57 | + | b64.WriteString(m[1]) | |
| 58 | + | } | |
| 59 | + | raw, err := base64.StdEncoding.DecodeString(b64.String()) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatalf("decode base64 payload: %v", err) | |
| 62 | + | } | |
| 63 | + | return raw | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestRenderKittyReencodesJPEGToPNG(t *testing.T) { | |
| 67 | + | // A JPEG handed to the kitty renderer must be re-encoded to PNG, | |
| 68 | + | // because kitty's f=100 path only decodes PNG. Otherwise the | |
| 69 | + | // terminal reserves the cell rectangle but paints nothing, which is | |
| 70 | + | // the empty-box symptom. | |
| 71 | + | jpg := testJPEG(t, 40, 30) | |
| 72 | + | seq := renderKitty(jpg, 20, 10) | |
| 73 | + | if seq == "" { | |
| 74 | + | t.Fatal("renderKitty returned empty sequence") | |
| 75 | + | } | |
| 76 | + | raw := kittyDecodePayload(t, seq) | |
| 77 | + | pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} | |
| 78 | + | if len(raw) < 8 || !bytes.Equal(raw[:8], pngMagic) { | |
| 79 | + | t.Fatalf("kitty payload is not PNG; got prefix %x", raw[:min(8, len(raw))]) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestRenderKittyLeavesPNGUntouched(t *testing.T) { | |
| 84 | + | src := testPNG(t, 40, 30) | |
| 85 | + | seq := renderKitty(src, 20, 10) | |
| 86 | + | raw := kittyDecodePayload(t, seq) | |
| 87 | + | if !bytes.Equal(raw, src) { | |
| 88 | + | t.Fatal("PNG payload was needlessly re-encoded") | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 26 | 92 | func TestRowsForInlineImageRoundsUp(t *testing.T) { | |
| 27 | 93 | t.Setenv("ZOT_CELL_ASPECT", "") | |
| 28 | 94 | data := testPNG(t, 100, 51) | |
| @@ -2,9 +2,13 @@ package tui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "encoding/base64" | |||
| 5 | "image" | 6 | "image" | ||
| 6 | "image/color" | 7 | "image/color" | ||
| 8 | + | "image/jpeg" | |||
| 7 | "image/png" | 9 | "image/png" | ||
| 10 | + | "regexp" | |||
| 11 | + | "strings" | |||
| 8 | "testing" | 12 | "testing" | ||
| 9 | ) | 13 | ) | ||
| 10 | 14 | ||||
| @@ -23,6 +27,68 @@ func testPNG(t *testing.T, w, h int) []byte { | |||||
| 23 | return buf.Bytes() | 27 | return buf.Bytes() | ||
| 24 | } | 28 | } | ||
| 25 | 29 | ||||
| 30 | + | func testJPEG(t *testing.T, w, h int) []byte { | |||
| 31 | + | t.Helper() | |||
| 32 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |||
| 33 | + | for y := 0; y < h; y++ { | |||
| 34 | + | for x := 0; x < w; x++ { | |||
| 35 | + | img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 128, A: 255}) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | var buf bytes.Buffer | |||
| 39 | + | if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | return buf.Bytes() | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | var kittyPayloadRE = regexp.MustCompile(`\x1b_G[^;]*;([^\x1b]*)\x1b\\`) | |||
| 46 | + | ||||
| 47 | + | // kittyDecodePayload reassembles the base64 chunks from a kitty | |||
| 48 | + | // graphics escape sequence and returns the decoded raw bytes. | |||
| 49 | + | func kittyDecodePayload(t *testing.T, seq string) []byte { | |||
| 50 | + | t.Helper() | |||
| 51 | + | ms := kittyPayloadRE.FindAllStringSubmatch(seq, -1) | |||
| 52 | + | if len(ms) == 0 { | |||
| 53 | + | t.Fatalf("no kitty payload chunks found in sequence") | |||
| 54 | + | } | |||
| 55 | + | var b64 strings.Builder | |||
| 56 | + | for _, m := range ms { | |||
| 57 | + | b64.WriteString(m[1]) | |||
| 58 | + | } | |||
| 59 | + | raw, err := base64.StdEncoding.DecodeString(b64.String()) | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Fatalf("decode base64 payload: %v", err) | |||
| 62 | + | } | |||
| 63 | + | return raw | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestRenderKittyReencodesJPEGToPNG(t *testing.T) { | |||
| 67 | + | // A JPEG handed to the kitty renderer must be re-encoded to PNG, | |||
| 68 | + | // because kitty's f=100 path only decodes PNG. Otherwise the | |||
| 69 | + | // terminal reserves the cell rectangle but paints nothing, which is | |||
| 70 | + | // the empty-box symptom. | |||
| 71 | + | jpg := testJPEG(t, 40, 30) | |||
| 72 | + | seq := renderKitty(jpg, 20, 10) | |||
| 73 | + | if seq == "" { | |||
| 74 | + | t.Fatal("renderKitty returned empty sequence") | |||
| 75 | + | } | |||
| 76 | + | raw := kittyDecodePayload(t, seq) | |||
| 77 | + | pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} | |||
| 78 | + | if len(raw) < 8 || !bytes.Equal(raw[:8], pngMagic) { | |||
| 79 | + | t.Fatalf("kitty payload is not PNG; got prefix %x", raw[:min(8, len(raw))]) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestRenderKittyLeavesPNGUntouched(t *testing.T) { | |||
| 84 | + | src := testPNG(t, 40, 30) | |||
| 85 | + | seq := renderKitty(src, 20, 10) | |||
| 86 | + | raw := kittyDecodePayload(t, seq) | |||
| 87 | + | if !bytes.Equal(raw, src) { | |||
| 88 | + | t.Fatal("PNG payload was needlessly re-encoded") | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 26 | func TestRowsForInlineImageRoundsUp(t *testing.T) { | 92 | func TestRowsForInlineImageRoundsUp(t *testing.T) { | ||
| 27 | t.Setenv("ZOT_CELL_ASPECT", "") | 93 | t.Setenv("ZOT_CELL_ASPECT", "") | ||
| 28 | data := testPNG(t, 100, 51) | 94 | data := testPNG(t, 100, 51) | ||