Compare commits

..
Author SHA1 Message Date
Jörg Breitbart f9c38786bb fix IIP resize, fix viewport resize 2026-09-05 11:53:50 +02:00
Jörg Breitbart 039e103ed8 add painting primitives to IImageSpec, move IIP resize to late rendering 2026-09-02 21:50:25 +02:00
Jörg Breitbart c9aabf9cf5 fix demo 2026-09-01 11:35:05 +02:00
Jörg Breitbart dc1fab5d5d multiple changes:
- renderer: delete static primitives
- renderer: use device pixels for drawing and clearing
- renderer: simplify placeholder creation
- renderer: localize dimensions in rescaleCanvas
- renderer: rescale on drawing
- storage: store raw bytes as blobs
- storage: alter addImage interface adding blob and metrics data
- storage: switch from pixel to byte counting
- storage: getBlobUsage()
- Drawable as storage/renderer handle
- use metrics for all protocols
- remove extractTileAtBufferCell
- use device pixels in CSI t 14|16 reports
2026-09-01 11:16:43 +02:00
Jörg Breitbart b7ef9fa25e cleanup primitives and move to own file 2026-08-30 22:53:15 +02:00
20 changed files with 604 additions and 1350 deletions
-630
View File
@@ -1,630 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IDisposable } from '@xterm/xterm';
import type { ImageRenderer } from './ImageRenderer';
import { ImageTileInfo, type ImageStorage } from './ImageStorage';
import type { IImageSpec, ITerminalExt, IResetHandler } from './Types';
import type { IMetrics } from 'IIPMetrics';
/**
* Note on Firefox Bug (https://bugzilla.mozilla.org/show_bug.cgi?id=2066911)
*
* ImageDecoder.decode() returns a corrupted VideoFrame,
* if the frame was closed from a previous decode.
*
* Therefore the AnimationManager class implements certain
* firefox shims gated by the isFirefox boolean:
* - IAnimation.highest tracks highest frameID ever decoded
* - whenever frameID is lesser or equal zu highest create new decoder
* - raw image bytes need to be held for new decoder
*
* To be removed once the bug got fixed.
*/
interface IAnimation {
/** image id in storage */
id: number;
/** image bytes */
data: Blob;
/** metrics of the image */
metrics: IMetrics;
/** width */
w: number;
/** height */
h: number;
/** number of frames */
numFrames: number;
frames: number[];
/** ImageDecoder instance */
decoder: ImageDecoder | undefined;
decoderPromise: Promise<ImageDecoder> | undefined;
cache: BitmapBuffer;
/** start timestamp */
startTime: number | undefined;
/** whether a decode task ist scheduled */
scheduled: boolean;
/** whether the image is in viewport */
inViewport: boolean;
/** firefox bug helper */
highest: number;
/** playtime of animation */
playtime: number;
/** frame timestamps */
timestamps: number[];
/** frame ID targeted by animation */
current: number;
/** frame ID currently visible (owned by spec.actual) */
actual: number;
/** idx of current on frames */
frameIdx: number;
}
interface IImageDraw {
imageId: number;
imgSpec: IImageSpec;
tileId: number;
col: number;
row: number;
count: number;
}
interface IBufferSlot {
id: number;
bm: ImageBitmap | null;
}
const enum Constants {
BUFFERSIZE = 4,
BUFFERMOD = BUFFERSIZE - 1,
MAX_FRAMES = 65535
}
class BitmapBuffer implements IDisposable {
private readonly _d: IBufferSlot[];
private _disposed = false;
constructor() {
this._d = Array(Constants.BUFFERSIZE).fill(0).map(() => ({ id: -1, bm: null }));
}
public dispose(): void {
this.clear();
this._disposed = true;
}
public peek(id: number): ImageBitmap | null {
const slot = this._d[id & Constants.BUFFERMOD];
return id === slot.id ? slot.bm : null;
}
public pop(id: number): ImageBitmap | null {
const slot = this._d[id & Constants.BUFFERMOD];
const res = id === slot.id
? slot.bm
: (slot.bm?.close(), null);
slot.bm = null;
slot.id = -1;
return res;
}
public push(id: number, bm: ImageBitmap): void {
/**
* A late decode task might still push bitmaps.
* Since push gives the buffer ownership of
* the bitmap, we use the isDisposed flag to
* free it right away.
*/
if (this._disposed) {
bm.close();
return;
}
const slot = this._d[id & Constants.BUFFERMOD];
slot.bm?.close();
slot.bm = bm;
slot.id = id;
}
public clear(): void {
for (let i = 0; i < Constants.BUFFERSIZE; ++i) {
const slot = this._d[i];
slot.bm?.close();
slot.bm = null;
slot.id = -1;
}
}
}
// eslint-disable-next-line
declare const InstallTrigger: any;
// FIXME: move to AnimationManager ctor to control it from xterm side
const isChromium = (window as any).chrome !== undefined;
const isFirefox: boolean = typeof InstallTrigger !== 'undefined' || 'MozAppearance' in document.documentElement.style;
/**
* Helper to create a read to use ImageDecoder instance from a blob.
*/
async function BlobDecoder(blob: Blob, type: string): Promise<ImageDecoder> {
/**
* Dealing with browser bugs - in theory this should be possible:
*
* const decoder = new ImageDecoder({ data: blob.stream(), type, preferAnimation: true });
* await decoder.tracks.ready;
* return decoder;
*
* But this straightforward handling with blob.stream() uncovers several bugs:
* 1. Chromium: Fails to stream chunked blobs reliably.
* 2. Firefox: Native lifecycle race condition. Closing an old decoder breaks the
* shared underlying native stream of the same blob for new decoders, throwing
* an uncatchable "Closed decoder" DOMException during `tracks.ready`.
*
* So we use a safer approach by pulling full raw bytes over first and transferring ownership.
*/
const data = await blob.arrayBuffer();
const decoder = new ImageDecoder({ data, type, preferAnimation: true, transfer: [data] });
await decoder.tracks.ready;
if (isChromium && type === 'image/avif') {
// chromium bug: random frame access in AVIF is
// only possible after reading frame 0
const res = await decoder.decode({ frameIndex: 0 });
res.image.close();
}
return decoder;
}
/**
* AnimationManager - orchestrates rendering of animated images.
*
* Current Workflow
* When IIPMetrics.imageType reports an animated image the IIP handler
* stores the image in the storage and registers an animation of it.
*
* The animation manager works mostly self-contained:
* - RAF loop
* - 3 frame ahead decoding
* - own bitmap ownership handling
*
* There are a few intersections with the ImageStorage and the ImageRenderer,
* which still need to be consolidated (TODOs):
* - ImageStorage: API for announcing memory state, better eviction strategy
* - ImageRenderer: API for better drawing primitives
*
* In particular the ownership model of ImageSpec bitmaps (actual and orig)
* needs a better story. We prolly need here an API to flip bitmaps.
*/
export class AnimationManager implements IDisposable, IResetHandler {
private _animations = new Map<number, IAnimation>();
private _animationFrame: number | undefined = undefined;
private _draws: IImageDraw[] = [];
constructor(
private _storage: ImageStorage,
private _renderer: ImageRenderer,
private _terminal: ITerminalExt
) {}
private _requestAF(cb: FrameRequestCallback): number | undefined {
return this._terminal._core._coreBrowserService?.window.requestAnimationFrame(cb);
}
private _cancelAF(id: number): void | undefined {
return this._terminal._core._coreBrowserService?.window.cancelAnimationFrame(id);
}
public dispose(): void {
this.reset();
}
/**
* Reset handler.
* Stops all animations and frees all ressources.
*/
public reset(): void {
for (const anim of this._animations.values()) {
anim.inViewport = false;
this._closeDecoder(anim);
anim.cache.dispose();
}
this._animations.clear();
this._draws = [];
if (this._animationFrame) this._cancelAF(this._animationFrame);
this._animationFrame = undefined;
}
/**
* Main hook to register an animated image for animation.
*
* @param id Image ID of the image storage the image was registered under.
* @param data Raw data bytes of the image.
* @param metrics Metrics of the image as returned by IIPMetrics.imageType.
* @param w Output width in pixel.
* @param h Output height in pixel.
*/
public async registerAnimation(id: number, data: Blob, metrics: IMetrics, w: number, h: number): Promise<void> {
let decoder = await BlobDecoder(data, metrics.mime);
const track = decoder.tracks.selectedTrack;
if (!track || track.frameCount < 2) {
decoder.close();
return;
}
const frameCount = Math.min(track.frameCount, Constants.MAX_FRAMES);
const frames: number[] = [];
const timestamps: number[] = [];
const durations: number[] = [];
let elapsed = 0;
for (let i = 0; i < frameCount; i++) {
try {
const result = await decoder.decode({ frameIndex: i });
const frame = result.image;
const width = frame.displayWidth;
const height = frame.displayHeight;
const duration = (frame.duration ?? 0) / 1000;
frame.close();
// skip wrong dimensions and zero duration
if (width !== metrics.width || height !== metrics.height || !duration) {
if (durations.length) {
durations[durations.length - 1] += duration;
elapsed += duration;
}
continue;
}
frames.push(i);
timestamps.push(elapsed);
durations.push(duration);
elapsed += duration;
} catch {
break;
}
}
/**
* FIXME: still needs a consolidation of short frame durations:
* We don't track v-sync, but just decode the next 3 follow-up frames.
* This creates nonsense decoder pressure decoding frames,
* that effectively never make it to the screen.
* Currently our shortest time resolution is 1ms,
* thus with 60 fps we would decode 15 1ms frames for no reason.
* We prolly should introduce a lower bound of 8ms:
* - in sync with 120 fps
* - only every second frame is nonsense with 60 fps
*/
if (frames.length < 2 || elapsed <= 0) {
decoder.close();
return;
}
if (isFirefox) {
decoder.close();
decoder = await BlobDecoder(data, metrics.mime);
}
const anim: IAnimation = {
id, data, w, h, decoder,
metrics,
numFrames: frames.length,
decoderPromise: undefined,
cache: new BitmapBuffer(),
frames,
startTime: undefined,
current: 0,
actual: 0,
scheduled: false, // set by _schedule
inViewport: true, // start with active prefetching
highest: -1,
playtime: elapsed,
timestamps,
frameIdx: 0
};
this._animations.set(id, anim);
this.viewportUpdated();
// early schedule to give decoder time to fill the cache before RAF
this._schedule(anim);
this._animationFrame ??= this._requestAF(this._loop);
}
/**
* Remove the animation for the given image id.
*/
public unregisterAnimation(id: number): void {
const a = this._animations.get(id);
if (a) {
a.inViewport = false;
this._closeDecoder(a);
a.cache.dispose();
this._animations.delete(id);
}
}
/**
* onRender handler.
* Scratches the viewport buffer to collect draws for animation RAF
* and to pause/resume invisible/visible animations.
*/
public viewportUpdated(_range?: { start: number, end: number }): void {
const rows = this._terminal.rows;
const cols = this._terminal.cols;
const buffer = this._terminal._core.buffer;
const actives: Set<number> = new Set();
this._draws = [];
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(row + buffer.ydisp);
if (!line) break;
for (let col = 0; col < cols; ++col) {
const e = line.getExtended(col)?.payload;
if (e instanceof ImageTileInfo) {
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
const imgSpec = this._storage.getImage(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
*/
while (++col < cols) {
const nextE = line.getExtended(col)?.payload;
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
}
count++;
}
col--;
if (imgSpec && this._animations.has(imageId)) {
this._draws.push({ imageId, imgSpec, tileId: startTile, col: startCol, row, count });
actives.add(imageId);
}
}
}
}
}
this._updateAnimations(actives);
}
/**
* Get the frameIdx for elapsed time.
* Note: The returned value is not the frame ID,
* but the index for IAnimation.frames.
*/
private _timedFrameIdx(a: IAnimation, elapsed: number): number {
elapsed %= a.playtime;
let low = 0;
let high = a.timestamps.length - 1;
while (low <= high) {
const mid = (low + high) >> 1;
const startTime = a.timestamps[mid];
if (elapsed < startTime) {
high = mid - 1;
} else {
if (mid === a.timestamps.length - 1 || elapsed < a.timestamps[mid + 1]) {
return mid;
}
low = mid + 1;
}
}
return 0;
}
/**
* Pause/resume decoding from viewport changes.
*/
private _updateAnimations(visibles: Set<number>): void {
for (const [id, anim] of this._animations) {
const isVisible = visibles.has(id);
if (isVisible && !anim.inViewport) {
/**
* There are two strategies possible here:
*
* 1. Resume decoding and pumping frames as soon as possible.
* 2. Delay resuming further by a debounce to prevent flooding
* new decoder instances on heavy scrolling.
*
* Currently the code below does 1. as the impact is rather
* small from tests and gives the better user experience.
*/
anim.inViewport = true;
const frameIdx = this._timedFrameIdx(anim, performance.now() - anim.startTime!);
anim.frameIdx = frameIdx;
anim.current = anim.frames[frameIdx];
if (!anim.decoder && !anim.decoderPromise) {
anim.decoderPromise = BlobDecoder(anim.data, anim.metrics.mime);
}
this._schedule(anim);
} else if (!isVisible && anim.inViewport) {
anim.inViewport = false;
anim.cache.clear();
this._closeDecoder(anim);
}
}
if (visibles.size && this._animationFrame === undefined) {
this._animationFrame = this._requestAF(this._loop);
}
}
/**
* RAF for animations in viewport.
*/
private _loop = (ts: number): void => {
if (!this._animations.size) {
this._animationFrame = undefined;
return;
}
let mustRender = false;
let anyVisible = false;
for (const anim of this._animations.values()) {
if (!anim.inViewport) continue;
const spec = this._storage.getImage(anim.id);
if (!spec) {
this.unregisterAnimation(anim.id);
continue;
}
anyVisible = true;
anim.startTime ??= ts;
const frameIdx = this._timedFrameIdx(anim, ts - anim.startTime);
anim.frameIdx = frameIdx;
const current = anim.frames[frameIdx];
if (current !== anim.current) {
anim.current = current;
const bm = anim.cache.pop(current);
if (bm) {
// FIXME: setting spec.actual delegates draws from onRender back to ImageStorage.render
// --> should be avoided to prevent double draws within same AF
// idea: create a mutex on image spec to tell storage.render to skip
// by swapping spec.actual we take ownership, thus have to close it here
// but only if we have more than 4 frames (ringbuffer size is 4)
// for <= 4 frames we move the bitmap back into cache
// FIXME: create a bitmap swapping API in storage
if (spec.actual instanceof ImageBitmap) {
if (anim.numFrames > Constants.BUFFERSIZE) {
spec.actual.close();
} else {
anim.cache.push(anim.actual, spec.actual);
}
}
spec.actual = bm;
anim.actual = current;
mustRender = true;
}
this._schedule(anim);
}
}
// FIXME: merge clears and draws into col x row rects
if (mustRender) {
for (let i = 0; i < this._draws.length; ++i) {
const d = this._draws[i];
this._renderer.clearLines(d.row, d.row);
this._renderer.draw(d.imgSpec, d.tileId, d.col, d.row, d.count);
}
}
this._animationFrame = anyVisible ? this._requestAF(this._loop) : undefined;
};
/**
* Try to schedule frame decoding.
*/
private _schedule(a: IAnimation): void {
if (!a.scheduled) {
let needsSchedule = false;
// FIXME: do we need a == 4 test too?
if (a.numFrames > 3) {
needsSchedule =
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]) ||
!a.cache.peek(a.frames[(a.frameIdx + 3) % a.numFrames]);
} else if (a.numFrames === 3) {
needsSchedule =
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]);
} else {
needsSchedule =
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]);
}
if (needsSchedule) {
a.scheduled = true;
setTimeout(() => this._decode(a), 0);
}
}
}
/**
* Decode up to next 3 frames.
*/
private async _decode(a: IAnimation): Promise<void> {
if (!a.inViewport) {
a.scheduled = false;
return;
}
if (!a.decoder) {
const decP = a.decoderPromise;
a.decoderPromise = undefined;
a.decoder = await (decP ?? BlobDecoder(a.data, a.metrics.mime));
}
const n1 = a.frames[(a.frameIdx + 1) % a.numFrames];
const n2 = a.frames[(a.frameIdx + 2) % a.numFrames];
const n3 = a.frames[(a.frameIdx + 3) % a.numFrames];
for (const n of [n1, n2, n3]) {
if (!a.cache.peek(n)) {
if (isFirefox) {
if (n <= a.highest) {
a.highest = -1;
a.decoder?.close();
a.decoder = await BlobDecoder(a.data, a.metrics.mime);
}
a.highest = Math.max(n, a.highest);
}
let res: ImageDecodeResult;
try {
res = await a.decoder.decode({ frameIndex: n });
} catch { continue; }
let bm: ImageBitmap;
try {
// FIXME: we should not resize here yet
// BETTER: rescale during draw:
// - eases the GPU druing decoding
// - rescales only what needs to be drawn
// --> funneling down
// FIXME: needs changes in image spec and rendering functions
bm = await createImageBitmap(res.image, { resizeWidth: a.w, resizeHeight: a.h });
} catch { continue; } finally { res?.image?.close(); }
if (!a.inViewport) {
bm.close();
break;
}
a.cache.push(n, bm);
}
}
a.scheduled = false;
}
/**
* Close the decoder defensively.
*/
private _closeDecoder(a: IAnimation): void {
const decoder = a.decoder;
a.decoder = undefined;
try { decoder?.close(); } catch {}
const decP = a.decoderPromise;
a.decoderPromise = undefined;
decP?.then(dec => dec && dec !== decoder ? dec.close() : null).catch(() => {});
}
}
+36 -43
View File
@@ -2,15 +2,14 @@
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
import type { IIPImageStorage } from './IIPImageStorage';
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
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, SequenceType } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
import type { AnimationManager } from 'AnimationManager';
import { imageType, UNSUPPORTED_TYPE } from './Metrics';
import { Drawable } from './Primitives';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
@@ -45,10 +44,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage,
private readonly _coreTerminal: ITerminalExt,
private readonly _aniManager: AnimationManager | undefined
private readonly _coreTerminal: ITerminalExt
) {
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
@@ -120,11 +117,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (seqType === SequenceType.REPORTCELLSIZE) {
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
// IMPORTANT: ReportCellSize uses logical points (CSS pixels)
let w = CELL_SIZE_DEFAULT.width;
let h = CELL_SIZE_DEFAULT.height;
if (this._renderer.dimensions) {
w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
w = dimensions.css.canvas.width / this._coreTerminal.cols;
h = dimensions.css.canvas.height / this._coreTerminal.rows;
}
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
@@ -149,9 +148,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
let w = 0;
let h = 0;
// early exit condition chain
let cond: number | boolean;
let metrics = UNSUPPORTED_TYPE;
@@ -159,12 +155,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (cond = !this._dec.end()) {
metrics = imageType(this._dec.data8);
if (cond = metrics.mime !== 'unsupported') {
w = metrics.width;
h = metrics.height;
if (cond = w && h && w * h < this._opts.pixelLimit) {
[w, h] = this._resize(w, h).map(Math.floor);
cond = w && h && w * h < this._opts.pixelLimit;
} else {
if (!(cond = metrics.width && metrics.height && metrics.width * metrics.height < this._opts.pixelLimit)) {
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
}
} else {
@@ -179,50 +170,52 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
let blob: Blob | ImageData;
let bmSrc: Blob | ImageData;
let imgBlob: Blob;
if (metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData(
bmSrc = 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: metrics.mime });
imgBlob = bmSrc = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release();
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
return createImageBitmap(bmSrc)
.then(bm => {
const id = this._storage.addImage(bm);
if (this._aniManager && metrics.animated) {
this._aniManager.registerAnimation(id, blob as Blob, metrics, w, h).catch(() => {});
}
const [w, h] = this._resize(metrics.width, metrics.height);
this._storage.addImage(new Drawable(bm), imgBlob, metrics, w / metrics.width, h / metrics.height);
return true;
})
.catch(e => {
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
// FIXME: try ImageDecoder here instead (e.g. some AVIF work in Firefox)
return true;
});
}
private _resize(w: number, h: number): [number, number] {
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;
const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;
let cw;
let ch;
let width;
let height;
const dimensions = this._coreTerminal.dimensions;
if (dimensions) {
width = dimensions.device.canvas.width;
height = dimensions.device.canvas.height;
cw = width / this._coreTerminal.cols;
ch = height / this._coreTerminal.rows;
} else {
cw = CELL_SIZE_DEFAULT.width;
ch = CELL_SIZE_DEFAULT.height;
width = cw * this._coreTerminal.cols;
height = ch * this._coreTerminal.rows;
}
const rw = this._dim(this._header.width!, width, cw);
const rh = this._dim(this._header.height!, height, ch);
const rw = this._dim(this._header.width ?? '', width, cw);
const rh = this._dim(this._header.height ?? '', height, ch);
if (!rw && !rh) {
const wf = width / w; // TODO: should this respect initial cursor offset?
const hf = (height - ch) / h; // TODO: fix offset issues from float cell height
+5 -3
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { IAddImageOpts } from './Types';
import { IAddImageOpts, IDrawable, IMetrics } from './Types';
import { ImageStorage } from './ImageStorage';
/**
@@ -22,7 +22,9 @@ export class IIPImageStorage {
* Add an IIP image to storage.
* Always uses scrolling mode — cursor advances past the image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
return this._storage.addImage(img, this._addImageOpts);
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, prescaleX: number, prescaleY: number): void {
this._addImageOpts.prescaleX = prescaleX;
this._addImageOpts.prescaleY = prescaleY;
this._storage.addImage(src, data, metrics, this._addImageOpts);
}
}
-235
View File
@@ -1,235 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
animated: boolean;
}
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0,
animated: false
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 32) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23],
animated: isAPNG(d)
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height, animated: false };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[6] | d[7] << 8,
height: d[8] | d[9] << 8,
animated: isAGIF(d)
};
}
// 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],
animated: false
};
}
// WEBP: RIFF | xxxx | WEBP | VP8x
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
switch (d[15]) {
case 0x58: // Extended WebP VP8X --> "X"
return {
mime: 'image/webp',
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
height: (d[27] | d[28] << 8 | d[29] << 16) + 1,
animated: (d[20] & 0x02) !== 0
};
case 0x4C: // Lossless WebP VP8L --> "L"
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
return {
mime: 'image/webp',
width: (dim & 0x3FFF) + 1,
height: (dim >>> 14 & 0x3FFF) + 1,
animated: false
};
case 0x20: // Lossy WebP VP8 --> " "
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
return {
mime: 'image/webp',
width: (d[26] | d[27] << 8) & 0x3FFF,
height: (d[28] | d[29] << 8) & 0x3FFF,
animated: false
};
}
return UNSUPPORTED_TYPE;
}
// AVIF: Box size | ftyp | avif/avis
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
let pos = -1;
let anim = d32[2] === 0x73697661; // avis is meant to be animated
// search for boxes within first 1024 bytes
const limit = Math.min(d.length - 16, 1024);
for (let i = 8; i < limit; i++) {
// scan for ispe for dimensions
if (pos === -1 && d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
pos = i;
}
// scan for stsz or moov
if (!anim) {
const isStsz = d[i] === 0x73 && d[i + 1] === 0x74 && d[i + 2] === 0x73 && d[i + 3] === 0x7A;
const isMoov = d[i] === 0x6D && d[i + 1] === 0x6F && d[i + 2] === 0x6F && d[i + 3] === 0x76;
if (isStsz || isMoov) {
anim = true;
}
}
if (pos !== -1 && anim) {
break;
}
}
if (pos !== -1) {
// dimensions are in BE at +8 (width) at +12 (height)
const width =
d[pos + 8] << 24 |
d[pos + 9] << 16 |
d[pos + 10] << 8 |
d[pos + 11];
const height =
d[pos + 12] << 24 |
d[pos + 13] << 16 |
d[pos + 14] << 8 |
d[pos + 15];
if (width > 0 && height > 0) {
return { mime: 'image/avif', width, height, animated: anim };
}
}
return UNSUPPORTED_TYPE;
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
if (blockLength < 2) return [0, 0];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
if (blockLength < 2) return [0, 0];
}
}
function isAGIF(data: Uint8Array): boolean {
let pos = 10;
const gPacked = data[pos];
const hasGP = (gPacked & 0x80) !== 0;
pos += 3;
if (hasGP) {
const gpSize = 2 << (gPacked & 0x07);
pos += 3 * gpSize;
}
let gceCount = 0;
while (pos < data.length) {
const blockType = data[pos];
if (blockType === 0x21) { // Extension Block
if (pos + 1 >= data.length) return false;
const extLabel = data[pos + 1];
if (extLabel === 0xF9) { // Graphic Control Extension
gceCount++;
if (gceCount > 1) return true; // early exit on 2nd frame
}
pos += 2;
while (pos < data.length) {
const blockSize = data[pos];
if (blockSize === 0x00) break;
pos += blockSize + 1;
}
if (pos >= data.length) return false;
pos++;
} else if (blockType === 0x2C) { // Image Descriptor
if (pos + 10 >= data.length) return false;
pos += 10;
const lPacked = data[pos - 1];
if ((lPacked & 0x80) !== 0) {
const lpSize = 2 << (lPacked & 0x07);
pos += 3 * lpSize;
}
if (pos >= data.length) return false;
pos++;
while (pos < data.length) {
const blockSize = data[pos];
if (blockSize === 0x00) break;
pos += blockSize + 1;
}
if (pos >= data.length) return false;
pos++;
} else if (blockType === 0x3B) {
break;
} else {
return false;
}
}
return false;
}
function isAPNG(d: Uint8Array): boolean {
let p = 8;
while (p + 8 < d.length) {
const length = (d[p] << 24 | d[p + 1] << 16 | d[p + 2] << 8 | d[p + 3]) >>> 0;
// 'acTL' before 'IDAT': APNG
if (d[p + 4] === 0x61 && d[p + 5] === 0x63 && d[p + 6] === 0x54 && d[p + 7] === 0x4c) {
return true;
}
// 'IDAT' first: no APNG
if (d[p + 4] === 0x49 && d[p + 5] === 0x44 && d[p + 6] === 0x41 && d[p + 7] === 0x54) {
return false;
}
p += 12 + length;
}
return false;
}
+6 -17
View File
@@ -15,7 +15,6 @@ import { SixelHandler } from './SixelHandler';
import { SixelImageStorage } from './SixelImageStorage';
import { IIPImageStorage } from './IIPImageStorage';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
import { AnimationManager } from './AnimationManager';
/**
@@ -71,9 +70,7 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432,
animationSupport: true, // alpha
animationPixelLimit: 4194304 // limit to 2048 * 2048 pixels
kittySizeLimit: 33554432
};
// max palette size supported by the sixel lib (compile time setting)
@@ -107,7 +104,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map();
private _aniManager: AnimationManager | undefined;
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
@@ -148,10 +144,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
terminal.options.windowOptions = windowOps;
}
if (this._opts.animationSupport && typeof ImageDecoder === 'function') {
this._aniManager = new AnimationManager(this._storage, this._renderer, terminal);
this._disposeLater(terminal.onRender(range => this._aniManager!.viewportUpdated(range)));
}
// apply placeholder setting
this._renderer.showPlaceholder(this._opts.showPlaceholder);
this._disposeLater(
this._renderer,
@@ -196,7 +190,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler
if (this._opts.iipSupport) {
const iipStorage = new IIPImageStorage(this._storage!);
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal, this._aniManager);
const iipHandler = new IIPHandler(this._opts, iipStorage, terminal);
this._handlers.set('iip', iipHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
@@ -227,7 +221,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
for (const handler of this._handlers.values()) {
handler.reset();
}
this._aniManager?.reset();
return false;
}
@@ -260,10 +253,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
return this._storage?.getImageAtBufferCell(x, y);
}
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
return this._storage?.extractTileAtBufferCell(x, y);
}
private _report(s: string): void {
this._terminal?._core.input(s, false);
}
@@ -354,8 +343,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
switch (params[1]) {
// we only implement read and read_max here
case GaAction.READ:
let width = this._renderer?.dimensions?.css.canvas.width;
let height = this._renderer?.dimensions?.css.canvas.height;
let width = this._renderer?.dimensions?.device.canvas.width;
let height = this._renderer?.dimensions?.device.canvas.height;
if (!width || !height) {
// for some reason we have no working image renderer
// --> fallback to default cell size
+106 -211
View File
@@ -3,19 +3,14 @@
* @license MIT
*/
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import type { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createCanvas } from './Primitives';
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
/**
* ImageRenderer - terminal frontend extension:
* - provide primitives for canvas, ImageData, Bitmap (static)
* - add canvas layer to DOM (browser only for now)
* - draw image tiles onRender
*/
@@ -24,51 +19,14 @@ export class ImageRenderer extends Disposable implements IDisposable {
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
private _oldOpen: ((parent: HTMLElement) => void) | undefined;
private _renderService: IRenderService | undefined;
private _oldSetRenderer: ((renderer: any) => void) | undefined;
// drawing primitive - canvas
public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
return canvas;
}
// drawing primitive - ImageData with optional buffer
public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {
if (typeof ImageData !== 'function') {
const imgData = ctx.createImageData(width, height);
if (buffer) {
imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));
}
return imgData;
}
return buffer
? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)
: new ImageData(width, height);
}
// drawing primitive - ImageBitmap
public static createImageBitmap(img: ImageBitmapSource): Promise<ImageBitmap | undefined> {
if (typeof createImageBitmap !== 'function') {
return Promise.resolve(undefined);
}
return createImageBitmap(img);
}
// some local variables for faster access
private _dimensions: IRenderDimensions | undefined;
private _cellSize: ICellSize | undefined;
constructor(private _terminal: ITerminalExt) {
super();
@@ -100,8 +58,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
this._renderService = undefined;
this._layers.clear();
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}));
}
@@ -110,15 +66,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Enable the placeholder.
*/
public showPlaceholder(value: boolean): void {
if (value) {
if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
}
this._placeholder = value ? this._createPlaceHolder() : undefined;
this._renderService?.refreshRows(0, this._terminal.rows);
}
@@ -131,22 +79,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
/**
* Current cell size (float).
* Current cell size.
*/
public get cellSize(): ICellSize {
return {
width: this.dimensions?.css.cell.width || -1,
height: this.dimensions?.css.cell.height || -1
};
public getCellSize(dimensions?: IRenderDimensions): ICellSize | undefined {
dimensions ??= this._terminal.dimensions;
if (dimensions) {
return {
width: dimensions.device.canvas.width / this._terminal.cols,
height: dimensions.device.canvas.height / this._terminal.rows,
};
}
}
/**
* Clear a region of the image layer canvas.
*/
public clearLines(start: number, end: number, layer?: ImageLayer): void {
const y = start * (this.dimensions?.css.cell.height || 0);
const w = this.dimensions?.css.canvas.width || 0;
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);
const deviceGrid = this._cellSize;
if (!deviceGrid || !this._dimensions) return;
const y = Math.floor(start * deviceGrid.height);
const w = this._dimensions.device.canvas.width;
const h = Math.ceil((end + 1 - start) * deviceGrid.height);
if (!layer || layer === 'top') {
this._layers.get('top')?.clearRect(0, y, w, h);
}
@@ -174,142 +127,103 @@ export class ImageRenderer extends Disposable implements IDisposable {
*/
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
const ctx = this._layers.get(imgSpec.layer);
if (!ctx) {
return;
}
const { width, height } = this.cellSize;
const initialGrid = imgSpec.cellSize;
const deviceGrid = this._cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
if (!ctx || !imgSpec.src.native || !deviceGrid) {
return;
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const img = imgSpec.src;
const cols = Math.ceil(img.width * imgSpec.prescaleX / initialGrid.width);
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const dx = col * width;
const dy = row * height;
const gridWidth = initialGrid.width / imgSpec.prescaleX;
const gridHeight = initialGrid.height / imgSpec.prescaleY;
// safari bug: never access image source out of bounds
const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
// crops are initialGrid px
const sx = (tileId % cols) * gridWidth + imgSpec.precropX;
const sy = Math.floor(tileId / cols) * gridHeight + imgSpec.precropY;
const dx = Math.floor(col * deviceGrid.width + imgSpec.offsetX);
const dy = Math.floor(row * deviceGrid.height + imgSpec.offsetY);
// safari bug: never access image source out of bounds, thus we clamp its dimensions
const sWidth = Math.min(count * gridWidth, img.width - sx);
const sHeight = Math.min(gridHeight, img.height - sy);
const dWidth = Math.ceil(sWidth / gridWidth * deviceGrid.width * imgSpec.scaleX);
const dHeight = Math.ceil(sHeight / gridHeight * deviceGrid.height * imgSpec.scaleY);
if (imgSpec.blendMode === 'overwrite') {
ctx.clearRect(dx, dy, dWidth, dHeight);
}
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
img.native,
Math.floor(sx), Math.floor(sy), Math.ceil(sWidth), Math.ceil(sHeight),
dx, dy, dWidth, dHeight
);
}
/**
* Extract a single tile from an image.
*/
public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
const cols = Math.ceil(img.width / width);
const sx = (tileId % cols) * width;
const sy = Math.floor(tileId / cols) * height;
const finalWidth = width + sx > img.width ? img.width - sx : width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
);
return canvas;
}
}
/**
* Draw a line with placeholder on the image layer canvas.
*/
public drawPlaceholder(col: number, row: number, count: number = 1): void {
const ctx = this._layers.get('top');
if (ctx) {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
if (width === -1 || height === -1) {
return;
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
width * count,
height,
col * width,
row * height,
width * count,
height
);
const deviceGrid = this._cellSize;
if (!ctx || !deviceGrid) {
return;
}
this._placeholder ??= this._createPlaceHolder();
if (!this._placeholder) return;
ctx.drawImage(
this._placeholder,
0, 0, 1, 1,
col * deviceGrid.width,
row * deviceGrid.height,
count * deviceGrid.width,
deviceGrid.height
);
}
/**
* Rescale image layer canvas if needed.
* Checked once from `ImageStorage.render`.
* NOTE: This method updates dimensions on instance properties
* for faster access during draw and clear calls.
* So make sure to always call this before doing any draws.
*/
public rescaleCanvas(): void {
const w = this.dimensions?.css.canvas.width || 0;
const h = this.dimensions?.css.canvas.height || 0;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
ctx.canvas.width = w;
ctx.canvas.height = h;
public rescaleCanvas(force: boolean = false): void {
const dimensions = this._terminal.dimensions;
if (dimensions) {
const cssW = dimensions.css.canvas.width;
const cssH = dimensions.css.canvas.height;
const devW = dimensions.device.canvas.width;
const devH = dimensions.device.canvas.height;
let recalc = force;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== devW
|| ctx.canvas.height !== devH
|| ctx.canvas.style.width !== `${cssW}px`
|| ctx.canvas.style.height !== `${cssH}px`
) {
ctx.canvas.width = devW;
ctx.canvas.height = devH;
ctx.canvas.style.width = `${cssW}px`;
ctx.canvas.style.height = `${cssH}px`;
recalc = true;
}
}
}
}
/**
* Rescale image in storage if needed.
*/
private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {
if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {
return;
}
const { width: originalWidth, height: originalHeight } = spec.origCellSize;
if (currentWidth === originalWidth && currentHeight === originalHeight) {
spec.actual = spec.orig;
spec.actualCellSize.width = originalWidth;
spec.actualCellSize.height = originalHeight;
return;
}
const canvas = ImageRenderer.createCanvas(
this.document,
Math.ceil(spec.orig!.width * currentWidth / originalWidth),
Math.ceil(spec.orig!.height * currentHeight / originalHeight)
);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
spec.actual = canvas;
spec.actualCellSize.width = currentWidth;
spec.actualCellSize.height = currentHeight;
if (recalc) {
this._dimensions = dimensions;
this._cellSize = this.getCellSize(dimensions);
}
} else {
// we were not able to get render dimensions:
// set cellsize to undefined so we don't try to draw anything
this._cellSize = this.getCellSize();
}
}
@@ -336,9 +250,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
if (this._layers.has(layer)) {
return;
}
const canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
const canvas = createCanvas(
this.document,
this.dimensions?.device.canvas.width || 0,
this.dimensions?.device.canvas.height || 0,
this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0,
);
canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement;
@@ -366,6 +283,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
return;
}
this._layers.set(layer, ctx);
// force rescaling to update stored cellSize (maybe not be populated yet)
this.rescaleCanvas(true);
this.clearAll(layer);
}
@@ -381,41 +300,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
return this._layers.has(layer);
}
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
// create blueprint to fill placeholder with
const bWidth = 32; // must be 2^n
const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);
const ctx = blueprint.getContext('2d', { alpha: false });
/**
* Create a semi-transparent gray 1x1 placeholder
*/
private _createPlaceHolder(): HTMLCanvasElement | undefined {
const imgData = new ImageData(1, 1);
new Uint32Array(imgData.data.buffer).fill(128 << 24 | 128 << 16 | 128 << 8 | 128);
const canvas = createCanvas(this.document, 1, 1);
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return;
const imgData = ImageRenderer.createImageData(ctx, bWidth, height);
const d32 = new Uint32Array(imgData.data.buffer);
const black = toRGBA8888(0, 0, 0);
const white = toRGBA8888(255, 255, 255);
d32.fill(black);
for (let y = 0; y < height; ++y) {
const shift = y % 2;
const offset = y * bWidth;
for (let x = 0; x < bWidth; x += 2) {
d32[offset + x + shift] = white;
}
}
ctx.putImageData(imgData, 0, 0);
// create placeholder line, width aligned to blueprint width
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
this._placeholder = undefined;
return;
}
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
return canvas;
}
public get document(): Document | undefined {
+69 -74
View File
@@ -6,10 +6,13 @@
import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer';
import type {
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts,
IDrawable,
IMetrics
} from './Types';
import type { IBufferLine } from 'common/buffer/Types';
import { CellData } from 'common/buffer/CellData';
import { createCanvas } from './Primitives';
// fallback default cell size
export const CELL_SIZE_DEFAULT: ICellSize = {
@@ -17,7 +20,7 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
height: 14
};
export class ImageTileInfo {
class ImageTileInfo {
constructor(
public imageId = -1,
public tileId = -1) {
@@ -43,7 +46,7 @@ export class ImageStorage implements IDisposable {
// whether render should do a full clear
private _needsFullClear = false;
// hard limit of stored pixels (fallback limit of 10 MB)
private _pixelLimit: number = 2500000;
private _byteLimit: number = 10000000;
private _workCell: CellData = new CellData();
private _viewportMetrics: { cols: number, rows: number };
@@ -83,43 +86,54 @@ export class ImageStorage implements IDisposable {
this._renderer.clearAll();
}
/**
* FIXME: below we need to introduce separation of:
* - RAM usage
* - VRAM usage
* - Blob unsage
*/
public getLimit(): number {
return this._pixelLimit * 4 / 1000000;
return this._byteLimit / 1000000;
}
public setLimit(value: number): void {
if (value < 0.5 || value > 1000) {
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1GB');
}
this._pixelLimit = (value / 4 * 1000000) >>> 0;
this._byteLimit = (value * 1000000) >>> 0;
this._evictOldest(0);
}
public getUsage(): number {
return this._getStoredPixels() * 4 / 1000000;
return this._getVRAMUsage() / 1000000;
}
private _getStoredPixels(): number {
let storedPixels = 0;
private _getVRAMUsage(): number {
let bytes = 0;
for (const spec of this._images.values()) {
if (spec.orig) {
storedPixels += spec.orig.width * spec.orig.height;
if (spec.actual && spec.actual !== spec.orig) {
storedPixels += spec.actual.width * spec.actual.height;
}
bytes += spec.src.bytes;
}
return bytes;
}
public getBlobUsage(): number {
let size = 0;
for (const spec of this._images.values()) {
if (spec.data instanceof Blob) {
size += spec.data.size;
}
}
return storedPixels;
return size;
}
private _delImg(id: number): void {
const spec = this._images.get(id);
if (!spec) return;
this._images.delete(id);
// FIXME: really ugly workaround to get bitmaps deallocated :(
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close();
}
spec.src.close();
spec.data = undefined;
this.onImageDeleted?.(id);
}
@@ -143,13 +157,6 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = false;
}
/**
* Retrieve image spec from storage.
*/
public getImage(id: number): IImageSpec | undefined {
return this._images.get(id);
}
/**
* Delete an image by its internal storage ID.
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
@@ -163,8 +170,10 @@ export class ImageStorage implements IDisposable {
}
/**
* Method to add an image to the storage.
* @param img - The image to add (canvas or bitmap).
* Method to add an drawable to the storage.
* @param src - The drawable to add.
* @param data - Blob or Uint8Array of the original image data.
* @param metrics - IMetrics of the original image.
* @param opts - Options for addImage:
* - scrolling: When true, cursor advances with the image.
* When false, image is placed at ORIGIN and cursor does not move.
@@ -173,17 +182,16 @@ export class ImageStorage implements IDisposable {
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
* @returns The internal image ID assigned to the stored image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, opts: IAddImageOpts): number {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
this._evictOldest(src.bytes);
// calc rows x cols needed to display the image
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const cols = Math.ceil(img.width / cellSize.width);
const rows = Math.ceil(img.height / cellSize.height);
const prescaleX = opts.prescaleX ?? 1.0;
const prescaleY = opts.prescaleY ?? 1.0;
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
const cols = Math.ceil(src.width * prescaleX / cellSize.width);
const rows = Math.ceil(src.height * prescaleY / cellSize.height);
const imageId = ++this._lastId;
@@ -260,15 +268,24 @@ export class ImageStorage implements IDisposable {
// create storage entry
const imgSpec: IImageSpec = {
orig: img,
origCellSize: cellSize,
actual: img,
actualCellSize: { ...cellSize }, // clone needed, since later modified
src,
data,
metrics,
cellSize,
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type,
layer: opts.layer,
zIndex: opts.zIndex
zIndex: opts.zIndex,
prescaleX,
prescaleY,
precropX: 0,
precropY: 0,
scaleX: 1.0,
scaleY: 1.0,
offsetX: 0,
offsetY: 0,
blendMode: 'alpha'
};
// finally add the image
@@ -383,9 +400,7 @@ export class ImageStorage implements IDisposable {
}
col--;
if (imgSpec) {
if (imgSpec.actual) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
}
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
}
@@ -440,7 +455,7 @@ export class ImageStorage implements IDisposable {
continue;
}
// found an image tile at oldCol, check if it qualifies for right exapansion
const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);
const tilesPerRow = Math.ceil(imgSpec.src.width * imgSpec.prescaleX / imgSpec.cellSize.width);
if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
continue;
}
@@ -477,30 +492,13 @@ export class ImageStorage implements IDisposable {
if (line) {
const e = line.getExtended(x)?.payload;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) {
const orig = this._images.get(e.imageId)?.orig;
if (window.ImageBitmap && orig instanceof ImageBitmap) {
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);
const src = this._images.get(e.imageId)?.src;
if (src?.native instanceof ImageBitmap || src?.native instanceof VideoFrame) {
const canvas = createCanvas(window.document, src.width, src.height);
canvas.getContext('2d')?.drawImage(src.native, 0, 0, src.width, src.height);
return canvas;
}
return orig as HTMLCanvasElement;
}
}
}
/**
* Extract active single tile at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y);
if (line) {
const e = line.getExtended(x)?.payload;
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId);
if (spec) {
return this._renderer.extractTile(spec, e.tileId);
}
return src?.native;
}
}
}
@@ -508,15 +506,12 @@ export class ImageStorage implements IDisposable {
// TODO: Do we need some blob offloading tricks here to avoid early eviction?
// also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size
private _evictOldest(room: number): number {
const used = this._getStoredPixels();
const used = this._getVRAMUsage();
let current = used;
while (this._pixelLimit < current + room && this._images.size) {
while (this._byteLimit < current + room && this._images.size) {
const spec = this._images.get(++this._lowestId);
if (spec && spec.orig) {
current -= spec.orig.width * spec.orig.height;
if (spec.actual && spec.orig !== spec.actual) {
current -= spec.actual.width * spec.actual.height;
}
if (spec) {
current -= spec.src.bytes;
spec.marker?.dispose();
this._delImg(this._lowestId);
}
@@ -4,7 +4,8 @@
*/
import { assert } from 'chai';
import { imageType, IMetrics } from './IIPMetrics';
import { imageType } from './Metrics';
import type { IMetrics } from 'Types';
// fix missing nodejs decl
declare const require: (s: string) => any;
@@ -12,48 +13,48 @@ const fs = require('fs');
const TEST_IMAGES: [string, IMetrics][] = [
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48, animated: true }],
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['w3c_home.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
['spinfox.png', { mime: 'image/png', width: 148, height: 148, animated: true }],
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448, animated: false }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600, animated: false }],
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8, animated: false }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000, animated: false }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600, animated: false }],
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48 }],
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }],
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
['spinfox.png', { mime: 'image/png', width: 148, height: 148 }],
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
// VP8
['1.webp', { mime: 'image/webp', width: 550, height: 368, animated: false }],
['2.webp', { mime: 'image/webp', width: 550, height: 404, animated: false }],
['3.webp', { mime: 'image/webp', width: 1280, height: 720, animated: false }],
['4.webp', { mime: 'image/webp', width: 1024, height: 772, animated: false }],
['5.webp', { mime: 'image/webp', width: 1024, height: 752, animated: false }],
['1.webp', { mime: 'image/webp', width: 550, height: 368 }],
['2.webp', { mime: 'image/webp', width: 550, height: 404 }],
['3.webp', { mime: 'image/webp', width: 1280, height: 720 }],
['4.webp', { mime: 'image/webp', width: 1024, height: 772 }],
['5.webp', { mime: 'image/webp', width: 1024, height: 752 }],
// VP8X
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301 }],
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395 }],
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600 }],
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163 }],
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300 }],
// VP8L
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301 }],
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395 }],
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600 }],
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163 }],
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300 }],
// some AVIF test images
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799, animated: false }],
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048, animated: false }],
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024, animated: false }],
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799 }],
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048 }],
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024 }],
];
+137
View File
@@ -0,0 +1,137 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IMetrics } from './Types';
export const UNSUPPORTED_TYPE: IMetrics = {
mime: 'unsupported',
width: 0,
height: 0
};
export function imageType(d: Uint8Array): IMetrics {
if (d.length < 32) {
return UNSUPPORTED_TYPE;
}
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
// + first chunk must be IHDR
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
return {
mime: 'image/png',
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
};
}
// JPEG: FF D8 FF
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
const [width, height] = jpgSize(d);
return { mime: 'image/jpeg', width, height };
}
// GIF: GIF87a or GIF89a
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
return {
mime: 'image/gif',
width: d[7] << 8 | d[6],
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]
};
}
// WEBP: RIFF | xxxx | WEBP | VP8x
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
switch (d[15]) {
case 0x58: // Extended WebP VP8X --> "X"
return {
mime: 'image/webp',
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
height: (d[27] | d[28] << 8 | d[29] << 16) + 1
};
case 0x4C: // Lossless WebP VP8L --> "L"
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
return {
mime: 'image/webp',
width: (dim & 0x3FFF) + 1,
height: (dim >>> 14 & 0x3FFF) + 1
};
case 0x20: // Lossy WebP VP8 --> " "
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
return {
mime: 'image/webp',
width: (d[26] | d[27] << 8) & 0x3FFF,
height: (d[28] | d[29] << 8) & 0x3FFF
};
}
return UNSUPPORTED_TYPE;
}
// AVIF: Box size | ftyp | avif/avis
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
let pos = -1;
// search for ispe box within first 1024 bytes
const limit = Math.min(d.length - 16, 1024);
for (let i = 8; i < limit; i++) {
// scan for ispe
if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
pos = i;
break;
}
}
if (pos !== -1) {
// dimensions are in BE at +8 (width) at +12 (height)
const width =
d[pos + 8] << 24 |
d[pos + 9] << 16 |
d[pos + 10] << 8 |
d[pos + 11];
const height =
d[pos + 12] << 24 |
d[pos + 13] << 16 |
d[pos + 14] << 8 |
d[pos + 15];
if (width > 0 && height > 0) {
return { mime: 'image/avif', width, height };
}
}
return UNSUPPORTED_TYPE;
}
return UNSUPPORTED_TYPE;
}
function jpgSize(d: Uint8Array): [number, number] {
const len = d.length;
let i = 4;
let blockLength = d[i] << 8 | d[i + 1];
while (true) {
i += blockLength;
if (i >= len) {
// exhausted without size info
return [0, 0];
}
if (d[i] !== 0xFF) {
return [0, 0];
}
if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
if (i + 8 < len) {
return [
d[i + 7] << 8 | d[i + 8],
d[i + 5] << 8 | d[i + 6]
];
}
return [0, 0];
}
i += 2;
blockLength = d[i] << 8 | d[i + 1];
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDrawable } from './Types';
export function createCanvas(
localDocument: Document | undefined,
width: number,
height: number,
cssWidth?: number,
cssHeight?: number
): HTMLCanvasElement {
/**
* NOTE: We normally dont care, from which document the canvas
* gets created, so we can fall back to global document,
* if the terminal has no document associated yet.
* This way early image loads before calling .open keep working
* (still discouraged though, as the metrics will be screwed up).
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
canvas.style.width = `${(cssWidth ?? width) | 0}px`;
canvas.style.height = `${(cssHeight ?? height) | 0}px`;
return canvas;
}
/**
* Drawable is a resource the ImageRenderer knows to draw on the screen.
*/
export class Drawable implements IDrawable {
public readonly width: number;
public readonly height: number;
// @readonly: only for memory tracking
public bytes: number;
constructor(public native: ImageBitmap | VideoFrame | HTMLCanvasElement) {
if (native instanceof VideoFrame) {
this.width = native.displayWidth;
this.height = native.displayHeight;
// NOTE: native.allocationSize() is too expensive
this.bytes = native.codedWidth * native.codedHeight * 4;
} else {
this.width = native.width;
this.height = native.height;
this.bytes = native.width * native.height * 4;
}
}
public close(): void {
if (this.bytes === 0) return;
this.bytes = 0;
try {
if (this.native instanceof HTMLCanvasElement) {
this.native.width = 0;
this.native.height = 0;
} else {
this.native.close?.();
}
} catch {}
this.native = null!;
}
}
+3 -3
View File
@@ -7,9 +7,9 @@ import { SixelImageStorage } from './SixelImageStorage';
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
import { RGBA8888 } from 'sixel/lib/Types';
import { ImageRenderer } from './ImageRenderer';
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
import { createCanvas, Drawable } from './Primitives';
// always free decoder ressources after decoding if it exceeds this limit
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
@@ -98,12 +98,12 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
return true;
}
const canvas = ImageRenderer.createCanvas(undefined, width, height);
const canvas = createCanvas(undefined, width, height);
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
this._dec.release();
}
this._storage.addImage(canvas);
this._storage.addImage(new Drawable(canvas), undefined, { width, height, mime: 'image/sixel' });
return true;
}
}
+4 -7
View File
@@ -4,7 +4,7 @@
*/
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts, IDrawable, IMetrics } from './Types';
import { ImageRenderer } from './ImageRenderer';
/**
@@ -27,9 +27,9 @@ export class SixelImageStorage {
* Add a sixel image to storage.
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics): void {
this._addImageOpts.scrolling = this._opts.sixelScrolling;
this._storage.addImage(img, this._addImageOpts);
this._storage.addImage(src, data, metrics, this._addImageOpts);
}
/**
@@ -39,10 +39,7 @@ export class SixelImageStorage {
*/
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
+58 -6
View File
@@ -40,8 +40,6 @@ export interface IImageAddonOptions {
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
animationSupport: boolean;
animationPixelLimit: number;
}
export interface IResetHandler {
@@ -96,16 +94,70 @@ export interface IAddImageOpts {
layer: ImageLayer;
zIndex: number;
cursorPos: CursorPos;
prescaleX?: number;
prescaleY?: number;
}
export interface IDrawable {
native: ImageBitmap | VideoFrame | HTMLCanvasElement;
width: number;
height: number;
close(): void;
readonly bytes: number;
}
export type ImageType = 'unsupported'
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/qoi'
| 'image/webp'
| 'image/avif'
// sixel
| 'image/sixel'
// rgb|a blobs for kitty
| 'image/rgb'
| 'image/rgba';
export interface IMetrics {
mime: ImageType;
width: number;
height: number;
}
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
actual: HTMLCanvasElement | ImageBitmap | undefined;
actualCellSize: ICellSize;
/** entries for a later IImageSource type */
/** drawable for screen rendering */
src: IDrawable;
/** image bytes as blob */
data: Blob | undefined;
/** metrics about image like mime and dimensions */
metrics: IMetrics;
/** entries for a later IPlacement type */
/** cell size at time of insert */
cellSize: ICellSize;
/** eviction marker */
marker: IMarker | undefined;
/** used tiles as eviction hint */
tileCount: number;
/** buffer it was placed on */
bufferType: 'alternate' | 'normal';
/** layer it was placed on */
layer: ImageLayer;
zIndex: number;
/** scaling factor on source dimensions, default = 1.0 */
prescaleX: number;
prescaleY: number;
/** crop offset into source, default = 0 (currently unused) */
precropX: number;
precropY: number;
/** output scaling, default = 1.0 (currently unused) */
scaleX: number;
scaleY: number;
/** output offset, default = 0 (currently unused) */
offsetX: number;
offsetY: number;
/** compositing mode on output, default = 'alpha' (currently unused) */
blendMode: 'overwrite' | 'alpha';
}
@@ -19,6 +19,7 @@ import {
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
import { createCanvas } from '../Primitives';
const enum Constants {
// Memory limit for base64 decoder (4MB, same as IIPHandler)
@@ -573,8 +574,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
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;
const cw = this._renderer.dimensions?.device.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.device.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
@@ -636,7 +637,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCanvas = createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
throw new Error('Failed to create offset canvas context');
@@ -700,7 +701,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
const canvas = createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject);
});
@@ -7,6 +7,8 @@ import { IDisposable } from '@xterm/xterm';
import { ImageStorage } from '../ImageStorage';
import { ImageLayer, IAddImageOpts } from '../Types';
import { IKittyImageData } from './KittyGraphicsTypes';
import { Drawable } from '../Primitives';
import { UNSUPPORTED_TYPE } from '../Metrics';
// Kitty-specific image storage controller.
//
@@ -102,7 +104,8 @@ export class KittyImageStorage implements IDisposable {
this._addImageOpts.scrolling = scrolling;
this._addImageOpts.layer = layer;
this._addImageOpts.zIndex = zIndex;
const storageId = this._storage.addImage(image, this._addImageOpts);
// FIXME: store image data in blob, fill metrics correctly
const storageId = this._storage.addImage(new Drawable(image), undefined, UNSUPPORTED_TYPE, this._addImageOpts);
this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId);
}
+29 -35
View File
@@ -26,8 +26,6 @@ export interface IImageAddonOptions {
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
animationSupport: boolean;
animationPixelLimit: number;
}
// eslint-disable-next-line
@@ -140,9 +138,7 @@ test.describe('ImageAddon', () => {
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432,
animationSupport: true,
animationPixelLimit: 4194304
kittySizeLimit: 33554432
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -159,9 +155,7 @@ test.describe('ImageAddon', () => {
iipSupport: false,
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000,
animationSupport: false,
animationPixelLimit: 1000
kittySizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -300,10 +294,10 @@ test.describe('ImageAddon', () => {
test('size params is optional/informative', async () => {
// too small size param
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
deepStrictEqual(await getImageSize(1), [640, 80]);
// no size aparm at all
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
deepStrictEqual(await getImageSize(2), [640, 80]);
});
test.skip('FilePart support - bytewise', async () => {
// opener
@@ -314,7 +308,7 @@ test.describe('ImageAddon', () => {
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
deepStrictEqual(await getImageSize(1), [640, 80]);
});
test('FilePart support - chunks', async () => {
// opener
@@ -325,7 +319,7 @@ test.describe('ImageAddon', () => {
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
deepStrictEqual(await getImageSize(1), [640, 80]);
});
test('FilePart support - edgecases', async () => {
// multiple opener do not harm
@@ -353,39 +347,39 @@ test.describe('ImageAddon', () => {
// finalizer skipped
// write spinfox.png
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
});
});
test.describe('IIP support - testimages', () => {
test('palette.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[0][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[0][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[0][1]);
});
test('spinfox.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
});
test('w3c gif', async () => {
await ctx.proxy.write(TESTDATA_IIP[2][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[2][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[2][1]);
});
test('w3c jpeg', async () => {
await ctx.proxy.write(TESTDATA_IIP[3][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[3][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[3][1]);
});
test('w3c png', async () => {
await ctx.proxy.write(TESTDATA_IIP[4][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[4][1]);
});
});
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]);
deepStrictEqual(await getImageSize(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]);
deepStrictEqual(await getImageSize(2), [640, 80]);
const pngScrape = await getImageAtBufferCell(0, 0);
const qoiScrape = await getImageAtBufferCell(0, 11);
deepStrictEqual(qoiScrape, pngScrape);
@@ -412,20 +406,20 @@ test.describe('ImageAddon', () => {
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]);
deepStrictEqual(await getImageSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
});
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize
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]);
deepStrictEqual(await getImageSize(1), [320, 160]);
});
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize
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)]);
deepStrictEqual(await getImageSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
});
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent
@@ -433,11 +427,11 @@ test.describe('ImageAddon', () => {
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)]);
deepStrictEqual(await getImageSize(1), [width, Math.floor(width * 80 / 640)]);
// height provided in pixel
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]);
deepStrictEqual(await getImageSize(2), [Math.floor(200 * 640 / 80), 200]);
});
}
});
@@ -458,10 +452,10 @@ test.describe('ImageAddon', () => {
async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return {
cellWidth: Math.round(dimensions.css.cell.width),
cellHeight: Math.round(dimensions.css.cell.height),
width: Math.round(dimensions.css.canvas.width),
height: Math.round(dimensions.css.canvas.height)
cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.device.canvas.height)
};
}
@@ -477,11 +471,11 @@ async function getScrollbackPlusRows(): Promise<number> {
return ctx.page.evaluate('window.term.options.scrollback + window.term.rows');
}
async function getOrigSize(id: number): Promise<[number, number]> {
async function getImageSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).orig.width,
window.imageAddon._storage._images.get(${id}).orig.height
]`);
window.imageAddon._storage._images.get(${id}).src.width * window.imageAddon._storage._images.get(${id}).prescaleX,
window.imageAddon._storage._images.get(${id}).src.height * window.imageAddon._storage._images.get(${id}).prescaleY
]`).then(r => r.map(Math.floor));
}
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
@@ -489,12 +483,12 @@ async function getImageAtBufferCell(x: number, y: number): Promise<string | unde
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
await ctx.proxy.write('\x1b[H' + imageSeq);
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
+28 -28
View File
@@ -123,7 +123,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [1, 1]);
deepStrictEqual(await getImageSize(1), [1, 1]);
});
test('stores 3x1 RGB PNG with a=T', async () => {
@@ -131,7 +131,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(seq);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
deepStrictEqual(await getImageSize(1), [3, 1]);
});
test('transmit only (a=t) does not display but stores in handler', async () => {
@@ -1107,7 +1107,7 @@ test.describe('Kitty Graphics Protocol', () => {
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [20, 50]);
deepStrictEqual(await getImageSize(1), [20, 50]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
});
@@ -1283,7 +1283,7 @@ test.describe('Kitty Graphics Protocol', () => {
const cursor = await getCursor();
const cellDims: number[] = await ctx.page.evaluate(() => {
const d = (window as any).term._core._renderService.dimensions.css.cell;
const d = (window as any).term._core._renderService.dimensions.device.cell;
return [d.width, d.height];
});
const expectedR = Math.ceil((100 / 200) * 10 * cellDims[0] / cellDims[1]);
@@ -1301,7 +1301,7 @@ test.describe('Kitty Graphics Protocol', () => {
const cursor = await getCursor();
const cellDims: number[] = await ctx.page.evaluate(() => {
const d = (window as any).term._core._renderService.dimensions.css.cell;
const d = (window as any).term._core._renderService.dimensions.device.cell;
return [d.width, d.height];
});
const expectedC = Math.ceil((200 / 100) * 5 * cellDims[1] / cellDims[0]);
@@ -1535,7 +1535,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
});
test('transmit only (a=t) stores 200x100 image without display', async () => {
@@ -1564,7 +1564,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
});
test('handles 3-chunk transmission', async () => {
@@ -1580,7 +1580,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`);
await timeout(200);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
});
test('verifies chunked data assembles correctly', async () => {
@@ -1713,7 +1713,7 @@ test.describe('Kitty Graphics Protocol', () => {
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 getImageSize(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]);
});
@@ -1747,7 +1747,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
});
@@ -1756,7 +1756,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
});
@@ -1772,7 +1772,7 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await timeout(200);
deepStrictEqual(await getOrigSize(1), [200, 100]);
deepStrictEqual(await getImageSize(1), [200, 100]);
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
});
@@ -1893,28 +1893,28 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
deepStrictEqual(await getImageSize(1), [3, 1]);
});
test('stores image with correct original dimensions (2x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [2, 2]);
deepStrictEqual(await getImageSize(1), [2, 2]);
});
test('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]);
deepStrictEqual(await getImageSize(1), [5, 1]);
});
test('stores image with correct original dimensions (4x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [4, 2]);
deepStrictEqual(await getImageSize(1), [4, 2]);
});
});
@@ -2034,21 +2034,21 @@ test.describe('Kitty Graphics Protocol', () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [3, 1]);
deepStrictEqual(await getImageSize(1), [3, 1]);
});
test('stores image with correct original dimensions (2x2)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [2, 2]);
deepStrictEqual(await getImageSize(1), [2, 2]);
});
test('stores image with correct original dimensions (5x1)', async () => {
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
await timeout(100);
strictEqual(await getImageStorageLength(), 1);
deepStrictEqual(await getOrigSize(1), [5, 1]);
deepStrictEqual(await getImageSize(1), [5, 1]);
});
});
@@ -2217,7 +2217,7 @@ test.describe('Kitty Graphics Protocol', () => {
test.describe('text overwrite removes tiles', () => {
test('a=T placement', async () => {
await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
ok(await hasTileAtBufferCell(0, 1));
await ctx.proxy.write('\x1b[2;6H#######');
for (let x = 5; x < 12; x++) {
@@ -2236,10 +2236,10 @@ test.describe('Kitty Graphics Protocol', () => {
async function getDimensions(): Promise<IDimensions> {
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
return {
cellWidth: Math.round(dimensions.css.cell.width),
cellHeight: Math.round(dimensions.css.cell.height),
width: Math.round(dimensions.css.canvas.width),
height: Math.round(dimensions.css.canvas.height)
cellWidth: Math.round(dimensions.device.cell.width),
cellHeight: Math.round(dimensions.device.cell.height),
width: Math.round(dimensions.device.canvas.width),
height: Math.round(dimensions.device.canvas.height)
};
}
@@ -2251,10 +2251,10 @@ async function getImageStorageLength(): Promise<number> {
return ctx.page.evaluate('window.imageAddon._storage._images.size');
}
async function getOrigSize(id: number): Promise<[number, number]> {
async function getImageSize(id: number): Promise<[number, number]> {
return ctx.page.evaluate<any>(`[
window.imageAddon._storage._images.get(${id}).orig.width,
window.imageAddon._storage._images.get(${id}).orig.height
window.imageAddon._storage._images.get(${id}).src.width,
window.imageAddon._storage._images.get(${id}).src.height
]`);
}
@@ -2279,5 +2279,5 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
}
-5
View File
@@ -125,10 +125,5 @@ declare module '@xterm/addon-image' {
* Get original image canvas at buffer position.
*/
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
/**
* Extract single tile canvas at buffer position.
*/
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
}
}
@@ -203,11 +203,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
const x = pos[0] - 1;
const y = pos[1] - 1;
const canvas = ev.shiftKey
// ctrl+shift+click: get single tile
? imageAddon.extractTileAtBufferCell(x, this._terminal.buffer.active.viewportY + y)
// ctrl+click: get original image
: imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
// ctrl+click: get original image
const canvas = imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
canvas?.toBlob(data => data && window.open(URL.createObjectURL(data), '_blank'));
});
}
+4 -4
View File
@@ -1137,13 +1137,13 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
switch (type) {
case WindowsOptionsReportType.GET_WIN_SIZE_PIXELS:
const canvasWidth = this._renderService.dimensions.css.canvas.width.toFixed(0);
const canvasHeight = this._renderService.dimensions.css.canvas.height.toFixed(0);
const canvasWidth = this._renderService.dimensions.device.canvas.width.toFixed(0);
const canvasHeight = this._renderService.dimensions.device.canvas.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[4;${canvasHeight};${canvasWidth}t`);
break;
case WindowsOptionsReportType.GET_CELL_SIZE_PIXELS:
const cellWidth = this._renderService.dimensions.css.cell.width.toFixed(0);
const cellHeight = this._renderService.dimensions.css.cell.height.toFixed(0);
const cellWidth = this._renderService.dimensions.device.cell.width.toFixed(0);
const cellHeight = this._renderService.dimensions.device.cell.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[6;${cellHeight};${cellWidth}t`);
break;
}