Skip to content

Commit 97635ce

Browse files
committed
feat: enhance Unicode image handling with PNG transfer and placement command validation
1 parent 7f6cfdd commit 97635ce

File tree

2 files changed

+101
-39
lines changed

2 files changed

+101
-39
lines changed

kitty.go

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,17 @@ func (r *KittyRenderer) GetLastImageID() uint32 {
107107
}
108108

109109
func reserveUnicodeImageNum(opts *KittyOptions) (uint32, error) {
110-
const maxUnicodeImageNum = 0xFFFFFF
110+
const maxUnicodeImageNum = uint64(^uint32(0))
111111

112112
if opts != nil && opts.ImageNum > 0 {
113-
if opts.ImageNum > maxUnicodeImageNum {
114-
return 0, fmt.Errorf("unicode placeholders require image number <= 0xFFFFFF")
113+
if uint64(opts.ImageNum) > maxUnicodeImageNum {
114+
return 0, fmt.Errorf("unicode placeholders require image number <= 0xFFFFFFFF")
115115
}
116116
return uint32(opts.ImageNum), nil
117117
}
118118

119119
imageNum := atomic.AddUint32(&globalKittyImageNum, 1)
120-
if imageNum > maxUnicodeImageNum {
120+
if imageNum == 0 {
121121
atomic.StoreUint32(&globalKittyImageNum, 1)
122122
imageNum = 1
123123
}
@@ -167,30 +167,25 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
167167

168168
kittyOpts := opts.KittyOpts
169169

170-
// Check if using Unicode placeholders - this requires a different two-step approach
170+
// Check if using Unicode placeholders - this uses a three-step flow:
171+
// transmit image data, create virtual placement (U=1), then draw placeholders.
171172
useUnicode := kittyOpts != nil && kittyOpts.UseUnicode
172173

173174
if useUnicode {
174-
// Two-step process for Unicode placeholders (matches old termimg behavior):
175-
// 1. Transmit image data (no display) using PNG format
176-
// 2. Create placement with U=1 and explicit cols/rows
177-
// 3. Generate placeholder characters
178-
179175
imageNum, err := reserveUnicodeImageNum(kittyOpts)
180176
if err != nil {
181177
return "", err
182178
}
183179

184-
// Encode as PNG for transmission
180+
// Encode as PNG for transfer.
185181
var buf bytes.Buffer
186182
if err := png.Encode(&buf, processed); err != nil {
187183
return "", fmt.Errorf("failed to encode png: %w", err)
188184
}
189185
data := buf.Bytes()
190186
encoded := Base64Encode(data)
191187

192-
// Step 1: Transmit image data in chunks (no display)
193-
// Format: f=100 (PNG), t=d (direct), i=<id>, q=2 (quiet)
188+
// Step 1: Transmit image data in chunks (no immediate placement).
194189
remaining := encoded
195190
first := true
196191
for len(remaining) > 0 {
@@ -217,15 +212,14 @@ func (r *KittyRenderer) Render(img image.Image, opts RenderOptions) (string, err
217212
output.WriteString(seq)
218213
}
219214

220-
// Step 2: Create virtual placement with Unicode mode
221-
// Format: a=p (placement), U=1 (unicode), i=<id>, c=<cols>, r=<rows>, q=2
215+
// Step 2: Create virtual placement that placeholders will reference.
222216
placementSeq := fmt.Sprintf("\x1b_Ga=p,U=1,i=%d,c=%d,r=%d,q=2\x1b\\", imageNum, cols, rows)
223217
if inTmux() {
224218
placementSeq = wrapTmuxPassthrough(placementSeq)
225219
}
226220
output.WriteString(placementSeq)
227221

228-
// Step 3: Generate placeholder characters
222+
// Step 3: Generate placeholder characters.
229223
placeholders := r.generateUnicodePlaceholders(imageNum, cols, rows)
230224
output.WriteString(placeholders)
231225

@@ -486,15 +480,9 @@ func (r *KittyRenderer) PlaceImageWithSize(imageID string, xCells, yCells, zInde
486480

487481
var output strings.Builder
488482

489-
// Move to the starting position (absolute)
490-
// Terminal coordinates are 1-based
491-
output.WriteString(fmt.Sprintf("\x1b[%d;%dH", yCells+1, xCells+1))
492-
493-
// Generate the placeholder area string
494-
// RenderPlaceholderAreaWithImageID handles color encoding and does NOT save/restore cursor
495-
area := CreatePlaceholderArea(imgID, uint16(heightCells), uint16(widthCells))
496-
placeholders := RenderPlaceholderAreaWithImageID(area, imgID)
497-
output.WriteString(placeholders)
483+
// Render each row at an explicit absolute cursor position to avoid terminal-
484+
// specific newline behavior from shifting rows back to column 1.
485+
output.WriteString(renderAnchoredPlaceholderArea(imgID, xCells, yCells, widthCells, heightCells))
498486

499487
if inTmux() {
500488
result := wrapTmuxPassthrough(output.String())
@@ -505,6 +493,33 @@ func (r *KittyRenderer) PlaceImageWithSize(imageID string, xCells, yCells, zInde
505493
return err
506494
}
507495

496+
// renderAnchoredPlaceholderArea emits each placeholder row with an absolute
497+
// cursor address instead of newlines so row alignment is stable across
498+
// terminals with different LF/CR handling.
499+
func renderAnchoredPlaceholderArea(imageID uint32, xCells, yCells, widthCells, heightCells int) string {
500+
if widthCells <= 0 || heightCells <= 0 {
501+
return ""
502+
}
503+
504+
area := CreatePlaceholderArea(imageID, uint16(heightCells), uint16(widthCells))
505+
506+
r := (imageID >> 16) & 0xFF
507+
g := (imageID >> 8) & 0xFF
508+
b := imageID & 0xFF
509+
colorStart := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
510+
511+
var builder strings.Builder
512+
for rowIdx, row := range area {
513+
builder.WriteString(fmt.Sprintf("\x1b[%d;%dH", yCells+rowIdx+1, xCells+1))
514+
builder.WriteString(colorStart)
515+
for _, placeholder := range row {
516+
builder.WriteString(placeholder)
517+
}
518+
builder.WriteString("\x1b[39m")
519+
}
520+
return builder.String()
521+
}
522+
508523
// SendFile optimizes transfer by sending file path instead of data when possible
509524
func (r *KittyRenderer) SendFile(filePath string, opts RenderOptions) error {
510525
if filePath == "" {
@@ -686,19 +701,12 @@ func CreatePlaceholderArea(imageID uint32, rows, columns uint16) [][]string {
686701
func RenderPlaceholderAreaWithImageID(area [][]string, imageID uint32) string {
687702
var builder strings.Builder
688703

689-
// Build color encoding for the image ID
690-
// For IDs <= 255, use 256-color mode for better compatibility
691-
// For larger IDs, use 24-bit RGB encoding
692-
var colorStart string
693-
if imageID <= 255 {
694-
colorStart = fmt.Sprintf("\x1b[38;5;%dm", imageID)
695-
} else {
696-
// Encode ID in RGB: R = id & 0xFF, G = (id >> 8) & 0xFF, B = (id >> 16) & 0xFF
697-
r := imageID & 0xFF
698-
g := (imageID >> 8) & 0xFF
699-
b := (imageID >> 16) & 0xFF
700-
colorStart = fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
701-
}
704+
// Encode ID in truecolor RGB: (R<<16 | G<<8 | B) == imageID&0xFFFFFF.
705+
// Using 38;2 avoids palette-index ambiguity from 38;5 in terminals.
706+
r := (imageID >> 16) & 0xFF
707+
g := (imageID >> 8) & 0xFF
708+
b := imageID & 0xFF
709+
colorStart := fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)
702710

703711
// Set foreground color to encode image ID
704712
builder.WriteString(colorStart)

kitty_test.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,26 @@ func TestKittyUnicodeHonorsImageNumber(t *testing.T) {
170170
assert.Equal(t, uint32(42), renderer.GetLastImageID(), "last image ID should match caller-provided Unicode image number")
171171
}
172172

173+
func TestKittyUnicodeUsesPngTransferWithPlacementCommand(t *testing.T) {
174+
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
175+
opts := RenderOptions{
176+
KittyOpts: &KittyOptions{
177+
UseUnicode: true,
178+
ImageNum: 42,
179+
},
180+
features: &TerminalFeatures{
181+
FontWidth: 8,
182+
FontHeight: 16,
183+
},
184+
}
185+
186+
renderer := &KittyRenderer{}
187+
output, err := renderer.Render(img, opts)
188+
assert.NoError(t, err)
189+
assert.Contains(t, output, "f=100,t=d,i=42", "Unicode path should transmit image data using PNG transfer")
190+
assert.Contains(t, output, "a=p,U=1,i=42", "Unicode path should emit explicit virtual placement command")
191+
}
192+
173193
func TestProcessImageUnicodeHonorsExplicitResize(t *testing.T) {
174194
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
175195
opts := RenderOptions{
@@ -206,6 +226,10 @@ func TestProcessImageUnicodeScaleAutoWithSingleDimension(t *testing.T) {
206226
}
207227

208228
func TestKittyUnicodeInvalidImageNumberDoesNotMutateLastID(t *testing.T) {
229+
if strconv.IntSize < 64 {
230+
t.Skip("requires 64-bit int to construct value > 0xFFFFFFFF")
231+
}
232+
209233
img := image.NewRGBA(image.Rect(0, 0, 16, 16))
210234
renderer := &KittyRenderer{}
211235

@@ -226,7 +250,7 @@ func TestKittyUnicodeInvalidImageNumberDoesNotMutateLastID(t *testing.T) {
226250
invalidOpts := RenderOptions{
227251
KittyOpts: &KittyOptions{
228252
UseUnicode: true,
229-
ImageNum: 0x1000000, // exceeds 24-bit limit
253+
ImageNum: int(uint64(^uint32(0)) + 1), // exceeds 32-bit limit
230254
},
231255
features: &TerminalFeatures{
232256
FontWidth: 8,
@@ -237,3 +261,33 @@ func TestKittyUnicodeInvalidImageNumberDoesNotMutateLastID(t *testing.T) {
237261
assert.Error(t, err)
238262
assert.Equal(t, uint32(42), renderer.GetLastImageID(), "failed render should not overwrite last successful image ID")
239263
}
264+
265+
func TestRenderPlaceholderAreaWithImageIDUsesBigEndianRGB(t *testing.T) {
266+
area := CreatePlaceholderArea(0x123456, 1, 1)
267+
rendered := RenderPlaceholderAreaWithImageID(area, 0x123456)
268+
269+
assert.Contains(t, rendered, "\x1b[38;2;18;52;86m", "RGB encoding should match (R<<16 | G<<8 | B)")
270+
assert.NotContains(t, rendered, "\x1b[38;2;86;52;18m", "little-endian RGB encoding should not be used")
271+
}
272+
273+
func TestRenderPlaceholderAreaWithImageIDUsesTruecolorForLowIDs(t *testing.T) {
274+
area := CreatePlaceholderArea(1, 1, 1)
275+
rendered := RenderPlaceholderAreaWithImageID(area, 1)
276+
277+
assert.Contains(t, rendered, "\x1b[38;2;0;0;1m", "low IDs should still be encoded as truecolor bytes")
278+
assert.NotContains(t, rendered, "\x1b[38;5;1m", "palette mode should not be used for ID encoding")
279+
}
280+
281+
func TestRenderAnchoredPlaceholderAreaPositionsEveryRow(t *testing.T) {
282+
rendered := renderAnchoredPlaceholderArea(1, 5, 5, 2, 3)
283+
assert.Contains(t, rendered, "\x1b[6;6H")
284+
assert.Contains(t, rendered, "\x1b[7;6H")
285+
assert.Contains(t, rendered, "\x1b[8;6H")
286+
assert.NotContains(t, rendered, "\n")
287+
assert.Equal(t, 3, strings.Count(rendered, "\x1b[39m"))
288+
}
289+
290+
func TestRenderAnchoredPlaceholderAreaEmptyWhenInvalidDimensions(t *testing.T) {
291+
assert.Equal(t, "", renderAnchoredPlaceholderArea(1, 0, 0, 0, 3))
292+
assert.Equal(t, "", renderAnchoredPlaceholderArea(1, 0, 0, 3, 0))
293+
}

0 commit comments

Comments
 (0)