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(); + } + }); +});