@xterm/addon-image 0.10.0-beta.16 → 0.10.0-beta.161

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