From d9082215e76a9d5e0152d096acd9e9220c7f0345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 23:28:18 +0200 Subject: [PATCH 1/5] qoi support for IIP --- addons/addon-image/package.json | 2 +- addons/addon-image/src/IIPHandler.ts | 43 +++++++++++++++------------- addons/addon-image/src/IIPMetrics.ts | 10 ++++++- package-lock.json | 19 +++++++++--- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/addons/addon-image/package.json b/addons/addon-image/package.json index 3d5fa37f6..fc6864a0e 100644 --- a/addons/addon-image/package.json +++ b/addons/addon-image/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } } diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index 76492c405..6f94ef8ed 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -7,6 +7,7 @@ import { ImageRenderer } from './ImageRenderer'; import { IIPImageStorage } from './IIPImageStorage'; import { CELL_SIZE_DEFAULT } from './ImageStorage'; import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; +import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm'; import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; @@ -36,6 +37,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; private _dec: Base64Decoder; + private _qoiDec: QoiDecoder; private _metrics = UNSUPPORTED_TYPE; constructor( @@ -47,6 +49,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3); const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes); this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes); + this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA); } public reset(): void {} @@ -115,27 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler { return true; } - // HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it - const blob = new Blob([this._dec.data8 as Uint8Array], { type: this._metrics.mime }); - this._dec.release(); - - if (!window.createImageBitmap) { - const url = URL.createObjectURL(blob); - const img = new Image(); - return new Promise(r => { - img.addEventListener('load', () => { - URL.revokeObjectURL(url); - const canvas = ImageRenderer.createCanvas(window.document, w, h); - canvas.getContext('2d')?.drawImage(img, 0, 0, w, h); - this._storage.addImage(canvas); - r(true); - }); - img.src = url; - // sanity measure to avoid terminal blocking from dangling promise - // happens from corrupt data (onload never gets fired) - setTimeout(() => r(true), 1000); - }); + let blob: Blob | ImageData; + if (this._metrics.mime === 'image/qoi') { + const data = this._qoiDec.decode(this._dec.data8); + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, + this._qoiDec.height + ); + this._qoiDec.release(); + if (w === this._qoiDec.width && h === this._qoiDec.height) { + // use fast-path if we don't need to rescale + this._dec.release(); + const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height); + canvas.getContext('2d')?.putImageData(blob, 0, 0); + this._storage.addImage(canvas); + return true; + } + } else { + blob = new Blob([this._dec.data8], { type: this._metrics.mime }); } + this._dec.release(); return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h }) .then(bm => { this._storage.addImage(bm); diff --git a/addons/addon-image/src/IIPMetrics.ts b/addons/addon-image/src/IIPMetrics.ts index d5470466f..b377cf061 100644 --- a/addons/addon-image/src/IIPMetrics.ts +++ b/addons/addon-image/src/IIPMetrics.ts @@ -4,7 +4,7 @@ */ -export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | ''; +export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | ''; export interface IMetrics { mime: ImageType; @@ -45,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics { height: d[9] << 8 | d[8] }; } + // QOI: qoif + if (d32[0] === 0x66696F71) { + return { + mime: 'image/qoi', + width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7], + height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11] + }; + } return UNSUPPORTED_TYPE; } diff --git a/package-lock.json b/package-lock.json index 6130135fd..40aa0b683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "license": "MIT", "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } }, "addons/addon-ligatures": { @@ -245,6 +245,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -558,6 +559,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -599,6 +601,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -1796,6 +1799,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -2361,6 +2365,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2751,6 +2756,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3746,6 +3752,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7010,6 +7017,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7850,6 +7858,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8016,6 +8025,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8064,6 +8074,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -8435,9 +8446,9 @@ } }, "node_modules/xterm-wasm-parts": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz", - "integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.4.1.tgz", + "integrity": "sha512-/qwoasrSyrzs90WHTN4wpISAEz1u9sOpLsDhkd9a2h27l1pz/KZd9h4p+X2pUR7OqyXvDsOdj0dV3906s3wh8g==", "dev": true, "license": "MIT", "dependencies": { From e8b5fd3c234b16676c22689c31dde6c50c5afaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 14:28:02 +0200 Subject: [PATCH 2/5] add iip resize & qoi tests --- addons/addon-image/test/ImageAddon.test.ts | 71 ++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 67769204f..3bf69911f 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -8,6 +8,7 @@ import { readFileSync } from 'fs'; import { FINALIZER, introducer, sixelEncode } from 'sixel'; import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils'; import { deepStrictEqual, ok, strictEqual } from 'assert'; +import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm'; /** * Plugin ctor options. @@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [ [readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]], [readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]] ]; +const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64'); +const qoiEnc = new QoiEncoder(1024*1024); +const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80); +const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64'); let ctx: ITestContext; test.beforeAll(async ({ browser }) => { @@ -86,10 +91,10 @@ test.describe('ImageAddon', () => { test.beforeEach(async ({}, testInfo) => { // DEBT: This test never worked on webkit - if (ctx.browser.browserType().name() === 'webkit') { - testInfo.skip(); - return; - } + //if (ctx.browser.browserType().name() === 'webkit') { + // testInfo.skip(); + // return; + //} await ctx.page.evaluate(` window.term.reset() window.imageAddon?.dispose(); @@ -312,6 +317,60 @@ test.describe('ImageAddon', () => { deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]); }); }); + + test.describe('IIP - resizing', () => { + /** + * The correct resize behavior is wonky and needs to be tested against iTerm2, + * especially for missing params to derive correct default behavior. + * We document the current behavior here w'o claiming to be fully in line with iTerm2. + * ref: https://iterm2.com/documentation-images.html + * imgcat: https://iterm2.com/utilities/imgcat + */ + test('palette.png: N --> width=20 height=5 preserveAspectRatio=0', async () => { + // cell based resize + const header = 'size=525;width=20;height=5;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); + }); + test('palette.png: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { + // pixel based resize + const header = 'size=525;width=320px;height=160px;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + deepStrictEqual(await getOrigSize(1), [320, 160]); + }); + test('palette.png: N% --> width=50% height=30% preserveAspectRatio=0', async () => { + // % of viewport resize + const header = 'size=525;width=50%;height=30%;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); + }); + test('palette.png: ommitted dimension assumes preserveAspectRatio=1', async () => { + // width provided in percent + const header = 'size=525;width=50%'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + const dim = await getDimensions(); + const width = Math.floor(dim.width * 0.5); + deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); + // height provided in pixel + const header2 = 'size=525;height=200px'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_PNG_BASE64}\x07`); + deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); + }); + }); + + test.describe('QOI support', () => { + test('palette should yield same bytes from PNG and QOI', async () => { + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`); + deepStrictEqual(await getOrigSize(1), [640, 80]); + await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`); + deepStrictEqual(await getOrigSize(2), [640, 80]); + const pngScrape = await getImageAtBufferCell(0, 0); + const qoiScrape = await getImageAtBufferCell(0, 11); + deepStrictEqual(qoiScrape, pngScrape); + }); + }); }); /** @@ -345,3 +404,7 @@ async function getOrigSize(id: number): Promise<[number, number]> { window.imageAddon._storage._images.get(${id}).orig.height ]`); } + +async function getImageAtBufferCell(x: number, y: number): Promise { + return ctx.page.evaluate(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`); +} From 0d2975884f54cc5eada861db7eab7b7dc033cca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 14:40:31 +0200 Subject: [PATCH 3/5] activate tests for webkit --- addons/addon-image/test/ImageAddon.test.ts | 5 ----- addons/addon-image/test/KittyGraphics.test.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 3bf69911f..61bdea5b3 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -90,11 +90,6 @@ test.afterAll(async () => await ctx.page.close()); test.describe('ImageAddon', () => { test.beforeEach(async ({}, testInfo) => { - // DEBT: This test never worked on webkit - //if (ctx.browser.browserType().name() === 'webkit') { - // testInfo.skip(); - // return; - //} await ctx.page.evaluate(` window.term.reset() window.imageAddon?.dispose(); diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 315538aa1..ac5acae77 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => { // TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data) test.beforeEach(async ({}, testInfo) => { - // DEBT: This test never worked on webkit - if (ctx.browser.browserType().name() === 'webkit') { - testInfo.skip(); - return; - } await ctx.page.evaluate(` window.term.reset() window.imageAddon?.dispose(); From 31c53bc9c2103f918a6d16543389b813fecbb1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 14:54:57 +0200 Subject: [PATCH 4/5] IIP resizing tests for QOI --- addons/addon-image/test/ImageAddon.test.ts | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 61bdea5b3..379e4615f 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -365,6 +365,44 @@ test.describe('ImageAddon', () => { const qoiScrape = await getImageAtBufferCell(0, 11); deepStrictEqual(qoiScrape, pngScrape); }); + test.describe.only('IIP resizing - QOI format', () => { + /** + * Same as the tests above, but with QOI format. + * This is needed, as QOI uses a slightly different parse path. + */ + test('palette.qoi: N --> width=20 height=5 preserveAspectRatio=0', async () => { + // cell based resize + const header = `size=${qoiData.length};width=20;height=5;preserveAspectRatio=0`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); + }); + test('palette.qoi: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { + // pixel based resize + const header = `size=${qoiData.length};width=320px;height=160px;preserveAspectRatio=0`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + deepStrictEqual(await getOrigSize(1), [320, 160]); + }); + test('palette.qoi: N% --> width=50% height=30% preserveAspectRatio=0', async () => { + // % of viewport resize + const header = `size=${qoiData.length};width=50%;height=30%;preserveAspectRatio=0`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); + }); + test('palette.qoi: ommitted dimension assumes preserveAspectRatio=1', async () => { + // width provided in percent + const header = `size=${qoiData.length};width=50%`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const dim = await getDimensions(); + const width = Math.floor(dim.width * 0.5); + deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); + // height provided in pixel + const header2 = `size=${qoiData.length};height=200px`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_QOI_BASE64}\x07`); + deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); + }); + }); }); }); From b2eff5ac6712ee41932184054e5f3648c6c029b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 15:09:40 +0200 Subject: [PATCH 5/5] merge resize tests for PNG & QOI into one block --- addons/addon-image/test/ImageAddon.test.ts | 96 ++++++++-------------- 1 file changed, 33 insertions(+), 63 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 379e4615f..ac2bed61c 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -313,49 +313,7 @@ test.describe('ImageAddon', () => { }); }); - test.describe('IIP - resizing', () => { - /** - * The correct resize behavior is wonky and needs to be tested against iTerm2, - * especially for missing params to derive correct default behavior. - * We document the current behavior here w'o claiming to be fully in line with iTerm2. - * ref: https://iterm2.com/documentation-images.html - * imgcat: https://iterm2.com/utilities/imgcat - */ - test('palette.png: N --> width=20 height=5 preserveAspectRatio=0', async () => { - // cell based resize - const header = 'size=525;width=20;height=5;preserveAspectRatio=0'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - const dim = await getDimensions(); - deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); - }); - test('palette.png: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { - // pixel based resize - const header = 'size=525;width=320px;height=160px;preserveAspectRatio=0'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - deepStrictEqual(await getOrigSize(1), [320, 160]); - }); - test('palette.png: N% --> width=50% height=30% preserveAspectRatio=0', async () => { - // % of viewport resize - const header = 'size=525;width=50%;height=30%;preserveAspectRatio=0'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - const dim = await getDimensions(); - deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); - }); - test('palette.png: ommitted dimension assumes preserveAspectRatio=1', async () => { - // width provided in percent - const header = 'size=525;width=50%'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - const dim = await getDimensions(); - const width = Math.floor(dim.width * 0.5); - deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); - // height provided in pixel - const header2 = 'size=525;height=200px'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_PNG_BASE64}\x07`); - deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); - }); - }); - - test.describe('QOI support', () => { + test.describe('IIP - QOI support', () => { test('palette should yield same bytes from PNG and QOI', async () => { await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`); deepStrictEqual(await getOrigSize(1), [640, 80]); @@ -365,44 +323,56 @@ test.describe('ImageAddon', () => { const qoiScrape = await getImageAtBufferCell(0, 11); deepStrictEqual(qoiScrape, pngScrape); }); - test.describe.only('IIP resizing - QOI format', () => { - /** - * Same as the tests above, but with QOI format. - * This is needed, as QOI uses a slightly different parse path. - */ - test('palette.qoi: N --> width=20 height=5 preserveAspectRatio=0', async () => { + }); + + test.describe('IIP - resizing', () => { + /** + * The correct resize behavior is wonky and needs to be tested against iTerm2, + * especially for missing params to derive correct default behavior. + * We document the current behavior here w'o claiming to be fully in line with iTerm2. + * ref: https://iterm2.com/documentation-images.html + * imgcat: https://iterm2.com/utilities/imgcat + * + * NOTE: QOI has a slightly different parse path, thus we test resizing explicitly + */ + const images = [ + ['palette.png', 525, PALETTE_PNG_BASE64], + ['palette.qoi', qoiData.length, PALETTE_QOI_BASE64] + ]; + for (const [name, size, payload] of images) { + test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => { // cell based resize - const header = `size=${qoiData.length};width=20;height=5;preserveAspectRatio=0`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=20;height=5;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); const dim = await getDimensions(); deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); }); - test('palette.qoi: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { + test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => { // pixel based resize - const header = `size=${qoiData.length};width=320px;height=160px;preserveAspectRatio=0`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=320px;height=160px;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); deepStrictEqual(await getOrigSize(1), [320, 160]); }); - test('palette.qoi: N% --> width=50% height=30% preserveAspectRatio=0', async () => { + test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => { // % of viewport resize - const header = `size=${qoiData.length};width=50%;height=30%;preserveAspectRatio=0`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=50%;height=30%;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); const dim = await getDimensions(); deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); }); - test('palette.qoi: ommitted dimension assumes preserveAspectRatio=1', async () => { + test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => { // width provided in percent - const header = `size=${qoiData.length};width=50%`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=50%'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); const dim = await getDimensions(); const width = Math.floor(dim.width * 0.5); deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); // height provided in pixel - const header2 = `size=${qoiData.length};height=200px`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_QOI_BASE64}\x07`); + const header2 = 'height=200px'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`); deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); }); - }); + } }); });