@xterm/addon-image 0.10.0-beta.28 → 0.10.0-beta.281

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,819 @@
1
+ /**
2
+ * Copyright (c) 2026 The xterm.js authors. All rights reserved.
3
+ * @license MIT
4
+ */
5
+
6
+ import { IDisposable } from '@xterm/xterm';
7
+ import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
8
+ import { ImageRenderer } from '../ImageRenderer';
9
+ import { CELL_SIZE_DEFAULT } from '../ImageStorage';
10
+ import { KittyImageStorage } from './KittyImageStorage';
11
+ import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
12
+ import {
13
+ KittyAction,
14
+ KittyFormat,
15
+ KittyCompression,
16
+ IKittyCommand,
17
+ IPendingTransmission,
18
+ IKittyImageData,
19
+ KittyPixelConstants,
20
+ parseKittyCommand
21
+ } from './KittyGraphicsTypes';
22
+
23
+ const enum Constants {
24
+ // Memory limit for base64 decoder (4MB, same as IIPHandler)
25
+ DECODER_KEEP_DATA = 4194304,
26
+ DECODER_INITIAL_DATA = 4194304, // 4MB
27
+ // Local mirror of const enum (esbuild can't inline const enums from external packages)
28
+ DECODER_OK = 0,
29
+ // Maximum control data size
30
+ MAX_CONTROL_DATA_SIZE = 512,
31
+ // Semicolon codepoint
32
+ SEMICOLON = 0x3B
33
+ }
34
+
35
+ const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
36
+
37
+ // Kitty graphics protocol handler with streaming base64 decoding.
38
+ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
39
+ private _aborted = false;
40
+ private _decodeError = false;
41
+
42
+ private _activeDecoder: Base64Decoder | null = null;
43
+ private readonly _maxEncodedBytes: number;
44
+ private readonly _initialEncodedBytes: number;
45
+
46
+ // Streaming related states
47
+
48
+ // True while receiving control data (before semicolon).
49
+ private _inControlData = true;
50
+
51
+ // Buffer for control data.
52
+ private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
53
+ private _controlLength = 0;
54
+
55
+ // Pre-calculated encoded size limit
56
+ private _encodedSizeLimit = 0;
57
+ private _totalEncodedSize = 0;
58
+
59
+ // Parsed command. These are the control data before semicolon.
60
+ private _parsedCommand: IKittyCommand | null = null;
61
+
62
+ // Storage related states
63
+
64
+ private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
65
+ // Tracks the pending key of the most recently started chunked upload.
66
+ // Per spec, subsequent chunks only need m= (and optionally q=), without i=.
67
+ // When a chunk arrives with no i=, this key is used to find the pending upload.
68
+ private _lastPendingKey: number | undefined;
69
+
70
+ constructor(
71
+ private readonly _opts: IImageAddonOptions,
72
+ private readonly _renderer: ImageRenderer,
73
+ private readonly _kittyStorage: KittyImageStorage,
74
+ private readonly _coreTerminal: ITerminalExt
75
+ ) {
76
+ // Convert decoded size limit -> max encoded bytes.
77
+ this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
78
+ // ensure we preallocate more than configured limit while using 4mb initial size.
79
+ this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
80
+ }
81
+
82
+ public reset(): void {
83
+ this._cleanupAllPending();
84
+ if (this._activeDecoder) {
85
+ this._activeDecoder.release();
86
+ this._activeDecoder = null;
87
+ }
88
+ this._kittyStorage.reset();
89
+ }
90
+
91
+ public dispose(): void {
92
+ this.reset();
93
+ }
94
+
95
+ private _removePendingEntry(key: number): void {
96
+ this._pendingTransmissions.delete(key);
97
+ if (this._lastPendingKey === key) {
98
+ this._lastPendingKey = undefined;
99
+ }
100
+ }
101
+
102
+ private _cleanupAllPending(): void {
103
+ for (const pending of this._pendingTransmissions.values()) {
104
+ pending.decoder.release();
105
+ }
106
+ this._pendingTransmissions.clear();
107
+ this._lastPendingKey = undefined;
108
+ }
109
+
110
+ public start(): void {
111
+ this._aborted = false;
112
+ this._decodeError = false;
113
+ this._inControlData = true;
114
+ this._controlLength = 0;
115
+ this._parsedCommand = null;
116
+ // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
117
+ this._encodedSizeLimit = this._maxEncodedBytes;
118
+ this._totalEncodedSize = 0;
119
+ this._activeDecoder = null;
120
+ }
121
+
122
+ public put(data: Uint32Array, start: number, end: number): void {
123
+ if (this._aborted) return;
124
+
125
+ if (!this._inControlData) {
126
+ this._streamPayload(data, start, end);
127
+ } else {
128
+ // Scan for semicolon
129
+ let controlEnd = end;
130
+ for (let i = start; i < end; i++) {
131
+ if (data[i] === Constants.SEMICOLON) {
132
+ this._inControlData = false;
133
+ controlEnd = i;
134
+ break;
135
+ }
136
+ }
137
+
138
+ // Copy control data
139
+ const copyLength = controlEnd - start;
140
+ if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
141
+ this._aborted = true;
142
+ return;
143
+ }
144
+ this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
145
+ this._controlLength += copyLength;
146
+
147
+ if (!this._inControlData) {
148
+ // Found semicolon - parse control data early for validation
149
+ this._parsedCommand = parseKittyCommand(this._parseControlDataString());
150
+
151
+ // Early validation: i+I conflict
152
+ if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
153
+ this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
154
+ this._aborted = true;
155
+ return;
156
+ }
157
+
158
+ // Delete action doesn't need payload - skip streaming
159
+ if (this._parsedCommand.action === KittyAction.DELETE) {
160
+ return;
161
+ }
162
+
163
+ // Stream remaining as payload
164
+ const payloadStart = controlEnd + 1;
165
+ if (payloadStart < end) {
166
+ this._streamPayload(data, payloadStart, end);
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ // Stream payload bytes into the base64 decoder.
173
+ private _streamPayload(data: Uint32Array, start: number, end: number): void {
174
+ if (this._aborted) return;
175
+
176
+ // Check size limit (compare encoded bytes against pre-calculated limit)
177
+ // Include cumulative size from pending transmission for multi-chunk images.
178
+ // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
179
+ const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
180
+ const pending = this._pendingTransmissions.get(pendingKey);
181
+ const previousEncodedSize = pending?.totalEncodedSize ?? 0;
182
+ this._totalEncodedSize += end - start;
183
+ const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
184
+ if (cumulativeEncodedSize > this._encodedSizeLimit) {
185
+ const decoderToRelease = this._activeDecoder ?? pending?.decoder;
186
+ if (decoderToRelease) {
187
+ decoderToRelease.release();
188
+ }
189
+ this._activeDecoder = null;
190
+ if (pending) {
191
+ this._removePendingEntry(pendingKey);
192
+ }
193
+ this._aborted = true;
194
+ return;
195
+ }
196
+
197
+ if (this._decodeError) return;
198
+
199
+ if (pending?.decoder && !this._activeDecoder) {
200
+ this._activeDecoder = pending.decoder;
201
+ }
202
+ if (!this._activeDecoder) {
203
+ this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
204
+ this._activeDecoder.init();
205
+ }
206
+
207
+ if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
208
+ this._activeDecoder.release();
209
+ this._activeDecoder = null;
210
+ this._decodeError = true;
211
+ if (pending) {
212
+ this._removePendingEntry(pendingKey);
213
+ }
214
+ }
215
+ }
216
+
217
+ public end(success: boolean): boolean | Promise<boolean> {
218
+ if (this._aborted || !success) {
219
+ if (this._activeDecoder) {
220
+ this._activeDecoder.release();
221
+ this._activeDecoder = null;
222
+ }
223
+ return true;
224
+ }
225
+
226
+ // No semicolon = no payload (delete, capability query)
227
+ if (this._inControlData) {
228
+ return this._handleNoPayloadCommand();
229
+ }
230
+
231
+ // Use command parsed early in put() - i+I already validated there
232
+ const cmd = this._parsedCommand!;
233
+
234
+ // Delete action was handled by skipping payload - just execute
235
+ if (cmd.action === KittyAction.DELETE) {
236
+ return this._handleDelete(cmd);
237
+ }
238
+
239
+ // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
240
+ const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
241
+ const isMoreComing = cmd.more === 1;
242
+ const pending = this._pendingTransmissions.get(pendingKey);
243
+
244
+ if (isMoreComing) {
245
+ if (this._activeDecoder) {
246
+ if (pending) {
247
+ pending.totalEncodedSize += this._totalEncodedSize;
248
+ pending.decodeError = pending.decodeError || this._decodeError;
249
+ } else {
250
+ this._pendingTransmissions.set(pendingKey, {
251
+ cmd: { ...cmd },
252
+ decoder: this._activeDecoder,
253
+ totalEncodedSize: this._totalEncodedSize,
254
+ decodeError: this._decodeError
255
+ });
256
+ }
257
+ this._lastPendingKey = pendingKey;
258
+ this._activeDecoder = null;
259
+ }
260
+ return true;
261
+ }
262
+
263
+ // Final chunk received — clear the last pending key
264
+ if (pending) {
265
+ this._lastPendingKey = undefined;
266
+ }
267
+
268
+ let decodeError = this._decodeError;
269
+ let finalCmd = cmd;
270
+ let decoder = this._activeDecoder;
271
+
272
+ if (pending) {
273
+ finalCmd = pending.cmd;
274
+ decoder = pending.decoder;
275
+ decodeError = decodeError || pending.decodeError;
276
+ this._pendingTransmissions.delete(pendingKey);
277
+ }
278
+
279
+ let imageBytes = new Uint8Array(0);
280
+ if (decoder) {
281
+ if (decoder.end() !== DECODER_OK) {
282
+ decodeError = true;
283
+ }
284
+ imageBytes = decoder.data8;
285
+ }
286
+ this._activeDecoder = null;
287
+
288
+ // Handle command first — handlers create Blob/ImageData from imageBytes,
289
+ // which copies the data. Only then is it safe to release the decoder's
290
+ // wasm memory that imageBytes points into.
291
+ const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
292
+ if (decoder) {
293
+ decoder.release();
294
+ }
295
+ return result;
296
+ }
297
+
298
+ // Command handling
299
+
300
+ private _parseControlDataString(): string {
301
+ let str = '';
302
+ for (let i = 0; i < this._controlLength; i++) {
303
+ str += String.fromCodePoint(this._controlData[i]);
304
+ }
305
+ return str;
306
+ }
307
+
308
+ private _handleNoPayloadCommand(): boolean | Promise<boolean> {
309
+ const cmd = parseKittyCommand(this._parseControlDataString());
310
+
311
+ // Per spec: specifying both i and I is an error
312
+ if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
313
+ this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
314
+ return true;
315
+ }
316
+
317
+ const action = cmd.action ?? 't';
318
+
319
+ switch (action) {
320
+ case KittyAction.DELETE:
321
+ return this._handleDelete(cmd);
322
+ case KittyAction.QUERY:
323
+ this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
324
+ return true;
325
+ case KittyAction.PLACEMENT:
326
+ return this._handlePlacement(cmd);
327
+ default:
328
+ // TODO: Implement remaining actions when needed:
329
+ // - a=f (frame): animation frame operations
330
+ // - a=a (animation): animation control
331
+ // - a=c (compose): compose images
332
+ if (cmd.id !== undefined) {
333
+ this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
334
+ }
335
+ return true;
336
+ }
337
+ }
338
+
339
+ private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
340
+ const action = cmd.action ?? 't';
341
+
342
+ switch (action) {
343
+ case KittyAction.TRANSMIT: {
344
+ const result = this._handleTransmit(cmd, bytes, decodeError);
345
+ // Only send response when _handleTransmit didn't already respond
346
+ // (it handles unsupported transmission medium responses internally)
347
+ if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
348
+ if (decodeError) {
349
+ this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
350
+ } else if (bytes.length > 0) {
351
+ this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
352
+ }
353
+ }
354
+ return result;
355
+ }
356
+ case KittyAction.TRANSMIT_DISPLAY:
357
+ return this._handleTransmitDisplay(cmd, bytes, decodeError);
358
+ case KittyAction.QUERY:
359
+ return this._handleQuery(cmd, bytes, decodeError);
360
+ case KittyAction.PLACEMENT:
361
+ // a=p ignores any payload — image data was already transmitted
362
+ return this._handlePlacement(cmd);
363
+ default:
364
+ // TODO: Implement remaining actions when needed:
365
+ // - a=f (frame): animation frame operations
366
+ // - a=a (animation): animation control
367
+ // - a=c (compose): compose images
368
+ if (cmd.id !== undefined) {
369
+ this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
370
+ }
371
+ return true;
372
+ }
373
+ }
374
+
375
+ private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
376
+ if (cmd.id === undefined) {
377
+ return true;
378
+ }
379
+ const id = cmd.id;
380
+ const image = this._kittyStorage.getImage(id);
381
+ if (!image) {
382
+ this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
383
+ return true;
384
+ }
385
+ const result = this._displayImage(image, cmd);
386
+ return result.then(success => {
387
+ this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
388
+ return true;
389
+ });
390
+ }
391
+
392
+ private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
393
+ // TODO: Support file-based transmission modes (t=f, t=t, t=s)
394
+ // Currently only supports direct transmission (t=d, the default).
395
+ // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
396
+ // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
397
+ // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
398
+ // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
399
+ // 1. Check cmd.transmission (t key) before treating bytes as image data
400
+ // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
401
+ // 3. For t=d: treat bytes as image data (current behavior)
402
+ // When implementing, also update _handleQuery to accept these transmission mediums.
403
+ const transmission = cmd.transmission ?? 'd';
404
+ if (transmission !== 'd') {
405
+ if (cmd.id !== undefined) {
406
+ this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
407
+ }
408
+ return true;
409
+ }
410
+
411
+ if (decodeError || bytes.length === 0) return true;
412
+
413
+ this._kittyStorage.storeImage(cmd.id, {
414
+ data: new Blob([bytes as BlobPart]),
415
+ width: cmd.width ?? 0,
416
+ height: cmd.height ?? 0,
417
+ format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
418
+ compression: cmd.compression ?? ''
419
+ });
420
+ return true;
421
+ }
422
+
423
+ private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
424
+ if (decodeError) {
425
+ if (cmd.id !== undefined) {
426
+ this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
427
+ }
428
+ return true;
429
+ }
430
+
431
+ this._handleTransmit(cmd, bytes, decodeError);
432
+
433
+ const id = cmd.id ?? this._kittyStorage.lastImageId;
434
+ const image = this._kittyStorage.getImage(id);
435
+ if (image) {
436
+ const result = this._displayImage(image, cmd);
437
+ if (cmd.id !== undefined) {
438
+ return result.then(success => {
439
+ this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
440
+ return true;
441
+ });
442
+ }
443
+ return result.then(() => true);
444
+ }
445
+ return true;
446
+ }
447
+
448
+ private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
449
+ const id = cmd.id ?? 0;
450
+ const quiet = cmd.quiet ?? 0;
451
+
452
+ // Per spec: reject unsupported transmission mediums (only t=d is supported atm)
453
+ // TODO: When filesystem support is added (Node.js/Electron), update this to accept
454
+ // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
455
+ const transmission = cmd.transmission ?? 'd';
456
+ if (transmission !== 'd') {
457
+ this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
458
+ return true;
459
+ }
460
+
461
+ // Check decode error first (invalid base64)
462
+ if (decodeError) {
463
+ this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
464
+ return true;
465
+ }
466
+
467
+ // Capability query (no payload) - just respond OK
468
+ if (bytes.length === 0) {
469
+ this._sendResponse(id, 'OK', quiet);
470
+ return true;
471
+ }
472
+
473
+ const format = cmd.format ?? KittyFormat.RGBA;
474
+
475
+ if (format === KittyFormat.PNG) {
476
+ this._sendResponse(id, 'OK', quiet);
477
+ } else {
478
+ const width = cmd.width ?? 0;
479
+ const height = cmd.height ?? 0;
480
+
481
+ if (!width || !height) {
482
+ this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
483
+ return true;
484
+ }
485
+
486
+ const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
487
+ const expectedBytes = width * height * bytesPerPixel;
488
+
489
+ if (bytes.length < expectedBytes) {
490
+ this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
491
+ return true;
492
+ }
493
+
494
+ this._sendResponse(id, 'OK', quiet);
495
+ }
496
+ return true;
497
+ }
498
+
499
+ private _handleDelete(cmd: IKittyCommand): boolean {
500
+ // Per spec: default delete selector is 'a' (delete all visible placements)
501
+ const selector = cmd.deleteSelector ?? 'a';
502
+
503
+ // TODO: Distinguish lowercase (delete placements only) from uppercase
504
+ // (delete placements + free stored image data). Currently both variants
505
+ // free everything since we don't separate stored data from placements.
506
+ switch (selector) {
507
+ case 'a':
508
+ case 'A':
509
+ this._cleanupAllPending();
510
+ this._kittyStorage.deleteAll();
511
+ break;
512
+ case 'i':
513
+ case 'I':
514
+ // TODO: When placement id tracking is implemented (see TODO in
515
+ // KittyImageStorage), d=i with p=<pid> should delete only that
516
+ // specific placement, while d=i without p should delete all
517
+ // placements for the image.
518
+ if (cmd.id !== undefined) {
519
+ const pending = this._pendingTransmissions.get(cmd.id);
520
+ if (pending) {
521
+ pending.decoder.release();
522
+ }
523
+ this._removePendingEntry(cmd.id);
524
+ this._kittyStorage.deleteById(cmd.id);
525
+ }
526
+ break;
527
+ default:
528
+ // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
529
+ break;
530
+ }
531
+ return true;
532
+ }
533
+
534
+ private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
535
+ const isOk = message === 'OK';
536
+ if (isOk && quiet >= 1) return;
537
+ if (!isOk && quiet >= 2) return;
538
+
539
+ const pPart = placementId ? `,p=${placementId}` : '';
540
+ const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
541
+ this._coreTerminal._core.coreService.triggerDataEvent(response);
542
+ }
543
+
544
+ // Image display
545
+
546
+ private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
547
+ return this._decodeAndDisplay(image, cmd)
548
+ .then(() => true)
549
+ .catch(() => false);
550
+ }
551
+
552
+ private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
553
+ let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
554
+
555
+ try {
556
+ const cropX = Math.max(0, cmd.x ?? 0);
557
+ const cropY = Math.max(0, cmd.y ?? 0);
558
+ const cropW = cmd.sourceWidth || (bitmap.width - cropX);
559
+ const cropH = cmd.sourceHeight || (bitmap.height - cropY);
560
+
561
+ const maxCropW = Math.max(0, bitmap.width - cropX);
562
+ const maxCropH = Math.max(0, bitmap.height - cropY);
563
+ const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
564
+ const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
565
+
566
+ if (finalCropW === 0 || finalCropH === 0) {
567
+ throw new Error('invalid source rectangle');
568
+ }
569
+
570
+ if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
571
+ const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
572
+ bitmap.close();
573
+ bitmap = cropped;
574
+ }
575
+
576
+ const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
577
+ const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
578
+
579
+ // Per spec: c/r default to image's natural cell dimensions.
580
+ // If only one of c/r is specified, compute the other from image aspect ratio.
581
+ let imgCols: number;
582
+ let imgRows: number;
583
+ if (cmd.columns !== undefined && cmd.rows !== undefined) {
584
+ imgCols = cmd.columns;
585
+ imgRows = cmd.rows;
586
+ } else if (cmd.columns !== undefined) {
587
+ imgCols = cmd.columns;
588
+ imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
589
+ } else if (cmd.rows !== undefined) {
590
+ imgRows = cmd.rows;
591
+ imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
592
+ } else {
593
+ imgCols = Math.ceil(bitmap.width / cw);
594
+ imgRows = Math.ceil(bitmap.height / ch);
595
+ }
596
+
597
+ let w = bitmap.width;
598
+ let h = bitmap.height;
599
+
600
+ // Scale bitmap to fit placement rectangle when c/r are specified
601
+ if (cmd.columns !== undefined || cmd.rows !== undefined) {
602
+ w = Math.round(imgCols * cw);
603
+ h = Math.round(imgRows * ch);
604
+ }
605
+
606
+ if (w * h > this._opts.pixelLimit) {
607
+ throw new Error('image exceeds pixel limit');
608
+ }
609
+
610
+ // Save cursor position before addImage modifies it
611
+ const buffer = this._coreTerminal._core.buffer;
612
+ const savedX = buffer.x;
613
+ const savedY = buffer.y;
614
+ const savedYbase = buffer.ybase;
615
+
616
+ // Determine layer based on z-index: negative = behind text, 0+ = on top.
617
+ // When z<0 we always use the bottom layer even without allowTransparency —
618
+ // the image will simply be hidden behind the opaque text background, which
619
+ // is the correct behavior (client asked for "behind text").
620
+ const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
621
+ const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
622
+
623
+ if (w !== bitmap.width || h !== bitmap.height) {
624
+ const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
625
+ bitmap.close();
626
+ bitmap = scaled;
627
+ }
628
+
629
+ // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
630
+ const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
631
+ const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
632
+ if (xOffset !== 0 || yOffset !== 0) {
633
+ // Per spec: X/Y is not added to c/r area. When c/r are explicit, the
634
+ // total placement area remains c*cw × r*ch pixels and the offset image
635
+ // is clipped to fit. When c/r are unset, the padded canvas determines
636
+ // the natural cell dimensions.
637
+ const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
638
+ const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
639
+ const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
640
+ const offsetCtx = offsetCanvas.getContext('2d');
641
+ if (!offsetCtx) {
642
+ throw new Error('Failed to create offset canvas context');
643
+ }
644
+ offsetCtx.drawImage(bitmap, xOffset, yOffset);
645
+
646
+ const offsetBitmap = await createImageBitmap(offsetCanvas);
647
+ offsetCanvas.width = offsetCanvas.height = 0;
648
+ bitmap.close();
649
+ bitmap = offsetBitmap;
650
+ w = bitmap.width;
651
+ h = bitmap.height;
652
+ if (w * h > this._opts.pixelLimit) {
653
+ throw new Error('image exceeds pixel limit');
654
+ }
655
+ if (cmd.columns === undefined) {
656
+ imgCols = Math.ceil(bitmap.width / cw);
657
+ }
658
+ if (cmd.rows === undefined) {
659
+ imgRows = Math.ceil(bitmap.height / ch);
660
+ }
661
+ }
662
+
663
+ const zIndex = cmd.zIndex ?? 0;
664
+ this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
665
+ bitmap = undefined; // ownership transferred to storage
666
+
667
+ // Kitty cursor movement
668
+ // Per spec: cursor placed at first column after last image column,
669
+ // on the last row of the image. C=1 means don't move cursor.
670
+ if (cmd.cursorMovement === 1) {
671
+ // C=1: restore cursor to position before image was placed
672
+ const scrolled = buffer.ybase - savedYbase;
673
+ buffer.x = savedX;
674
+ // Can't restore cursor to scrollback?
675
+ buffer.y = Math.max(savedY - scrolled, 0);
676
+ } else {
677
+ // Default (C=0): advance cursor horizontally past the image
678
+ // addImage already positioned cursor on the last row via lineFeeds
679
+ buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
680
+ }
681
+ } catch (e) {
682
+ bitmap?.close();
683
+ throw e;
684
+ }
685
+ }
686
+
687
+ // Create ImageBitmap from already-decoded image data.
688
+ private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
689
+ let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
690
+
691
+ if (image.compression === KittyCompression.ZLIB) {
692
+ bytes = await this._decompressZlib(bytes);
693
+ }
694
+
695
+ if (image.format === KittyFormat.PNG) {
696
+ const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
697
+ if (!window.createImageBitmap) {
698
+ const url = URL.createObjectURL(blob);
699
+ const img = new Image();
700
+ return new Promise<ImageBitmap>((resolve, reject) => {
701
+ img.addEventListener('load', () => {
702
+ URL.revokeObjectURL(url);
703
+ const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
704
+ canvas.getContext('2d')?.drawImage(img, 0, 0);
705
+ createImageBitmap(canvas).then(resolve).catch(reject);
706
+ });
707
+ img.addEventListener('error', () => {
708
+ URL.revokeObjectURL(url);
709
+ reject(new Error('Failed to load image'));
710
+ });
711
+ img.src = url;
712
+ });
713
+ }
714
+ return createImageBitmap(blob);
715
+ }
716
+
717
+ // Raw pixel data
718
+ const width = image.width;
719
+ const height = image.height;
720
+
721
+ if (!width || !height) {
722
+ throw new Error('Width and height required for raw pixel data');
723
+ }
724
+
725
+ const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
726
+ const expectedBytes = width * height * bytesPerPixel;
727
+
728
+ if (bytes.length < expectedBytes) {
729
+ throw new Error('Insufficient pixel data');
730
+ }
731
+
732
+ const pixelCount = width * height;
733
+
734
+ if (image.format === KittyFormat.RGBA) {
735
+ // RGBA: use bytes directly — no copy needed
736
+ return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
737
+ }
738
+
739
+ // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
740
+ // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
741
+ // Assumes little-endian (all modern browsers/Node.js).
742
+ const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
743
+ const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
744
+ const dst32 = new Uint32Array(data.buffer);
745
+ const alignedPixels = pixelCount & ~3; // round down to multiple of 4
746
+
747
+ let srcOffset = 0;
748
+ let dstOffset = 0;
749
+ for (let i = 0; i < alignedPixels; i += 4) {
750
+ const b0 = src32[srcOffset++];
751
+ const b1 = src32[srcOffset++];
752
+ const b2 = src32[srcOffset++];
753
+ // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
754
+ dst32[dstOffset++] = 0xFF000000 | b0;
755
+ dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
756
+ dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
757
+ dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
758
+ }
759
+
760
+ // Handle remaining 1–3 pixels
761
+ let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
762
+ let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
763
+ for (let i = alignedPixels; i < pixelCount; i++) {
764
+ data[dstByte] = bytes[srcByte];
765
+ data[dstByte + 1] = bytes[srcByte + 1];
766
+ data[dstByte + 2] = bytes[srcByte + 2];
767
+ data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
768
+ srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
769
+ dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
770
+ }
771
+
772
+ return createImageBitmap(new ImageData(data, width, height));
773
+ }
774
+
775
+ private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
776
+ try {
777
+ return await this._decompress(compressed, 'deflate');
778
+ } catch {
779
+ return await this._decompress(compressed, 'deflate-raw');
780
+ }
781
+ }
782
+
783
+ private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
784
+ const ds = new DecompressionStream(format);
785
+ const writer = ds.writable.getWriter();
786
+ writer.write(compressed as BufferSource);
787
+ writer.close();
788
+
789
+ const chunks: Uint8Array[] = [];
790
+ const reader = ds.readable.getReader();
791
+
792
+ while (true) {
793
+ const { done, value } = await reader.read();
794
+ if (done) break;
795
+ chunks.push(value);
796
+ }
797
+
798
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
799
+ const result = new Uint8Array(totalLength);
800
+ let offset = 0;
801
+ for (const chunk of chunks) {
802
+ result.set(chunk, offset);
803
+ offset += chunk.length;
804
+ }
805
+ return result;
806
+ }
807
+
808
+ public get images(): ReadonlyMap<number, IKittyImageData> {
809
+ return this._kittyStorage.images;
810
+ }
811
+
812
+ public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
813
+ return this._kittyStorage.kittyIdToStorageId;
814
+ }
815
+
816
+ public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
817
+ return this._pendingTransmissions;
818
+ }
819
+ }