diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 0daab3cd7..68bc1df0d 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -526,14 +526,35 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise { - const bitmap = await this._createBitmap(image); + let bitmap = await this._createBitmap(image); + + const cropX = Math.max(0, cmd.x ?? 0); + const cropY = Math.max(0, cmd.y ?? 0); + const cropW = cmd.sourceWidth ?? (bitmap.width - cropX); + const cropH = cmd.sourceHeight ?? (bitmap.height - cropY); + + const maxCropW = Math.max(0, bitmap.width - cropX); + const maxCropH = Math.max(0, bitmap.height - cropY); + const finalCropW = Math.max(0, Math.min(cropW, maxCropW)); + const finalCropH = Math.max(0, Math.min(cropH, maxCropH)); + + if (finalCropW === 0 || finalCropH === 0) { + bitmap.close(); + throw new Error('invalid source rectangle'); + } + + if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) { + const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH); + bitmap.close(); + bitmap = cropped; + } const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width; const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height; // Per spec: c/r default to image's natural cell dimensions - const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw); - const imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch); + let imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw); + let imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch); let w = bitmap.width; let h = bitmap.height; @@ -545,6 +566,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } if (w * h > this._opts.pixelLimit) { + bitmap.close(); throw new Error('image exceeds pixel limit'); } @@ -561,15 +583,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0; const layer: ImageLayer = wantsBottom ? 'bottom' : 'top'; - const zIndex = cmd.zIndex ?? 0; + let finalBitmap = bitmap; if (w !== bitmap.width || h !== bitmap.height) { - const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h }); + finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h }); bitmap.close(); - this._kittyStorage.addImage(image.id, resized, true, layer, zIndex); - } else { - this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex); } + // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions + const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1); + const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1); + if (xOffset !== 0 || yOffset !== 0) { + const offsetCanvas = ImageRenderer.createCanvas(window.document, finalBitmap.width + xOffset, finalBitmap.height + yOffset); + const offsetCtx = offsetCanvas.getContext('2d'); + if (!offsetCtx) { + finalBitmap.close(); + throw new Error('Failed to create offset canvas context'); + } + offsetCtx.drawImage(finalBitmap, xOffset, yOffset); + + const offsetBitmap = await createImageBitmap(offsetCanvas); + offsetCanvas.width = offsetCanvas.height = 0; + finalBitmap.close(); + finalBitmap = offsetBitmap; + w = finalBitmap.width; + h = finalBitmap.height; + if (w * h > this._opts.pixelLimit) { + finalBitmap.close(); + throw new Error('image exceeds pixel limit'); + } + if (cmd.columns === undefined) { + imgCols = Math.ceil(finalBitmap.width / cw); + } + if (cmd.rows === undefined) { + imgRows = Math.ceil(finalBitmap.height / ch); + } + } + + const zIndex = cmd.zIndex ?? 0; + this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex); + // Kitty cursor movement // Per spec: cursor placed at first column after last image column, // on the last row of the image. C=1 means don't move cursor. diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts index c441bbb03..82b2b0a9a 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts @@ -51,6 +51,14 @@ export const enum KittyKey { X_OFFSET = 'x', // The top edge (in pixels) of the image area to display Y_OFFSET = 'y', + // Width (in pixels) of the source rectangle to display + SOURCE_WIDTH = 'w', + // Height (in pixels) of the source rectangle to display + SOURCE_HEIGHT = 'h', + // Horizontal offset (in pixels) within the first cell + X_PLACEMENT_OFFSET = 'X', + // Vertical offset (in pixels) within the first cell + Y_PLACEMENT_OFFSET = 'Y', // Number of terminal columns to display the image over COLUMNS = 'c', // Number of terminal rows to display the image over @@ -88,6 +96,10 @@ export interface IKittyCommand { height?: number; x?: number; y?: number; + sourceWidth?: number; + sourceHeight?: number; + xOffset?: number; + yOffset?: number; columns?: number; rows?: number; more?: number; @@ -163,6 +175,10 @@ export function parseKittyCommand(data: string): IKittyCommand { case KittyKey.HEIGHT: cmd.height = numValue; break; case KittyKey.X_OFFSET: cmd.x = numValue; break; case KittyKey.Y_OFFSET: cmd.y = numValue; break; + case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break; + case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break; + case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break; + case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break; case KittyKey.COLUMNS: cmd.columns = numValue; break; case KittyKey.ROWS: cmd.rows = numValue; break; case KittyKey.MORE: cmd.more = numValue; break; diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 2b638a0dc..aa68bf60a 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -102,7 +102,6 @@ test.afterAll(async () => await ctx.page.close()); test.describe('Kitty Graphics Protocol', () => { // TODO: Add tests for larger images with various dimensions - // TODO: Add tests for image placement keys (x, y, w, h, X, Y, c, r) // TODO: Add tests for virtual placement (U=1) // TODO: Add tests for animation frames // TODO: Add performance tests for streaming large images @@ -1398,6 +1397,40 @@ test.describe('Kitty Graphics Protocol', () => { deepStrictEqual(pixels?.slice(4, 8), [255, 0, 0, 255]); // x=19: Red deepStrictEqual(pixels?.slice(8, 12), [255, 128, 0, 255]); // x=20: Orange }); + + test('applies source crop via x/y/w/h before display', async () => { + await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); + await timeout(200); + + deepStrictEqual(await getOrigSize(1), [20, 50]); + deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]); + deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]); + }); + + test('scales cropped source region to c/r placement rectangle', async () => { + // Firefox's createImageBitmap uses different resize sampling, producing + // slightly off pixel values compared to Chromium, so skip on Firefox. + if (ctx.browser.browserType().name() === 'firefox') { + test.skip(); + } + await ctx.proxy.write(`\x1b_Ga=T,f=100,x=1,y=0,w=1,h=1,c=4,r=2;${KITTY_RGB_3X1_BASE64}\x1b\\`); + await timeout(200); + + deepStrictEqual(await getCursor(), [4, 1]); + const left = await getPixel(0, 0, 2, 10); + const right = await getPixel(0, 0, 25, 10); + deepStrictEqual(left, [0, 255, 0, 255]); + deepStrictEqual(right, [0, 255, 0, 255]); + }); + + test('applies sub-cell offset via X/Y within first cell', async () => { + await ctx.proxy.write(`\x1b_Ga=T,f=100,X=5,Y=3;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 0]); + deepStrictEqual(await getPixel(0, 0, 4, 2), [0, 0, 0, 0]); + deepStrictEqual(await getPixel(0, 0, 5, 3), [0, 0, 0, 255]); + }); }); test.describe('Query support', () => {