add kitty graphics source crop (x/y/w/h) and sub-cell offset (X/Y)
This commit is contained in:
@@ -526,14 +526,35 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user