diff --git a/.eslintrc.json b/.eslintrc.json
index 8c47598205..3d9322d36c 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -33,6 +33,8 @@
"addons/addon-unicode-graphemes/src/tsconfig.json",
"addons/addon-unicode-graphemes/test/tsconfig.json",
"addons/addon-unicode-graphemes/benchmark/tsconfig.json",
+ "addons/addon-web-fonts/src/tsconfig.json",
+ "addons/addon-web-fonts/test/tsconfig.json",
"addons/addon-web-links/src/tsconfig.json",
"addons/addon-web-links/test/tsconfig.json",
"addons/addon-webgl/src/tsconfig.json",
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d2f39e6b88..f7616b7c02 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,6 +59,9 @@ jobs:
./addons/addon-web-links/lib/* \
./addons/addon-web-links/out/* \
./addons/addon-web-links/out-*/* \
+ ./addons/addon-web-fonts/lib/* \
+ ./addons/addon-web-fonts/out/* \
+ ./addons/addon-web-fonts/out-*/* \
./addons/addon-webgl/lib/* \
./addons/addon-webgl/out/* \
./addons/addon-webgl/out-*st/*
@@ -220,6 +223,8 @@ jobs:
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
- name: Integration tests (addon-unicode11)
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
+ - name: Integration tests (addon-web-fonts)
+ run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
- name: Integration tests (addon-web-links)
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
- name: Integration tests (addon-webgl)
diff --git a/addons/addon-web-fonts/LICENSE b/addons/addon-web-fonts/LICENSE
new file mode 100644
index 0000000000..447eb79f4d
--- /dev/null
+++ b/addons/addon-web-fonts/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2024, The xterm.js authors (https://github.com/xtermjs/xterm.js)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/addons/addon-web-fonts/README.md b/addons/addon-web-fonts/README.md
new file mode 100644
index 0000000000..ce43cbe5c5
--- /dev/null
+++ b/addons/addon-web-fonts/README.md
@@ -0,0 +1,220 @@
+## @xterm/addon-web-fonts
+
+Addon to use webfonts with [xterm.js](https://github.com/xtermjs/xterm.js).
+This addon requires xterm.js v5+.
+
+### Install
+
+```bash
+npm install --save @xterm/addon-web-fonts
+```
+
+### Issue with Webfonts
+
+Webfonts are announced by CSS `font-face` rules (or its Javascript `FontFace` counterparts).
+Since font files tend to be quite big assets, browser engines often postpone their loading
+to an actual styling request of a codepoint matching a font file's `unicode-range`.
+In short - font files will not be loaded until really needed.
+
+xterm.js on the other hand heavily relies on exact measurement of character glyphs
+to layout its output. This is done by determining the glyph width (DOM renderer) or
+by creating a glyph texture (WebGl renderer) for every output character.
+For performance reasons both is done in synchronous code and cached.
+This logic only works properly, if a font glyph is available on its first usage,
+otherwise the browser will pick a glyph from a fallback font messing up the metrics.
+
+For webfonts and xterm.js this means that we cannot rely on the default loading strategy
+of the browser, but have to preload the font files before using that font in xterm.js.
+
+
+### Static Preloading for the Rescue?
+
+If you dont mind higher initial loading times with a white page shown,
+you can tell the browser to preload the needed font files by placing the following
+link elements in the document's head above any other CSS/Javascript:
+```html
+
+
+ ...
+
+```
+Browsers also will resort to system fonts, if the preloading takes too long.
+So this solution has only a very limited scope.
+
+
+### Loading with WebFontsAddon
+
+The webfonts addon offers several ways to deal with font assets loading
+without leaving the terminal in an unusable state.
+
+Recap - normally boostrapping of a new terminal involves these basic steps (Typescript):
+
+```typescript
+import { Terminal } from '@xterm/xterm';
+import { XYAddon } from '@xterm/addon-xy';
+
+// create a `Terminal` instance with some options, e.g. a custom font family
+const terminal = new Terminal({fontFamily: 'monospace'});
+
+// create and load all addons you want to use, e.g. fit addon
+const xyInstance = new XYAddon();
+terminal.loadAddon(xyInstance);
+
+// finally: call `open` of the terminal instance
+terminal.open(your_terminal_div_element); // <-- critical path for webfonts
+// more boostrapping goes here ...
+```
+This code is guaranteed to work in all browsers synchronously, as the identifier `monospace`
+will always be available. It will also work synchronously with any installed system font,
+but breaks horribly with webfonts. The actual culprit here is the call to `terminal.open`,
+which attaches the terminal to the DOM and starts the renderer with all the glyph caching
+mentioned above, while the webfont is not yet fully available.
+
+To fix that, the webfonts addon provides a waiting condition (Typescript):
+```typescript
+import { Terminal } from '@xterm/xterm';
+import { XYAddon } from '@xterm/addon-xy';
+import { WebFontsAddon } from '@xterm/addon-web-fonts';
+
+// create a `Terminal` instance, now with webfonts
+const terminal = new Terminal({fontFamily: '"Web Mono 1", "Super Powerline", monospace'});
+const xyInstance = new XYAddon();
+terminal.loadAddon(xyInstance);
+
+const webFontsInstance = new WebFontsAddon();
+terminal.loadAddon(webFontsInstance);
+
+// wait for webfonts to be fully loaded
+webFontsInstance.loadFonts(['Web Mono 1', 'Super Powerline']).then(() => {
+ terminal.open(your_terminal_div_element);
+ // more boostrapping goes here ...
+});
+```
+Here `loadFonts` will look up the font face objects in `document.fonts`
+and load them before continuing. For this to work, you have to make sure,
+that the CSS `font-face` rules for these webfonts are loaded beforehand,
+otherwise `loadFonts` will not find the font family names (promise will be
+rejected for missing font family names).
+
+Please note, that this cannot run synchronous anymore, so you will have to split your
+bootstrapping code into several stages. If that is too much of a hassle,
+you can also move the whole bootstrapping under the waiting condition by using
+the static loader instead (Typescript):
+```typescript
+import { Terminal } from '@xterm/xterm';
+import { XYAddon } from '@xterm/addon-xy';
+// import static loader
+import { loadFonts } from '@xterm/addon-web-fonts';
+
+loadFonts(['Web Mono 1', 'Super Powerline']).then(() => {
+ // create a `Terminal` instance, now with webfonts
+ const terminal = new Terminal({fontFamily: '"Web Mono 1", "Super Powerline", monospace'});
+ const xyInstance = new XYAddon();
+ terminal.loadAddon(xyInstance);
+
+ // optional when using static loader
+ const webfontsInstance = new WebFontsAddon();
+ terminal.loadAddon(webfontsInstance);
+
+ terminal.open(your_terminal_div_element);
+ // more boostrapping goes here ...
+});
+```
+With the static loader creating and loading of the actual addon can be omitted,
+as fonts are already loaded before any terminal setup happens.
+
+### Webfont Loading at Runtime
+
+Given you have a terminal already running and want to change the font family
+to a different not yet loaded webfont:
+```typescript
+// either create font face objects in javascript
+const ff1 = new FontFace('New Web Mono', url1, ...);
+const ff2 = new FontFace('New Web Mono', url2, ...);
+// and await their loading
+loadFonts([ff1, ff2]).then(() => {
+ // apply new webfont to terminal
+ terminal.options.fontFamily = 'New Web Mono';
+ // since the new font might have slighly different metrics,
+ // also run the fit addon here (or any other custom resize logic)
+ fitAddon.fit();
+});
+
+// or alternatively use CSS to add new font-face rules, e.g.
+document.styleSheets[0].insertRule(
+ "@font-face { font-family: 'New Web Mono'; src: url(newfont.woff); }", 0);
+// and await the new font family name
+loadFonts(['New Web Mono']).then(() => {
+ // apply new webfont to terminal
+ terminal.options.fontFamily = 'New Web Mono';
+ // since the new font might have slighly different metrics,
+ // also run the fit addon here (or any other custom resize logic)
+ fitAddon.fit();
+});
+```
+
+### Forced Layout Update
+
+If you have the addon loaded into your terminal, you can force the terminal to update
+the layout with the method `WebFontsAddon.relayout`. This might come handy,
+if the terminal shows webfont related output issue for unknown reasons:
+```typescript
+...
+// given - terminal shows weird font issues, run:
+webFontsInstance.relayout().then(() => {
+ // also run resize logic here, e.g. fit addon
+ fitAddon.fit();
+});
+```
+Note that this method is only meant as a quickfix on a running terminal to keep it
+in a working condition. A production-ready integration should never rely on it,
+better fix the real root cause (most likely not properly awaiting the font loader
+higher up in the code).
+
+
+### Webfonts from Fontsource
+
+The addon has been tested to work with webfonts from fontsource.
+Javascript example for `vite` with ESM import:
+```javascript
+import { Terminal } from '@xterm/xterm';
+import { FitAddon } from '@xterm/addon-fit';
+import { loadFonts } from '@xterm/addon-web-fonts';
+import '@xterm/xterm/css/xterm.css';
+import '@fontsource/roboto-mono';
+import '@fontsource/roboto-mono/400.css';
+import '@fontsource/roboto-mono/400-italic.css';
+import '@fontsource/roboto-mono/700.css';
+import '@fontsource/roboto-mono/700-italic.css';
+
+async function main() {
+ let fontFamily = '"Roboto Mono", monospace';
+ try {
+ await loadFonts(['Roboto Mono']);
+ } catch (e) {
+ fontFamily = 'monospace';
+ }
+
+ const terminal = new Terminal({ fontFamily });
+ const fitAddon = new FitAddon();
+ terminal.loadAddon(fitAddon);
+ terminal.open(document.getElementById('your-xterm-container-div'));
+ fitAddon.fit();
+
+ // sync writing shows up in Roboto Mono w'o FOUT
+ // and a fallback to monospace
+ terminal.write('put any unicode char here');
+}
+
+main();
+```
+The fontsource packages download the font files to your project folder to be delivered
+from there later on. For security sensitive projects this should be the preferred way,
+as it brings the font files under your control.
+
+The example furthermore contains proper exception handling with a fallback
+(skipped in all other examples for better readability).
+
+---
+
+Also see the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-web-fonts/typings/addon-web-fonts.d.ts).
diff --git a/addons/addon-web-fonts/package.json b/addons/addon-web-fonts/package.json
new file mode 100644
index 0000000000..1daac3768f
--- /dev/null
+++ b/addons/addon-web-fonts/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@xterm/addon-web-fonts",
+ "version": "0.1.0",
+ "author": {
+ "name": "The xterm.js authors",
+ "url": "https://xtermjs.org/"
+ },
+ "main": "lib/addon-web-fonts.js",
+ "module": "lib/addon-web-fonts.mjs",
+ "types": "typings/addon-web-fonts.d.ts",
+ "repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-fonts",
+ "license": "MIT",
+ "keywords": [
+ "terminal",
+ "xterm",
+ "xterm.js"
+ ],
+ "scripts": {
+ "build": "../../node_modules/.bin/tsc -p .",
+ "prepackage": "npm run build",
+ "package": "../../node_modules/.bin/webpack",
+ "prepublishOnly": "npm run package",
+ "start": "node ../../demo/start"
+ },
+ "peerDependencies": {
+ "@xterm/xterm": "^5.0.0"
+ },
+ "dependencies": {}
+}
diff --git a/addons/addon-web-fonts/src/WebFontsAddon.ts b/addons/addon-web-fonts/src/WebFontsAddon.ts
new file mode 100644
index 0000000000..cc0ccd1f5c
--- /dev/null
+++ b/addons/addon-web-fonts/src/WebFontsAddon.ts
@@ -0,0 +1,152 @@
+/**
+ * Copyright (c) 2024 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+import type { Terminal, ITerminalAddon } from '@xterm/xterm';
+import type { WebFontsAddon as IWebFontsApi } from '@xterm/addon-web-fonts';
+
+
+/**
+ * Unquote a font family name.
+ */
+function unquote(s: string): string {
+ if (s[0] === '"' && s[s.length - 1] === '"') return s.slice(1, -1);
+ if (s[0] === '\'' && s[s.length - 1] === '\'') return s.slice(1, -1);
+ return s;
+}
+
+
+/**
+ * Quote a font family name conditionally.
+ * @see https://mathiasbynens.be/notes/unquoted-font-family
+ */
+function quote(s: string): string {
+ const pos = s.match(/([-_a-zA-Z0-9\xA0-\u{10FFFF}]+)/u);
+ const neg = s.match(/^(-?\d|--)/m);
+ if (!neg && pos && pos[1] === s) return s;
+ return `"${s.replace(/"/g, '\\"')}"`;
+}
+
+
+function splitFamily(family: string | undefined): string[] {
+ if (!family) return [];
+ return family.split(',').map(e => unquote(e.trim()));
+}
+
+
+function createFamily(families: string[]): string {
+ return families.map(quote).join(', ');
+}
+
+
+/**
+ * Hash a font face from it properties.
+ * Used in `loadFonts` to avoid bloating
+ * `document.fonts` from multiple calls.
+ */
+function hashFontFace(ff: FontFace): string {
+ return JSON.stringify([
+ unquote(ff.family),
+ ff.stretch,
+ ff.style,
+ ff.unicodeRange,
+ ff.weight
+ ]);
+}
+
+
+/**
+ * Wait for webfont resources to be loaded.
+ *
+ * Without any argument, all fonts currently listed in
+ * `document.fonts` will be loaded.
+ * For a more fine-grained loading strategy you can populate
+ * the `fonts` argument with:
+ * - font families : loads all fontfaces in `document.fonts`
+ * matching the family names
+ * - fontface objects : loads given fontfaces and adds them to
+ * `document.fonts`
+ *
+ * The returned promise will resolve, when all loading is done.
+ */
+function _loadFonts(fonts?: (string | FontFace)[]): Promise {
+ const ffs = Array.from(document.fonts);
+ if (!fonts || !fonts.length) {
+ return Promise.all(ffs.map(ff => ff.load()));
+ }
+ let toLoad: FontFace[] = [];
+ const ffsHashed = ffs.map(ff => hashFontFace(ff));
+ for (const font of fonts) {
+ if (font instanceof FontFace) {
+ const fontHashed = hashFontFace(font);
+ const idx = ffsHashed.indexOf(fontHashed);
+ if (idx === -1) {
+ document.fonts.add(font);
+ ffs.push(font);
+ ffsHashed.push(fontHashed);
+ toLoad.push(font);
+ } else {
+ toLoad.push(ffs[idx]);
+ }
+ } else {
+ // string as font
+ const familyFiltered = ffs.filter(ff => font === unquote(ff.family));
+ toLoad = toLoad.concat(familyFiltered);
+ if (!familyFiltered.length) {
+ return Promise.reject(`font family "${font}" not registered in document.fonts`);
+ }
+ }
+ }
+ return Promise.all(toLoad.map(ff => ff.load()));
+}
+
+
+export function loadFonts(fonts?: (string | FontFace)[]): Promise {
+ return document.fonts.ready.then(() => _loadFonts(fonts));
+}
+
+
+export class WebFontsAddon implements ITerminalAddon, IWebFontsApi {
+ private _term: Terminal | undefined;
+
+ constructor(public initialRelayout: boolean = true) { }
+
+ public dispose(): void {
+ this._term = undefined;
+ }
+
+ public activate(term: Terminal): void {
+ this._term = term;
+ if (this.initialRelayout) {
+ document.fonts.ready.then(() => this.relayout());
+ }
+ }
+
+ public loadFonts(fonts?: (string | FontFace)[]): Promise {
+ return loadFonts(fonts);
+ }
+
+ public async relayout(): Promise {
+ if (!this._term) {
+ return;
+ }
+ await document.fonts.ready;
+ const family = this._term.options.fontFamily;
+ const families = splitFamily(family);
+ const webFamilies = Array.from(new Set(Array.from(document.fonts).map(e => unquote(e.family))));
+ const dirty: string[] = [];
+ const clean: string[] = [];
+ for (const fam of families) {
+ (webFamilies.indexOf(fam) !== -1 ? dirty : clean).push(fam);
+ }
+ if (!dirty.length) {
+ return;
+ }
+ await _loadFonts(dirty);
+ if (this._term) {
+ this._term.options.fontFamily = clean.length ? createFamily(clean) : 'monospace';
+ this._term.options.fontFamily = family;
+ }
+ }
+}
diff --git a/addons/addon-web-fonts/src/tsconfig.json b/addons/addon-web-fonts/src/tsconfig.json
new file mode 100644
index 0000000000..cd297308fd
--- /dev/null
+++ b/addons/addon-web-fonts/src/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es2021",
+ "lib": [
+ "dom",
+ "es2015",
+ "dom.iterable"
+ ],
+ "rootDir": ".",
+ "outDir": "../out",
+ "sourceMap": true,
+ "removeComments": true,
+ "strict": true,
+ "types": [
+ "../../../node_modules/@types/mocha"
+ ],
+ "paths": {
+ "@xterm/addon-web-fonts": [
+ "../typings/addon-web-fonts.d.ts"
+ ]
+ }
+ },
+ "include": [
+ "./**/*",
+ "../../../typings/xterm.d.ts"
+ ]
+}
diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts
new file mode 100644
index 0000000000..1315b1ee6a
--- /dev/null
+++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Copyright (c) 2024 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+import test from '@playwright/test';
+import { deepStrictEqual, strictEqual } from 'assert';
+import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
+
+let ctx: ITestContext;
+test.beforeAll(async ({ browser }) => {
+ ctx = await createTestContext(browser);
+ await openTerminal(ctx, { cols: 40 });
+});
+test.afterAll(async () => await ctx.page.close());
+
+test.describe('WebFontsAddon', () => {
+
+ test.beforeEach(async () => {
+ // make sure that we start with no webfonts in the document
+ const empty = await await getDocumentFonts();
+ deepStrictEqual(empty, []);
+ });
+ test.afterEach(async () => {
+ // for font loading tests to work, we have to remove added rules and fonts
+ // to work around the quite aggressive font caching done by the browsers
+ await ctx.page.evaluate(`
+ document.styleSheets[0].deleteRule(1);
+ document.styleSheets[0].deleteRule(0);
+ document.fonts.clear();
+ `);
+ });
+
+ test.describe('font loading at runtime', () => {
+ test('loadFonts (JS)', async () => {
+ await ctx.page.evaluate(`
+ const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
+ const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
+ loadFonts([ff1, ff2]);
+ `);
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
+ });
+ test('loadFonts (CSS, unquoted)', async () => {
+ await ctx.page.evaluate(`
+ document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
+ document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1);
+ loadFonts(['Kongtext', 'BPdots']);
+ `);
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
+ });
+ test('loadFonts (CSS, quoted)', async ({ browser }) => {
+ // NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
+ await ctx.page.evaluate(`
+ document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
+ document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1);
+ loadFonts(['Kongtext', 'BPdots']);
+ `);
+ if (browser.browserType().name() === 'firefox') {
+ deepStrictEqual(await getDocumentFonts(), [{ family: '"Kongtext"', status: 'loaded' }, { family: '"BPdots"', status: 'loaded' }]);
+ } else {
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
+ }
+ });
+ test('FontFace hashing', async () => {
+ // multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
+ await ctx.page.evaluate(`
+ const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
+ const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
+ loadFonts([ff1, ff2]);
+ loadFonts([ff1, ff2]);
+ loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
+ `);
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
+ });
+
+ test('autoload & relayout from ctor', async ({ browser }) => {
+ // to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
+ // now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
+ const data = await ctx.page.evaluate(`
+ document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
+ `);
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
+
+ // broken case: webfont in ctor without addon usage
+ await ctx.page.evaluate(`
+ window.helperTerm = new Terminal({fontFamily: '"Kongtext", ' + term.options.fontFamily});
+ window.helperTerm.open(term.element);
+ `);
+
+ // safari loads the font, firefox & chrome dont
+ if (browser.browserType().name() === 'webkit') {
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
+ } else {
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
+ }
+
+ // good case: addon fixes layout for webfont in ctor
+ // the relayout happens async, so wait a bit with a promise
+ await ctx.page.evaluate(`
+ window.helperTerm.dispose();
+ window.helperTerm = new Terminal({fontFamily: '"Kongtext", ' + term.options.fontFamily});
+ window._webfontsAddon = new WebFontsAddon();
+ window.helperTerm.loadAddon(window._webfontsAddon);
+ window.helperTerm.open(term.element);
+ `);
+ await timeout(100);
+ deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
+
+ // cleanup this messy test case
+ await ctx.page.evaluate(`
+ window.helperTerm.dispose();
+ window._webfontsAddon.dispose();
+ `);
+ });
+ });
+
+});
+
+async function getDocumentFonts(): Promise {
+ return ctx.page.evaluate(`Array.from(document.fonts).map(ff => ({family: ff.family, status: ff.status}))`);
+}
diff --git a/addons/addon-web-fonts/test/playwright.config.ts b/addons/addon-web-fonts/test/playwright.config.ts
new file mode 100644
index 0000000000..22834be116
--- /dev/null
+++ b/addons/addon-web-fonts/test/playwright.config.ts
@@ -0,0 +1,35 @@
+import { PlaywrightTestConfig } from '@playwright/test';
+
+const config: PlaywrightTestConfig = {
+ testDir: '.',
+ timeout: 10000,
+ projects: [
+ {
+ name: 'ChromeStable',
+ use: {
+ browserName: 'chromium',
+ channel: 'chrome'
+ }
+ },
+ {
+ name: 'FirefoxStable',
+ use: {
+ browserName: 'firefox'
+ }
+ },
+ {
+ name: 'WebKit',
+ use: {
+ browserName: 'webkit'
+ }
+ }
+ ],
+ reporter: 'list',
+ webServer: {
+ command: 'npm run start',
+ port: 3000,
+ timeout: 120000,
+ reuseExistingServer: !process.env.CI
+ }
+};
+export default config;
diff --git a/addons/addon-web-fonts/test/tsconfig.json b/addons/addon-web-fonts/test/tsconfig.json
new file mode 100644
index 0000000000..120fccdc31
--- /dev/null
+++ b/addons/addon-web-fonts/test/tsconfig.json
@@ -0,0 +1,42 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es2021",
+ "lib": [
+ "es2021",
+ ],
+ // "downlevelIteration": true,
+ "rootDir": ".",
+ "outDir": "../out-test",
+ "sourceMap": true,
+ "removeComments": true,
+ "baseUrl": ".",
+ "paths": {
+ "common/*": [
+ "../../../src/common/*"
+ ],
+ "browser/*": [
+ "../../../src/browser/*"
+ ]
+ },
+ "strict": true,
+ "types": [
+ "../../../node_modules/@types/node"
+ ]
+ },
+ "include": [
+ "./**/*",
+ "../../../typings/xterm.d.ts"
+ ],
+ "references": [
+ {
+ "path": "../../../src/common"
+ },
+ {
+ "path": "../../../src/browser"
+ },
+ {
+ "path": "../../../test/playwright"
+ }
+ ]
+}
diff --git a/addons/addon-web-fonts/tsconfig.json b/addons/addon-web-fonts/tsconfig.json
new file mode 100644
index 0000000000..2d820dd1a6
--- /dev/null
+++ b/addons/addon-web-fonts/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "include": [],
+ "references": [
+ { "path": "./src" },
+ { "path": "./test" }
+ ]
+}
diff --git a/addons/addon-web-fonts/typings/addon-web-fonts.d.ts b/addons/addon-web-fonts/typings/addon-web-fonts.d.ts
new file mode 100644
index 0000000000..c7a159ed1a
--- /dev/null
+++ b/addons/addon-web-fonts/typings/addon-web-fonts.d.ts
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2024 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+
+import { Terminal, ITerminalAddon } from '@xterm/xterm';
+
+declare module '@xterm/addon-web-fonts' {
+
+ /**
+ * Addon to use webfonts in xterm.js
+ */
+ export class WebFontsAddon implements ITerminalAddon {
+ /**
+ * @param initialRelayout Force initial relayout, if a webfont was found (default true).
+ */
+ constructor(initialRelayout?: boolean);
+ public activate(terminal: Terminal): void;
+ public dispose(): void;
+
+ /**
+ * Wait for webfont resources to be loaded.
+ *
+ * Without any argument, all fonts currently listed in
+ * `document.fonts` will be loaded.
+ * For a more fine-grained loading strategy you can populate
+ * the `fonts` argument with:
+ * - font families : loads all fontfaces in `document.fonts`
+ * matching the family names
+ * - fontface objects : loads given fontfaces and adds them to
+ * `document.fonts`
+ *
+ * The returned promise will resolve, when all loading is done.
+ */
+ public loadFonts(fonts?: (string | FontFace)[]): Promise;
+
+ /**
+ * Force a terminal relayout by altering `options.FontFamily`.
+ *
+ * Found webfonts in `fontFamily` are temporarily removed until the webfont
+ * resources are fully loaded.
+ *
+ * Call this method, if a terminal with webfonts is stuck with broken
+ * glyph metrics.
+ *
+ * The returned promise will resolve, when font loading and layouting are done.
+ */
+ public relayout(): Promise;
+ }
+
+ /**
+ * Wait for webfont resources to be loaded.
+ *
+ * Without any argument, all fonts currently listed in
+ * `document.fonts` will be loaded.
+ * For a more fine-grained loading strategy you can populate
+ * the `fonts` argument with:
+ * - font families : loads all fontfaces in `document.fonts`
+ * matching the family names
+ * - fontface objects : loads given fontfaces and adds them to
+ * `document.fonts`
+ *
+ * The returned promise will resolve, when all loading is done.
+ */
+ function loadFonts(fonts?: (string | FontFace)[]): Promise;
+}
diff --git a/addons/addon-web-fonts/webpack.config.js b/addons/addon-web-fonts/webpack.config.js
new file mode 100644
index 0000000000..f75d2d501b
--- /dev/null
+++ b/addons/addon-web-fonts/webpack.config.js
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2024 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+const path = require('path');
+
+const addonName = 'WebFontsAddon';
+const mainFile = 'addon-web-fonts.js';
+
+module.exports = {
+ entry: `./out/${addonName}.js`,
+ devtool: 'source-map',
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ use: ["source-map-loader"],
+ enforce: "pre",
+ exclude: /node_modules/
+ }
+ ]
+ },
+ output: {
+ filename: mainFile,
+ path: path.resolve('./lib'),
+ library: addonName,
+ libraryTarget: 'umd',
+ // Force usage of globalThis instead of global / self. (This is cross-env compatible)
+ globalObject: 'globalThis',
+ },
+ mode: 'production'
+};
diff --git a/bin/esbuild.mjs b/bin/esbuild.mjs
index 9ed69edab8..95b3856e6a 100644
--- a/bin/esbuild.mjs
+++ b/bin/esbuild.mjs
@@ -138,6 +138,7 @@ if (config.addon) {
"@xterm/addon-image": "./addons/addon-image/lib/addon-image.mjs",
"@xterm/addon-search": "./addons/addon-search/lib/addon-search.mjs",
"@xterm/addon-serialize": "./addons/addon-serialize/lib/addon-serialize.mjs",
+ "@xterm/addon-web-fonts": "./addons/addon-web-fonts/lib/addon-web-fonts.mjs",
"@xterm/addon-web-links": "./addons/addon-web-links/lib/addon-web-links.mjs",
"@xterm/addon-webgl": "./addons/addon-webgl/lib/addon-webgl.mjs",
"@xterm/addon-unicode11": "./addons/addon-unicode11/lib/addon-unicode11.mjs",
diff --git a/bin/test_integration.js b/bin/test_integration.js
index 00e5b57c24..758fdebd8e 100644
--- a/bin/test_integration.js
+++ b/bin/test_integration.js
@@ -29,6 +29,7 @@ const addons = [
'serialize',
'unicode-graphemes',
'unicode11',
+ 'web-fonts',
'web-links',
'webgl',
];
diff --git a/demo/bpdots.regular.otf b/demo/bpdots.regular.otf
new file mode 100644
index 0000000000..e5b5d1dcb7
Binary files /dev/null and b/demo/bpdots.regular.otf differ
diff --git a/demo/client.ts b/demo/client.ts
index 49ba67438e..732efda5bf 100644
--- a/demo/client.ts
+++ b/demo/client.ts
@@ -23,6 +23,7 @@ import { FitAddon } from '@xterm/addon-fit';
import { LigaturesAddon } from '@xterm/addon-ligatures';
import { SearchAddon, ISearchOptions } from '@xterm/addon-search';
import { SerializeAddon } from '@xterm/addon-serialize';
+import { WebFontsAddon, loadFonts } from '@xterm/addon-web-fonts';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Unicode11Addon } from '@xterm/addon-unicode11';
@@ -37,6 +38,7 @@ export interface IWindowWithTerminal extends Window {
ImageAddon?: typeof ImageAddon; // eslint-disable-line @typescript-eslint/naming-convention
SearchAddon?: typeof SearchAddon; // eslint-disable-line @typescript-eslint/naming-convention
SerializeAddon?: typeof SerializeAddon; // eslint-disable-line @typescript-eslint/naming-convention
+ WebFontsAddon?: typeof WebFontsAddon; // eslint-disable-line @typescript-eslint/naming-convention
WebLinksAddon?: typeof WebLinksAddon; // eslint-disable-line @typescript-eslint/naming-convention
WebglAddon?: typeof WebglAddon; // eslint-disable-line @typescript-eslint/naming-convention
Unicode11Addon?: typeof Unicode11Addon; // eslint-disable-line @typescript-eslint/naming-convention
@@ -52,7 +54,7 @@ let socket;
let pid;
let autoResize: boolean = true;
-type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webLinks' | 'webgl' | 'ligatures';
+type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures';
interface IDemoAddon {
name: T;
@@ -65,11 +67,12 @@ interface IDemoAddon {
T extends 'ligatures' ? typeof LigaturesAddon :
T extends 'search' ? typeof SearchAddon :
T extends 'serialize' ? typeof SerializeAddon :
- T extends 'webLinks' ? typeof WebLinksAddon :
- T extends 'unicode11' ? typeof Unicode11Addon :
- T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon :
- T extends 'webgl' ? typeof WebglAddon :
- never
+ T extends 'webFonts' ? typeof WebFontsAddon :
+ T extends 'webLinks' ? typeof WebLinksAddon :
+ T extends 'unicode11' ? typeof Unicode11Addon :
+ T extends 'unicodeGraphemes' ? typeof UnicodeGraphemesAddon :
+ T extends 'webgl' ? typeof WebglAddon :
+ never
);
instance?: (
T extends 'attach' ? AttachAddon :
@@ -79,11 +82,12 @@ interface IDemoAddon {
T extends 'ligatures' ? LigaturesAddon :
T extends 'search' ? SearchAddon :
T extends 'serialize' ? SerializeAddon :
- T extends 'webLinks' ? WebLinksAddon :
- T extends 'unicode11' ? Unicode11Addon :
- T extends 'unicodeGraphemes' ? UnicodeGraphemesAddon :
- T extends 'webgl' ? WebglAddon :
- never
+ T extends 'webFonts' ? WebFontsAddon :
+ T extends 'webLinks' ? WebLinksAddon :
+ T extends 'unicode11' ? Unicode11Addon :
+ T extends 'unicodeGraphemes' ? UnicodeGraphemesAddon :
+ T extends 'webgl' ? WebglAddon :
+ never
);
}
@@ -94,6 +98,7 @@ const addons: { [T in AddonType]: IDemoAddon } = {
image: { name: 'image', ctor: ImageAddon, canChange: true },
search: { name: 'search', ctor: SearchAddon, canChange: true },
serialize: { name: 'serialize', ctor: SerializeAddon, canChange: true },
+ webFonts: { name: 'webFonts', ctor: WebFontsAddon, canChange: true },
webLinks: { name: 'webLinks', ctor: WebLinksAddon, canChange: true },
webgl: { name: 'webgl', ctor: WebglAddon, canChange: true },
unicode11: { name: 'unicode11', ctor: Unicode11Addon, canChange: true },
@@ -169,6 +174,7 @@ const disposeRecreateButtonHandler: () => void = () => {
addons.unicode11.instance = undefined;
addons.unicodeGraphemes.instance = undefined;
addons.ligatures.instance = undefined;
+ addons.webFonts.instance = undefined;
addons.webLinks.instance = undefined;
addons.webgl.instance = undefined;
document.getElementById('dispose').innerHTML = 'Recreate Terminal';
@@ -218,6 +224,7 @@ if (document.location.pathname === '/test') {
window.Unicode11Addon = Unicode11Addon;
window.UnicodeGraphemesAddon = UnicodeGraphemesAddon;
window.LigaturesAddon = LigaturesAddon;
+ window.WebFontsAddon = WebFontsAddon;
window.WebLinksAddon = WebLinksAddon;
window.WebglAddon = WebglAddon;
} else {
@@ -245,6 +252,7 @@ if (document.location.pathname === '/test') {
addVtButtons();
initImageAddonExposed();
testEvents();
+ testWebfonts();
}
function createTerminal(): void {
@@ -278,12 +286,14 @@ function createTerminal(): void {
} catch (e) {
console.warn(e);
}
+ addons.webFonts.instance = new WebFontsAddon();
addons.webLinks.instance = new WebLinksAddon();
typedTerm.loadAddon(addons.fit.instance);
typedTerm.loadAddon(addons.image.instance);
typedTerm.loadAddon(addons.search.instance);
typedTerm.loadAddon(addons.serialize.instance);
typedTerm.loadAddon(addons.unicodeGraphemes.instance);
+ typedTerm.loadAddon(addons.webFonts.instance);
typedTerm.loadAddon(addons.webLinks.instance);
typedTerm.loadAddon(addons.clipboard.instance);
@@ -1418,3 +1428,23 @@ function testEvents(): void {
document.getElementById('event-focus').addEventListener('click', ()=> term.focus());
document.getElementById('event-blur').addEventListener('click', ()=> term.blur());
}
+
+function testWebfonts() {
+ document.getElementById('webfont-kongtext').addEventListener('click', async () => {
+ const ff = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
+ await loadFonts([ff]);
+ term.options.fontFamily = 'Kongtext';
+ term.options.lineHeight = 1.3;
+ addons.fit.instance?.fit();
+ setTimeout(() => term.write('\x1b[?12h\x1b]12;#776CF9\x07\x1b[38;2;119;108;249;48;2;21;8;150m\x1b[2J\x1b[2;5H**** COMMODORE 64 BASIC V2 ****\r\n\r\n 64K RAM SYSTEM 38911 BASIC BYTES FREE\r\n\r\nREADY.\r\nLOAD '), 1000);
+ setTimeout(() => {term.write('🤣\x1b[m\x1b[99;1H'); term.input('\r');}, 5000);
+ });
+ document.getElementById('webfont-bpdots').addEventListener('click', async () => {
+ document.styleSheets[0].insertRule("@font-face { font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype'); weight: 400 }", 0);
+ await loadFonts(['BPdots']);
+ term.options.fontFamily = 'BPdots';
+ term.options.lineHeight = 1.3;
+ term.options.fontSize = 20;
+ addons.fit.instance?.fit();
+ });
+}
diff --git a/demo/font-licenses.txt b/demo/font-licenses.txt
new file mode 100644
index 0000000000..5b1b5ba664
--- /dev/null
+++ b/demo/font-licenses.txt
@@ -0,0 +1,41 @@
+
+BPdots is licensed under the Creative Commons Attribution-NoDerivs License (CC BY-ND).
+
+
+kongtext license text:
+
+Thanks for downloading one of codeman38's retro video game fonts,
+as seen on Memepool, BoingBoing, and all around the blogosphere.
+
+So, you're wondering what the license is for these fonts? Pretty simple;
+it's based upon that used for Bitstream's Vera font set .
+
+Basically, here are the key points summarized, in as little legalese as possible;
+I hate reading license agreements as much as you probably do:
+
+With one specific exception, you have full permission to bundle these fonts in
+your own free or commercial projects-- and by projects, I'm referring to not
+just software but also electronic documents and print publications.
+
+So what's the exception? Simple: you can't re-sell these fonts
+in a commercial font collection. I've seen too many font CDs for sale in stores
+that are just a repackaging of thousands of freeware fonts found on the internet,
+and in my mind, that's quite a bit like highway robbery. Note that this *only*
+applies to products that are font collections in and of themselves;
+you may freely bundle these fonts with an operating system, application program,
+or the like.
+
+Feel free to modify these fonts and even to release the modified versions,
+as long as you change the original font names (to ensure consistency among
+people with the font installed) and as long as you give credit somewhere
+in the font file to codeman38 or zone38.net. I may even incorporate these changes
+into a later version of my fonts if you wish to send me the modifed fonts via e-mail.
+
+Also, feel free to mirror these fonts on your own site, as long as you make it
+reasonably clear that these fonts are not your own work. I'm not asking for much;
+linking to zone38.net or even just mentioning the nickname codeman38 should be enough.
+
+Well, that pretty much sums it up... so without further ado,
+install and enjoy these fonts from the golden age of video games.
+
+[ codeman38 | cody@zone38.net | http://www.zone38.net/ ]
diff --git a/demo/index.html b/demo/index.html
index 064518475b..691871ee06 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -116,6 +116,10 @@