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] 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": {