initial move to base repo
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { Base64Decoder } from './base64.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
// limit hold memory in base64 decoder
|
||||
const KEEP_DATA = 4194304;
|
||||
|
||||
// default IIP header values
|
||||
const DEFAULT_HEADER: IHeaderFields = {
|
||||
name: 'Unnamed file',
|
||||
size: 0,
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
preserveAspectRatio: 1,
|
||||
inline: 0
|
||||
};
|
||||
|
||||
|
||||
export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private _aborted = false;
|
||||
private _hp = new HeaderParser();
|
||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||
private _dec = new Base64Decoder(KEEP_DATA);
|
||||
private _metrics = UNSUPPORTED_TYPE;
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {}
|
||||
|
||||
public reset(): void {}
|
||||
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._header = DEFAULT_HEADER;
|
||||
this._metrics = UNSUPPORTED_TYPE;
|
||||
this._hp.reset();
|
||||
}
|
||||
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted) return;
|
||||
|
||||
if (this._hp.state === HeaderState.END) {
|
||||
if (this._dec.put(data, start, end)) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
} else {
|
||||
const dataPos = this._hp.parse(data, start, end);
|
||||
if (dataPos === -1) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
if (dataPos > 0) {
|
||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
this._dec.init(this._header.size);
|
||||
if (this._dec.put(data, dataPos, end)) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public end(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted) return true;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
// early exit condition chain
|
||||
let cond: number | boolean = true;
|
||||
if (cond = success) {
|
||||
if (cond = !this._dec.end()) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
if (cond = this._metrics.mime !== 'unsupported') {
|
||||
w = this._metrics.width;
|
||||
h = this._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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cond) {
|
||||
this._dec.release();
|
||||
return true;
|
||||
}
|
||||
|
||||
const blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
this._dec.release();
|
||||
|
||||
const win = this._coreTerminal._core._coreBrowserService.window;
|
||||
if (!win.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<boolean>(r => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(win, w, h);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
||||
this._storage.addImage(canvas);
|
||||
r(true);
|
||||
});
|
||||
img.src = url;
|
||||
// sanity measure to avoid terminal blocking from dangling promise
|
||||
// happens from corrupt data (onload never gets fired)
|
||||
setTimeout(() => r(true), 1000);
|
||||
});
|
||||
}
|
||||
return win.createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||
.then(bm => {
|
||||
this._storage.addImage(bm as unknown as HTMLCanvasElement);
|
||||
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;
|
||||
|
||||
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
|
||||
const f = Math.min(wf, hf);
|
||||
return f < 1 ? [w * f, h * f] : [w, h];
|
||||
}
|
||||
return !rw
|
||||
? [w * rh / h, rh]
|
||||
: this._header.preserveAspectRatio || !rw || !rh
|
||||
? [rw, h * rw / w] : [rw, rh];
|
||||
}
|
||||
|
||||
private _dim(s: string, total: number, cdim: number): number {
|
||||
if (s === 'auto') return 0;
|
||||
if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100;
|
||||
if (s.endsWith('px')) return parseInt(s.slice(0, -2));
|
||||
return parseInt(s) * cdim;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
|
||||
|
||||
|
||||
const CASES: [string, IHeaderFields][] = [
|
||||
['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}],
|
||||
['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
|
||||
// utf-8 encoding in name
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}],
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
|
||||
// full header spec
|
||||
[
|
||||
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||
{
|
||||
inline: 1,
|
||||
width: '10px',
|
||||
height: '20%',
|
||||
preserveAspectRatio: 1,
|
||||
size: 123456,
|
||||
name: 'ümläutß'
|
||||
}
|
||||
],
|
||||
[
|
||||
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||
{
|
||||
inline: 1,
|
||||
width: 'auto',
|
||||
height: '20',
|
||||
preserveAspectRatio: 1,
|
||||
size: 123456,
|
||||
name: 'ümläutß'
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
function fromBs(bs: string): Uint32Array {
|
||||
const r = new Uint32Array(bs.length);
|
||||
for (let i = 0; i < r.length; ++i) r[i] = bs.charCodeAt(i);
|
||||
return r;
|
||||
}
|
||||
|
||||
describe('IIPHeaderParser', () => {
|
||||
it('at once', () => {
|
||||
const hp = new HeaderParser();
|
||||
for (const example of CASES) {
|
||||
hp.reset();
|
||||
const inp = fromBs(example[0]);
|
||||
const res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, example[1]);
|
||||
}
|
||||
});
|
||||
it('bytewise', () => {
|
||||
const hp = new HeaderParser();
|
||||
for (const example of CASES) {
|
||||
hp.reset();
|
||||
const inp = fromBs(example[0]);
|
||||
let pos = 0;
|
||||
let res = -2;
|
||||
while (res === -2 && pos < inp.length) {
|
||||
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||
}
|
||||
assert.strictEqual(res, 1);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, example[1]);
|
||||
}
|
||||
});
|
||||
it('no File= starter', () => {
|
||||
const hp = new HeaderParser();
|
||||
let inp = fromBs('size=123456;name=dGVzdA==:');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, -1);
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||
});
|
||||
it('empty key - error', () => {
|
||||
const hp = new HeaderParser();
|
||||
let inp = fromBs('File=size=123456;=dGVzdA==:');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, -1);
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||
});
|
||||
it('empty size value - set to 0', () => {
|
||||
const hp = new HeaderParser();
|
||||
let inp = fromBs('File=size=;name=dGVzdA==:');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, {name: 'test', size: 0});
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||
});
|
||||
it('empty name value - set to empty string', () => {
|
||||
const hp = new HeaderParser();
|
||||
let inp = fromBs('File=size=123456;name=:');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, {name: '', size: 123456});
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||
});
|
||||
it('empty size value - error', () => {
|
||||
const hp = new HeaderParser();
|
||||
let inp = fromBs('File=inline=1;width=;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:');
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, -1);
|
||||
hp.reset();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
|
||||
export interface IHeaderFields {
|
||||
// base-64 encoded filename. Defaults to "Unnamed file".
|
||||
name: string;
|
||||
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
||||
size: number;
|
||||
/**
|
||||
* Optional width and height to render:
|
||||
* - N: N character cells.
|
||||
* - Npx: N pixels.
|
||||
* - N%: N percent of the session's width or height.
|
||||
* - auto: The image's inherent size will be used to determine an appropriate dimension.
|
||||
*/
|
||||
width?: string;
|
||||
height?: string;
|
||||
// Optional, defaults to 1 respecting aspect ratio (width takes precedence).
|
||||
preserveAspectRatio?: number;
|
||||
// Optional, defaults to 0. If set to 1, the file will be displayed inline, else downloaded (download not supported).
|
||||
inline?: number;
|
||||
}
|
||||
|
||||
export const enum HeaderState {
|
||||
START = 0,
|
||||
ABORT = 1,
|
||||
KEY = 2,
|
||||
VALUE = 3,
|
||||
END = 4
|
||||
}
|
||||
|
||||
// field value decoders
|
||||
|
||||
// ASCII bytes to string
|
||||
function toStr(data: Uint32Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
s += String.fromCharCode(data[i]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// digits to integer
|
||||
function toInt(data: Uint32Array): number {
|
||||
let v = 0;
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
if (data[i] < 48 || data[i] > 57) {
|
||||
throw new Error('illegal char');
|
||||
}
|
||||
v = v * 10 + data[i] - 48;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// check for correct size entry
|
||||
function toSize(data: Uint32Array): string {
|
||||
const v = toStr(data);
|
||||
if (!v.match(/^((auto)|(\d+?((px)|(%)){0,1}))$/)) {
|
||||
throw new Error('illegal size');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// name is base64 encoded utf-8
|
||||
function toName(data: Uint32Array): string {
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
return Buffer.from(toStr(data), 'base64').toString();
|
||||
}
|
||||
const bs = atob(toStr(data));
|
||||
const b = new Uint8Array(bs.length);
|
||||
for (let i = 0; i < b.length; ++i) {
|
||||
b[i] = bs.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(b);
|
||||
}
|
||||
|
||||
const DECODERS: {[key: string]: (v: Uint32Array) => any} = {
|
||||
inline: toInt,
|
||||
size: toInt,
|
||||
name: toName,
|
||||
width: toSize,
|
||||
height: toSize,
|
||||
preserveAspectRatio: toInt
|
||||
};
|
||||
|
||||
|
||||
const FILE_MARKER = [70, 105, 108, 101];
|
||||
const MAX_FIELDCHARS = 1024;
|
||||
|
||||
|
||||
export class HeaderParser {
|
||||
public state: HeaderState = HeaderState.START;
|
||||
private _buffer = new Uint32Array(MAX_FIELDCHARS);
|
||||
private _position = 0;
|
||||
private _key = '';
|
||||
public fields: {[key: string]: any} = {};
|
||||
|
||||
public reset(): void {
|
||||
this._buffer.fill(0);
|
||||
this.state = HeaderState.START;
|
||||
this._position = 0;
|
||||
this.fields = {};
|
||||
this._key = '';
|
||||
}
|
||||
|
||||
public parse(data: Uint32Array, start: number, end: number): number {
|
||||
let state = this.state;
|
||||
let pos = this._position;
|
||||
const buffer = this._buffer;
|
||||
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
|
||||
if (state === HeaderState.START && pos > 6) return -1;
|
||||
for (let i = start; i < end; ++i) {
|
||||
const c = data[i];
|
||||
switch (c) {
|
||||
case 59: // ;
|
||||
if (!this._storeValue(pos)) return this._a();
|
||||
state = HeaderState.KEY;
|
||||
pos = 0;
|
||||
break;
|
||||
case 61: // =
|
||||
if (state === HeaderState.START) {
|
||||
for (let k = 0; k < FILE_MARKER.length; ++k) {
|
||||
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
||||
}
|
||||
state = HeaderState.KEY;
|
||||
pos = 0;
|
||||
} else if (state === HeaderState.KEY) {
|
||||
if (!this._storeKey(pos)) return this._a();
|
||||
state = HeaderState.VALUE;
|
||||
pos = 0;
|
||||
} else if (state === HeaderState.VALUE) {
|
||||
if (pos >= MAX_FIELDCHARS) return this._a();
|
||||
buffer[pos++] = c;
|
||||
}
|
||||
break;
|
||||
case 58: // :
|
||||
if (state === HeaderState.VALUE) {
|
||||
if (!this._storeValue(pos)) return this._a();
|
||||
}
|
||||
this.state = HeaderState.END;
|
||||
return i + 1;
|
||||
default:
|
||||
if (pos >= MAX_FIELDCHARS) return this._a();
|
||||
buffer[pos++] = c;
|
||||
}
|
||||
}
|
||||
this.state = state;
|
||||
this._position = pos;
|
||||
return -2;
|
||||
}
|
||||
|
||||
private _a(): number {
|
||||
this.state = HeaderState.ABORT;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private _storeKey(pos: number): boolean {
|
||||
const k = toStr(this._buffer.subarray(0, pos));
|
||||
if (k) {
|
||||
this._key = k;
|
||||
this.fields[k] = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _storeValue(pos: number): boolean {
|
||||
if (this._key) {
|
||||
try {
|
||||
const v = this._buffer.slice(0, pos);
|
||||
this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { imageType, IMetrics } from './IIPMetrics';
|
||||
|
||||
// fix missing nodejs decl
|
||||
declare const require: (s: string) => any;
|
||||
const fs = require('fs');
|
||||
|
||||
|
||||
const TEST_IMAGES: [string, IMetrics][] = [
|
||||
['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 }],
|
||||
['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 }]
|
||||
];
|
||||
|
||||
|
||||
describe('IIPMetrics', () => {
|
||||
it('bunch of testimages', () => {
|
||||
for (let i = 0; i < TEST_IMAGES.length; ++i) {
|
||||
const imageData = fs.readFileSync('./addons/xterm-addon-image/fixture/testimages/' + TEST_IMAGES[i][0]);
|
||||
assert.deepStrictEqual(imageType(imageData), TEST_IMAGES[i][1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
|
||||
|
||||
export interface IMetrics {
|
||||
mime: ImageType;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_TYPE: IMetrics = {
|
||||
mime: 'unsupported',
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
|
||||
export function imageType(d: Uint8Array): IMetrics {
|
||||
if (d.length < 24) {
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
|
||||
// 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 E0 xx xx JFIF or FF D8 FF E1 xx xx Exif 00 00
|
||||
if ((d32[0] === 0xE0FFD8FF || d32[0] === 0xE1FFD8FF)
|
||||
&& (
|
||||
(d[6] === 0x4a && d[7] === 0x46 && d[8] === 0x49 && d[9] === 0x46)
|
||||
|| (d[6] === 0x45 && d[7] === 0x78 && d[8] === 0x69 && d[9] === 0x66)
|
||||
)
|
||||
) {
|
||||
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]
|
||||
};
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IIPHandler } from './IIPHandler';
|
||||
import { ITerminalAddon, IDisposable } from 'xterm';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { SixelHandler } from './SixelHandler';
|
||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||
|
||||
|
||||
// default values of addon ctor options
|
||||
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
enableSizeReports: true,
|
||||
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 256,
|
||||
sixelSizeLimit: 25000000,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
const MAX_SIXEL_PALETTE_SIZE = 4096;
|
||||
|
||||
// definitions for _xtermGraphicsAttributes sequence
|
||||
const enum GaItem {
|
||||
COLORS = 1,
|
||||
SIXEL_GEO = 2,
|
||||
REGIS_GEO = 3
|
||||
}
|
||||
const enum GaAction {
|
||||
READ = 1,
|
||||
SET_DEFAULT = 2,
|
||||
SET = 3,
|
||||
READ_MAX = 4
|
||||
}
|
||||
const enum GaStatus {
|
||||
SUCCESS = 0,
|
||||
ITEM_ERROR = 1,
|
||||
ACTION_ERROR = 2,
|
||||
FAILURE = 3
|
||||
}
|
||||
|
||||
|
||||
export class ImageAddon implements ITerminalAddon {
|
||||
private _opts: IImageAddonOptions;
|
||||
private _defaultOpts: IImageAddonOptions;
|
||||
private _storage: ImageStorage | undefined;
|
||||
private _renderer: ImageRenderer | undefined;
|
||||
private _disposables: IDisposable[] = [];
|
||||
private _terminal: ITerminalExt | undefined;
|
||||
private _handlers: Map<String, IResetHandler> = new Map();
|
||||
|
||||
constructor(opts?: Partial<IImageAddonOptions>) {
|
||||
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
||||
this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
for (const obj of this._disposables) {
|
||||
obj.dispose();
|
||||
}
|
||||
this._disposables.length = 0;
|
||||
this._handlers.clear();
|
||||
}
|
||||
|
||||
private _disposeLater(...args: IDisposable[]): void {
|
||||
for (const obj of args) {
|
||||
this._disposables.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
public activate(terminal: ITerminalExt): void {
|
||||
this._terminal = terminal;
|
||||
|
||||
// internal data structures
|
||||
this._renderer = new ImageRenderer(terminal, this._opts.showPlaceholder);
|
||||
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
||||
|
||||
// enable size reports
|
||||
if (this._opts.enableSizeReports) {
|
||||
// const windowOptions = terminal.getOption('windowOptions');
|
||||
// windowOptions.getWinSizePixels = true;
|
||||
// windowOptions.getCellSizePixels = true;
|
||||
// windowOptions.getWinSizeChars = true;
|
||||
// terminal.setOption('windowOptions', windowOptions);
|
||||
const windowOps = terminal.options.windowOptions || {};
|
||||
windowOps.getWinSizePixels = true;
|
||||
windowOps.getCellSizePixels = true;
|
||||
windowOps.getWinSizeChars = true;
|
||||
terminal.options.windowOptions = windowOps;
|
||||
}
|
||||
|
||||
this._disposeLater(
|
||||
this._renderer,
|
||||
this._storage,
|
||||
|
||||
// DECSET/DECRST/DA1/XTSMGRAPHICS handlers
|
||||
terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),
|
||||
terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),
|
||||
terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),
|
||||
terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),
|
||||
|
||||
// render hook
|
||||
terminal.onRender(range => this._storage?.render(range)),
|
||||
|
||||
/**
|
||||
* reset handlers covered:
|
||||
* - DECSTR
|
||||
* - RIS
|
||||
* - Terminal.reset()
|
||||
*/
|
||||
terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),
|
||||
terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),
|
||||
terminal._core._inputHandler.onRequestReset(() => this.reset()),
|
||||
|
||||
// wipe canvas and delete alternate images on buffer switch
|
||||
terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),
|
||||
|
||||
// extend images to the right on resize
|
||||
terminal.onResize(metrics => this._storage?.viewportResize(metrics))
|
||||
);
|
||||
|
||||
// SIXEL handler
|
||||
if (this._opts.sixelSupport) {
|
||||
const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
|
||||
this._handlers.set('sixel', sixelHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// iTerm IIP handler
|
||||
if (this._opts.iipSupport) {
|
||||
const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
|
||||
this._handlers.set('iip', iipHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
||||
public reset(): boolean {
|
||||
// reset options customizable by sequences to defaults
|
||||
this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;
|
||||
this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;
|
||||
// also clear image storage
|
||||
this._storage?.reset();
|
||||
// reset protocol handlers
|
||||
for (const handler of this._handlers.values()) {
|
||||
handler.reset();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public get storageLimit(): number {
|
||||
return this._storage?.getLimit() || -1;
|
||||
}
|
||||
|
||||
public set storageLimit(limit: number) {
|
||||
this._storage?.setLimit(limit);
|
||||
this._opts.storageLimit = limit;
|
||||
}
|
||||
|
||||
public get storageUsage(): number {
|
||||
if (this._storage) {
|
||||
return this._storage.getUsage();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public get showPlaceholder(): boolean {
|
||||
return this._opts.showPlaceholder;
|
||||
}
|
||||
|
||||
public set showPlaceholder(value: boolean) {
|
||||
this._opts.showPlaceholder = value;
|
||||
this._renderer?.showPlaceholder(value);
|
||||
}
|
||||
|
||||
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||
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.coreService.triggerDataEvent(s);
|
||||
}
|
||||
|
||||
private _decset(params: (number | number[])[]): boolean {
|
||||
for (let i = 0; i < params.length; ++i) {
|
||||
switch (params[i]) {
|
||||
case 80:
|
||||
this._opts.sixelScrolling = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _decrst(params: (number | number[])[]): boolean {
|
||||
for (let i = 0; i < params.length; ++i) {
|
||||
switch (params[i]) {
|
||||
case 80:
|
||||
this._opts.sixelScrolling = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// overload DA to return something more appropriate
|
||||
private _da1(params: (number | number[])[]): boolean {
|
||||
if (params[0]) {
|
||||
return true;
|
||||
}
|
||||
// reported features:
|
||||
// 62 - VT220
|
||||
// 4 - SIXEL support
|
||||
// 9 - charsets
|
||||
// 22 - ANSI colors
|
||||
if (this._opts.sixelSupport) {
|
||||
this._report(`\x1b[?62;4;9;22c`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of xterm's graphics attribute sequence.
|
||||
*
|
||||
* Supported features:
|
||||
* - read/change palette limits (max 4096 by sixel lib)
|
||||
* - read SIXEL canvas geometry (reports current window canvas or
|
||||
* squared pixelLimit if canvas > pixel limit)
|
||||
*
|
||||
* Everything else is deactivated.
|
||||
*/
|
||||
private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {
|
||||
if (params.length < 2) {
|
||||
return true;
|
||||
}
|
||||
if (params[0] === GaItem.COLORS) {
|
||||
switch (params[1]) {
|
||||
case GaAction.READ:
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
|
||||
return true;
|
||||
case GaAction.SET_DEFAULT:
|
||||
this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
|
||||
// also reset protocol handlers for now
|
||||
for (const handler of this._handlers.values()) {
|
||||
handler.reset();
|
||||
}
|
||||
return true;
|
||||
case GaAction.SET:
|
||||
if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {
|
||||
this._opts.sixelPaletteLimit = params[2];
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
|
||||
} else {
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
|
||||
}
|
||||
return true;
|
||||
case GaAction.READ_MAX:
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);
|
||||
return true;
|
||||
default:
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (params[0] === GaItem.SIXEL_GEO) {
|
||||
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;
|
||||
if (!width || !height) {
|
||||
// for some reason we have no working image renderer
|
||||
// --> fallback to default cell size
|
||||
const cellSize = CELL_SIZE_DEFAULT;
|
||||
width = (this._terminal?.cols || 80) * cellSize.width;
|
||||
height = (this._terminal?.rows || 24) * cellSize.height;
|
||||
}
|
||||
if (width * height < this._opts.pixelLimit) {
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);
|
||||
} else {
|
||||
// if we overflow pixelLimit report that squared instead
|
||||
const x = Math.floor(Math.sqrt(this._opts.pixelLimit));
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);
|
||||
}
|
||||
return true;
|
||||
case GaAction.READ_MAX:
|
||||
// read_max returns pixelLimit as square area
|
||||
const x = Math.floor(Math.sqrt(this._opts.pixelLimit));
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);
|
||||
return true;
|
||||
default:
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// exit with error on ReGIS or any other requests
|
||||
this._report(`\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { toRGBA8888 } from 'sixel/lib/Colors';
|
||||
import { IDisposable } from 'xterm';
|
||||
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
|
||||
|
||||
const PLACEHOLDER_LENGTH = 4096;
|
||||
const 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
|
||||
*/
|
||||
export class ImageRenderer implements IDisposable {
|
||||
public canvas: HTMLCanvasElement | undefined;
|
||||
private _ctx: CanvasRenderingContext2D | null | undefined;
|
||||
private _placeholder: HTMLCanvasElement | undefined;
|
||||
private _placeholderBitmap: ImageBitmap | undefined;
|
||||
private _optionsRefresh: IDisposable | undefined;
|
||||
private _oldOpen: ((parent: HTMLElement) => void) | undefined;
|
||||
private _renderService: IRenderService | undefined;
|
||||
private _oldSetRenderer: ((renderer: any) => void) | undefined;
|
||||
|
||||
// drawing primitive - canvas
|
||||
public static createCanvas(base: Window, width: number, height: number): HTMLCanvasElement {
|
||||
const canvas = base.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);
|
||||
}
|
||||
|
||||
|
||||
constructor(private _terminal: ITerminalExt, private _showPlaceholder: boolean) {
|
||||
this._oldOpen = this._terminal._core.open;
|
||||
this._terminal._core.open = (parent: HTMLElement): void => {
|
||||
this._oldOpen?.call(this._terminal._core, parent);
|
||||
this._open();
|
||||
};
|
||||
if (this._terminal._core.screenElement) {
|
||||
this._open();
|
||||
}
|
||||
// hack to spot fontSize changes
|
||||
this._optionsRefresh = this._terminal._core.optionsService.onOptionChange(option => {
|
||||
if (option === 'fontSize') {
|
||||
this.rescaleCanvas();
|
||||
this._renderService?.refreshRows(0, this._terminal.rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public dispose(): void {
|
||||
this._optionsRefresh?.dispose();
|
||||
this._removeLayerFromDom();
|
||||
if (this._terminal._core && this._oldOpen) {
|
||||
this._terminal._core.open = this._oldOpen;
|
||||
this._oldOpen = undefined;
|
||||
}
|
||||
if (this._renderService && this._oldSetRenderer) {
|
||||
this._renderService.setRenderer = this._oldSetRenderer;
|
||||
this._oldSetRenderer = undefined;
|
||||
}
|
||||
this._renderService = undefined;
|
||||
this.canvas = undefined;
|
||||
this._ctx = undefined;
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
this._placeholder = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, PLACEHOLDER_HEIGHT));
|
||||
}
|
||||
} else {
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
this._placeholder = undefined;
|
||||
}
|
||||
this._renderService?.refreshRows(0, this._terminal.rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions of the terminal.
|
||||
* Forwarded from internal render service.
|
||||
*/
|
||||
public get dimensions(): IRenderDimensions | undefined {
|
||||
return this._renderService?.dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current cell size (float).
|
||||
*/
|
||||
public get cellSize(): ICellSize {
|
||||
return {
|
||||
width: this.dimensions?.css.cell.width || -1,
|
||||
height: this.dimensions?.css.cell.height || -1
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a region of the image layer canvas.
|
||||
*/
|
||||
public clearLines(start: number, end: number): void {
|
||||
this._ctx?.clearRect(
|
||||
0,
|
||||
start * (this.dimensions?.css.cell.height || 0),
|
||||
this.dimensions?.css.canvas.width || 0,
|
||||
(++end - start) * (this.dimensions?.css.cell.height || 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear whole image canvas.
|
||||
*/
|
||||
public clearAll(): void {
|
||||
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw neighboring tiles on the image layer canvas.
|
||||
*/
|
||||
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
|
||||
if (!this._ctx) {
|
||||
return;
|
||||
}
|
||||
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 dx = col * width;
|
||||
const dy = row * height;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
this._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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._terminal._core._coreBrowserService.window, 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 {
|
||||
if ((this._placeholderBitmap || this._placeholder) && this._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 (height >= this._placeholder!.height) {
|
||||
this._createPlaceHolder(height + 1);
|
||||
}
|
||||
this._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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescale image layer canvas if needed.
|
||||
* Checked once from `ImageStorage.render`.
|
||||
*/
|
||||
public rescaleCanvas(): void {
|
||||
if (!this.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
|
||||
this.canvas.width = this.dimensions!.css.canvas.width || 0;
|
||||
this.canvas.height = this.dimensions!.css.canvas.height || 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._terminal._core._coreBrowserService.window,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy init for the renderer.
|
||||
*/
|
||||
private _open(): void {
|
||||
this._renderService = this._terminal._core._renderService;
|
||||
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
|
||||
this._renderService.setRenderer = (renderer: any) => {
|
||||
this._removeLayerFromDom();
|
||||
this._oldSetRenderer?.call(this._renderService, renderer);
|
||||
this._insertLayerToDom();
|
||||
};
|
||||
this._insertLayerToDom();
|
||||
if (this._showPlaceholder) {
|
||||
this._createPlaceHolder();
|
||||
}
|
||||
}
|
||||
|
||||
private _insertLayerToDom(): void {
|
||||
this.canvas = ImageRenderer.createCanvas(this._terminal._core._coreBrowserService.window, this.dimensions?.css.canvas.width || 0, this.dimensions?.css.canvas.height || 0);
|
||||
this.canvas.classList.add('xterm-image-layer');
|
||||
this._terminal._core.screenElement?.appendChild(this.canvas);
|
||||
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
}
|
||||
|
||||
private _removeLayerFromDom(): void {
|
||||
this.canvas?.parentNode?.removeChild(this.canvas);
|
||||
}
|
||||
|
||||
private _createPlaceHolder(height: number = 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._terminal._core._coreBrowserService.window, bWidth, height);
|
||||
const ctx = blueprint.getContext('2d', { alpha: false });
|
||||
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) || PLACEHOLDER_LENGTH;
|
||||
this._placeholder = ImageRenderer.createCanvas(this._terminal._core._coreBrowserService.window, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IDisposable } from 'xterm';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
|
||||
|
||||
|
||||
// fallback default cell size
|
||||
export const CELL_SIZE_DEFAULT: ICellSize = {
|
||||
width: 7,
|
||||
height: 14
|
||||
};
|
||||
|
||||
/**
|
||||
* Extend extended attribute to also hold image tile information.
|
||||
*
|
||||
* Object definition is copied from base repo to fully mimick its behavior.
|
||||
* Image data is added as additional public properties `imageId` and `tileId`.
|
||||
*/
|
||||
class ExtendedAttrsImage implements IExtendedAttrsImage {
|
||||
private _ext: number = 0;
|
||||
public get ext(): number {
|
||||
if (this._urlId) {
|
||||
return (
|
||||
(this._ext & ~ExtFlags.UNDERLINE_STYLE) |
|
||||
(this.underlineStyle << 26)
|
||||
);
|
||||
}
|
||||
return this._ext;
|
||||
}
|
||||
public set ext(value: number) { this._ext = value; }
|
||||
|
||||
public get underlineStyle(): UnderlineStyle {
|
||||
// Always return the URL style if it has one
|
||||
if (this._urlId) {
|
||||
return UnderlineStyle.DASHED;
|
||||
}
|
||||
return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
|
||||
}
|
||||
public set underlineStyle(value: UnderlineStyle) {
|
||||
this._ext &= ~ExtFlags.UNDERLINE_STYLE;
|
||||
this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;
|
||||
}
|
||||
|
||||
public get underlineColor(): number {
|
||||
return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
}
|
||||
public set underlineColor(value: number) {
|
||||
this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
}
|
||||
|
||||
private _urlId: number = 0;
|
||||
public get urlId(): number {
|
||||
return this._urlId;
|
||||
}
|
||||
public set urlId(value: number) {
|
||||
this._urlId = value;
|
||||
}
|
||||
|
||||
constructor(
|
||||
ext: number = 0,
|
||||
urlId: number = 0,
|
||||
public imageId = -1,
|
||||
public tileId = -1
|
||||
) {
|
||||
this._ext = ext;
|
||||
this._urlId = urlId;
|
||||
}
|
||||
|
||||
public clone(): IExtendedAttrsImage {
|
||||
/**
|
||||
* Technically we dont need a clone variant of ExtendedAttrsImage,
|
||||
* as we never clone a cell holding image data.
|
||||
* Note: Clone is only meant to be used by the InputHandler for
|
||||
* sticky attributes, which is never the case for image data.
|
||||
* We still provide a proper clone method to reflect the full ext attr
|
||||
* state in case there are future use cases for clone.
|
||||
*/
|
||||
return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;
|
||||
}
|
||||
}
|
||||
const EMPTY_ATTRS = new ExtendedAttrsImage();
|
||||
|
||||
|
||||
/**
|
||||
* ImageStorage - extension of CoreTerminal:
|
||||
* - hold image data
|
||||
* - write/read image data to/from buffer
|
||||
*
|
||||
* TODO: image composition for overwrites
|
||||
*/
|
||||
export class ImageStorage implements IDisposable {
|
||||
// storage
|
||||
private _images: Map<number, IImageSpec> = new Map();
|
||||
// last used id
|
||||
private _lastId = 0;
|
||||
// last evicted id
|
||||
private _lowestId = 0;
|
||||
// whether a full clear happened before
|
||||
private _fullyCleared = false;
|
||||
// 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 _viewportMetrics: { cols: number, rows: number };
|
||||
|
||||
constructor(
|
||||
private _terminal: ITerminalExt,
|
||||
private _renderer: ImageRenderer,
|
||||
private _opts: IImageAddonOptions
|
||||
) {
|
||||
try {
|
||||
this.setLimit(this._opts.storageLimit);
|
||||
} catch (e: any) {
|
||||
console.error(e.message);
|
||||
console.warn(`storageLimit is set to ${this.getLimit()} MB`);
|
||||
}
|
||||
this._viewportMetrics = {
|
||||
cols: this._terminal.cols,
|
||||
rows: this._terminal.rows
|
||||
};
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
for (const spec of this._images.values()) {
|
||||
spec.marker?.dispose();
|
||||
}
|
||||
// NOTE: marker.dispose above already calls ImageBitmap.close
|
||||
// therefore we can just wipe the map here
|
||||
this._images.clear();
|
||||
this._renderer.clearAll();
|
||||
}
|
||||
|
||||
public getLimit(): number {
|
||||
return this._pixelLimit * 4 / 1000000;
|
||||
}
|
||||
|
||||
public setLimit(value: number): void {
|
||||
if (value < 1 || value > 1000) {
|
||||
throw RangeError('invalid storageLimit, should be at least 1 MB and not exceed 1G');
|
||||
}
|
||||
this._pixelLimit = (value / 4 * 1000000) >>> 0;
|
||||
this._evictOldest(0);
|
||||
}
|
||||
|
||||
public getUsage(): number {
|
||||
return this._getStoredPixels() * 4 / 1000000;
|
||||
}
|
||||
|
||||
private _getStoredPixels(): number {
|
||||
let storedPixels = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return storedPixels;
|
||||
}
|
||||
|
||||
private _delImg(id: number): void {
|
||||
const spec = this._images.get(id);
|
||||
this._images.delete(id);
|
||||
// FIXME: really ugly workaround to get bitmaps deallocated :(
|
||||
const win = this._terminal._core._coreBrowserService.window;
|
||||
if (spec && win.ImageBitmap && spec.orig instanceof ImageBitmap) {
|
||||
spec.orig.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe canvas and images on alternate buffer.
|
||||
*/
|
||||
public wipeAlternate(): void {
|
||||
// remove all alternate tagged images
|
||||
const zero = [];
|
||||
for (const [id, spec] of this._images.entries()) {
|
||||
if (spec.bufferType === 'alternate') {
|
||||
spec.marker?.dispose();
|
||||
zero.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of zero) {
|
||||
this._delImg(id);
|
||||
}
|
||||
// mark canvas to be wiped on next render
|
||||
this._needsFullClear = true;
|
||||
this._fullyCleared = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only advance text cursor.
|
||||
* This is an edge case from empty sixels carrying only a height but no pixels.
|
||||
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
|
||||
*/
|
||||
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 rows = Math.ceil(height / cellSize.height);
|
||||
for (let i = 1; i < rows; ++i) {
|
||||
this._terminal._core._inputHandler.lineFeed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement): void {
|
||||
// never allow storage to exceed memory limit
|
||||
this._evictOldest(img.width * img.height);
|
||||
|
||||
// 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 imageId = ++this._lastId;
|
||||
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const termCols = this._terminal.cols;
|
||||
const termRows = this._terminal.rows;
|
||||
const originX = buffer.x;
|
||||
const originY = buffer.y;
|
||||
let offset = originX;
|
||||
let tileCount = 0;
|
||||
|
||||
if (!this._opts.sixelScrolling) {
|
||||
buffer.x = 0;
|
||||
buffer.y = 0;
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(buffer.y + buffer.ybase);
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
if (offset + col >= termCols) break;
|
||||
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
||||
tileCount++;
|
||||
}
|
||||
if (this._opts.sixelScrolling) {
|
||||
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
||||
} else {
|
||||
if (++buffer.y >= termRows) break;
|
||||
}
|
||||
buffer.x = offset;
|
||||
}
|
||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||
|
||||
// cursor positioning modes
|
||||
if (this._opts.sixelScrolling) {
|
||||
buffer.x = offset;
|
||||
} else {
|
||||
buffer.x = originX;
|
||||
buffer.y = originY;
|
||||
}
|
||||
|
||||
// deleted images with zero tile count
|
||||
const zero = [];
|
||||
for (const [id, spec] of this._images.entries()) {
|
||||
if (spec.tileCount < 1) {
|
||||
spec.marker?.dispose();
|
||||
zero.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of zero) {
|
||||
this._delImg(id);
|
||||
}
|
||||
|
||||
// eviction marker:
|
||||
// delete the image when the marker gets disposed
|
||||
const endMarker = this._terminal.registerMarker(0);
|
||||
endMarker?.onDispose(() => {
|
||||
const spec = this._images.get(imageId);
|
||||
if (spec) {
|
||||
this._delImg(imageId);
|
||||
}
|
||||
});
|
||||
|
||||
// since markers do not work on alternate for some reason,
|
||||
// we evict images here manually
|
||||
if (this._terminal.buffer.active.type === 'alternate') {
|
||||
this._evictOnAlternate();
|
||||
}
|
||||
|
||||
// create storage entry
|
||||
const imgSpec: IImageSpec = {
|
||||
orig: img,
|
||||
origCellSize: cellSize,
|
||||
actual: img,
|
||||
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
||||
marker: endMarker || undefined,
|
||||
tileCount,
|
||||
bufferType: this._terminal.buffer.active.type
|
||||
};
|
||||
|
||||
// finally add the image
|
||||
this._images.set(imageId, imgSpec);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render method. Collects buffer information and triggers
|
||||
* canvas updates.
|
||||
*/
|
||||
// TODO: Should we move this to the ImageRenderer?
|
||||
public render(range: { start: number, end: number }): void {
|
||||
// exit early if we dont have a canvas
|
||||
if (!this._renderer.canvas) {
|
||||
return;
|
||||
}
|
||||
// rescale if needed
|
||||
this._renderer.rescaleCanvas();
|
||||
// exit early if we dont have any images to test for
|
||||
if (!this._images.size) {
|
||||
if (!this._fullyCleared) {
|
||||
this._renderer.clearAll();
|
||||
this._fullyCleared = true;
|
||||
this._needsFullClear = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// buffer switches force a full clear
|
||||
if (this._needsFullClear) {
|
||||
this._renderer.clearAll();
|
||||
this._fullyCleared = true;
|
||||
this._needsFullClear = false;
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const cols = this._terminal._core.cols;
|
||||
|
||||
// clear drawing area
|
||||
this._renderer.clearLines(start, end);
|
||||
|
||||
// walk all cells in viewport and draw tiles found
|
||||
for (let row = start; row <= end; ++row) {
|
||||
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
|
||||
if (!line) return;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
||||
let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
const imgSpec = this._images.get(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
|
||||
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
|
||||
&& (e = line._extendedAttrs[col] || EMPTY_ATTRS)
|
||||
&& (e.imageId === imageId)
|
||||
&& (e.tileId === startTile + count)
|
||||
) {
|
||||
count++;
|
||||
}
|
||||
col--;
|
||||
if (imgSpec) {
|
||||
if (imgSpec.actual) {
|
||||
this._renderer.draw(imgSpec, startTile, startCol, row, count);
|
||||
}
|
||||
} else if (this._opts.showPlaceholder) {
|
||||
this._renderer.drawPlaceholder(startCol, row, count);
|
||||
}
|
||||
this._fullyCleared = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public viewportResize(metrics: { cols: number, rows: number }): void {
|
||||
// exit early if we have nothing in storage
|
||||
if (!this._images.size) {
|
||||
this._viewportMetrics = metrics;
|
||||
return;
|
||||
}
|
||||
|
||||
// handle only viewport width enlargements, exit all other cases
|
||||
// TODO: needs patch for tile counter
|
||||
if (this._viewportMetrics.cols >= metrics.cols) {
|
||||
this._viewportMetrics = metrics;
|
||||
return;
|
||||
}
|
||||
|
||||
// walk scrollbuffer at old col width to find all possible expansion matches
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const rows = buffer.lines.length;
|
||||
const oldCol = this._viewportMetrics.cols - 1;
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(row) as IBufferLineExt;
|
||||
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS;
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
const imgSpec = this._images.get(imageId);
|
||||
if (!imgSpec) {
|
||||
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);
|
||||
if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
|
||||
continue;
|
||||
}
|
||||
// expand only if right side is empty (nothing got wrapped from below)
|
||||
let hasData = false;
|
||||
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
|
||||
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
|
||||
hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasData) {
|
||||
continue;
|
||||
}
|
||||
// do right expansion on terminal buffer
|
||||
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
|
||||
let lastTile = e.tileId;
|
||||
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
|
||||
this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
|
||||
imgSpec.tileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// store new viewport metrics
|
||||
this._viewportMetrics = metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve original canvas at buffer position.
|
||||
*/
|
||||
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
|
||||
if (e.imageId && e.imageId !== -1) {
|
||||
const orig = this._images.get(e.imageId)?.orig;
|
||||
const win = this._terminal._core._coreBrowserService.window;
|
||||
if (win.ImageBitmap && orig instanceof ImageBitmap) {
|
||||
const canvas = ImageRenderer.createCanvas(win, orig.width, orig.height);
|
||||
canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.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) as IBufferLineExt;
|
||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
|
||||
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
||||
const spec = this._images.get(e.imageId);
|
||||
if (spec) {
|
||||
return this._renderer.extractTile(spec, e.tileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
let current = used;
|
||||
while (this._pixelLimit < 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;
|
||||
}
|
||||
spec.marker?.dispose();
|
||||
this._delImg(this._lowestId);
|
||||
}
|
||||
}
|
||||
return used - current;
|
||||
}
|
||||
|
||||
private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
|
||||
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const old = line._extendedAttrs[x];
|
||||
if (old) {
|
||||
if (old.imageId !== undefined) {
|
||||
// found an old ExtendedAttrsImage, since we know that
|
||||
// they are always isolated instances (single cell usage),
|
||||
// we can re-use it and just update their id entries
|
||||
const oldSpec = this._images.get(old.imageId);
|
||||
if (oldSpec) {
|
||||
// early eviction for in-viewport overwrites
|
||||
oldSpec.tileCount--;
|
||||
}
|
||||
old.imageId = imageId;
|
||||
old.tileId = tileId;
|
||||
return;
|
||||
}
|
||||
// found a plain ExtendedAttrs instance, clone it to new entry
|
||||
line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fall-through: always create new ExtendedAttrsImage entry
|
||||
line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
|
||||
line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
|
||||
}
|
||||
|
||||
private _evictOnAlternate(): void {
|
||||
// nullify tile count of all images on alternate buffer
|
||||
for (const spec of this._images.values()) {
|
||||
if (spec.bufferType === 'alternate') {
|
||||
spec.tileCount = 0;
|
||||
}
|
||||
}
|
||||
// re-count tiles on whole buffer
|
||||
const buffer = this._terminal._core.buffer;
|
||||
for (let y = 0; y < this._terminal.rows; ++y) {
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
for (let x = 0; x < this._terminal.cols; ++x) {
|
||||
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const imgId = line._extendedAttrs[x]?.imageId;
|
||||
if (imgId) {
|
||||
const spec = this._images.get(imgId);
|
||||
if (spec) {
|
||||
spec.tileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// deleted images with zero tile count
|
||||
const zero = [];
|
||||
for (const [id, spec] of this._images.entries()) {
|
||||
if (spec.bufferType === 'alternate' && !spec.tileCount) {
|
||||
spec.marker?.dispose();
|
||||
zero.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of zero) {
|
||||
this._delImg(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright (c) 2020, 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ImageStorage } from './ImageStorage';
|
||||
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';
|
||||
|
||||
// always free decoder ressources after decoding if it exceeds this limit
|
||||
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
|
||||
|
||||
// custom default palette: VT340 (lower 16 colors) + ANSI256 (up to 256) + zeroed (up to 4096)
|
||||
const DEFAULT_PALETTE = PALETTE_ANSI_256;
|
||||
DEFAULT_PALETTE.set(PALETTE_VT340_COLOR);
|
||||
|
||||
|
||||
export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
private _size = 0;
|
||||
private _aborted = false;
|
||||
private _dec: Decoder | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {
|
||||
DecoderAsync({
|
||||
memoryLimit: this._opts.pixelLimit * 4,
|
||||
palette: DEFAULT_PALETTE,
|
||||
paletteLimit: this._opts.sixelPaletteLimit
|
||||
}).then(d => this._dec = d);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
/**
|
||||
* reset sixel decoder to defaults:
|
||||
* - release all memory
|
||||
* - nullify palette (4096)
|
||||
* - apply default palette (256)
|
||||
*/
|
||||
if (this._dec) {
|
||||
this._dec.release();
|
||||
// FIXME: missing interface on decoder to nullify full palette
|
||||
(this._dec as any)._palette.fill(0);
|
||||
this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);
|
||||
}
|
||||
}
|
||||
|
||||
public hook(params: IParams): void {
|
||||
this._size = 0;
|
||||
this._aborted = false;
|
||||
if (this._dec) {
|
||||
const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(
|
||||
this._coreTerminal._core._inputHandler._curAttrData,
|
||||
this._coreTerminal._core._themeService.colors);
|
||||
this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);
|
||||
}
|
||||
}
|
||||
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted || !this._dec) {
|
||||
return;
|
||||
}
|
||||
this._size += end - start;
|
||||
if (this._size > this._opts.sixelSizeLimit) {
|
||||
console.warn(`SIXEL: too much data, aborting`);
|
||||
this._aborted = true;
|
||||
this._dec.release();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._dec.decode(data, start, end);
|
||||
} catch (e) {
|
||||
console.warn(`SIXEL: error while decoding image - ${e}`);
|
||||
this._aborted = true;
|
||||
this._dec.release();
|
||||
}
|
||||
}
|
||||
|
||||
public unhook(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted || !success || !this._dec) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const width = this._dec.width;
|
||||
const height = this._dec.height;
|
||||
|
||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||
if (!width || ! height) {
|
||||
if (height) {
|
||||
this._storage.advanceCursor(height);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const canvas = ImageRenderer.createCanvas(this._coreTerminal._core._coreBrowserService.window, width, height);
|
||||
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8, width, height), 0, 0);
|
||||
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
|
||||
this._dec.release();
|
||||
}
|
||||
this._storage.addImage(canvas);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Some helpers to extract current terminal colors.
|
||||
*/
|
||||
|
||||
// get currently active background color from terminal
|
||||
// also respect INVERSE setting
|
||||
function extractActiveBg(attr: AttributeData, colors: ReadonlyColorSet): RGBA8888 {
|
||||
let bg = 0;
|
||||
if (attr.isInverse()) {
|
||||
if (attr.isFgDefault()) {
|
||||
bg = convertLe(colors.foreground.rgba);
|
||||
} else if (attr.isFgRGB()) {
|
||||
const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getFgColor());
|
||||
bg = toRGBA8888(...t);
|
||||
} else {
|
||||
bg = convertLe(colors.ansi[attr.getFgColor()].rgba);
|
||||
}
|
||||
} else {
|
||||
if (attr.isBgDefault()) {
|
||||
bg = convertLe(colors.background.rgba);
|
||||
} else if (attr.isBgRGB()) {
|
||||
const t = (attr.constructor as typeof AttributeData).toColorRGB(attr.getBgColor());
|
||||
bg = toRGBA8888(...t);
|
||||
} else {
|
||||
bg = convertLe(colors.ansi[attr.getBgColor()].rgba);
|
||||
}
|
||||
}
|
||||
return bg;
|
||||
}
|
||||
|
||||
// rgba values on the color managers are always in BE, thus convert to LE
|
||||
function convertLe(color: number): RGBA8888 {
|
||||
if (BIG_ENDIAN) return color;
|
||||
return (color & 0xFF) << 24 | (color >>> 8 & 0xFF) << 16 | (color >>> 16 & 0xFF) << 8 | color >>> 24 & 0xFF;
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IDisposable, IMarker, Terminal } from 'xterm';
|
||||
|
||||
// private imports from base repo we build against
|
||||
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
|
||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||
|
||||
export const enum Cell {
|
||||
CONTENT = 0, // codepoint and wcwidth information (enum Content)
|
||||
FG = 1, // foreground color in lower 3 bytes (rgb), attrs in 4th byte (enum FgFlags)
|
||||
BG = 2, // background color in lower 3 bytes (rgb), attrs in 4th byte (enum BgFlags)
|
||||
SIZE = 3 // size of single cell on buffer array
|
||||
}
|
||||
|
||||
// export some privates for local usage
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
|
||||
/**
|
||||
* Plugin ctor options.
|
||||
*/
|
||||
export interface IImageAddonOptions {
|
||||
enableSizeReports: boolean;
|
||||
pixelLimit: number;
|
||||
storageLimit: number;
|
||||
showPlaceholder: boolean;
|
||||
sixelSupport: boolean;
|
||||
sixelScrolling: boolean;
|
||||
sixelPaletteLimit: number;
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
}
|
||||
|
||||
export interface IResetHandler {
|
||||
// attached to RIS and DECSTR
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub into private interfaces.
|
||||
* This should be kept in line with common libs.
|
||||
* Any change made here should be replayed in the accessors test case to
|
||||
* have a somewhat reliable testing against code changes in the core repo.
|
||||
*/
|
||||
|
||||
// overloaded IExtendedAttrs to hold image refs
|
||||
export interface IExtendedAttrsImage extends IExtendedAttrs {
|
||||
imageId: number;
|
||||
tileId: number;
|
||||
clone(): IExtendedAttrsImage;
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
export interface IBufferLineExt extends IBufferLine {
|
||||
_extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined};
|
||||
_data: Uint32Array;
|
||||
}
|
||||
|
||||
interface IInputHandlerExt extends IInputHandler {
|
||||
_parser: IEscapeSequenceParser;
|
||||
_curAttrData: AttributeData;
|
||||
_dirtyRowTracker: {
|
||||
markRangeDirty(y1: number, y2: number): void;
|
||||
markAllDirty(): void;
|
||||
markDirty(y: number): void;
|
||||
};
|
||||
onRequestReset(handler: () => void): IDisposable;
|
||||
}
|
||||
|
||||
export interface ICoreTerminalExt extends ITerminal {
|
||||
_themeService: IThemeService;
|
||||
_inputHandler: IInputHandlerExt;
|
||||
_renderService: IRenderService;
|
||||
_coreBrowserService: ICoreBrowserService;
|
||||
}
|
||||
|
||||
export interface ITerminalExt extends Terminal {
|
||||
_core: ICoreTerminalExt;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
|
||||
/**
|
||||
* Some storage definitions.
|
||||
*/
|
||||
export interface ICellSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface IImageSpec {
|
||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
origCellSize: ICellSize;
|
||||
actual: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
actualCellSize: ICellSize;
|
||||
marker: IMarker | undefined;
|
||||
tileCount: number;
|
||||
bufferType: 'alternate' | 'normal';
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ThroughputRuntimeCase, perfContext } from 'xterm-benchmark';
|
||||
import { Base64Decoder } from './base64.wasm';
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
function toBytes(s: string): Uint8Array {
|
||||
const bytes = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; ++i) {
|
||||
bytes[i] = s.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const d256 = 'ABCD'.repeat(64);
|
||||
const d4096 = 'ABCD'.repeat(64 * 16);
|
||||
const d65536 = 'ABCD'.repeat(64 * 16 * 16);
|
||||
const d1M = 'ABCD'.repeat(64 * 16 * 16 * 16);
|
||||
const b256 = toBytes(d256);
|
||||
const b4096 = toBytes(d4096);
|
||||
const b65536 = toBytes(d65536);
|
||||
const b1M = toBytes(d1M);
|
||||
const dec = new Base64Decoder(4000000);
|
||||
|
||||
|
||||
const RUNS = 100;
|
||||
|
||||
perfContext('Base64', () => {
|
||||
perfContext('Node - Buffer', () => {
|
||||
new ThroughputRuntimeCase('decode - 256', () => {
|
||||
Buffer.from(d256, 'base64');
|
||||
return { payloadSize: d256.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
|
||||
new ThroughputRuntimeCase('decode - 4096', () => {
|
||||
Buffer.from(d4096, 'base64');
|
||||
return { payloadSize: d4096.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
|
||||
new ThroughputRuntimeCase('decode - 65536', () => {
|
||||
Buffer.from(d65536, 'base64');
|
||||
return { payloadSize: d65536.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
|
||||
new ThroughputRuntimeCase('decode - 1048576', () => {
|
||||
Buffer.from(d1M, 'base64');
|
||||
return { payloadSize: d1M.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
});
|
||||
|
||||
perfContext('Base64Decoder', () => {
|
||||
new ThroughputRuntimeCase('decode - 256', () => {
|
||||
dec.init(192);
|
||||
dec.put(b256, 0, b256.length);
|
||||
dec.end();
|
||||
return { payloadSize: b256.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
|
||||
new ThroughputRuntimeCase('decode - 4096', () => {
|
||||
dec.init(3072);
|
||||
dec.put(b4096, 0, b4096.length);
|
||||
dec.end();
|
||||
return { payloadSize: b4096.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
|
||||
new ThroughputRuntimeCase('decode - 65536', () => {
|
||||
dec.init(49152);
|
||||
dec.put(b65536, 0, b65536.length);
|
||||
dec.end();
|
||||
return { payloadSize: b65536.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
|
||||
new ThroughputRuntimeCase('decode - 1048576', () => {
|
||||
dec.init(786432);
|
||||
dec.put(b1M, 0, b1M.length);
|
||||
dec.end();
|
||||
return { payloadSize: b1M.length };
|
||||
}, { repeat: RUNS }).showAverageThroughput();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { assert } from 'chai';
|
||||
import { Base64Decoder } from './base64.wasm';
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
|
||||
// some helpers
|
||||
function toBs(bytes: Uint8Array): string {
|
||||
let bs = '';
|
||||
for (let i = 0; i < bytes.length; ++i) bs += String.fromCharCode(bytes[i]);
|
||||
return bs;
|
||||
}
|
||||
function fromBs(bs: string): Uint8Array {
|
||||
const r = new Uint8Array(bs.length);
|
||||
for (let i = 0; i < r.length; ++i) r[i] = bs.charCodeAt(i);
|
||||
return r;
|
||||
}
|
||||
function encNative(bytes: Uint8Array): string {
|
||||
return typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64') : btoa(toBs(bytes));
|
||||
}
|
||||
function rtrim(x: string, c: string): string {
|
||||
let end = x.length - 1;
|
||||
while (c.indexOf(x[end]) >= 0) end -= 1;
|
||||
return x.slice(0, end + 1);
|
||||
}
|
||||
const MAP = new Uint8Array(
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
.split('')
|
||||
.map(el => el.charCodeAt(0))
|
||||
);
|
||||
|
||||
|
||||
describe('Base64Decoder', () => {
|
||||
describe('decoding', () => {
|
||||
it('single bytes', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
for (let i = 0; i < 256; ++i) {
|
||||
dec.init(1);
|
||||
const inp = new Uint8Array([i]);
|
||||
const data = fromBs(encNative(inp));
|
||||
assert.strictEqual(dec.put(data, 0, data.length), 0);
|
||||
assert.strictEqual(dec.end(), 0);
|
||||
assert.deepEqual(dec.data8, inp);
|
||||
}
|
||||
});
|
||||
it('1+2 bytes', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
for (let a = 0; a < 256; ++a) {
|
||||
for (let b = 0; b < 256; ++b) {
|
||||
dec.init(2);
|
||||
const inp = new Uint8Array([a, b]);
|
||||
const data = fromBs(encNative(inp));
|
||||
assert.strictEqual(dec.put(data, 0, data.length), 0);
|
||||
assert.strictEqual(dec.end(), 0);
|
||||
assert.deepEqual(dec.data8, inp);
|
||||
}
|
||||
}
|
||||
});
|
||||
it('2+3 bytes', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
for (let a = 0; a < 256; ++a) {
|
||||
for (let b = 0; b < 256; ++b) {
|
||||
dec.init(3);
|
||||
const inp = new Uint8Array([0, a, b]);
|
||||
const data = fromBs(encNative(inp));
|
||||
assert.strictEqual(dec.put(data, 0, data.length), 0);
|
||||
assert.strictEqual(dec.end(), 0);
|
||||
assert.deepEqual(dec.data8, inp);
|
||||
}
|
||||
}
|
||||
});
|
||||
it('3+4 bytes', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
for (let a = 0; a < 256; ++a) {
|
||||
for (let b = 0; b < 256; ++b) {
|
||||
dec.init(4);
|
||||
const inp = new Uint8Array([0, 0, a, b]);
|
||||
const data = fromBs(encNative(inp));
|
||||
assert.strictEqual(dec.put(data, 0, data.length), 0);
|
||||
assert.strictEqual(dec.end(), 0);
|
||||
assert.deepEqual(dec.data8, inp);
|
||||
}
|
||||
}
|
||||
});
|
||||
it('padding', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
const d = fromBs('Hello, here comes the mouse');
|
||||
const encData = [];
|
||||
const encDataTrimmed = [];
|
||||
for (let i = 1; i < d.length; ++i) {
|
||||
encData.push(encNative(d.slice(0, i)));
|
||||
encDataTrimmed.push(rtrim(encNative(d.slice(0, i)), '='));
|
||||
}
|
||||
for (let i = 0; i < encData.length; ++i) {
|
||||
// with padding
|
||||
dec.init(i + 1);
|
||||
let enc = fromBs(encData[i]);
|
||||
assert.strictEqual(dec.put(enc, 0, enc.length), 0);
|
||||
assert.strictEqual(dec.end(), 0);
|
||||
assert.deepEqual(dec.data8, d.slice(0, i + 1));
|
||||
// w'o padding
|
||||
dec.init(i + 1);
|
||||
enc = fromBs(encDataTrimmed[i]);
|
||||
assert.strictEqual(dec.put(enc, 0, enc.length), 0);
|
||||
assert.strictEqual(dec.end(), 0);
|
||||
assert.deepEqual(dec.data8, d.slice(0, i + 1));
|
||||
}
|
||||
});
|
||||
it('exit on false byte', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
for (let pos = 0; pos < 8; ++pos) {
|
||||
const inp = new Uint8Array([65, 65, 65, 65, 65, 65, 65, 65]);
|
||||
for (let i = 0; i < 256; ++i) {
|
||||
dec.release();
|
||||
dec.init(6);
|
||||
inp[pos] = i;
|
||||
dec.put(inp, 0, 8);
|
||||
assert.strictEqual(dec.end(), MAP.includes(i) ? 0 : 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('memory', () => {
|
||||
it('always release (keepSize 0)', () => {
|
||||
const dec = new Base64Decoder(0);
|
||||
dec.init(16);
|
||||
dec.put(fromBs('A'.repeat(16)), 0, 16);
|
||||
dec.end();
|
||||
assert.strictEqual(dec.data8.length, 12);
|
||||
dec.release();
|
||||
assert.strictEqual(dec.data8.length, 0);
|
||||
assert.isNull((dec as any)._mem);
|
||||
});
|
||||
it('keep 1 page (keepSize 65536)', () => {
|
||||
const dec = new Base64Decoder(65536);
|
||||
dec.init(384);
|
||||
dec.put(fromBs('A'.repeat(512)), 0, 512);
|
||||
dec.end();
|
||||
assert.strictEqual(dec.data8.length, 384);
|
||||
dec.release();
|
||||
assert.strictEqual(dec.data8.length, 0);
|
||||
assert.isNotNull((dec as any)._mem);
|
||||
// grow to 2 pages + free afterwards
|
||||
dec.init(65536);
|
||||
dec.put(fromBs('A'.repeat(65536)), 0, 65536);
|
||||
dec.end();
|
||||
assert.strictEqual(dec.data8.length, 49152);
|
||||
dec.release();
|
||||
assert.strictEqual(dec.data8.length, 0);
|
||||
assert.isNull((dec as any)._mem);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { InWasm, IWasmInstance, OutputMode, OutputType } from 'inwasm';
|
||||
|
||||
|
||||
// memory addresses in uint32
|
||||
const enum P32 {
|
||||
D0 = 256,
|
||||
D1 = 512,
|
||||
D2 = 768,
|
||||
D3 = 1024,
|
||||
STATE = 1280,
|
||||
STATE_WP = 1280,
|
||||
STATE_SP = 1281,
|
||||
STATE_DP = 1282,
|
||||
STATE_ESIZE = 1283,
|
||||
STATE_BSIZE = 1284,
|
||||
STATE_DATA = 1288 // 16 aligned
|
||||
}
|
||||
|
||||
/**
|
||||
* wasm base64 decoder.
|
||||
*/
|
||||
const wasmDecode = InWasm({
|
||||
name: 'decode',
|
||||
type: OutputType.INSTANCE,
|
||||
mode: OutputMode.SYNC,
|
||||
srctype: 'Clang-C',
|
||||
imports: {
|
||||
env: { memory: new WebAssembly.Memory({ initial: 1 }) }
|
||||
},
|
||||
exports: {
|
||||
dec: () => 0,
|
||||
end: () => 0
|
||||
},
|
||||
compile: {
|
||||
switches: ['-Wl,-z,stack-size=0', '-Wl,--stack-first']
|
||||
},
|
||||
code: `
|
||||
typedef struct {
|
||||
unsigned int wp;
|
||||
unsigned int sp;
|
||||
unsigned int dp;
|
||||
unsigned int e_size;
|
||||
unsigned int b_size;
|
||||
unsigned int dummy[3];
|
||||
unsigned char data[0];
|
||||
} State;
|
||||
|
||||
unsigned int *D0 = (unsigned int *) ${P32.D0*4};
|
||||
unsigned int *D1 = (unsigned int *) ${P32.D1*4};
|
||||
unsigned int *D2 = (unsigned int *) ${P32.D2*4};
|
||||
unsigned int *D3 = (unsigned int *) ${P32.D3*4};
|
||||
State *state = (State *) ${P32.STATE*4};
|
||||
|
||||
__attribute__((noinline)) int dec() {
|
||||
unsigned int nsp = (state->wp - 1) & ~3;
|
||||
unsigned char *src = state->data + state->sp;
|
||||
unsigned char *end = state->data + nsp;
|
||||
unsigned char *dst = state->data + state->dp;
|
||||
unsigned int accu;
|
||||
|
||||
while (src < end) {
|
||||
if ((accu = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]]) >> 24) return 1;
|
||||
*((unsigned int *) dst) = accu;
|
||||
dst += 3;
|
||||
src += 4;
|
||||
}
|
||||
state->sp = nsp;
|
||||
state->dp = dst - state->data;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int end() {
|
||||
int rem = state->wp - state->sp;
|
||||
if (rem > 4 && dec()) return 1;
|
||||
rem = state->wp - state->sp;
|
||||
if (rem < 2) return 1;
|
||||
|
||||
unsigned char *src = state->data + state->sp;
|
||||
unsigned int accu = D0[src[0]] | D1[src[1]];
|
||||
int dp = 1;
|
||||
if (rem > 2 && src[2] != 61) {
|
||||
accu |= D2[src[2]];
|
||||
dp++;
|
||||
}
|
||||
if (rem == 4 && src[3] != 61) {
|
||||
accu |= D3[src[3]];
|
||||
dp++;
|
||||
}
|
||||
if (accu >> 24) return 1;
|
||||
*((unsigned int *) (state->data + state->dp)) = accu;
|
||||
state->dp += dp;
|
||||
return state->dp != state->b_size;
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
// SIMD version - commented out for now due to missing Safari support
|
||||
// const wasmDecode = InWasm({
|
||||
// name: 'decode',
|
||||
// type: OutputType.INSTANCE,
|
||||
// mode: OutputMode.SYNC,
|
||||
// srctype: 'Clang-C',
|
||||
// imports: {
|
||||
// env: { memory: new WebAssembly.Memory({ initial: 1 }) }
|
||||
// },
|
||||
// exports: {
|
||||
// dec: () => 0,
|
||||
// end: () => 0
|
||||
// },
|
||||
// compile: {
|
||||
// switches: ['-msimd128', '-Wl,-z,stack-size=0', '-Wl,--stack-first']
|
||||
// },
|
||||
// code: `
|
||||
// #include <wasm_simd128.h>
|
||||
// typedef struct {
|
||||
// unsigned int wp;
|
||||
// unsigned int sp;
|
||||
// unsigned int dp;
|
||||
// unsigned int e_size;
|
||||
// unsigned int b_size;
|
||||
// unsigned int dummy[3];
|
||||
// unsigned char data[0];
|
||||
// } State;
|
||||
//
|
||||
// unsigned int *D0 = (unsigned int *) ${P32.D0*4};
|
||||
// unsigned int *D1 = (unsigned int *) ${P32.D1*4};
|
||||
// unsigned int *D2 = (unsigned int *) ${P32.D2*4};
|
||||
// unsigned int *D3 = (unsigned int *) ${P32.D3*4};
|
||||
// State *state = (State *) ${P32.STATE*4};
|
||||
//
|
||||
// #define packed_byte(x) wasm_i8x16_splat((char) x)
|
||||
// #define packed_dword(x) wasm_i32x4_splat(x)
|
||||
// #define masked(x, mask) wasm_v128_and(x, wasm_i32x4_splat(mask))
|
||||
//
|
||||
// int dec4() {
|
||||
// unsigned int nsp = (state->wp - 1) & ~3;
|
||||
// unsigned char *src = state->data + state->sp;
|
||||
// unsigned char *end = state->data + nsp;
|
||||
// unsigned char *dst = state->data + state->dp;
|
||||
// unsigned int accu;
|
||||
//
|
||||
// while (src < end) {
|
||||
// if ((accu = D0[src[0]] | D1[src[1]] | D2[src[2]] | D3[src[3]]) >> 24) return 1;
|
||||
// *((unsigned int *) dst) = accu;
|
||||
// dst += 3;
|
||||
// src += 4;
|
||||
// }
|
||||
// state->sp = nsp;
|
||||
// state->dp = dst - state->data;
|
||||
// return 0;
|
||||
// }
|
||||
//
|
||||
// int dec() {
|
||||
// unsigned int nsp = (state->wp - 1) & ~15;
|
||||
// unsigned char *src = state->data + state->sp;
|
||||
// unsigned char *end = state->data + nsp;
|
||||
// unsigned char *dst = state->data + state->dp;
|
||||
// unsigned int accu;
|
||||
//
|
||||
// v128_t err = wasm_i8x16_splat(0);
|
||||
//
|
||||
// while (src < end) {
|
||||
// v128_t data = wasm_v128_load((v128_t *) src);
|
||||
//
|
||||
// // wasm-simd rewrite of http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html#vector-lookup-pshufb
|
||||
// const v128_t higher_nibble = wasm_u32x4_shr(data, 4) & packed_byte(0x0f);
|
||||
// const char linv = 1;
|
||||
// const char hinv = 0;
|
||||
//
|
||||
// const v128_t lower_bound_LUT = wasm_i8x16_make(
|
||||
// /* 0 */ linv, /* 1 */ linv, /* 2 */ 0x2b, /* 3 */ 0x30,
|
||||
// /* 4 */ 0x41, /* 5 */ 0x50, /* 6 */ 0x61, /* 7 */ 0x70,
|
||||
// /* 8 */ linv, /* 9 */ linv, /* a */ linv, /* b */ linv,
|
||||
// /* c */ linv, /* d */ linv, /* e */ linv, /* f */ linv
|
||||
// );
|
||||
// const v128_t upper_bound_LUT = wasm_i8x16_make(
|
||||
// /* 0 */ hinv, /* 1 */ hinv, /* 2 */ 0x2b, /* 3 */ 0x39,
|
||||
// /* 4 */ 0x4f, /* 5 */ 0x5a, /* 6 */ 0x6f, /* 7 */ 0x7a,
|
||||
// /* 8 */ hinv, /* 9 */ hinv, /* a */ hinv, /* b */ hinv,
|
||||
// /* c */ hinv, /* d */ hinv, /* e */ hinv, /* f */ hinv
|
||||
// );
|
||||
// // the difference between the shift and lower bound
|
||||
// const v128_t shift_LUT = wasm_i8x16_make(
|
||||
// /* 0 */ 0x00, /* 1 */ 0x00, /* 2 */ 0x3e - 0x2b, /* 3 */ 0x34 - 0x30,
|
||||
// /* 4 */ 0x00 - 0x41, /* 5 */ 0x0f - 0x50, /* 6 */ 0x1a - 0x61, /* 7 */ 0x29 - 0x70,
|
||||
// /* 8 */ 0x00, /* 9 */ 0x00, /* a */ 0x00, /* b */ 0x00,
|
||||
// /* c */ 0x00, /* d */ 0x00, /* e */ 0x00, /* f */ 0x00
|
||||
// );
|
||||
//
|
||||
// const v128_t upper_bound = wasm_i8x16_swizzle(upper_bound_LUT, higher_nibble);
|
||||
// const v128_t lower_bound = wasm_i8x16_swizzle(lower_bound_LUT, higher_nibble);
|
||||
//
|
||||
// const v128_t below = wasm_i8x16_lt(data, lower_bound);
|
||||
// const v128_t above = wasm_i8x16_gt(data, upper_bound);
|
||||
// const v128_t eq_2f = wasm_i8x16_eq(data, packed_byte(0x2f));
|
||||
//
|
||||
// // in_range = not (below or above) or eq_2f
|
||||
// // outside = not in_range = below or above and not eq_2f (from deMorgan law)
|
||||
// const v128_t outside = wasm_v128_andnot(eq_2f, above | below);
|
||||
// err = wasm_v128_or(err, outside);
|
||||
//
|
||||
// const v128_t shift = wasm_i8x16_swizzle(shift_LUT, higher_nibble);
|
||||
// const v128_t t0 = wasm_i8x16_add(data, shift);
|
||||
// v128_t v = wasm_i8x16_add(t0, wasm_v128_and(eq_2f, packed_byte(-3)));
|
||||
//
|
||||
// // pack bytes
|
||||
// const v128_t ca = masked(v, 0x003f003f);
|
||||
// const v128_t db = masked(v, 0x3f003f00);
|
||||
// const v128_t t00 = wasm_v128_or(wasm_u32x4_shr(db, 8), wasm_i32x4_shl(ca, 6));
|
||||
// v128_t res = wasm_v128_or(wasm_u32x4_shr(t00, 16), wasm_i32x4_shl(t00, 12));
|
||||
// res = wasm_i8x16_swizzle(res, wasm_i8x16_const(2, 1, 0, 6, 5, 4, 10, 9, 8, 14, 13, 12, 16, 16, 16, 16));
|
||||
//
|
||||
// wasm_v128_store((v128_t *) dst, res);
|
||||
// dst += 12;
|
||||
// src += 16;
|
||||
// }
|
||||
//
|
||||
// if (wasm_i8x16_bitmask(err) != 0) return 1;
|
||||
//
|
||||
// state->sp = nsp;
|
||||
// state->dp = dst - state->data;
|
||||
// return 0;
|
||||
// }
|
||||
//
|
||||
// int end() {
|
||||
// int rem = state->wp - state->sp;
|
||||
// if (rem > 4 && dec4()) return 1;
|
||||
// rem = state->wp - state->sp;
|
||||
// if (rem < 2) return 1;
|
||||
//
|
||||
// unsigned char *src = state->data + state->sp;
|
||||
// unsigned int accu = D0[src[0]] | D1[src[1]];
|
||||
// int dp = 1;
|
||||
// if (rem > 2 && src[2] != 61) {
|
||||
// accu |= D2[src[2]];
|
||||
// dp++;
|
||||
// }
|
||||
// if (rem == 4 && src[3] != 61) {
|
||||
// accu |= D3[src[3]];
|
||||
// dp++;
|
||||
// }
|
||||
// if (accu >> 24) return 1;
|
||||
// *((unsigned int *) (state->data + state->dp)) = accu;
|
||||
// state->dp += dp;
|
||||
// return state->dp != state->b_size;
|
||||
// }
|
||||
// `
|
||||
// });
|
||||
|
||||
// FIXME: currently broken in inwasm
|
||||
type ExtractDefinition<Type> = Type extends () => IWasmInstance<infer X> ? X : never;
|
||||
type DecodeDefinition = ExtractDefinition<typeof wasmDecode>;
|
||||
|
||||
// base64 map
|
||||
const MAP = new Uint8Array(
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
.split('')
|
||||
.map(el => el.charCodeAt(0))
|
||||
);
|
||||
|
||||
// init decoder maps in LE order
|
||||
const D = new Uint32Array(1024);
|
||||
D.fill(0xFF000000);
|
||||
for (let i = 0; i < MAP.length; ++i) D[MAP[i]] = i << 2;
|
||||
for (let i = 0; i < MAP.length; ++i) D[256 + MAP[i]] = i >> 4 | ((i << 4) & 0xFF) << 8;
|
||||
for (let i = 0; i < MAP.length; ++i) D[512 + MAP[i]] = (i >> 2) << 8 | ((i << 6) & 0xFF) << 16;
|
||||
for (let i = 0; i < MAP.length; ++i) D[768 + MAP[i]] = i << 16;
|
||||
|
||||
const EMPTY = new Uint8Array(0);
|
||||
|
||||
/**
|
||||
* base64 streamline inplace decoder.
|
||||
*
|
||||
* Features / assumptions:
|
||||
* - optimized uint32 read/write (only LE support!)
|
||||
* - lazy chunkwise decoding
|
||||
* - errors out on any non base64 chars (no support for NL formatted base64)
|
||||
* - decodes in wasm
|
||||
* - inplace decoding to save memory
|
||||
* - supports a keepSize for lazy memory release
|
||||
*/
|
||||
export class Base64Decoder {
|
||||
private _d!: Uint8Array;
|
||||
private _m32!: Uint32Array;
|
||||
private _inst!: IWasmInstance<DecodeDefinition>;
|
||||
private _mem!: WebAssembly.Memory;
|
||||
|
||||
constructor(public keepSize: number) {}
|
||||
|
||||
/**
|
||||
* Currently decoded bytes (borrowed).
|
||||
* Must be accessed before calling `release` or `init`.
|
||||
*/
|
||||
public get data8(): Uint8Array {
|
||||
return this._inst ? this._d.subarray(0, this._m32[P32.STATE_DP]) : EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release memory conditionally based on `keepSize`.
|
||||
* If memory gets released, also the wasm instance will be freed and recreated on next `init`,
|
||||
* otherwise the instance will be reused.
|
||||
*/
|
||||
public release(): void {
|
||||
if (!this._inst) return;
|
||||
if (this._mem.buffer.byteLength > this.keepSize) {
|
||||
this._inst = this._m32 = this._d = this._mem = null!;
|
||||
} else {
|
||||
this._m32[P32.STATE_WP] = 0;
|
||||
this._m32[P32.STATE_SP] = 0;
|
||||
this._m32[P32.STATE_DP] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the decoder for new base64 data.
|
||||
* Must be called before doing any decoding attempts.
|
||||
* `size` is the amount of decoded bytes to be expected.
|
||||
* The method will either spawn a new wasm instance or grow
|
||||
* the needed memory of an existing instance.
|
||||
*/
|
||||
public init(size: number): void {
|
||||
let m = this._m32;
|
||||
const bytes = (Math.ceil(size / 3) + P32.STATE_DATA) * 4;
|
||||
if (!this._inst) {
|
||||
this._mem = new WebAssembly.Memory({ initial: Math.ceil(bytes / 65536) });
|
||||
this._inst = wasmDecode({ env: { memory: this._mem } });
|
||||
m = new Uint32Array(this._mem.buffer, 0);
|
||||
m.set(D, P32.D0);
|
||||
this._d = new Uint8Array(this._mem.buffer, P32.STATE_DATA * 4);
|
||||
} else if (this._mem.buffer.byteLength < bytes) {
|
||||
this._mem.grow(Math.ceil((bytes - this._mem.buffer.byteLength) / 65536));
|
||||
m = new Uint32Array(this._mem.buffer, 0);
|
||||
this._d = new Uint8Array(this._mem.buffer, P32.STATE_DATA * 4);
|
||||
}
|
||||
m[P32.STATE_BSIZE] = size;
|
||||
m[P32.STATE_ESIZE] = Math.ceil(size / 3) * 4;
|
||||
m[P32.STATE_WP] = 0;
|
||||
m[P32.STATE_SP] = 0;
|
||||
m[P32.STATE_DP] = 0;
|
||||
this._m32 = m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put bytes in `data` from `start` to `end` (exclusive) into the decoder.
|
||||
* Also decodes base64 data inplace once the payload exceeds 2^17 bytes.
|
||||
* Returns 1 on error, else 0.
|
||||
*/
|
||||
public put(data: Uint8Array | Uint16Array | Uint32Array, start: number, end: number): number {
|
||||
if (!this._inst) return 1;
|
||||
const m = this._m32;
|
||||
if (end - start + m[P32.STATE_WP] > m[P32.STATE_ESIZE]) return 1;
|
||||
this._d.set(data.subarray(start, end), m[P32.STATE_WP]);
|
||||
m[P32.STATE_WP] += end - start;
|
||||
// max chunk in input handler is 2^17, try to run in "tandem mode"
|
||||
// also assures that we dont run into illegal offsets in the wasm part
|
||||
return m[P32.STATE_WP] - m[P32.STATE_SP] >= 131072 ? this._inst.exports.dec() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current decoding.
|
||||
* Decodes leftover payload and finally checks for the correct amount of
|
||||
* decoded bytes by comparing to the value given to `init`.
|
||||
* Returns 1 on error, else 0.
|
||||
*/
|
||||
public end(): number {
|
||||
return this._inst ? this._inst.exports.end() : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"outDir": "../out",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"preserveWatchOutput": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"browser/*": [ "../../../src/browser/*" ],
|
||||
"common/*": [ "../../../src/common/*" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../src/browser" },
|
||||
{ "path": "../../../src/common" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user