From 163104e14ff3acf4361c43503e6ea393ca46f306 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sun, 23 Aug 2026 20:12:13 -0700 Subject: [PATCH 1/2] Fix pattern custom glyph rendering across documents --- .../CustomGlyphRasterizer.test.ts | 54 +++++++++++++ .../src/customGlyphs/CustomGlyphRasterizer.ts | 29 ++++++- .../test/WebglCustomGlyphs.test.ts | 77 +++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts create mode 100644 addons/addon-webgl/test/WebglCustomGlyphs.test.ts diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts new file mode 100644 index 000000000..dca1b0ff7 --- /dev/null +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { createPatternCanvas } from './CustomGlyphRasterizer'; + +describe('CustomGlyphRasterizer', () => { + describe('createPatternCanvas', () => { + it('prefers an offscreen canvas without using the DOM canvas factory', () => { + const expectedCanvas = {} as OffscreenCanvas; + const calls: string[] = []; + + const canvas = createPatternCanvas( + 2, + 3, + (width, height) => { + calls.push(`offscreen:${width}x${height}`); + return expectedCanvas; + }, + () => { + calls.push('dom'); + return {} as HTMLCanvasElement; + } + ); + + assert.deepEqual({ canvas, calls }, { + canvas: expectedCanvas, + calls: ['offscreen:2x3'] + }); + }); + + it('uses the main-realm DOM canvas factory when OffscreenCanvas is unavailable', () => { + const expectedCanvas = {} as HTMLCanvasElement; + const calls: string[] = []; + + const canvas = createPatternCanvas( + 4, + 5, + undefined, + (width, height) => { + calls.push(`dom:${width}x${height}`); + return expectedCanvas; + } + ); + + assert.deepEqual({ canvas, calls }, { + canvas: expectedCanvas, + calls: ['dom:4x5'] + }); + }); + }); +}); diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts index 137cca7e3..e5d9bf998 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts @@ -8,6 +8,29 @@ import type { ILogService } from 'common/services/Services'; import { customGlyphDefinitions } from './CustomGlyphDefinitions'; import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types'; +type PatternCanvas = HTMLCanvasElement | OffscreenCanvas; +type PatternCanvasFactory = (width: number, height: number) => PatternCanvas; + +const createOffscreenPatternCanvas: PatternCanvasFactory | undefined = typeof OffscreenCanvas === 'undefined' + ? undefined + : (width, height) => new OffscreenCanvas(width, height); + +const createDomPatternCanvas: PatternCanvasFactory = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createPatternCanvas( + width: number, + height: number, + offscreenCanvasFactory: PatternCanvasFactory | undefined = createOffscreenPatternCanvas, + domCanvasFactory: PatternCanvasFactory = createDomPatternCanvas +): PatternCanvas { + return offscreenCanvasFactory?.(width, height) ?? domCanvasFactory(width, height); +} + /** * Try drawing a custom block element or box drawing character, returning whether it was * successfully drawn. @@ -458,9 +481,9 @@ function drawPatternChar( if (!pattern) { const width = charDefinition[0].length; const height = charDefinition.length; - const tmpCanvas = ctx.canvas.ownerDocument.createElement('canvas'); - tmpCanvas.width = width; - tmpCanvas.height = height; + // The atlas canvas can be adopted into another document, so temporary resources must not use + // its mutable ownerDocument. + const tmpCanvas = createPatternCanvas(width, height); const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d')); const imageData = new ImageData(width, height); diff --git a/addons/addon-webgl/test/WebglCustomGlyphs.test.ts b/addons/addon-webgl/test/WebglCustomGlyphs.test.ts new file mode 100644 index 000000000..0a55b6aee --- /dev/null +++ b/addons/addon-webgl/test/WebglCustomGlyphs.test.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import test, { expect } from '@playwright/test'; +import { platform } from 'os'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +interface ITestRendererWithAtlasCanvas { + readonly _charAtlas?: { + readonly _tmpCanvas?: HTMLCanvasElement; + }; +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +test.describe('WebGL custom glyphs', () => { + if (platform() === 'linux') { + test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit'); + } + + test('pattern glyphs render after the terminal is adopted into another document', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx); + await ctx.page.evaluate(` + window.addon = new window.WebglAddon({ customGlyphs: true }); + window.term.loadAddon(window.addon); + `); + await ctx.page.evaluate(() => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const auxiliaryDocument = frame.contentDocument; + if (!auxiliaryDocument?.body || !window.term.element) { + throw new Error('Auxiliary document and terminal element must be available'); + } + const renderer = window.term._core?._renderService?._renderer?.value as ITestRendererWithAtlasCanvas | undefined; + const atlasCanvas = renderer?._charAtlas?._tmpCanvas; + if (!atlasCanvas) { + throw new Error('Texture atlas canvas must be available'); + } + // Model the atlas state after an uncached glyph has attached the measurement canvas. + window.term.element.appendChild(atlasCanvas); + auxiliaryDocument.body.appendChild(window.term.element); + if (atlasCanvas.ownerDocument !== auxiliaryDocument) { + throw new Error('Texture atlas canvas must be adopted into the auxiliary document'); + } + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in the auxiliary document'); + }; + }); + + await writeAndWaitForRender(ctx, '\u2591'); + await writeAndWaitForRender(ctx, 'X'); + + const line = await ctx.page.evaluate(() => window.term.buffer.active.getLine(0)?.translateToString(true)); + expect(errors, `renderer must not create pattern canvases in the auxiliary document: ${errors[0] ?? ''}`).toEqual([]); + expect(line).toBe('\u2591X'); + } finally { + ctx.page.off('pageerror', onError); + await ctx.page.close(); + } + }); +}); From fbaf28af5c772126d0a5bf894b202e5575582465 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sun, 23 Aug 2026 22:48:22 -0700 Subject: [PATCH 2/2] restore text overwrite of image tiles --- addons/addon-image/src/ImageStorage.ts | 69 ++++++++----------- addons/addon-image/test/ImageAddon.test.ts | 26 +++++++ addons/addon-image/test/KittyGraphics.test.ts | 19 +++++ 3 files changed, 75 insertions(+), 39 deletions(-) diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index 75851343c..322b9e02f 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -427,55 +427,46 @@ export class ImageStorage implements IDisposable { const placeholderCalls: { col: number, row: number, count: number }[] = []; // walk all cells in viewport and collect tiles found - // Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag) - // because text writes clear the BG flag but leave image tile data intact. - // This lets top-layer images survive text overwrites (kitty C=1 behavior). 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) { - let e: IExtendedAttrsImage; if (line.getBg(col) & BgFlags.HAS_EXTENDED) { - e = line._extendedAttrs[col] ?? EMPTY_ATTRS; - } else { - const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; - if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) { + let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS; + const imageId = e.imageId; + if (imageId === undefined || imageId === -1) { continue; } - e = maybeImg; - } - 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 - * Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED. - */ - while (++col < cols) { - const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined; - if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { - break; + 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++; } - count++; - } - col--; - if (imgSpec) { - if (imgSpec.actual) { - drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); + col--; + if (imgSpec) { + if (imgSpec.actual) { + drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); + } + } else if (this._opts.showPlaceholder) { + placeholderCalls.push({ col: startCol, row, count }); } - } else if (this._opts.showPlaceholder) { - placeholderCalls.push({ col: startCol, row, count }); + this._fullyCleared = false; } - this._fullyCleared = false; } } } diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index f3cde06a8..f4e54d7f2 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -435,6 +435,15 @@ test.describe('ImageAddon', () => { }); } }); + + test.describe('text overwrite removes tiles', () => { + test('sixel', async () => { + await assertTextClearsTiles(SIXEL_SEQ_0); + }); + test('iip', async () => { + await assertTextClearsTiles(TESTDATA_IIP[0][0]); + }); + }); }); /** @@ -472,3 +481,20 @@ async function getOrigSize(id: number): Promise<[number, number]> { async function getImageAtBufferCell(x: number, y: number): Promise { return ctx.page.evaluate(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`); } + +async function hasTileAtBufferCell(x: number, y: number): Promise { + return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`); +} + +async function assertTextClearsTiles(imageSeq: string): Promise { + await ctx.proxy.write('\x1b[H' + imageSeq); + await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true); + ok(await hasTileAtBufferCell(0, 1)); + await ctx.proxy.write('\x1b[2;6H#######'); + for (let x = 5; x < 12; x++) { + strictEqual(await hasTileAtBufferCell(x, 1), false); + } + ok(await hasTileAtBufferCell(0, 1)); + ok(await hasTileAtBufferCell(13, 1)); + ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######')); +} diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index a7c8e60c1..8c1833454 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -2213,6 +2213,21 @@ test.describe('Kitty Graphics Protocol', () => { await pollFor(ctx.page, 'window._imageAddedCount', 2); }); }); + + test.describe('text overwrite removes tiles', () => { + test('a=T placement', async () => { + await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); + await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true); + ok(await hasTileAtBufferCell(0, 1)); + await ctx.proxy.write('\x1b[2;6H#######'); + for (let x = 5; x < 12; x++) { + strictEqual(await hasTileAtBufferCell(x, 1), false); + } + ok(await hasTileAtBufferCell(0, 1)); + ok(await hasTileAtBufferCell(13, 1)); + ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######')); + }); + }); }); /** @@ -2262,3 +2277,7 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb return Array.from(ctx2d.getImageData(x, y, w, h).data); }, [col, row, x, y, w, h]); } + +async function hasTileAtBufferCell(x: number, y: number): Promise { + return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`); +}