@xterm/addon-image 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Copyright (c) 2023 The xterm.js authors. All rights reserved.
3
+ * @license MIT
4
+ */
5
+
6
+ import { assert } from 'chai';
7
+ import { imageType, IMetrics } from './IIPMetrics';
8
+
9
+ // fix missing nodejs decl
10
+ declare const require: (s: string) => any;
11
+ const fs = require('fs');
12
+
13
+
14
+ const TEST_IMAGES: [string, IMetrics][] = [
15
+ ['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48 }],
16
+ ['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
17
+ ['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48 }],
18
+ ['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48 }],
19
+ ['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
20
+ ['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48 }],
21
+ ['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48 }],
22
+ ['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48 }],
23
+ ['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48 }],
24
+ ['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
25
+ ['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }],
26
+ ['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
27
+ ['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }],
28
+ ['spinfox.png', { mime: 'image/png', width: 148, height: 148 }],
29
+ ['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
30
+ ['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
31
+ ['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
32
+ ['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
33
+ ];
34
+
35
+
36
+ describe('IIPMetrics', () => {
37
+ it('bunch of testimages', () => {
38
+ for (let i = 0; i < TEST_IMAGES.length; ++i) {
39
+ const imageData = fs.readFileSync('./addons/addon-image/fixture/testimages/' + TEST_IMAGES[i][0]);
40
+ assert.deepStrictEqual(imageType(imageData), TEST_IMAGES[i][1]);
41
+ }
42
+ });
43
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Copyright (c) 2023 The xterm.js authors. All rights reserved.
3
+ * @license MIT
4
+ */
5
+
6
+
7
+ export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
8
+
9
+ export interface IMetrics {
10
+ mime: ImageType;
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ export const UNSUPPORTED_TYPE: IMetrics = {
16
+ mime: 'unsupported',
17
+ width: 0,
18
+ height: 0
19
+ };
20
+
21
+ export function imageType(d: Uint8Array): IMetrics {
22
+ if (d.length < 24) {
23
+ return UNSUPPORTED_TYPE;
24
+ }
25
+ const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
26
+ // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
27
+ // + first chunk must be IHDR
28
+ if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
29
+ return {
30
+ mime: 'image/png',
31
+ width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
32
+ height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
33
+ };
34
+ }
35
+ // JPEG: FF D8 FF E0 xx xx JFIF or FF D8 FF E1 xx xx Exif 00 00
36
+ if ((d32[0] === 0xE0FFD8FF || d32[0] === 0xE1FFD8FF)
37
+ && (
38
+ (d[6] === 0x4a && d[7] === 0x46 && d[8] === 0x49 && d[9] === 0x46)
39
+ || (d[6] === 0x45 && d[7] === 0x78 && d[8] === 0x69 && d[9] === 0x66)
40
+ )
41
+ ) {
42
+ const [width, height] = jpgSize(d);
43
+ return { mime: 'image/jpeg', width, height };
44
+ }
45
+ // GIF: GIF87a or GIF89a
46
+ if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
47
+ return {
48
+ mime: 'image/gif',
49
+ width: d[7] << 8 | d[6],
50
+ height: d[9] << 8 | d[8]
51
+ };
52
+ }
53
+ return UNSUPPORTED_TYPE;
54
+ }
55
+
56
+ function jpgSize(d: Uint8Array): [number, number] {
57
+ const len = d.length;
58
+ let i = 4;
59
+ let blockLength = d[i] << 8 | d[i + 1];
60
+ while (true) {
61
+ i += blockLength;
62
+ if (i >= len) {
63
+ // exhausted without size info
64
+ return [0, 0];
65
+ }
66
+ if (d[i] !== 0xFF) {
67
+ return [0, 0];
68
+ }
69
+ if (d[i + 1] === 0xC0 || d[i + 1] === 0xC2) {
70
+ if (i + 8 < len) {
71
+ return [
72
+ d[i + 7] << 8 | d[i + 8],
73
+ d[i + 5] << 8 | d[i + 6]
74
+ ];
75
+ }
76
+ return [0, 0];
77
+ }
78
+ i += 2;
79
+ blockLength = d[i] << 8 | d[i + 1];
80
+ }
81
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Copyright (c) 2020 The xterm.js authors. All rights reserved.
3
+ * @license MIT
4
+ */
5
+
6
+ import { IIPHandler } from './IIPHandler';
7
+ import { ITerminalAddon, IDisposable } from '@xterm/xterm';
8
+ import { ImageRenderer } from './ImageRenderer';
9
+ import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
10
+ import { SixelHandler } from './SixelHandler';
11
+ import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
12
+
13
+
14
+ // default values of addon ctor options
15
+ const DEFAULT_OPTIONS: IImageAddonOptions = {
16
+ enableSizeReports: true,
17
+ pixelLimit: 16777216, // limit to 4096 * 4096 pixels
18
+ sixelSupport: true,
19
+ sixelScrolling: true,
20
+ sixelPaletteLimit: 256,
21
+ sixelSizeLimit: 25000000,
22
+ storageLimit: 128,
23
+ showPlaceholder: true,
24
+ iipSupport: true,
25
+ iipSizeLimit: 20000000
26
+ };
27
+
28
+ // max palette size supported by the sixel lib (compile time setting)
29
+ const MAX_SIXEL_PALETTE_SIZE = 4096;
30
+
31
+ // definitions for _xtermGraphicsAttributes sequence
32
+ const enum GaItem {
33
+ COLORS = 1,
34
+ SIXEL_GEO = 2,
35
+ REGIS_GEO = 3
36
+ }
37
+ const enum GaAction {
38
+ READ = 1,
39
+ SET_DEFAULT = 2,
40
+ SET = 3,
41
+ READ_MAX = 4
42
+ }
43
+ const enum GaStatus {
44
+ SUCCESS = 0,
45
+ ITEM_ERROR = 1,
46
+ ACTION_ERROR = 2,
47
+ FAILURE = 3
48
+ }
49
+
50
+
51
+ export class ImageAddon implements ITerminalAddon {
52
+ private _opts: IImageAddonOptions;
53
+ private _defaultOpts: IImageAddonOptions;
54
+ private _storage: ImageStorage | undefined;
55
+ private _renderer: ImageRenderer | undefined;
56
+ private _disposables: IDisposable[] = [];
57
+ private _terminal: ITerminalExt | undefined;
58
+ private _handlers: Map<String, IResetHandler> = new Map();
59
+
60
+ constructor(opts?: Partial<IImageAddonOptions>) {
61
+ this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
62
+ this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);
63
+ }
64
+
65
+ public dispose(): void {
66
+ for (const obj of this._disposables) {
67
+ obj.dispose();
68
+ }
69
+ this._disposables.length = 0;
70
+ this._handlers.clear();
71
+ }
72
+
73
+ private _disposeLater(...args: IDisposable[]): void {
74
+ for (const obj of args) {
75
+ this._disposables.push(obj);
76
+ }
77
+ }
78
+
79
+ public activate(terminal: ITerminalExt): void {
80
+ this._terminal = terminal;
81
+
82
+ // internal data structures
83
+ this._renderer = new ImageRenderer(terminal);
84
+ this._storage = new ImageStorage(terminal, this._renderer, this._opts);
85
+
86
+ // enable size reports
87
+ if (this._opts.enableSizeReports) {
88
+ // const windowOptions = terminal.getOption('windowOptions');
89
+ // windowOptions.getWinSizePixels = true;
90
+ // windowOptions.getCellSizePixels = true;
91
+ // windowOptions.getWinSizeChars = true;
92
+ // terminal.setOption('windowOptions', windowOptions);
93
+ const windowOps = terminal.options.windowOptions || {};
94
+ windowOps.getWinSizePixels = true;
95
+ windowOps.getCellSizePixels = true;
96
+ windowOps.getWinSizeChars = true;
97
+ terminal.options.windowOptions = windowOps;
98
+ }
99
+
100
+ this._disposeLater(
101
+ this._renderer,
102
+ this._storage,
103
+
104
+ // DECSET/DECRST/DA1/XTSMGRAPHICS handlers
105
+ terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),
106
+ terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),
107
+ terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),
108
+ terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),
109
+
110
+ // render hook
111
+ terminal.onRender(range => this._storage?.render(range)),
112
+
113
+ /**
114
+ * reset handlers covered:
115
+ * - DECSTR
116
+ * - RIS
117
+ * - Terminal.reset()
118
+ */
119
+ terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),
120
+ terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),
121
+ terminal._core._inputHandler.onRequestReset(() => this.reset()),
122
+
123
+ // wipe canvas and delete alternate images on buffer switch
124
+ terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),
125
+
126
+ // extend images to the right on resize
127
+ terminal.onResize(metrics => this._storage?.viewportResize(metrics))
128
+ );
129
+
130
+ // SIXEL handler
131
+ if (this._opts.sixelSupport) {
132
+ const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
133
+ this._handlers.set('sixel', sixelHandler);
134
+ this._disposeLater(
135
+ terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
136
+ );
137
+ }
138
+
139
+ // iTerm IIP handler
140
+ if (this._opts.iipSupport) {
141
+ const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
142
+ this._handlers.set('iip', iipHandler);
143
+ this._disposeLater(
144
+ terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
145
+ );
146
+ }
147
+ }
148
+
149
+ // Note: storageLimit is skipped here to not intoduce a surprising side effect.
150
+ public reset(): boolean {
151
+ // reset options customizable by sequences to defaults
152
+ this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;
153
+ this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;
154
+ // also clear image storage
155
+ this._storage?.reset();
156
+ // reset protocol handlers
157
+ for (const handler of this._handlers.values()) {
158
+ handler.reset();
159
+ }
160
+ return false;
161
+ }
162
+
163
+ public get storageLimit(): number {
164
+ return this._storage?.getLimit() || -1;
165
+ }
166
+
167
+ public set storageLimit(limit: number) {
168
+ this._storage?.setLimit(limit);
169
+ this._opts.storageLimit = limit;
170
+ }
171
+
172
+ public get storageUsage(): number {
173
+ if (this._storage) {
174
+ return this._storage.getUsage();
175
+ }
176
+ return -1;
177
+ }
178
+
179
+ public get showPlaceholder(): boolean {
180
+ return this._opts.showPlaceholder;
181
+ }
182
+
183
+ public set showPlaceholder(value: boolean) {
184
+ this._opts.showPlaceholder = value;
185
+ this._renderer?.showPlaceholder(value);
186
+ }
187
+
188
+ public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
189
+ return this._storage?.getImageAtBufferCell(x, y);
190
+ }
191
+
192
+ public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
193
+ return this._storage?.extractTileAtBufferCell(x, y);
194
+ }
195
+
196
+ private _report(s: string): void {
197
+ this._terminal?._core.coreService.triggerDataEvent(s);
198
+ }
199
+
200
+ private _decset(params: (number | number[])[]): boolean {
201
+ for (let i = 0; i < params.length; ++i) {
202
+ switch (params[i]) {
203
+ case 80:
204
+ this._opts.sixelScrolling = false;
205
+ break;
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+
211
+ private _decrst(params: (number | number[])[]): boolean {
212
+ for (let i = 0; i < params.length; ++i) {
213
+ switch (params[i]) {
214
+ case 80:
215
+ this._opts.sixelScrolling = true;
216
+ break;
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+
222
+ // overload DA to return something more appropriate
223
+ private _da1(params: (number | number[])[]): boolean {
224
+ if (params[0]) {
225
+ return true;
226
+ }
227
+ // reported features:
228
+ // 62 - VT220
229
+ // 4 - SIXEL support
230
+ // 9 - charsets
231
+ // 22 - ANSI colors
232
+ if (this._opts.sixelSupport) {
233
+ this._report(`\x1b[?62;4;9;22c`);
234
+ return true;
235
+ }
236
+ return false;
237
+ }
238
+
239
+ /**
240
+ * Implementation of xterm's graphics attribute sequence.
241
+ *
242
+ * Supported features:
243
+ * - read/change palette limits (max 4096 by sixel lib)
244
+ * - read SIXEL canvas geometry (reports current window canvas or
245
+ * squared pixelLimit if canvas > pixel limit)
246
+ *
247
+ * Everything else is deactivated.
248
+ */
249
+ private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {
250
+ if (params.length < 2) {
251
+ return true;
252
+ }
253
+ if (params[0] === GaItem.COLORS) {
254
+ switch (params[1]) {
255
+ case GaAction.READ:
256
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
257
+ return true;
258
+ case GaAction.SET_DEFAULT:
259
+ this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;
260
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
261
+ // also reset protocol handlers for now
262
+ for (const handler of this._handlers.values()) {
263
+ handler.reset();
264
+ }
265
+ return true;
266
+ case GaAction.SET:
267
+ if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {
268
+ this._opts.sixelPaletteLimit = params[2];
269
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
270
+ } else {
271
+ this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
272
+ }
273
+ return true;
274
+ case GaAction.READ_MAX:
275
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);
276
+ return true;
277
+ default:
278
+ this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
279
+ return true;
280
+ }
281
+ }
282
+ if (params[0] === GaItem.SIXEL_GEO) {
283
+ switch (params[1]) {
284
+ // we only implement read and read_max here
285
+ case GaAction.READ:
286
+ let width = this._renderer?.dimensions?.css.canvas.width;
287
+ let height = this._renderer?.dimensions?.css.canvas.height;
288
+ if (!width || !height) {
289
+ // for some reason we have no working image renderer
290
+ // --> fallback to default cell size
291
+ const cellSize = CELL_SIZE_DEFAULT;
292
+ width = (this._terminal?.cols || 80) * cellSize.width;
293
+ height = (this._terminal?.rows || 24) * cellSize.height;
294
+ }
295
+ if (width * height < this._opts.pixelLimit) {
296
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);
297
+ } else {
298
+ // if we overflow pixelLimit report that squared instead
299
+ const x = Math.floor(Math.sqrt(this._opts.pixelLimit));
300
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);
301
+ }
302
+ return true;
303
+ case GaAction.READ_MAX:
304
+ // read_max returns pixelLimit as square area
305
+ const x = Math.floor(Math.sqrt(this._opts.pixelLimit));
306
+ this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);
307
+ return true;
308
+ default:
309
+ this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
310
+ return true;
311
+ }
312
+ }
313
+ // exit with error on ReGIS or any other requests
314
+ this._report(`\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);
315
+ return true;
316
+ }
317
+ }