Skip to content

Commit 6e35b44

Browse files
authored
Merge pull request #5719 from xtermjs/anthonykim1/rectangleCrop
Implement rectangle crop + sub-cell offset
2 parents d99e6bb + 30c97a2 commit 6e35b44

3 files changed

Lines changed: 159 additions & 9 deletions

File tree

addons/addon-image/src/kitty/KittyGraphicsHandler.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -526,14 +526,35 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
526526
}
527527

528528
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
529-
const bitmap = await this._createBitmap(image);
529+
let bitmap = await this._createBitmap(image);
530+
531+
const cropX = Math.max(0, cmd.x ?? 0);
532+
const cropY = Math.max(0, cmd.y ?? 0);
533+
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
534+
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
535+
536+
const maxCropW = Math.max(0, bitmap.width - cropX);
537+
const maxCropH = Math.max(0, bitmap.height - cropY);
538+
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
539+
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
540+
541+
if (finalCropW === 0 || finalCropH === 0) {
542+
bitmap.close();
543+
throw new Error('invalid source rectangle');
544+
}
545+
546+
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
547+
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
548+
bitmap.close();
549+
bitmap = cropped;
550+
}
530551

531552
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
532553
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
533554

534555
// Per spec: c/r default to image's natural cell dimensions
535-
const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
536-
const imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch);
556+
let imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
557+
let imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch);
537558

538559
let w = bitmap.width;
539560
let h = bitmap.height;
@@ -545,6 +566,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
545566
}
546567

547568
if (w * h > this._opts.pixelLimit) {
569+
bitmap.close();
548570
throw new Error('image exceeds pixel limit');
549571
}
550572

@@ -561,15 +583,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
561583
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
562584
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
563585

564-
const zIndex = cmd.zIndex ?? 0;
586+
let finalBitmap = bitmap;
565587
if (w !== bitmap.width || h !== bitmap.height) {
566-
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
588+
finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
567589
bitmap.close();
568-
this._kittyStorage.addImage(image.id, resized, true, layer, zIndex);
569-
} else {
570-
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
571590
}
572591

592+
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
593+
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
594+
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
595+
if (xOffset !== 0 || yOffset !== 0) {
596+
const offsetCanvas = ImageRenderer.createCanvas(window.document, finalBitmap.width + xOffset, finalBitmap.height + yOffset);
597+
const offsetCtx = offsetCanvas.getContext('2d');
598+
if (!offsetCtx) {
599+
finalBitmap.close();
600+
throw new Error('Failed to create offset canvas context');
601+
}
602+
offsetCtx.drawImage(finalBitmap, xOffset, yOffset);
603+
604+
const offsetBitmap = await createImageBitmap(offsetCanvas);
605+
offsetCanvas.width = offsetCanvas.height = 0;
606+
finalBitmap.close();
607+
finalBitmap = offsetBitmap;
608+
w = finalBitmap.width;
609+
h = finalBitmap.height;
610+
if (w * h > this._opts.pixelLimit) {
611+
finalBitmap.close();
612+
throw new Error('image exceeds pixel limit');
613+
}
614+
if (cmd.columns === undefined) {
615+
imgCols = Math.ceil(finalBitmap.width / cw);
616+
}
617+
if (cmd.rows === undefined) {
618+
imgRows = Math.ceil(finalBitmap.height / ch);
619+
}
620+
}
621+
622+
const zIndex = cmd.zIndex ?? 0;
623+
this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex);
624+
573625
// Kitty cursor movement
574626
// Per spec: cursor placed at first column after last image column,
575627
// on the last row of the image. C=1 means don't move cursor.

addons/addon-image/src/kitty/KittyGraphicsTypes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ export const enum KittyKey {
5151
X_OFFSET = 'x',
5252
// The top edge (in pixels) of the image area to display
5353
Y_OFFSET = 'y',
54+
// Width (in pixels) of the source rectangle to display
55+
SOURCE_WIDTH = 'w',
56+
// Height (in pixels) of the source rectangle to display
57+
SOURCE_HEIGHT = 'h',
58+
// Horizontal offset (in pixels) within the first cell
59+
X_PLACEMENT_OFFSET = 'X',
60+
// Vertical offset (in pixels) within the first cell
61+
Y_PLACEMENT_OFFSET = 'Y',
5462
// Number of terminal columns to display the image over
5563
COLUMNS = 'c',
5664
// Number of terminal rows to display the image over
@@ -88,6 +96,10 @@ export interface IKittyCommand {
8896
height?: number;
8997
x?: number;
9098
y?: number;
99+
sourceWidth?: number;
100+
sourceHeight?: number;
101+
xOffset?: number;
102+
yOffset?: number;
91103
columns?: number;
92104
rows?: number;
93105
more?: number;
@@ -163,6 +175,10 @@ export function parseKittyCommand(data: string): IKittyCommand {
163175
case KittyKey.HEIGHT: cmd.height = numValue; break;
164176
case KittyKey.X_OFFSET: cmd.x = numValue; break;
165177
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
178+
case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;
179+
case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;
180+
case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;
181+
case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;
166182
case KittyKey.COLUMNS: cmd.columns = numValue; break;
167183
case KittyKey.ROWS: cmd.rows = numValue; break;
168184
case KittyKey.MORE: cmd.more = numValue; break;

addons/addon-image/test/KittyGraphics.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ test.afterAll(async () => await ctx.page.close());
102102

103103
test.describe('Kitty Graphics Protocol', () => {
104104
// TODO: Add tests for larger images with various dimensions
105-
// TODO: Add tests for image placement keys (x, y, w, h, X, Y, c, r)
106105
// TODO: Add tests for virtual placement (U=1)
107106
// TODO: Add tests for animation frames
108107
// TODO: Add performance tests for streaming large images
@@ -1398,6 +1397,89 @@ test.describe('Kitty Graphics Protocol', () => {
13981397
deepStrictEqual(pixels?.slice(4, 8), [255, 0, 0, 255]); // x=19: Red
13991398
deepStrictEqual(pixels?.slice(8, 12), [255, 128, 0, 255]); // x=20: Orange
14001399
});
1400+
1401+
test('applies source crop via x/y/w/h before display', async () => {
1402+
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
1403+
await timeout(200);
1404+
1405+
deepStrictEqual(await getOrigSize(1), [20, 50]);
1406+
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
1407+
deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]);
1408+
});
1409+
1410+
test('scales cropped source region to c/r placement rectangle', async () => {
1411+
// Firefox's createImageBitmap uses different resize sampling, producing
1412+
// slightly off pixel values compared to Chromium, so skip on Firefox.
1413+
if (ctx.browser.browserType().name() === 'firefox') {
1414+
test.skip();
1415+
}
1416+
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\\`);
1417+
await timeout(200);
1418+
1419+
deepStrictEqual(await getCursor(), [4, 1]);
1420+
const left = await getPixel(0, 0, 2, 10);
1421+
const right = await getPixel(0, 0, 25, 10);
1422+
deepStrictEqual(left, [0, 255, 0, 255]);
1423+
deepStrictEqual(right, [0, 255, 0, 255]);
1424+
});
1425+
1426+
test('applies sub-cell offset via X/Y within first cell', async () => {
1427+
await ctx.proxy.write(`\x1b_Ga=T,f=100,X=5,Y=3;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
1428+
await timeout(100);
1429+
1430+
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 0]);
1431+
deepStrictEqual(await getPixel(0, 0, 4, 2), [0, 0, 0, 0]);
1432+
deepStrictEqual(await getPixel(0, 0, 5, 3), [0, 0, 0, 255]);
1433+
});
1434+
1435+
test('w=0 is treated as unset (displays full width)', async () => {
1436+
await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
1437+
await timeout(200);
1438+
1439+
deepStrictEqual(await getOrigSize(1), [200, 100]);
1440+
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
1441+
deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
1442+
});
1443+
1444+
test('h=0 is treated as unset (displays full height)', async () => {
1445+
await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
1446+
await timeout(200);
1447+
1448+
deepStrictEqual(await getOrigSize(1), [200, 100]);
1449+
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
1450+
deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
1451+
});
1452+
1453+
test('x exceeding image width produces no display', async () => {
1454+
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=999;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
1455+
await timeout(200);
1456+
1457+
strictEqual(await getImageStorageLength(), 0);
1458+
});
1459+
1460+
test('negative x/y values are clamped to 0', async () => {
1461+
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
1462+
await timeout(200);
1463+
1464+
deepStrictEqual(await getOrigSize(1), [200, 100]);
1465+
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
1466+
});
1467+
1468+
test('combined crop and sub-cell offset', async () => {
1469+
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50,X=5,Y=3;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
1470+
await timeout(200);
1471+
1472+
deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 0]);
1473+
deepStrictEqual(await getPixel(0, 0, 4, 2), [0, 0, 0, 0]);
1474+
deepStrictEqual(await getPixel(0, 0, 5, 3), [255, 128, 0, 255]);
1475+
});
1476+
1477+
test('sub-cell offset with explicit c/r advances cursor correctly', async () => {
1478+
await ctx.proxy.write(`\x1b_Ga=T,f=100,X=5,Y=3,c=4,r=2;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
1479+
await timeout(100);
1480+
1481+
deepStrictEqual(await getCursor(), [4, 1]);
1482+
});
14011483
});
14021484

14031485
test.describe('Query support', () => {

0 commit comments

Comments
 (0)