diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 865060f8..c40e86ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,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/* @@ -223,6 +226,8 @@ jobs: run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes - name: Integration tests (addon-unicode11) run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11 + - name: Integration tests (addon-web-fonts) + run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts - name: Integration tests (addon-web-links) run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links - name: Integration tests (addon-webgl) diff --git a/.mailmap b/.mailmap deleted file mode 100644 index a8de62a9..00000000 --- a/.mailmap +++ /dev/null @@ -1,7 +0,0 @@ -Antonis Kalipetis -Antonis Kalipetis -Daniel Imms -Paris Kasidiaris -Paris Kasidiaris -Thanasis Daglis -Thanasis Daglis diff --git a/.mocha.env b/.mocha.env deleted file mode 100644 index 78a9f930..00000000 --- a/.mocha.env +++ /dev/null @@ -1 +0,0 @@ -NODE_PATH=./out diff --git a/.mocharc.yml b/.mocharc.yml deleted file mode 100644 index fb397fdd..00000000 --- a/.mocharc.yml +++ /dev/null @@ -1,5 +0,0 @@ -require: - - source-map-support/register -reporter: spec -color: true -check-leaks: true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..897af65d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 1fe5ba08..849808a6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,4 @@ { - "chat.tools.terminal.autoApprove": { - "npm run build": true, - "npm run esbuild": true, - "npm run dev": true, - "npm run test-integration": true, - "npm run test-integration-chromium": true, - "npm run test-integration-firefox": true, - "npm run test-integration-webkit": true, - "npm run lint": true, - "npm run lint-fix": true, - "npm run lint-api": true, - "npm run test-unit": true, - }, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.quoteStyle": "single" } diff --git a/README.md b/README.md index 4ff46aa6..d001f160 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,27 @@ -# [![xterm.js logo](images/logo-full.png)](https://xtermjs.org) +# [![xterm.js](images/logo-full.png)](https://xtermjs.org) -Xterm.js is a front-end component written in TypeScript that lets applications bring fully-featured terminals to their users in the browser. It's used by popular projects such as VS Code, Hyper and Theia. +Xterm.js is a frontend component that enables applications to bring fully-featured terminals to their users in the browser. It's used by popular projects such as [VS Code](https://code.visualstudio.com/) (and its forks), [Tabby](https://tabby.sh/) and [Hyper](https://hyper.is/). ## Features - **Terminal apps just work**: Xterm.js works with most terminal apps such as `bash`, `vim`, and `tmux`, including support for curses-based apps and mouse events. -- **Performant**: Xterm.js is *really* fast, it even includes a GPU-accelerated renderer. +- **Performant**: Xterm.js is *really* fast and includes an optional GPU-accelerated renderer. - **Rich Unicode support**: Supports CJK, emojis, and IMEs. -- **Self-contained**: Requires zero dependencies to work. -- **Accessible**: Screen reader and minimum contrast ratio support can be turned on. -- **And much more**: Links, theming, addons, well documented API, etc. +- **Self-contained**: The core library has zero dependencies. +- **Accessible**: Screen reader mode and minimum contrast ratio support can be turned on. +- **And much more**: Links, theming, custom glyphs, addons, well documented API, etc. ## What xterm.js is not - Xterm.js is not a terminal application that you can download and use on your computer. -- Xterm.js is not `bash`. Xterm.js can be connected to processes like `bash` and let you interact with them (provide input, receive output). +- Xterm.js is not `bash`. Xterm.js can be connected to processes like `bash` and let you interact with them (provide input, receive output) through a library like [node-pty](https://github.com/microsoft/node-pty). ## Getting Started -First, you need to install the module, we ship exclusively through [npm](https://www.npmjs.com/), so you need that installed and then add xterm.js as a dependency by running: +First, you need to install the module. We ship exclusively through [npm](https://www.npmjs.com), so you need that installed and then add [@xterm/xterm](https://www.npmjs.com/package/@xterm/xterm) as a dependency by running: ```bash -npm install @xterm/xterm +npm install --save @xterm/xterm ``` To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to the head of your HTML page. Then create a `
` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`. @@ -54,12 +54,10 @@ import { Terminal } from '@xterm/xterm'; ### Addons -⚠️ *This section describes the new addon format introduced in v3.14.0, see [here](https://github.com/xtermjs/xterm.js/blob/3.14.2/README.md#addons) for the instructions on the old format* - Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project: ```bash -npm i -S @xterm/addon-web-links +npm install --save @xterm/addon-web-links ``` Then import the addon, instantiate it and call `Terminal.loadAddon`: @@ -80,50 +78,51 @@ The xterm.js team maintains the following addons, but anyone can build them: - [`@xterm/addon-clipboard`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-clipboard): Access the browser's clipboard - [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit): Fits the terminal to the containing element - [`@xterm/addon-image`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image): Adds image support +- [`@xterm/addon-ligatures`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-ligatures): Enables rendering of ligatures +- [`@xterm/addon-progress`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-progress): Adds support for the progress API (`OSC 9;4`) - [`@xterm/addon-search`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-search): Adds search functionality -- [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize): Serializes the terminal's buffer to a VT sequences or HTML +- [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize): Serializes the terminal's buffer to VT sequences or HTML +- [`@xterm/addon-unicode-graphemes`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-unicode-graphemes): Enhanced unicode support including grapheme clustering (experimental) - [`@xterm/addon-unicode11`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-unicode11): Updates character widths to their unicode11 values +- [`@xterm/addon-web-fonts`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-fonts): Easily integrate web fonts - [`@xterm/addon-web-links`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-links): Adds web link detection and interaction - [`@xterm/addon-webgl`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgl): Renders xterm.js using a `canvas` element's webgl2 context ## Browser Support -Since xterm.js is typically implemented as a developer tool, only modern browsers are supported officially. Specifically the latest versions of *Chrome*, *Edge*, *Firefox*, and *Safari*. - -Xterm.js works seamlessly in [Electron](https://electronjs.org/) apps and may even work on earlier versions of the browsers. These are the versions we strive to keep working. +Since xterm.js is typically implemented as a developer tool, generally only modern evergreen browsers are supported officially. Specifically the latest versions of *Chrome*, *Edge*, *Firefox*, and *Safari*. Xterm.js also works seamlessly in [Electron](https://electronjs.org/) apps and may even work on earlier versions of the browsers. These are the versions we strive to keep working. ### Node.js Support -We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the serialize addon so it can get all state restored upon reconnection. +We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection. ## API The full API for xterm.js is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API. -Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs. +Some APIs may be marked with *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs. ## Releases -Xterm.js follows a monthly release cycle roughly. - -All current and past releases are available on this repo's [Releases page](https://github.com/sourcelair/xterm.js/releases), you can view the [high-level roadmap on the wiki](https://github.com/xtermjs/xterm.js/wiki/Roadmap) and see what we're working on now by looking through [Milestones](https://github.com/sourcelair/xterm.js/milestones). +Stable releases are done on an as needed basis. All current and past releases are available on this repo's [releases page](https://github.com/sourcelair/xterm.js/releases), you can see what's planned for upcoming releases looking through the repository [milestones](https://github.com/sourcelair/xterm.js/milestones). ### Beta builds -Our CI releases beta builds to npm for every change that goes into master. Install the latest beta build with: +Beta releases are continuously published off the `master` branch. Install the latest beta build with: ```bash -npm install -S @xterm/xterm@beta +npm install --save @xterm/xterm@beta ``` -These should generally be stable, but some bugs may slip in. We recommend using the beta build primarily to test out new features and to verify bug fixes. +The principal implementation (VS Code) typically uses the latest or near the latest beta build. Generally they are quite stable but can potentially contain bugs or breaking changes. If stability is very important we recommend using the beta build primarily to test out new features and to verify bug fixes, unless you're tracking what's landing and are comfortable taking that risk. ## Contributing -You can read the [guide on the wiki](https://github.com/xtermjs/xterm.js/wiki/Contributing) to learn how to contribute and set up xterm.js for development. +Read [CONTRIBUTING.md](https://github.com/xtermjs/xterm.js/blob/master/CONTRIBUTING.md) to learn how to contribute to the project. ## Real-world uses -Xterm.js is used in several world-class applications to provide great terminal experiences. + +Xterm.js is used in many world-class applications to provide great terminal experiences. - [**SourceLair**](https://www.sourcelair.com/): In-browser IDE that provides its users with fully-featured Linux terminals based on xterm.js. - [**Microsoft Visual Studio Code**](http://code.visualstudio.com/): Modern, versatile, and powerful open source code editor that provides an integrated terminal based on xterm.js. @@ -232,12 +231,12 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) -Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Note: Please add any new contributions to the end of the list only. +Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list. ## License Agreement If you contribute code to this project, you implicitly allow your code to be distributed under the MIT license. You are also implicitly verifying that all code is your original work. -Copyright (c) 2017-2022, [The xterm.js authors](https://github.com/xtermjs/xterm.js/graphs/contributors) (MIT License)
+Copyright (c) 2017-2026, [The xterm.js authors](https://github.com/xtermjs/xterm.js/graphs/contributors) (MIT License)
Copyright (c) 2014-2017, SourceLair, Private Company ([www.sourcelair.com](https://www.sourcelair.com/home)) (MIT License)
Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) diff --git a/addons/addon-ligatures/.gitignore b/addons/addon-ligatures/.gitignore index f172c3a1..7b48eed8 100644 --- a/addons/addon-ligatures/.gitignore +++ b/addons/addon-ligatures/.gitignore @@ -3,7 +3,6 @@ node_modules/ coverage/ lib/ -fonts/ .env .vscode/ diff --git a/addons/addon-ligatures/LICENSE b/addons/addon-ligatures/LICENSE index b442934b..b7ca5139 100644 --- a/addons/addon-ligatures/LICENSE +++ b/addons/addon-ligatures/LICENSE @@ -1,6 +1,30 @@ +Copyright (c) 2019, 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. + +--- + +The code that analyzes font ligatures is forked from https://github.com/princjef/font-ligatures with this license: + MIT License -Copyright (c) 2018 +Copyright (c) 2018 Jeffrey Principe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/addons/addon-ligatures/fonts/FiraCode-Regular.otf b/addons/addon-ligatures/fonts/FiraCode-Regular.otf new file mode 100644 index 00000000..e7a9fda6 Binary files /dev/null and b/addons/addon-ligatures/fonts/FiraCode-Regular.otf differ diff --git a/addons/addon-ligatures/fonts/Monoid-Regular.ttf b/addons/addon-ligatures/fonts/Monoid-Regular.ttf new file mode 100644 index 00000000..a09e9faf Binary files /dev/null and b/addons/addon-ligatures/fonts/Monoid-Regular.ttf differ diff --git a/addons/addon-ligatures/fonts/UbuntuMono-Regular.ttf b/addons/addon-ligatures/fonts/UbuntuMono-Regular.ttf new file mode 100644 index 00000000..fdd309d7 Binary files /dev/null and b/addons/addon-ligatures/fonts/UbuntuMono-Regular.ttf differ diff --git a/addons/addon-ligatures/fonts/iosevka-regular.ttf b/addons/addon-ligatures/fonts/iosevka-regular.ttf new file mode 100644 index 00000000..963cbe2a Binary files /dev/null and b/addons/addon-ligatures/fonts/iosevka-regular.ttf differ diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index 85a19c36..5150f383 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -32,11 +32,14 @@ ], "license": "MIT", "dependencies": { - "font-finder": "^1.1.0", - "font-ligatures": "^1.4.1" + "lru-cache": "^6.0.0", + "opentype.js": "^0.8.0" }, "devDependencies": { + "@types/lru-cache": "^5.1.0", + "@types/opentype.js": "^0.7.0", "axios": "^1.6.0", + "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^2.10.0" } diff --git a/addons/addon-ligatures/src/font.ts b/addons/addon-ligatures/src/font.ts index ed7910cf..196651ac 100644 --- a/addons/addon-ligatures/src/font.ts +++ b/addons/addon-ligatures/src/font.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Font, loadBuffer } from 'font-ligatures'; +import { Font, loadBuffer } from './fontLigatures/index'; import parse from './parse'; diff --git a/addons/addon-ligatures/src/fontLigatures/flatten.ts b/addons/addon-ligatures/src/fontLigatures/flatten.ts new file mode 100644 index 00000000..967940f6 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/flatten.ts @@ -0,0 +1,40 @@ +import { ILookupTree, IFlattenedLookupTree, ILookupTreeEntry, IFlattenedLookupTreeEntry } from './types'; + +export default function flatten(tree: ILookupTree, visited: Map = new Map()): IFlattenedLookupTree { + const result: IFlattenedLookupTree = {}; + for (const [glyphId, entry] of Object.entries(tree.individual)) { + result[glyphId] = flattenEntry(entry, visited); + } + + for (const { range, entry } of tree.range) { + const flattened = flattenEntry(entry, visited); + for (let glyphId = range[0]; glyphId < range[1]; glyphId++) { + result[glyphId] = flattened; + } + } + + return result; +} + +function flattenEntry(entry: ILookupTreeEntry, visited: Map): IFlattenedLookupTreeEntry { + if (visited.has(entry)) { + return visited.get(entry)!; + } + + const result: IFlattenedLookupTreeEntry = {}; + visited.set(entry, result); + + if (entry.forward) { + result.forward = flatten(entry.forward, visited); + } + + if (entry.reverse) { + result.reverse = flatten(entry.reverse, visited); + } + + if (entry.lookup) { + result.lookup = entry.lookup; + } + + return result; +} diff --git a/addons/addon-ligatures/src/fontLigatures/index.test.ts b/addons/addon-ligatures/src/fontLigatures/index.test.ts new file mode 100644 index 00000000..7f4949b1 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/index.test.ts @@ -0,0 +1,358 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as path from 'path'; +import * as fs from 'fs'; +import { assert } from 'chai'; +import { loadBuffer } from './index'; + +interface IFont { + findLigatures(text: string): { outputGlyphs: number[], contextRanges: [number, number][] }; + findLigatureRanges(text: string): [number, number][]; +} + +interface ITestCase { + font: string; + input: string; + glyphs: number[]; + ranges: [number, number][]; +} + +const fira = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase => + ({ font: 'Fira Code', input, glyphs, ranges }); + +const iosevka = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase => + ({ font: 'Iosevka', input, glyphs, ranges }); + +const monoid = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase => + ({ font: 'Monoid', input, glyphs, ranges }); + +const ubuntu = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase => + ({ font: 'Ubuntu Mono', input, glyphs, ranges }); + +const firaCases: ITestCase[] = [ + fira('abc', [133, 145, 146], []), + fira('.=', [1614, 1081], [[0, 2]]), + fira('..=', [1614, 1614, 1083], [[0, 3]]), + fira('.-', [1614, 1080], [[0, 2]]), + fira(':=', [1614, 1055], [[0, 2]]), + fira('=:=', [1614, 1614, 1483], [[0, 3]]), + fira('=!=', [1614, 1614, 1484], [[0, 3]]), + fira('__', [1614, 1099], [[0, 2]]), + fira('==', [1614, 1485], [[0, 2]]), + fira('!=', [1614, 1058], [[0, 2]]), + fira('===', [1614, 1614, 1486], [[0, 3]]), + fira('!==', [1614, 1614, 1059], [[0, 3]]), + fira('=/=', [1614, 1614, 1491], [[0, 3]]), + fira('<-<', [1614, 1614, 1513], [[0, 3]]), + fira('<<-', [1614, 1614, 1522], [[0, 3]]), + fira('<--', [1614, 1614, 1511], [[0, 3]]), + fira('<-', [1614, 1510], [[0, 2]]), + fira('<->', [1614, 1614, 1512], [[0, 3]]), + fira('->', [1614, 1064], [[0, 2]]), + fira('-->', [1614, 1614, 1063], [[0, 3]]), + fira('->>', [1614, 1614, 1065], [[0, 3]]), + fira('>->', [1614, 1614, 1493], [[0, 3]]), + fira('<=<', [1614, 1614, 1519], [[0, 3]]), + fira('<<=', [1614, 1614, 1523], [[0, 3]]), + fira('<==', [1614, 1614, 1517], [[0, 3]]), + fira('<=>', [1614, 1614, 1518], [[0, 3]]), + fira('=>', [1614, 1488], [[0, 2]]), + fira('==>', [1614, 1614, 1487], [[0, 3]]), + fira('=>>', [1614, 1614, 1489], [[0, 3]]), + fira('>=>', [1614, 1614, 1495], [[0, 3]]), + fira('>>=', [1614, 1614, 1498], [[0, 3]]), + fira('>>-', [1614, 1614, 1497], [[0, 3]]), + fira('>-', [1614, 1492], [[0, 2]]), + fira('<~>', [1614, 1614, 1526], [[0, 3]]), + fira('-<', [1614, 1066], [[0, 2]]), + fira('-<<', [1614, 1614, 1067], [[0, 3]]), + fira('=<<', [1614, 1614, 1490], [[0, 3]]), + fira('<~~', [1614, 1614, 1527], [[0, 3]]), + fira('<~', [1614, 1525], [[0, 2]]), + fira('~~', [1614, 1534], [[0, 2]]), + fira('~>', [1614, 1533], [[0, 2]]), + fira('~~>', [1614, 1614, 1535], [[0, 3]]), + fira('<<<', [1614, 1614, 1524], [[0, 3]]), + fira('<<', [1614, 1521], [[0, 2]]), + fira('<=', [1614, 1516], [[0, 2]]), + fira('<>', [1614, 1520], [[0, 2]]), + fira('>=', [1614, 1494], [[0, 2]]), + fira('>>', [1614, 1496], [[0, 2]]), + fira('>>>', [1614, 1614, 1499], [[0, 3]]), + fira('{.', [1001, 977], [[0, 2]]), + fira('{|', [1614, 1049], [[0, 2]]), + fira('[|', [1614, 1050], [[0, 2]]), + fira('<:', [1614, 1506], [[0, 2]]), + fira(':>', [1614, 1056], [[0, 2]]), + fira('|]', [1614, 1474], [[0, 2]]), + fira('|}', [1614, 1473], [[0, 2]]), + fira('.}', [977, 1002], [[0, 2]]), + fira('<|||', [1614, 1614, 1614, 1504], [[0, 4]]), + fira('<||', [1614, 1614, 1503], [[0, 3]]), + fira('<|', [1614, 1502], [[0, 2]]), + fira('<|>', [1614, 1614, 1505], [[0, 3]]), + fira('|>', [1614, 1477], [[0, 2]]), + fira('||>', [1614, 1614, 1472], [[0, 3]]), + fira('|||>', [1614, 1614, 1614, 1470], [[0, 4]]), + fira('<$', [1614, 1507], [[0, 2]]), + fira('<$>', [1614, 1614, 1508], [[0, 3]]), + fira('$>', [1614, 1479], [[0, 2]]), + fira('<+', [1614, 1514], [[0, 2]]), + fira('<+>', [1614, 1614, 1515], [[0, 3]]), + fira('+>', [1614, 1482], [[0, 2]]), + fira('<*', [1614, 1500], [[0, 2]]), + fira('<*>', [1614, 1614, 1501], [[0, 3]]), + fira('*>', [1614, 1047], [[0, 2]]), + fira('/*', [1614, 1092], [[0, 2]]), + fira('*/', [1614, 1048], [[0, 2]]), + fira('///', [1614, 1614, 1097], [[0, 3]]), + fira('//', [1614, 1096], [[0, 2]]), + fira('', [1614, 1614, 1529], [[0, 3]]), + fira('/>', [1614, 1095], [[0, 2]]), + fira('0xff', [895, 270, 166, 166], [[0, 3]]), + fira('10x10', [896, 895, 270, 896, 895], [[1, 4]]), + fira('9:45', [904, 998, 899, 900], [[0, 2]]), + fira('[:]', [1003, 998, 1004], [[0, 2]]), + fira(';;', [1614, 1091], [[0, 2]]), + fira('::', [1614, 1052], [[0, 2]]), + fira(':::', [1614, 1614, 1053], [[0, 3]]), + fira('..', [1614, 1082], [[0, 2]]), + fira('...', [1614, 1614, 1085], [[0, 3]]), + fira('..<', [1614, 1614, 1084], [[0, 3]]), + fira('!!', [1614, 1057], [[0, 2]]), + fira('??', [1614, 1090], [[0, 2]]), + fira('%%', [1614, 1536], [[0, 2]]), + fira('&&', [1614, 1468], [[0, 2]]), + fira('||', [1614, 1469], [[0, 2]]), + fira('?.', [1614, 1089], [[0, 2]]), + fira('?:', [1614, 1087], [[0, 2]]), + fira('++', [1614, 1480], [[0, 2]]), + fira('+++', [1614, 1614, 1481], [[0, 3]]), + fira('--', [1614, 1061], [[0, 2]]), + fira('---', [1614, 1614, 1062], [[0, 3]]), + fira('**', [1614, 1045], [[0, 2]]), + fira('***', [1614, 1614, 1046], [[0, 3]]), + fira('~=', [1614, 1532], [[0, 2]]), + fira('~-', [1614, 1531], [[0, 2]]), + fira('www', [1614, 1614, 271], [[0, 3]]), + fira('-~', [1614, 1068], [[0, 2]]), + fira('~@', [1614, 1530], [[0, 2]]), + fira('^=', [1614, 1478], [[0, 2]]), + fira('?=', [1614, 1088], [[0, 2]]), + fira('/=', [1614, 1093], [[0, 2]]), + fira('/==', [1614, 1614, 1094], [[0, 3]]), + fira('-|', [1614, 1060], [[0, 2]]), + fira('_|_', [1614, 1614, 1098], [[0, 3]]), + fira('|-', [1614, 1475], [[0, 2]]), + fira('|=', [1614, 1476], [[0, 2]]), + fira('||=', [1614, 1614, 1471], [[0, 3]]), + fira('#!', [1614, 1071], [[0, 2]]), + fira('#=', [1614, 1075], [[0, 2]]), + fira('##', [1614, 1072], [[0, 2]]), + fira('###', [1614, 1614, 1073], [[0, 3]]), + fira('####', [1614, 1614, 1614, 1074], [[0, 4]]), + fira('#{', [1614, 1069], [[0, 2]]), + fira('#[', [1614, 1070], [[0, 2]]), + fira(']#', [1614, 1051], [[0, 2]]), + fira('#(', [1614, 1076], [[0, 2]]), + fira('#?', [1614, 1077], [[0, 2]]), + fira('#_', [1614, 1078], [[0, 2]]), + fira('#_(', [1614, 1614, 1079], [[0, 3]]), + fira('::=', [1614, 1614, 1054], [[0, 3]]), + fira('.?', [1614, 1086], [[0, 2]]), + fira('===>', [1614, 1614, 1486, 1148], [[0, 4]]) +]; + +const iosevkaCases: ITestCase[] = [ + iosevka('<-', [31, 3127], [[0, 2]]), + iosevka('<--', [31, 3129, 3139], [[0, 3]]), + iosevka('<---', [31, 3129, 3150, 3139], [[0, 4]]), + iosevka('<-----', [31, 3129, 3150, 3139, 3151, 3151], [[0, 6]]), + iosevka('->', [3126, 33], [[0, 2]]), + iosevka('-->', [3140, 3128, 33], [[0, 3]]), + iosevka('--->', [3140, 3150, 3128, 33], [[0, 4]]), + iosevka('----->', [3153, 3153, 3140, 3150, 3128, 33], [[0, 6]]), + iosevka('<->', [31, 3149, 33], [[0, 3]]), + iosevka('<-->', [31, 3129, 3128, 33], [[0, 4]]), + iosevka('<--->', [31, 3129, 3150, 3128, 33], [[0, 5]]), + iosevka('<----->', [31, 3129, 3150, 3150, 3150, 3128, 33], [[0, 7]]), + iosevka('<=', [3094, 3095], [[0, 2]]), + iosevka('<==', [31, 3158, 3168], [[0, 3]]), + iosevka('<===', [31, 3158, 3179, 3168], [[0, 4]]), + iosevka('<=====', [31, 3158, 3179, 3168, 3180, 3180], [[0, 6]]), + iosevka('=>', [3155, 33], [[0, 2]]), + iosevka('==>', [3169, 3157, 33], [[0, 3]]), + iosevka('===>', [3169, 3179, 3157, 33], [[0, 4]]), + iosevka('=====>', [3182, 3182, 3169, 3179, 3157, 33], [[0, 6]]), + iosevka('<=>', [31, 3178, 33], [[0, 3]]), + iosevka('<==>', [31, 3158, 3157, 33], [[0, 4]]), + iosevka('<===>', [31, 3158, 3179, 3157, 33], [[0, 5]]), + iosevka('<=====>', [31, 3158, 3179, 3179, 3179, 3157, 33], [[0, 7]]), + iosevka('', [779, 779, 628], [[0, 3]]), + monoid('<--', [776, 776, 627], [[0, 3]]), + monoid('->>', [780, 780, 626], [[0, 3]]), + monoid('<<-', [777, 777, 625], [[0, 3]]), + monoid('->', [781, 623], [[0, 2]]), + monoid('<-', [778, 624], [[0, 2]]), + monoid('=>', [793, 666], [[0, 2]]), + monoid('<=>', [785, 785, 760], [[0, 3]]), + monoid('<==>', [786, 786, 786, 771], [[0, 4]]), + monoid('==>', [787, 787, 672], [[0, 3]]), + monoid('<==', [788, 788, 671], [[0, 3]]), + monoid('>>=', [791, 791, 758], [[0, 3]]), + monoid('=<<', [792, 792, 759], [[0, 3]]), + monoid('--', [667, 667], [[0, 2]]), + monoid(':=', [29, 761], [[0, 2]]), + monoid('=:=', [789, 789, 665], [[0, 3]]), + monoid('==', [794, 641], [[0, 2]]), + monoid('!==', [782, 782, 646], [[0, 3]]), + monoid('!=', [783, 629], [[0, 2]]), + monoid('<=', [790, 630], [[0, 2]]), + monoid('>=', [792, 631], [[0, 2]]), + monoid('//', [621, 664], [[0, 2]]), + monoid('/**', [18, 753, 753], [[0, 3]]), + monoid('/*', [18, 753], [[0, 2]]), + monoid('*/', [754, 18], [[0, 2]]), + monoid('&&', [633, 775], [[0, 2]]), + monoid('.&', [17, 755], [[0, 2]]), + monoid('||', [634, 635], [[0, 2]]), + monoid('!!', [769, 770], [[0, 2]]), + monoid('::', [772, 773], [[0, 2]]), + monoid('>>', [637, 638], [[0, 2]]), + monoid('<<', [639, 640], [[0, 2]]), + monoid('¯\\_(ツ)_/¯', [113, 765, 66, 767, 613, 768, 66, 766, 113], [[0, 3], [3, 6], [6, 9]]), + monoid('__', [763, 764], [[0, 2]]) +]; + +const ubuntuCases: ITestCase[] = [ + ubuntu('==>', [32, 32, 33], []) +]; + +const fontPaths: Record = { + 'Fira Code': path.join(__dirname, '../../fonts/FiraCode-Regular.otf'), + 'Iosevka': path.join(__dirname, '../../fonts/iosevka-regular.ttf'), + 'Monoid': path.join(__dirname, '../../fonts/Monoid-Regular.ttf'), + 'Ubuntu Mono': path.join(__dirname, '../../fonts/UbuntuMono-Regular.ttf') +}; + +const fontCache: Map = new Map(); + +function loadFont(fontName: string): IFont { + let font = fontCache.get(fontName); + if (!font) { + const fontPath = fontPaths[fontName]; + const buffer = fs.readFileSync(fontPath); + font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); + fontCache.set(fontName, font); + } + return font; +} + +describe('addon-ligatures - index', () => { + describe('findLigatures', () => { + for (const { font: fontName, input, glyphs, ranges } of [...firaCases, ...iosevkaCases, ...monoidCases, ...ubuntuCases]) { + it(`${fontName}: '${input}'`, () => { + const font = loadFont(fontName); + const result = font.findLigatures(input); + assert.deepEqual(result.outputGlyphs, glyphs); + assert.deepEqual(result.contextRanges, ranges); + }); + } + }); + + describe('findLigatureRanges', () => { + for (const { font: fontName, input, ranges } of [...firaCases, ...iosevkaCases, ...monoidCases, ...ubuntuCases]) { + it(`${fontName}: '${input}'`, () => { + const font = loadFont(fontName); + const result = font.findLigatureRanges(input); + assert.deepEqual(result, ranges); + }); + } + }); + + describe('caching', () => { + it('findLigatures caches successive calls correctly', () => { + const fontPath = fontPaths['Fira Code']; + const buffer = fs.readFileSync(fontPath); + const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 }); + const result1 = font.findLigatures('in --> out'); + const result2 = font.findLigatures('in --> out'); + assert.deepEqual(result1, result2); + }); + + it('findLigatureRanges caches successive calls correctly', () => { + const fontPath = fontPaths['Fira Code']; + const buffer = fs.readFileSync(fontPath); + const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 }); + const result1 = font.findLigatureRanges('in --> out'); + const result2 = font.findLigatureRanges('in --> out'); + assert.deepEqual(result1, result2); + }); + + it('caches calls to findLigatures after findLigatureRanges correctly', () => { + const fontPath = fontPaths['Fira Code']; + const buffer = fs.readFileSync(fontPath); + + const uncached = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); + const uncachedResult1 = uncached.findLigatureRanges('in --> out'); + const uncachedResult2 = uncached.findLigatures('in --> out'); + + const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 }); + const result1 = font.findLigatureRanges('in --> out'); + const result2 = font.findLigatures('in --> out'); + + assert.deepEqual(result1, uncachedResult1); + assert.deepEqual(result2, uncachedResult2); + assert.deepEqual(result1, result2.contextRanges); + }); + + it('caches calls to findLigatureRanges after findLigatures correctly', () => { + const fontPath = fontPaths['Fira Code']; + const buffer = fs.readFileSync(fontPath); + + const uncached = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); + const uncachedResult1 = uncached.findLigatures('in --> out'); + const uncachedResult2 = uncached.findLigatureRanges('in --> out'); + + const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 }); + const result1 = font.findLigatures('in --> out'); + const result2 = font.findLigatureRanges('in --> out'); + + assert.deepEqual(result1, uncachedResult1); + assert.deepEqual(result2, uncachedResult2); + assert.deepEqual(result1.contextRanges, result2); + }); + }); +}); \ No newline at end of file diff --git a/addons/addon-ligatures/src/fontLigatures/index.ts b/addons/addon-ligatures/src/fontLigatures/index.ts new file mode 100644 index 00000000..d65e73bc --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/index.ts @@ -0,0 +1,262 @@ +import * as opentype from 'opentype.js'; +import LRUCache = require('lru-cache'); + +import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types'; +import mergeTrees from './merge'; +import walkTree from './walk'; +import mergeRange from './mergeRange'; + +import buildTreeGsubType6Format1 from './processors/6-1'; +import buildTreeGsubType6Format2 from './processors/6-2'; +import buildTreeGsubType6Format3 from './processors/6-3'; +import buildTreeGsubType8Format1 from './processors/8-1'; +import flatten from './flatten'; + +class FontImpl implements IFont { + private _font: opentype.Font; + private _lookupTrees: { tree: IFlattenedLookupTree, processForward: boolean }[] = []; + private _glyphLookups: { [glyphId: string]: number[] } = {}; + private _cache?: LRUCache; + + constructor(font: opentype.Font, options: Required) { + this._font = font; + + if (options.cacheSize > 0) { + this._cache = new LRUCache({ + max: options.cacheSize, + length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any + }); + } + + const caltFeatures = this._font.tables.gsub && this._font.tables.gsub.features.filter((f: { tag: string }) => f.tag === 'calt') || []; + const lookupIndices: number[] = caltFeatures + .reduce((acc: number[], val: { feature: { lookupListIndexes: number[] } }) => [...acc, ...val.feature.lookupListIndexes], []); + + const allLookups = this._font.tables.gsub && this._font.tables.gsub.lookups || []; + const lookupGroups = allLookups.filter((l: unknown, i: number) => lookupIndices.some(idx => idx === i)); + + for (const [index, lookup] of lookupGroups.entries()) { + const trees: ILookupTree[] = []; + switch (lookup.lookupType) { + case 6: + for (const [index, table] of lookup.subtables.entries()) { + switch (table.substFormat) { + case 1: + trees.push(buildTreeGsubType6Format1(table, allLookups, index)); + break; + case 2: + trees.push(buildTreeGsubType6Format2(table, allLookups, index)); + break; + case 3: + trees.push(buildTreeGsubType6Format3(table, allLookups, index)); + break; + } + } + break; + case 8: + for (const [index, table] of lookup.subtables.entries()) { + trees.push(buildTreeGsubType8Format1(table, index)); + } + break; + } + + const tree = flatten(mergeTrees(trees)); + + this._lookupTrees.push({ + tree, + processForward: lookup.lookupType !== 8 + }); + + for (const glyphId of Object.keys(tree)) { + if (!this._glyphLookups[glyphId]) { + this._glyphLookups[glyphId] = []; + } + + this._glyphLookups[glyphId].push(index); + } + } + } + + public findLigatures(text: string): ILigatureData { + const cached = this._cache && this._cache.get(text); + if (cached && !Array.isArray(cached)) { + return cached; + } + + const glyphIds: number[] = []; + for (const char of text) { + glyphIds.push(this._font.charToGlyphIndex(char)); + } + + // If there are no lookup groups, there's no point looking for + // replacements. This gives us a minor performance boost for fonts with + // no ligatures + if (this._lookupTrees.length === 0) { + return { + inputGlyphs: glyphIds, + outputGlyphs: glyphIds, + contextRanges: [] + }; + } + + const result = this._findInternal(glyphIds.slice()); + const finalResult: ILigatureData = { + inputGlyphs: glyphIds, + outputGlyphs: result.sequence, + contextRanges: result.ranges + }; + if (this._cache) { + this._cache.set(text, finalResult); + } + + return finalResult; + } + + public findLigatureRanges(text: string): [number, number][] { + // Short circuit the process if there are no possible ligatures in the + // font + if (this._lookupTrees.length === 0) { + return []; + } + + const cached = this._cache && this._cache.get(text); + if (cached) { + return Array.isArray(cached) ? cached : cached.contextRanges; + } + + const glyphIds: number[] = []; + for (const char of text) { + glyphIds.push(this._font.charToGlyphIndex(char)); + } + + const result = this._findInternal(glyphIds); + if (this._cache) { + this._cache.set(text, result.ranges); + } + + return result.ranges; + } + + private _findInternal(sequence: number[]): { sequence: number[], ranges: [number, number][] } { + const ranges: [number, number][] = []; + + let nextLookup = this._getNextLookup(sequence, 0); + while (nextLookup.index !== null) { + const lookup = this._lookupTrees[nextLookup.index]; + if (lookup.processForward) { + let lastGlyphIndex = nextLookup.last; + for (let i = nextLookup.first; i < lastGlyphIndex; i++) { + const result = walkTree(lookup.tree, sequence, i, i); + if (result) { + for (let j = 0; j < result.substitutions.length; j++) { + const sub = result.substitutions[j]; + if (sub !== null) { + sequence[i + j] = sub; + } + } + + mergeRange( + ranges, + result.contextRange[0] + i, + result.contextRange[1] + i + ); + + // Substitutions can end up extending the search range + if (i + result.length >= lastGlyphIndex) { + lastGlyphIndex = i + result.length + 1; + } + + i += result.length - 1; + } + } + } else { + // We don't need to do the lastGlyphIndex tracking here because + // reverse processing isn't allowed to replace more than one + // character at a time. + for (let i = nextLookup.last - 1; i >= nextLookup.first; i--) { + const result = walkTree(lookup.tree, sequence, i, i); + if (result) { + for (let j = 0; j < result.substitutions.length; j++) { + const sub = result.substitutions[j]; + if (sub !== null) { + sequence[i + j] = sub; + } + } + + mergeRange( + ranges, + result.contextRange[0] + i, + result.contextRange[1] + i + ); + + i -= result.length - 1; + } + } + } + + nextLookup = this._getNextLookup(sequence, nextLookup.index + 1); + } + + return { sequence, ranges }; + } + + /** + * Returns the lookup and glyph range for the first lookup that might + * contain a match. + * + * @param sequence Input glyph sequence + * @param start The first input to try + */ + private _getNextLookup(sequence: number[], start: number): { index: number | null, first: number, last: number } { + const result: { index: number | null, first: number, last: number } = { + index: null, + first: Infinity, + last: -1 + }; + + // Loop through each glyph and find the first valid lookup for it + for (let i = 0; i < sequence.length; i++) { + const lookups = this._glyphLookups[sequence[i]]; + if (!lookups) { + continue; + } + + for (let j = 0; j < lookups.length; j++) { + const lookupIndex = lookups[j]; + if (lookupIndex >= start) { + // Update the lookup information if it's the one we're + // storing or earlier than it. + if (result.index === null || lookupIndex <= result.index) { + result.index = lookupIndex; + + if (result.first > i) { + result.first = i; + } + + result.last = i + 1; + } + + break; + } + } + } + + return result; + } +} + +/** + * Load the font from it's binary data. The returned value can be used to find + * ligatures for the font. + * + * @param buffer ArrayBuffer of the font to load + */ +export function loadBuffer(buffer: ArrayBuffer, options?: IOptions): IFont { + const font = opentype.parse(buffer); + return new FontImpl(font, { + cacheSize: 0, + ...options + }); +} + +export { IFont as Font, ILigatureData as LigatureData, IOptions as Options }; diff --git a/addons/addon-ligatures/src/fontLigatures/merge.test.ts b/addons/addon-ligatures/src/fontLigatures/merge.test.ts new file mode 100644 index 00000000..d4e138a8 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/merge.test.ts @@ -0,0 +1,225 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import mergeTrees from './merge'; + +interface ILookupResult { + contextRange: [number, number]; + index: number; + subIndex: number; + length: number; + substitutions: number[]; +} + +function lookup(substitutionGlyph: number, index?: number, subIndex?: number): ILookupResult { + return { + contextRange: [0, 1], + index: index || 0, + subIndex: subIndex || 0, + length: 1, + substitutions: [substitutionGlyph] + }; +} + +describe('addon-ligatures - merge', () => { + describe('mergeTrees', () => { + it('combines disjoint trees', () => { + const result = mergeTrees([ + { + individual: { + '1': { lookup: lookup(1) } + }, + range: [] + }, + { + individual: {}, + range: [{ + entry: { lookup: lookup(2) }, + range: [2, 4] + }] + }, + { + individual: { + '5': { lookup: lookup(3) } + }, + range: [] + }, + { + individual: {}, + range: [{ + entry: { lookup: lookup(4) }, + range: [8, 10] + }] + } + ]); + + assert.deepEqual(result, { + individual: { + '1': { lookup: lookup(1) }, + '5': { lookup: lookup(3) } + }, + range: [{ + entry: { lookup: lookup(2) }, + range: [2, 4] + }, { + entry: { lookup: lookup(4) }, + range: [8, 10] + }] + }); + }); + + it('merges matching individual glyphs', () => { + const result = mergeTrees([ + { + individual: { + '1': { lookup: lookup(1, 1) } + }, + range: [] + }, + { + individual: { + '1': { lookup: lookup(2, 0) } + }, + range: [] + }, + { + individual: { + '1': { lookup: lookup(3, 2) } + }, + range: [] + } + ]); + + assert.deepEqual(result, { + individual: { + '1': { lookup: lookup(2, 0) } + }, + range: [] + }); + }); + + it('merges range glyphs overlapping individual glyphs', () => { + const result = mergeTrees([ + { + individual: { + '1': { lookup: lookup(1, 0) } + }, + range: [] + }, + { + individual: {}, + range: [{ + entry: { lookup: lookup(2, 1) }, + range: [0, 4] + }] + } + ]); + + assert.deepEqual(result, { + individual: { + '0': { lookup: lookup(2, 1) }, + '1': { lookup: lookup(1, 0) } + }, + range: [{ + entry: { lookup: lookup(2, 1) }, + range: [2, 4] + }] + }); + }); + + it('merges individual glyphs overlapping range glyphs', () => { + const result = mergeTrees([ + { + individual: {}, + range: [{ + entry: { lookup: lookup(2, 1) }, + range: [0, 4] + }] + }, + { + individual: { + '1': { lookup: lookup(1, 0) } + }, + range: [] + } + ]); + + assert.deepEqual(result, { + individual: { + '0': { lookup: lookup(2, 1) }, + '1': { lookup: lookup(1, 0) } + }, + range: [{ + entry: { lookup: lookup(2, 1) }, + range: [2, 4] + }] + }); + }); + + it('merges multiple overlapping ranges', () => { + const result = mergeTrees([ + { + individual: {}, + range: [{ + entry: { lookup: lookup(1, 2) }, + range: [0, 3] + }, { + entry: { lookup: lookup(2, 1) }, + range: [6, 12] + }, { + entry: { lookup: lookup(5, 3) }, + range: [15, 20] + }, { + entry: { lookup: lookup(7, 4) }, + range: [20, 22] + }] + }, + { + individual: {}, + range: [{ + entry: { lookup: lookup(3, 0) }, + range: [2, 8] + }, { + entry: { lookup: lookup(4, 0) }, + range: [10, 13] + }, { + entry: { lookup: lookup(6, 0) }, + range: [16, 21] + }] + } + ]); + + assert.deepEqual(result, { + individual: { + '2': { lookup: lookup(3, 0) }, + '12': { lookup: lookup(4, 0) }, + '15': { lookup: lookup(5, 3) }, + '20': { lookup: lookup(6, 0) }, + '21': { lookup: lookup(7, 4) } + }, + range: [{ + entry: { lookup: lookup(1, 2) }, + range: [0, 2] + }, { + entry: { lookup: lookup(3, 0) }, + range: [6, 8] + }, { + entry: { lookup: lookup(3, 0) }, + range: [3, 6] + }, { + entry: { lookup: lookup(2, 1) }, + range: [8, 10] + }, { + entry: { lookup: lookup(4, 0) }, + range: [10, 12] + }, { + entry: { lookup: lookup(6, 0) }, + range: [16, 20] + }] + }); + }); + }); +}); \ No newline at end of file diff --git a/addons/addon-ligatures/src/fontLigatures/merge.ts b/addons/addon-ligatures/src/fontLigatures/merge.ts new file mode 100644 index 00000000..262ea85a --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/merge.ts @@ -0,0 +1,393 @@ +import { ILookupTree, ILookupTreeEntry } from './types'; + +/** + * Merges the provided trees into a single lookup tree. When conflicting lookups + * are encountered between two trees, the one with the lower index, then the + * lower subindex is chosen. + * + * @param trees Array of trees to merge. Entries in earlier trees are favored + * over those in later trees when there is a choice. + */ +export default function mergeTrees(trees: ILookupTree[]): ILookupTree { + const result: ILookupTree = { + individual: {}, + range: [] + }; + + const mergedEntries = new WeakMap>(); + for (const tree of trees) { + mergeSubtree(result, tree, mergedEntries); + } + + return result; +} + +/** + * Recursively merges the data for the mergeTree into the mainTree. + * + * @param mainTree The tree where the values should be merged + * @param mergeTree The tree to be merged into the mainTree + * @param mergedEntries WeakMap to track already merged entry pairs + */ +function mergeSubtree(mainTree: ILookupTree, mergeTree: ILookupTree, mergedEntries: WeakMap>): void { + // Need to fix this recursively (and handle lookups) + for (const [glyphId, value] of Object.entries(mergeTree.individual)) { + // The main tree is guaranteed to have no overlaps between the + // individual and range values, so if we match an invididual, there + // must not be a range + if (mainTree.individual[glyphId]) { + mergeTreeEntry(mainTree.individual[glyphId], value, mergedEntries); + } else { + let matched = false; + for (const [index, { range, entry }] of mainTree.range.entries()) { + const overlap = getIndividualOverlap(Number(glyphId), range); + + // Don't overlap + if (overlap.both === null) { + continue; + } + + matched = true; + + // If they overlap, we have to split the range and then + // merge the overlap + mainTree.individual[glyphId] = value; + mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry), mergedEntries); + + // When there's an overlap, we also have to fix up the range + // that we had already processed + mainTree.range.splice(index, 1); + for (const glyph of overlap.second) { + if (Array.isArray(glyph)) { + mainTree.range.push({ + range: glyph, + entry: cloneEntry(entry) + }); + } else { + mainTree.individual[glyph] = cloneEntry(entry); + } + } + } + + if (!matched) { + mainTree.individual[glyphId] = value; + } + } + } + + for (const { range, entry } of mergeTree.range) { + // Ranges are more complicated, because they can overlap with + // multiple things, individual and range alike. We start by + // eliminating ranges that are already present in another range + let remainingRanges: (number | [number, number])[] = [range]; + + for (let index = 0; index < mainTree.range.length; index++) { + const { range, entry: resultEntry } = mainTree.range[index]; + for (const [remainingIndex, remainingRange] of remainingRanges.entries()) { + if (Array.isArray(remainingRange)) { + const overlap = getRangeOverlap(remainingRange, range); + if (overlap.both === null) { + continue; + } + + mainTree.range.splice(index, 1); + index--; + + const entryToMerge: ILookupTreeEntry = cloneEntry(resultEntry); + if (Array.isArray(overlap.both)) { + mainTree.range.push({ + range: overlap.both, + entry: entryToMerge + }); + } else { + mainTree.individual[overlap.both] = entryToMerge; + } + + mergeTreeEntry(entryToMerge, cloneEntry(entry), mergedEntries); + + for (const second of overlap.second) { + if (Array.isArray(second)) { + mainTree.range.push({ + range: second, + entry: cloneEntry(resultEntry) + }); + } else { + mainTree.individual[second] = cloneEntry(resultEntry); + } + } + + remainingRanges = overlap.first; + } else { + const overlap = getIndividualOverlap(remainingRange, range); + if (overlap.both === null) { + continue; + } + + // If they overlap, we have to split the range and then + // merge the overlap + mainTree.individual[remainingRange] = cloneEntry(entry); + mergeTreeEntry(mainTree.individual[remainingRange], cloneEntry(resultEntry), mergedEntries); + + // When there's an overlap, we also have to fix up the range + // that we had already processed + mainTree.range.splice(index, 1); + index--; + + for (const glyph of overlap.second) { + if (Array.isArray(glyph)) { + mainTree.range.push({ + range: glyph, + entry: cloneEntry(resultEntry) + }); + } else { + mainTree.individual[glyph] = cloneEntry(resultEntry); + } + } + + remainingRanges.splice(remainingIndex, 1, ...overlap.first); + break; + } + } + } + + // Next, we run the same against any individual glyphs + for (const glyphId of Object.keys(mainTree.individual)) { + for (const [remainingIndex, remainingRange] of remainingRanges.entries()) { + if (Array.isArray(remainingRange)) { + const overlap = getIndividualOverlap(Number(glyphId), remainingRange); + if (overlap.both === null) { + continue; + } + + // If they overlap, we have to merge the overlap + mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry), mergedEntries); + + // Update the remaining ranges + remainingRanges.splice(remainingIndex, 1, ...overlap.second); + break; + } else { + if (Number(glyphId) === remainingRange) { + mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry), mergedEntries); + break; + } + } + } + } + + // Any remaining ranges should just be added directly + for (const remainingRange of remainingRanges) { + if (Array.isArray(remainingRange)) { + mainTree.range.push({ + range: remainingRange, + entry: cloneEntry(entry) + }); + } else { + mainTree.individual[remainingRange] = cloneEntry(entry); + } + } + } +} + +/** + * Recursively merges the entry forr the mergeTree into the mainTree + * + * @param mainTree The entry where the values should be merged + * @param mergeTree The entry to merge into the mainTree + * @param mergedEntries WeakMap to track already merged entry pairs + */ +function mergeTreeEntry(mainTree: ILookupTreeEntry, mergeTree: ILookupTreeEntry, mergedEntries: WeakMap>): void { + // Check if we've already merged this pair + let mergedSet = mergedEntries.get(mainTree); + if (mergedSet?.has(mergeTree)) { + return; + } + if (!mergedSet) { + mergedSet = new Set(); + mergedEntries.set(mainTree, mergedSet); + } + mergedSet.add(mergeTree); + + if ( + mergeTree.lookup && ( + !mainTree.lookup || + mainTree.lookup.index > mergeTree.lookup.index || + (mainTree.lookup.index === mergeTree.lookup.index && mainTree.lookup.subIndex > mergeTree.lookup.subIndex) + ) + ) { + mainTree.lookup = mergeTree.lookup; + } + + if (mergeTree.forward) { + if (!mainTree.forward) { + mainTree.forward = mergeTree.forward; + } else { + mergeSubtree(mainTree.forward, mergeTree.forward, mergedEntries); + } + } + + if (mergeTree.reverse) { + if (!mainTree.reverse) { + mainTree.reverse = mergeTree.reverse; + } else { + mergeSubtree(mainTree.reverse, mergeTree.reverse, mergedEntries); + } + } +} + +interface IOverlap { + first: (number | [number, number])[]; + second: (number | [number, number])[]; + both: number | [number, number] | null; +} + +/** + * Determines the overlap (if any) between two ranges. Returns the distinct + * ranges for each range and the overlap (if any). + * + * @param first First range + * @param second Second range + */ +function getRangeOverlap(first: [number, number], second: [number, number]): IOverlap { + const result: IOverlap = { + first: [], + second: [], + both: null + }; + + // Both + if (first[0] < second[1] && second[0] < first[1]) { + const start = Math.max(first[0], second[0]); + const end = Math.min(first[1], second[1]); + result.both = rangeOrIndividual(start, end); + } + + // Before + if (first[0] < second[0]) { + const start = first[0]; + const end = Math.min(second[0], first[1]); + result.first.push(rangeOrIndividual(start, end)); + } else if (second[0] < first[0]) { + const start = second[0]; + const end = Math.min(second[1], first[0]); + result.second.push(rangeOrIndividual(start, end)); + } + + // After + if (first[1] > second[1]) { + const start = Math.max(first[0], second[1]); + const end = first[1]; + result.first.push(rangeOrIndividual(start, end)); + } else if (second[1] > first[1]) { + const start = Math.max(first[1], second[0]); + const end = second[1]; + result.second.push(rangeOrIndividual(start, end)); + } + + return result; +} + +/** + * Determines the overlap (if any) between the individual glyph and the range + * provided. Returns the glyphs and/or ranges that are unique to each provided + * and the overlap (if any). + * + * @param first Individual glyph + * @param second Range + */ +function getIndividualOverlap(first: number, second: [number, number]): IOverlap { + // Disjoint + if (first < second[0] || first > second[1]) { + return { + first: [first], + second: [second], + both: null + }; + } + + const result: IOverlap = { + first: [], + second: [], + both: first + }; + + if (second[0] < first) { + result.second.push(rangeOrIndividual(second[0], first)); + } + + if (second[1] > first) { + result.second.push(rangeOrIndividual(first + 1, second[1])); + } + + return result; +} + +/** + * Returns an individual glyph if the range is of size one or a range if it is + * larger. + * + * @param start Beginning of the range (inclusive) + * @param end End of the range (exclusive) + */ +function rangeOrIndividual(start: number, end: number): number | [number, number] { + if (end - start === 1) { + return start; + } + return [start, end]; + +} + +/** + * Clones an individual lookup tree entry. + * + * @param entry Lookup tree entry to clone + * @param visited Map to track already cloned entries (prevents infinite loops) + */ +function cloneEntry(entry: ILookupTreeEntry, visited: Map = new Map()): ILookupTreeEntry { + if (visited.has(entry)) { + return visited.get(entry)!; + } + + const result: ILookupTreeEntry = {}; + visited.set(entry, result); + + if (entry.forward) { + result.forward = cloneTree(entry.forward, visited); + } + + if (entry.reverse) { + result.reverse = cloneTree(entry.reverse, visited); + } + + if (entry.lookup) { + result.lookup = { + contextRange: entry.lookup.contextRange.slice() as [number, number], + index: entry.lookup.index, + length: entry.lookup.length, + subIndex: entry.lookup.subIndex, + substitutions: entry.lookup.substitutions.slice() + }; + } + + return result; +} + +/** + * Clones a lookup tree. + * + * @param tree Lookup tree to clone + * @param visited Map to track already cloned entries (prevents infinite loops) + */ +function cloneTree(tree: ILookupTree, visited: Map = new Map()): ILookupTree { + const individual: { [glyphId: string]: ILookupTreeEntry } = {}; + for (const [glyphId, entry] of Object.entries(tree.individual)) { + individual[glyphId] = cloneEntry(entry, visited); + } + + return { + individual, + range: tree.range.map(({ range, entry }) => ({ + range: range.slice() as [number, number], + entry: cloneEntry(entry, visited) + })) + }; +} diff --git a/addons/addon-ligatures/src/fontLigatures/mergeRange.test.ts b/addons/addon-ligatures/src/fontLigatures/mergeRange.test.ts new file mode 100644 index 00000000..790ea666 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/mergeRange.test.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import mergeRange from './mergeRange'; + +describe('addon-ligatures - mergeRange', () => { + it('inserts a new range before the existing ones', () => { + const result = mergeRange([[1, 2], [2, 3]], 0, 1); + assert.deepEqual(result, [[0, 1], [1, 2], [2, 3]]); + }); + + it('inserts in between two ranges', () => { + const result = mergeRange([[0, 2], [4, 6]], 2, 4); + assert.deepEqual(result, [[0, 2], [2, 4], [4, 6]]); + }); + + it('inserts after the last range', () => { + const result = mergeRange([[0, 2], [4, 6]], 6, 8); + assert.deepEqual(result, [[0, 2], [4, 6], [6, 8]]); + }); + + it('extends the beginning of a range', () => { + const result = mergeRange([[0, 2], [4, 6]], 3, 5); + assert.deepEqual(result, [[0, 2], [3, 6]]); + }); + + it('extends the end of a range', () => { + const result = mergeRange([[0, 2], [4, 6]], 1, 4); + assert.deepEqual(result, [[0, 4], [4, 6]]); + }); + + it('extends the last range', () => { + const result = mergeRange([[0, 2], [4, 6]], 5, 7); + assert.deepEqual(result, [[0, 2], [4, 7]]); + }); + + it('connects two ranges', () => { + const result = mergeRange([[0, 2], [4, 6]], 1, 5); + assert.deepEqual(result, [[0, 6]]); + }); + + it('connects more than two ranges', () => { + const result = mergeRange([[0, 2], [4, 6], [8, 10], [12, 14]], 1, 10); + assert.deepEqual(result, [[0, 10], [12, 14]]); + }); +}); diff --git a/addons/addon-ligatures/src/fontLigatures/mergeRange.ts b/addons/addon-ligatures/src/fontLigatures/mergeRange.ts new file mode 100644 index 00000000..ec530508 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/mergeRange.ts @@ -0,0 +1,66 @@ +/** + * Merges the range defined by the provided start and end into the list of + * existing ranges. The merge is done in place on the existing range for + * performance and is also returned. + * + * @param ranges Existing range list + * @param newRangeStart Start position of the range to merge, inclusive + * @param newRangeEnd End position of range to merge, exclusive + */ +export default function mergeRange(ranges: [number, number][], newRangeStart: number, newRangeEnd: number): [number, number][] { + let inRange = false; + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + if (!inRange) { + if (newRangeEnd <= range[0]) { + // Case 1: New range is before the search range + ranges.splice(i, 0, [newRangeStart, newRangeEnd]); + return ranges; + } + if (newRangeEnd <= range[1]) { + // Case 2: New range is either wholly contained within the + // search range or overlaps with the front of it + range[0] = Math.min(newRangeStart, range[0]); + return ranges; + } + if (newRangeStart < range[1]) { + // Case 3: New range either wholly contains the search range + // or overlaps with the end of it + range[0] = Math.min(newRangeStart, range[0]); + inRange = true; + } else { + // Case 4: New range starts after the search range + continue; + } + } else { + if (newRangeEnd <= range[0]) { + // Case 5: New range extends from previous range but doesn't + // reach the current one + ranges[i - 1][1] = newRangeEnd; + return ranges; + } + if (newRangeEnd <= range[1]) { + // Case 6: New range extends from prvious range into the + // current range + ranges[i - 1][1] = Math.max(newRangeEnd, range[1]); + ranges.splice(i, 1); + inRange = false; + return ranges; + } + // Case 7: New range extends from previous range past the + // end of the current range + ranges.splice(i, 1); + i--; + } + } + + if (inRange) { + // Case 8: New range extends past the last existing range + ranges[ranges.length - 1][1] = newRangeEnd; + } else { + // Case 9: New range starts after the last existing range + ranges.push([newRangeStart, newRangeEnd]); + } + + return ranges; +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/6-1.ts b/addons/addon-ligatures/src/fontLigatures/processors/6-1.ts new file mode 100644 index 00000000..c7f03b53 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/6-1.ts @@ -0,0 +1,82 @@ +import { ChainingContextualSubstitutionTable, Lookup } from '../tables'; +import { ILookupTree } from '../types'; + +import { listGlyphsByIndex } from './coverage'; +import { processInputPosition, processLookaheadPosition, processBacktrackPosition, getInputTree, IEntryMeta } from './helper'; + +/** + * Build lookup tree for GSUB lookup table 6, format 1. + * https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#61-chaining-context-substitution-format-1-simple-glyph-contexts + * + * @param table JSON representation of the table + * @param lookups List of lookup tables + * @param tableIndex Index of this table in the overall lookup + */ +export default function buildTree(table: ChainingContextualSubstitutionTable.IFormat1, lookups: Lookup[], tableIndex: number): ILookupTree { + const result: ILookupTree = { + individual: {}, + range: [] + }; + + const firstGlyphs = listGlyphsByIndex(table.coverage); + + for (const { glyphId, index } of firstGlyphs) { + const chainRuleSet = table.chainRuleSets[index]; + + // If the chain rule set is null there's nothing to do with this table. + if (!chainRuleSet) { + continue; + } + + for (const [subIndex, subTable] of chainRuleSet.entries()) { + let currentEntries: IEntryMeta[] = getInputTree( + result, + subTable.lookupRecords, + lookups, + 0, + glyphId + ).map(({ entry, substitution }) => ({ entry, substitutions: [substitution] })); + + // We walk forward, then backward + for (const [index, glyph] of subTable.input.entries()) { + currentEntries = processInputPosition( + [glyph], + index + 1, + currentEntries, + subTable.lookupRecords, + lookups + ); + } + + for (const glyph of subTable.lookahead) { + currentEntries = processLookaheadPosition( + [glyph], + currentEntries + ); + } + + for (const glyph of subTable.backtrack) { + currentEntries = processBacktrackPosition( + [glyph], + currentEntries + ); + } + + // When we get to the end, insert the lookup information + for (const { entry, substitutions } of currentEntries) { + entry.lookup = { + substitutions, + length: subTable.input.length + 1, + index: tableIndex, + subIndex, + contextRange: [ + -1 * subTable.backtrack.length, + 1 + subTable.input.length + subTable.lookahead.length + ] + }; + } + } + } + + return result; +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/6-2.ts b/addons/addon-ligatures/src/fontLigatures/processors/6-2.ts new file mode 100644 index 00000000..f3968242 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/6-2.ts @@ -0,0 +1,96 @@ +import { ChainingContextualSubstitutionTable, Lookup } from '../tables'; +import { ILookupTree } from '../types'; +import mergeTrees from '../merge'; + +import { listGlyphsByIndex } from './coverage'; +import getGlyphClass, { listClassGlyphs } from './classDef'; +import { processInputPosition, processLookaheadPosition, processBacktrackPosition, getInputTree, IEntryMeta } from './helper'; + +/** + * Build lookup tree for GSUB lookup table 6, format 2. + * https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#62-chaining-context-substitution-format-2-class-based-glyph-contexts + * + * @param table JSON representation of the table + * @param lookups List of lookup tables + * @param tableIndex Index of this table in the overall lookup + */ +export default function buildTree(table: ChainingContextualSubstitutionTable.IFormat2, lookups: Lookup[], tableIndex: number): ILookupTree { + const results: ILookupTree[] = []; + + const firstGlyphs = listGlyphsByIndex(table.coverage); + + for (const { glyphId } of firstGlyphs) { + const firstInputClass = getGlyphClass(table.inputClassDef, glyphId); + for (const [glyphId, inputClass] of firstInputClass.entries()) { + // istanbul ignore next - invalid font + if (inputClass === null) { + continue; + } + + const classSet = table.chainClassSet[inputClass]; + + // If the class set is null there's nothing to do with this table. + if (!classSet) { + continue; + } + + for (const [subIndex, subTable] of classSet.entries()) { + const result: ILookupTree = { + individual: {}, + range: [] + }; + + let currentEntries: IEntryMeta[] = getInputTree( + result, + subTable.lookupRecords, + lookups, + 0, + glyphId + ).map(({ entry, substitution }) => ({ entry, substitutions: [substitution] })); + + for (const [index, classNum] of subTable.input.entries()) { + currentEntries = processInputPosition( + listClassGlyphs(table.inputClassDef, classNum), + index + 1, + currentEntries, + subTable.lookupRecords, + lookups + ); + } + + for (const classNum of subTable.lookahead) { + currentEntries = processLookaheadPosition( + listClassGlyphs(table.lookaheadClassDef, classNum), + currentEntries + ); + } + + for (const classNum of subTable.backtrack) { + currentEntries = processBacktrackPosition( + listClassGlyphs(table.backtrackClassDef, classNum), + currentEntries + ); + } + + // When we get to the end, all of the entries we've accumulated + // should have a lookup defined + for (const { entry, substitutions } of currentEntries) { + entry.lookup = { + substitutions, + index: tableIndex, + subIndex, + length: subTable.input.length + 1, + contextRange: [ + -1 * subTable.backtrack.length, + 1 + subTable.input.length + subTable.lookahead.length + ] + }; + } + + results.push(result); + } + } + } + + return mergeTrees(results); +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/6-3.ts b/addons/addon-ligatures/src/fontLigatures/processors/6-3.ts new file mode 100644 index 00000000..334e6a56 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/6-3.ts @@ -0,0 +1,73 @@ +import { ChainingContextualSubstitutionTable, Lookup } from '../tables'; +import { ILookupTree } from '../types'; + +import { listGlyphsByIndex } from './coverage'; +import { processInputPosition, processLookaheadPosition, processBacktrackPosition, getInputTree, IEntryMeta } from './helper'; + +/** + * Build lookup tree for GSUB lookup table 6, format 3. + * https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#63-chaining-context-substitution-format-3-coverage-based-glyph-contexts + * + * @param table JSON representation of the table + * @param lookups List of lookup tables + * @param tableIndex Index of this table in the overall lookup + */ +export default function buildTree(table: ChainingContextualSubstitutionTable.IFormat3, lookups: Lookup[], tableIndex: number): ILookupTree { + const result: ILookupTree = { + individual: {}, + range: [] + }; + + const firstGlyphs = listGlyphsByIndex(table.inputCoverage[0]); + + for (const { glyphId } of firstGlyphs) { + let currentEntries: IEntryMeta[] = getInputTree( + result, + table.lookupRecords, + lookups, + 0, + glyphId + ).map(({ entry, substitution }) => ({ entry, substitutions: [substitution] })); + + for (const [index, coverage] of table.inputCoverage.slice(1).entries()) { + currentEntries = processInputPosition( + listGlyphsByIndex(coverage).map(glyph => glyph.glyphId), + index + 1, + currentEntries, + table.lookupRecords, + lookups + ); + } + + for (const coverage of table.lookaheadCoverage) { + currentEntries = processLookaheadPosition( + listGlyphsByIndex(coverage).map(glyph => glyph.glyphId), + currentEntries + ); + } + + for (const coverage of table.backtrackCoverage) { + currentEntries = processBacktrackPosition( + listGlyphsByIndex(coverage).map(glyph => glyph.glyphId), + currentEntries + ); + } + + // When we get to the end, all of the entries we've accumulated + // should have a lookup defined + for (const { entry, substitutions } of currentEntries) { + entry.lookup = { + substitutions, + index: tableIndex, + subIndex: 0, + length: table.inputCoverage.length, + contextRange: [ + -1 * table.backtrackCoverage.length, + table.inputCoverage.length + table.lookaheadCoverage.length + ] + }; + } + } + + return result; +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/8-1.ts b/addons/addon-ligatures/src/fontLigatures/processors/8-1.ts new file mode 100644 index 00000000..536b38c7 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/8-1.ts @@ -0,0 +1,69 @@ +import { IReverseChainingContextualSingleSubstitutionTable } from '../tables'; +import { ILookupTree, ILookupTreeEntry } from '../types'; + +import { listGlyphsByIndex } from './coverage'; +import { processLookaheadPosition, processBacktrackPosition, IEntryMeta } from './helper'; + +/** + * Build lookup tree for GSUB lookup table 8, format 1. + * https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#81-reverse-chaining-contextual-single-substitution-format-1-coverage-based-glyph-contexts + * + * @param table JSON representation of the table + * @param tableIndex Index of this table in the overall lookup + */ +export default function buildTree(table: IReverseChainingContextualSingleSubstitutionTable, tableIndex: number): ILookupTree { + const result: ILookupTree = { + individual: {}, + range: [] + }; + + const glyphs = listGlyphsByIndex(table.coverage); + + for (const { glyphId, index } of glyphs) { + const initialEntry: ILookupTreeEntry = {}; + if (Array.isArray(glyphId)) { + result.range.push({ + entry: initialEntry, + range: glyphId + }); + } else { + result.individual[glyphId] = initialEntry; + } + + let currentEntries: IEntryMeta[] = [{ + entry: initialEntry, + substitutions: [table.substitutes[index]] + }]; + + // We walk forward, then backward + for (const coverage of table.lookaheadCoverage) { + currentEntries = processLookaheadPosition( + listGlyphsByIndex(coverage).map(glyph => glyph.glyphId), + currentEntries + ); + } + + for (const coverage of table.backtrackCoverage) { + currentEntries = processBacktrackPosition( + listGlyphsByIndex(coverage).map(glyph => glyph.glyphId), + currentEntries + ); + } + + // When we get to the end, insert the lookup information + for (const { entry, substitutions } of currentEntries) { + entry.lookup = { + substitutions, + index: tableIndex, + subIndex: 0, + length: 1, + contextRange: [ + -1 * table.backtrackCoverage.length, + 1 + table.lookaheadCoverage.length + ] + }; + } + } + + return result; +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/classDef.ts b/addons/addon-ligatures/src/fontLigatures/processors/classDef.ts new file mode 100644 index 00000000..5dac85a1 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/classDef.ts @@ -0,0 +1,84 @@ +import { ClassDefTable } from '../tables'; + +/** + * Get the number of the class to which the glyph belongs, or null if it doesn't + * belong to any of them. + * + * @param table JSON representation of the class def table + * @param glyphId Index of the glyph to look for + */ +export default function getGlyphClass(table: ClassDefTable, glyphId: number | [number, number]): Map { + switch (table.format) { + // https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#class-definition-table-format-2 + case 2: + if (Array.isArray(glyphId)) { + return getRangeGlyphClass(table, glyphId); + } + return new Map([[ + glyphId, + getIndividualGlyphClass(table, glyphId) + ]]); + // https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#class-definition-table-format-1 + default: + return new Map([[glyphId, null]]); + } +} + +function getRangeGlyphClass(table: ClassDefTable.IFormat2, glyphId: [number, number]): Map { + const classStart: number = glyphId[0]; + const currentClass: number | null = getIndividualGlyphClass(table, classStart); + let search: number = glyphId[0] + 1; + + const result = new Map<[number, number] | number, number | null>(); + + while (search < glyphId[1]) { + const clazz = getIndividualGlyphClass(table, search); + if (clazz !== currentClass) { + if (search - classStart <= 1) { + result.set(classStart, currentClass); + } else { + result.set([classStart, search], currentClass); + } + } + search++; + } + + if (search - classStart <= 1) { + result.set(classStart, currentClass); + } else { + result.set([classStart, search], currentClass); + } + + return result; +} + +function getIndividualGlyphClass(table: ClassDefTable.IFormat2, glyphId: number): number | null { + for (const range of table.ranges) { + if (range.start <= glyphId && range.end >= glyphId) { + return range.classId; + } + } + + return null; +} + +export function listClassGlyphs(table: ClassDefTable, index: number): (number | [number, number])[] { + switch (table.format) { + case 2: + const results: (number | [number, number])[] = []; + for (const range of table.ranges) { + if (range.classId !== index) { + continue; + } + + if (range.end === range.start) { + results.push(range.start); + } else { + results.push([range.start, range.end + 1]); + } + } + return results; + default: + return []; + } +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/coverage.ts b/addons/addon-ligatures/src/fontLigatures/processors/coverage.ts new file mode 100644 index 00000000..b287a2f7 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/coverage.ts @@ -0,0 +1,43 @@ +import { CoverageTable } from '../tables'; + +/** + * Get the index of the given glyph in the coverage table, or null if it is not + * present in the table. + * + * @param table JSON representation of the coverage table + * @param glyphId Index of the glyph to look for + */ +export default function getCoverageGlyphIndex(table: CoverageTable, glyphId: number): number | null { + switch (table.format) { + // https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#coverage-format-1 + case 1: + const index = table.glyphs.indexOf(glyphId); + return index !== -1 + ? index + : null; + // https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#coverage-format-2 + case 2: + const range = table.ranges + .find(range => range.start <= glyphId && range.end >= glyphId); + return range + ? range.index + : null; + } +} + +export function listGlyphsByIndex(table: CoverageTable): { glyphId: number | [number, number], index: number }[] { + switch (table.format) { + case 1: + return table.glyphs.map((glyphId, index) => ({ glyphId, index })); + case 2: + const results: { glyphId: number | [number, number], index: number }[] = []; + for (const [index, range] of table.ranges.entries()) { + if (range.end === range.start) { + results.push({ glyphId: range.start, index }); + } else { + results.push({ glyphId: [range.start, range.end + 1], index }); + } + } + return results; + } +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/helper.ts b/addons/addon-ligatures/src/fontLigatures/processors/helper.ts new file mode 100644 index 00000000..3f06d672 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/helper.ts @@ -0,0 +1,187 @@ +import { ILookupTreeEntry, ILookupTree } from '../types'; +import { ISubstitutionLookupRecord, Lookup } from '../tables'; + +import { getIndividualSubstitutionGlyph, getRangeSubstitutionGlyphs } from './substitution'; + +export interface IEntryMeta { + entry: ILookupTreeEntry; + substitutions: (number | null)[]; +} + +export function processInputPosition( + glyphs: (number | [number, number])[], + position: number, + currentEntries: IEntryMeta[], + lookupRecords: ISubstitutionLookupRecord[], + lookups: Lookup[] +): IEntryMeta[] { + const nextEntries: IEntryMeta[] = []; + for (const currentEntry of currentEntries) { + currentEntry.entry.forward = { + individual: {}, + range: [] + }; + for (const glyph of glyphs) { + nextEntries.push(...getInputTree( + currentEntry.entry.forward, + lookupRecords, + lookups, + position, + glyph + ).map(({ entry, substitution }) => ({ + entry, + substitutions: [...currentEntry.substitutions, substitution] + }))); + } + } + + return nextEntries; +} + +export function processLookaheadPosition( + glyphs: (number | [number, number])[], + currentEntries: IEntryMeta[] +): IEntryMeta[] { + const nextEntries: IEntryMeta[] = []; + const processedEntries = new Set(); + + for (const currentEntry of currentEntries) { + // Skip if we've already processed this entry object + if (processedEntries.has(currentEntry.entry)) { + continue; + } + processedEntries.add(currentEntry.entry); + + if (!currentEntry.entry.forward) { + currentEntry.entry.forward = { + individual: {}, + range: [] + }; + } + + // All glyphs at this position share ONE entry - lookahead just needs to match, + // all paths lead to the same result + const sharedEntry: ILookupTreeEntry = {}; + + for (const glyph of glyphs) { + if (Array.isArray(glyph)) { + currentEntry.entry.forward.range.push({ + entry: sharedEntry, + range: glyph + }); + } else { + currentEntry.entry.forward.individual[glyph] = sharedEntry; + } + } + + nextEntries.push({ + entry: sharedEntry, + substitutions: currentEntry.substitutions + }); + } + + return nextEntries; +} + +export function processBacktrackPosition( + glyphs: (number | [number, number])[], + currentEntries: IEntryMeta[] +): IEntryMeta[] { + const nextEntries: IEntryMeta[] = []; + const processedEntries = new Set(); + + for (const currentEntry of currentEntries) { + // Skip if we've already processed this entry object + if (processedEntries.has(currentEntry.entry)) { + continue; + } + processedEntries.add(currentEntry.entry); + + if (!currentEntry.entry.reverse) { + currentEntry.entry.reverse = { + individual: {}, + range: [] + }; + } + + // All glyphs at this position share ONE entry - backtrack just needs to match, + // all paths lead to the same result + const sharedEntry: ILookupTreeEntry = {}; + + for (const glyph of glyphs) { + if (Array.isArray(glyph)) { + currentEntry.entry.reverse.range.push({ + entry: sharedEntry, + range: glyph + }); + } else { + currentEntry.entry.reverse.individual[glyph] = sharedEntry; + } + } + + nextEntries.push({ + entry: sharedEntry, + substitutions: currentEntry.substitutions + }); + } + + return nextEntries; +} + +export function getInputTree(tree: ILookupTree, substitutions: ISubstitutionLookupRecord[], lookups: Lookup[], inputIndex: number, glyphId: number | [number, number]): { entry: ILookupTreeEntry, substitution: number | null }[] { + const result: { entry: ILookupTreeEntry, substitution: number | null }[] = []; + if (!Array.isArray(glyphId)) { + tree.individual[glyphId] = {}; + result.push({ + entry: tree.individual[glyphId], + substitution: getSubstitutionAtPosition(substitutions, lookups, inputIndex, glyphId) + }); + } else { + const subs = getSubstitutionAtPositionRange(substitutions, lookups, inputIndex, glyphId); + for (const [range, substitution] of subs) { + const entry: ILookupTreeEntry = {}; + if (Array.isArray(range)) { + tree.range.push({ range, entry }); + } else { + tree.individual[range] = {}; + } + result.push({ entry, substitution }); + } + } + + return result; +} + +function getSubstitutionAtPositionRange(substitutions: ISubstitutionLookupRecord[], lookups: Lookup[], index: number, range: [number, number]): Map { + for (const substitution of substitutions.filter(s => s.sequenceIndex === index)) { + for (const substitutionTable of (lookups[substitution.lookupListIndex] as Lookup.IType1).subtables) { + const sub = getRangeSubstitutionGlyphs( + substitutionTable, + range + ); + + if (!Array.from(sub.values()).every(val => val !== null)) { + return sub; + } + } + } + + return new Map([[range, null]]); +} + +function getSubstitutionAtPosition(substitutions: ISubstitutionLookupRecord[], lookups: Lookup[], index: number, glyphId: number): number | null { + for (const substitution of substitutions.filter(s => s.sequenceIndex === index)) { + for (const substitutionTable of (lookups[substitution.lookupListIndex] as Lookup.IType1).subtables) { + const sub = getIndividualSubstitutionGlyph( + substitutionTable, + glyphId + ); + + if (sub !== null) { + return sub; + } + } + } + + return null; +} diff --git a/addons/addon-ligatures/src/fontLigatures/processors/substitution.ts b/addons/addon-ligatures/src/fontLigatures/processors/substitution.ts new file mode 100644 index 00000000..5eae2476 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/substitution.ts @@ -0,0 +1,62 @@ +import { SubstitutionTable } from '../tables'; + +import getCoverageGlyphIndex from './coverage'; + +/** + * Get the substitution glyph for the givne glyph, or null if the glyph was not + * found in the table. + * + * @param table JSON representation of the substitution table + * @param glyphId The index of the glpyh to find substitutions for + */ +export function getRangeSubstitutionGlyphs(table: SubstitutionTable, glyphId: [number, number]): Map<[number, number] | number, number | null> { + const replacementStart: number = glyphId[0]; + const currentReplacement: number | null = getIndividualSubstitutionGlyph(table, replacementStart); + let search: number = glyphId[0] + 1; + + const result = new Map<[number, number] | number, number | null>(); + + while (search < glyphId[1]) { + const sub = getIndividualSubstitutionGlyph(table, search); + if (sub !== currentReplacement) { + if (search - replacementStart <= 1) { + result.set(replacementStart, currentReplacement); + } else { + result.set([replacementStart, search], currentReplacement); + } + } + + search++; + } + + if (search - replacementStart <= 1) { + result.set(replacementStart, currentReplacement); + } else { + result.set([replacementStart, search], currentReplacement); + } + + return result; +} + +export function getIndividualSubstitutionGlyph(table: SubstitutionTable, glyphId: number): number | null { + const coverageIndex = getCoverageGlyphIndex(table.coverage, glyphId); + + // istanbul ignore next - invalid font + if (coverageIndex === null) { + return null; + } + + switch (table.substFormat) { + // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#11-single-substitution-format-1 + case 1: + // TODO: determine if there's a rhyme or reason to the 16-bit + // wraparound and if it can ever be a different number + return (glyphId + table.deltaGlyphId) % (2 ** 16); + // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#12-single-substitution-format-2 + case 2: + // eslint-disable-next-line eqeqeq + return table.substitute[coverageIndex] != null + ? table.substitute[coverageIndex] + : null; + } +} diff --git a/addons/addon-ligatures/src/fontLigatures/tables.ts b/addons/addon-ligatures/src/fontLigatures/tables.ts new file mode 100644 index 00000000..a2433dbf --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/tables.ts @@ -0,0 +1,112 @@ +export type SubstitutionTable = SubstitutionTable.IFormat1 | SubstitutionTable.IFormat2; +export namespace SubstitutionTable { + export interface IFormat1 { + substFormat: 1; + coverage: CoverageTable; + deltaGlyphId: number; + } + + export interface IFormat2 { + substFormat: 2; + coverage: CoverageTable; + substitute: number[]; + } +} + +export type CoverageTable = CoverageTable.IFormat1 | CoverageTable.IFormat2; +export namespace CoverageTable { + export interface IFormat1 { + format: 1; + glyphs: number[]; + } + + export interface IFormat2 { + format: 2; + ranges: { + start: number; + end: number; + index: number; + }[]; + } +} + +export type ChainingContextualSubstitutionTable = ChainingContextualSubstitutionTable.IFormat1 | + ChainingContextualSubstitutionTable.IFormat2 | ChainingContextualSubstitutionTable.IFormat3; +export namespace ChainingContextualSubstitutionTable { + export interface IFormat1 { + substFormat: 1; + coverage: CoverageTable; + chainRuleSets: ChainSubRuleTable[][]; + } + + export interface IFormat2 { + substFormat: 2; + coverage: CoverageTable; + backtrackClassDef: ClassDefTable; + inputClassDef: ClassDefTable; + lookaheadClassDef: ClassDefTable; + chainClassSet: (null | IChainSubClassRuleTable[])[]; + } + + export interface IFormat3 { + substFormat: 3; + backtrackCoverage: CoverageTable[]; + inputCoverage: CoverageTable[]; + lookaheadCoverage: CoverageTable[]; + lookupRecords: ISubstitutionLookupRecord[]; + } +} + +export interface IReverseChainingContextualSingleSubstitutionTable { + substFormat: 1; + coverage: CoverageTable; + backtrackCoverage: CoverageTable[]; + lookaheadCoverage: CoverageTable[]; + substitutes: number[]; +} + +export type ClassDefTable = ClassDefTable.IFormat2; +export namespace ClassDefTable { + export interface IFormat2 { + format: 2; + ranges: { + start: number; + end: number; + classId: number; + }[]; + } +} + +export interface ISubstitutionLookupRecord { + sequenceIndex: number; + lookupListIndex: number; +} + +export type ChainSubRuleTable = IChainSubClassRuleTable; +export interface IChainSubClassRuleTable { + backtrack: number[]; + input: number[]; + lookahead: number[]; + lookupRecords: ISubstitutionLookupRecord[]; +} + +export type Lookup = Lookup.IType1 | Lookup.IType6 | Lookup.IType8; +export namespace Lookup { + export interface IType1 { + lookupType: 1; + lookupFlag: number; + subtables: SubstitutionTable[]; + } + + export interface IType6 { + lookupType: 6; + lookupFlag: number; + subtables: ChainingContextualSubstitutionTable[]; + } + + export interface IType8 { + lookupType: 8; + lookupFlag: number; + subtables: IReverseChainingContextualSingleSubstitutionTable[]; + } +} diff --git a/addons/addon-ligatures/src/fontLigatures/types.ts b/addons/addon-ligatures/src/fontLigatures/types.ts new file mode 100644 index 00000000..4d6f44a0 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/types.ts @@ -0,0 +1,86 @@ +export interface ISubstitutionResult { + index: number; + contextRange: [number, number]; +} + +/** + * Information about ligatures found in a sequence of text + */ +export interface ILigatureData { + /** + * The list of font glyphs in the input text. + */ + inputGlyphs: number[]; + + /** + * The list of font glyphs after performing replacements for font ligatures. + */ + outputGlyphs: number[]; + + /** + * Sorted array of ranges that must be rendered together to produce the + * ligatures in the output sequence. The ranges are inclusive on the left and + * exclusive on the right. + */ + contextRanges: [number, number][]; +} + +export interface IFont { + /** + * Scans the provided text for font ligatures, returning an object with + * metadata about the text and any ligatures found. + * + * @param text String to search for ligatures + */ + findLigatures(text: string): ILigatureData; + + /** + * Scans the provided text for font ligatures, returning an array of ranges + * where ligatures are located. + * + * @param text String to search for ligatures + */ + findLigatureRanges(text: string): [number, number][]; +} + +export interface IOptions { + /** + * Optional size of previous results to store, measured in total number of + * characters from input strings. Defaults to no cache (0) + */ + cacheSize?: number; +} + +export interface ILookupTree { + individual: { + [glyphId: string]: ILookupTreeEntry; + }; + range: { + range: [number, number]; + entry: ILookupTreeEntry; + }[]; +} + +export interface ILookupTreeEntry { + lookup?: ILookupResult; + forward?: ILookupTree; + reverse?: ILookupTree; +} + +export interface ILookupResult { + substitutions: (number | null)[]; + length: number; + index: number; + subIndex: number; + contextRange: [number, number]; +} + +export interface IFlattenedLookupTree { + [glyphId: string]: IFlattenedLookupTreeEntry; +} + +export interface IFlattenedLookupTreeEntry { + lookup?: ILookupResult; + forward?: IFlattenedLookupTree; + reverse?: IFlattenedLookupTree; +} diff --git a/addons/addon-ligatures/src/fontLigatures/walk.ts b/addons/addon-ligatures/src/fontLigatures/walk.ts new file mode 100644 index 00000000..cfcf1252 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/walk.ts @@ -0,0 +1,67 @@ +import { IFlattenedLookupTree, ILookupResult } from './types'; + +export default function walkTree(tree: IFlattenedLookupTree, sequence: number[], startIndex: number, index: number): ILookupResult | undefined { + const glyphId = sequence[index]; + const subtree = tree[glyphId]; + if (!subtree) { + return undefined; + } + + let lookup = subtree.lookup; + if (subtree.reverse) { + const reverseLookup = walkReverse(subtree.reverse, sequence, startIndex); + + if ( + (!lookup && reverseLookup) || + ( + reverseLookup && lookup && ( + lookup.index > reverseLookup.index || + (lookup.index === reverseLookup.index && lookup.subIndex > reverseLookup.subIndex) + ) + ) + ) { + lookup = reverseLookup; + } + } + + if (++index >= sequence.length || !subtree.forward) { + return lookup; + } + + const forwardLookup = walkTree(subtree.forward, sequence, startIndex, index); + + if ( + (!lookup && forwardLookup) || + ( + forwardLookup && lookup && ( + lookup.index > forwardLookup.index || + (lookup.index === forwardLookup.index && lookup.subIndex > forwardLookup.subIndex) + ) + ) + ) { + lookup = forwardLookup; + } + + return lookup; +} + +function walkReverse(tree: IFlattenedLookupTree, sequence: number[], index: number): ILookupResult | undefined { + let subtree = tree[sequence[--index]]; + let lookup: ILookupResult | undefined = subtree && subtree.lookup; + while (subtree) { + if ( + (!lookup && subtree.lookup) || + (subtree.lookup && lookup && lookup.index > subtree.lookup.index) + ) { + lookup = subtree.lookup; + } + + if (--index < 0 || !subtree.reverse) { + break; + } + + subtree = subtree.reverse[sequence[index]]; + } + + return lookup; +} diff --git a/addons/addon-ligatures/src/index.ts b/addons/addon-ligatures/src/index.ts index bd8ff215..0c67f510 100644 --- a/addons/addon-ligatures/src/index.ts +++ b/addons/addon-ligatures/src/index.ts @@ -4,7 +4,7 @@ */ import type { Terminal } from '@xterm/xterm'; -import { Font } from 'font-ligatures'; +import { Font } from './fontLigatures/index'; import load from './font'; diff --git a/addons/addon-ligatures/test/parse.test.ts b/addons/addon-ligatures/src/parse.test.ts similarity index 96% rename from addons/addon-ligatures/test/parse.test.ts rename to addons/addon-ligatures/src/parse.test.ts index d6c56d2c..d2150e9d 100644 --- a/addons/addon-ligatures/test/parse.test.ts +++ b/addons/addon-ligatures/src/parse.test.ts @@ -4,11 +4,10 @@ */ import { assert } from 'chai'; - -const parse = require('../out-esbuild/parse').default; +import parse from './parse'; // TODO: integrate tests from http://test.csswg.org/suites/css-fonts-4_dev/nightly-unstable/ -describe('parse', () => { +describe('addon-ligatures - parse', () => { it('parses individual families', () => { assert.deepEqual(parse('monospace'), ['monospace']); }); diff --git a/addons/addon-ligatures/src/tsconfig.json b/addons/addon-ligatures/src/tsconfig.json index cc9a9bef..f54111a3 100644 --- a/addons/addon-ligatures/src/tsconfig.json +++ b/addons/addon-ligatures/src/tsconfig.json @@ -9,7 +9,9 @@ "noUnusedLocals": true, "preserveWatchOutput": true, "types": [ - "../../../node_modules/@types/mocha" + "../../../node_modules/@types/mocha", + // HACK: src shouldn't use node types but it's needed for index.test.ts + "../../../node_modules/@types/node" ], "paths": { "@xterm/addon-ligatures" : [ diff --git a/addons/addon-web-fonts/LICENSE b/addons/addon-web-fonts/LICENSE new file mode 100644 index 00000000..447eb79f --- /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 00000000..ce43cbe5 --- /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 00000000..856c7e25 --- /dev/null +++ b/addons/addon-web-fonts/package.json @@ -0,0 +1,26 @@ +{ + "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" + }, + "dependencies": {} +} diff --git a/addons/addon-web-fonts/src/WebFontsAddon.ts b/addons/addon-web-fonts/src/WebFontsAddon.ts new file mode 100644 index 00000000..cc0ccd1f --- /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 00000000..cd297308 --- /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 00000000..1315b1ee --- /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 00000000..22834be1 --- /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 00000000..120fccdc --- /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 00000000..2d820dd1 --- /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 00000000..c7a159ed --- /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 00000000..f75d2d50 --- /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 23303781..5e0a370c 100644 --- a/bin/esbuild.mjs +++ b/bin/esbuild.mjs @@ -140,6 +140,7 @@ if (config.addon) { "@xterm/addon-progress": "./addons/addon-progress/lib/addon-progress.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/publish.js b/bin/publish.js index fd676b3a..0fa2fadd 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -51,6 +51,7 @@ const addonPackageDirs = [ path.resolve(__dirname, '../addons/addon-serialize'), path.resolve(__dirname, '../addons/addon-unicode11'), path.resolve(__dirname, '../addons/addon-unicode-graphemes'), + path.resolve(__dirname, '../addons/addon-web-fonts'), path.resolve(__dirname, '../addons/addon-web-links'), path.resolve(__dirname, '../addons/addon-webgl') ]; diff --git a/bin/test_integration.js b/bin/test_integration.js index 68f3ffdb..37e6aca7 100644 --- a/bin/test_integration.js +++ b/bin/test_integration.js @@ -30,6 +30,7 @@ const addons = [ 'serialize', 'unicode-graphemes', 'unicode11', + 'web-fonts', 'web-links', 'webgl', ]; diff --git a/demo/client/client.ts b/demo/client/client.ts index 6a0cdead..68202ad7 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -18,6 +18,7 @@ import { AttachAddon } from '@xterm/addon-attach'; import { AddonImageWindow } from './components/window/addonImageWindow'; import { AddonSearchWindow } from './components/window/addonSearchWindow'; import { AddonSerializeWindow } from './components/window/addonSerializeWindow'; +import { AddonWebFontsWindow } from './components/window/addonWebFontsWindow'; import { AddonsWindow } from './components/window/addonsWindow'; import { CellInspectorWindow } from './components/window/cellInspectorWindow'; import { ControlBar } from './components/controlBar'; @@ -32,6 +33,7 @@ import { LigaturesAddon } from '@xterm/addon-ligatures'; import { ProgressAddon } from '@xterm/addon-progress'; import { SearchAddon, ISearchOptions } from '@xterm/addon-search'; import { SerializeAddon } from '@xterm/addon-serialize'; +import { WebFontsAddon } from '@xterm/addon-web-fonts'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; import { Unicode11Addon } from '@xterm/addon-unicode11'; @@ -75,6 +77,7 @@ const addons: AddonCollection = { progress: { name: 'progress', ctor: ProgressAddon, 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 }, @@ -213,7 +216,8 @@ if (document.location.pathname === '/test') { addonSearchWindow = controlBar.registerWindow(new AddonSearchWindow(typedTerm, addons), { afterId: 'addons', hidden: true, italics: true }); controlBar.registerWindow(new AddonSerializeWindow(typedTerm, addons), { afterId: 'addon-search', hidden: true, italics: true }); controlBar.registerWindow(new AddonImageWindow(typedTerm, addons), { afterId: 'addon-serialize', hidden: true, italics: true }); - addonWebglWindow = controlBar.registerWindow(new WebglWindow(typedTerm, addons), { afterId: 'addon-image', hidden: true, italics: true }); + controlBar.registerWindow(new AddonWebFontsWindow(typedTerm, addons), { afterId: 'addon-image', hidden: true, italics: true }); + addonWebglWindow = controlBar.registerWindow(new WebglWindow(typedTerm, addons), { afterId: 'addon-web-fonts', hidden: true, italics: true }); controlBar.registerWindow(new TestWindow(typedTerm, addons, { disposeRecreateButtonHandler, createNewWindowButtonHandler }), { afterId: 'options' }); actionElements = { findNext: addonSearchWindow.findNextInput, @@ -229,6 +233,7 @@ if (document.location.pathname === '/test') { controlBar.setTabVisible('addon-search', true); controlBar.setTabVisible('addon-serialize', true); controlBar.setTabVisible('addon-image', true); + controlBar.setTabVisible('addon-web-fonts', true); addonWebglWindow.setTextureAtlas(addons.webgl.instance.textureAtlas); addons.webgl.instance.onChangeTextureAtlas(e => addonWebglWindow.setTextureAtlas(e)); addons.webgl.instance.onAddTextureAtlasCanvas(e => addonWebglWindow.appendTextureAtlas(e)); @@ -295,6 +300,7 @@ function createTerminal(): Terminal { console.warn(e); } addons.webLinks.instance = new WebLinksAddon(); + addons.webFonts.instance = new WebFontsAddon(); typedTerm.loadAddon(addons.fit.instance); typedTerm.loadAddon(addons.image.instance); typedTerm.loadAddon(addons.progress.instance); @@ -302,6 +308,7 @@ function createTerminal(): Terminal { typedTerm.loadAddon(addons.serialize.instance); typedTerm.loadAddon(addons.unicodeGraphemes.instance); typedTerm.loadAddon(addons.webLinks.instance); + typedTerm.loadAddon(addons.webFonts.instance); typedTerm.loadAddon(addons.clipboard.instance); window.term = term; // Expose `term` to window for debugging purposes diff --git a/demo/client/components/window/addonSerializeWindow.ts b/demo/client/components/window/addonSerializeWindow.ts index 688708ab..ebc3df36 100644 --- a/demo/client/components/window/addonSerializeWindow.ts +++ b/demo/client/components/window/addonSerializeWindow.ts @@ -3,10 +3,8 @@ * @license MIT */ -import type { Terminal } from '@xterm/xterm'; import { BaseWindow } from './baseWindow'; import type { IControlWindow } from '../controlBar'; -import type { AddonCollection } from '../../types'; export class AddonSerializeWindow extends BaseWindow implements IControlWindow { public readonly id = 'addon-serialize'; diff --git a/demo/client/components/window/addonWebFontsWindow.ts b/demo/client/components/window/addonWebFontsWindow.ts new file mode 100644 index 00000000..c8fb288d --- /dev/null +++ b/demo/client/components/window/addonWebFontsWindow.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { loadFonts } from '@xterm/addon-web-fonts'; +import { BaseWindow } from './baseWindow'; +import type { IControlWindow } from '../controlBar'; + +export class AddonWebFontsWindow extends BaseWindow implements IControlWindow { + public readonly id = 'addon-web-fonts'; + public readonly label = 'web-fonts'; + + public build(container: HTMLElement): void { + const dl = document.createElement('dl'); + + // Kongtext font button + const dtKongtext = document.createElement('dt'); + dtKongtext.textContent = 'Kongtext'; + dl.appendChild(dtKongtext); + + const ddKongtext = document.createElement('dd'); + const btnKongtext = document.createElement('button'); + btnKongtext.textContent = 'Load Kongtext'; + btnKongtext.title = 'Load Kongtext font and apply C64 style'; + btnKongtext.addEventListener('click', async () => { + const ff = new FontFace('Kongtext', 'url(/fonts/kongtext.regular.ttf) format(\'truetype\')'); + await loadFonts([ff]); + this._terminal.options.fontFamily = 'Kongtext'; + this._terminal.options.lineHeight = 1.3; + this._addons.fit.instance?.fit(); + setTimeout(() => this._terminal.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(() => { this._terminal.write('🤣\x1b[m\x1b[99;1H'); this._terminal.input('\r'); }, 5000); + }); + ddKongtext.appendChild(btnKongtext); + dl.appendChild(ddKongtext); + + // BPdots font button + const dtBpdots = document.createElement('dt'); + dtBpdots.textContent = 'BPdots'; + dl.appendChild(dtBpdots); + + const ddBpdots = document.createElement('dd'); + const btnBpdots = document.createElement('button'); + btnBpdots.textContent = 'Load BPdots'; + btnBpdots.title = 'Load BPdots font'; + btnBpdots.addEventListener('click', async () => { + document.styleSheets[0].insertRule('@font-face { font-family: "BPdots"; src: url(/fonts/bpdots.regular.otf) format("opentype"); weight: 400 }', 0); + await loadFonts(['BPdots']); + this._terminal.options.fontFamily = 'BPdots'; + this._terminal.options.lineHeight = 1.3; + this._terminal.options.fontSize = 20; + this._addons.fit.instance?.fit(); + }); + ddBpdots.appendChild(btnBpdots); + dl.appendChild(ddBpdots); + + container.appendChild(dl); + } +} diff --git a/demo/client/components/window/cellInspectorWindow.ts b/demo/client/components/window/cellInspectorWindow.ts index 6a6f67d7..9098fc5e 100644 --- a/demo/client/components/window/cellInspectorWindow.ts +++ b/demo/client/components/window/cellInspectorWindow.ts @@ -5,8 +5,7 @@ import { BaseWindow } from './baseWindow'; import type { IControlWindow } from '../controlBar'; -import type { Terminal, IBufferCell } from '@xterm/xterm'; -import type { AddonCollection } from '../../types'; +import type { IBufferCell } from '@xterm/xterm'; // Underline style values from common/buffer/Constants.ts const enum UnderlineStyle { @@ -40,13 +39,6 @@ export class CellInspectorWindow extends BaseWindow implements IControlWindow { private _bgEl: HTMLElement; private _attrsEl: HTMLElement; - constructor( - terminal: Terminal, - addons: AddonCollection, - ) { - super(terminal, addons); - } - public build(container: HTMLElement): void { this._container = container; diff --git a/demo/client/components/window/gpuWindow.ts b/demo/client/components/window/gpuWindow.ts index 7035321f..84bccf11 100644 --- a/demo/client/components/window/gpuWindow.ts +++ b/demo/client/components/window/gpuWindow.ts @@ -43,7 +43,9 @@ export class GpuWindow extends BaseWindow implements IControlWindow { } private _styleAtlasPage(canvas: HTMLCanvasElement): void { - canvas.style.width = `${canvas.width / window.devicePixelRatio}px`; - canvas.style.height = `${canvas.height / window.devicePixelRatio}px`; + // eslint-disable-next-line no-restricted-syntax + const dpr = window.devicePixelRatio; + canvas.style.width = `${canvas.width / dpr}px`; + canvas.style.height = `${canvas.height / dpr}px`; } } diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index c2f810b8..d084784f 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -88,11 +88,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { terminal: Terminal, addons: AddonCollection, private readonly _handlers: { - updateTerminalSize: () => void, - updateTerminalContainerBackground: () => void + updateTerminalSize: () => void; + updateTerminalContainerBackground: () => void; }, ) { - super(terminal, addons) + super(terminal, addons); } public build(container: HTMLElement): void { @@ -226,6 +226,9 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { if (o === 'theme') { this._handlers.updateTerminalContainerBackground(); } + if (o === 'fontFamily') { + this._handlers.updateTerminalSize(); + } }); }); } @@ -238,7 +241,7 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { this._autoResize = value; } - private _getTheme() { + private _getTheme(): ITheme { const input = document.querySelector('#opt-theme'); let theme: ITheme; switch (input.value) { diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts index 1b2d5721..e811ce78 100644 --- a/demo/client/components/window/testWindow.ts +++ b/demo/client/components/window/testWindow.ts @@ -3,8 +3,6 @@ * @license MIT */ -/// - import { writeUnicodeTable } from '../../unicodeTable'; import type { IControlWindow } from '../controlBar'; import { BaseWindow } from './baseWindow'; @@ -21,8 +19,8 @@ export class TestWindow extends BaseWindow implements IControlWindow { terminal: Terminal, addons: AddonCollection, private readonly _handlers: { - disposeRecreateButtonHandler: () => void, - createNewWindowButtonHandler: () => void, + disposeRecreateButtonHandler: () => void; + createNewWindowButtonHandler: () => void; }, ) { super(terminal, addons); @@ -501,7 +499,7 @@ function customGlyphAlignmentHandler(term: Terminal): void { term.write(' \u{F5F9}\u{F5F9} \n\r'); term.write('\x1b[0m'); term.write('\n\r'); - + term.write('\x1b[0mProgress bar alignment tests:\x1b[33m\n\r'); term.write('\u{EE00}\u{EE01}\u{EE02} \u{EE03}\u{EE04}\u{EE05}'); diff --git a/demo/client/components/window/vtWindow.ts b/demo/client/components/window/vtWindow.ts index 7e6d2c74..b5d01367 100644 --- a/demo/client/components/window/vtWindow.ts +++ b/demo/client/components/window/vtWindow.ts @@ -22,7 +22,7 @@ export class VtWindow extends BaseWindow implements IControlWindow { container.appendChild(vtContainer); const vtFragment = document.createDocumentFragment(); - const buttonSpecs: { [key: string]: { label: string; description: string; paramCount?: number } } = { + const buttonSpecs: { [key: string]: { label: string, description: string, paramCount?: number } } = { 'A': { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, 'B': { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, 'C': { label: 'CUF →', description: 'Cursor Forward Ps Times' }, diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index b63728eb..f4fabf63 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -43,7 +43,9 @@ export class WebglWindow extends BaseWindow implements IControlWindow { } private _styleAtlasPage(canvas: HTMLCanvasElement): void { - canvas.style.width = `${canvas.width / window.devicePixelRatio}px`; - canvas.style.height = `${canvas.height / window.devicePixelRatio}px`; + // eslint-disable-next-line no-restricted-syntax + const dpr = window.devicePixelRatio; + canvas.style.width = `${canvas.width / dpr}px`; + canvas.style.height = `${canvas.height / dpr}px`; } } diff --git a/demo/client/tsconfig.json b/demo/client/tsconfig.json index 66a068bc..17aae876 100644 --- a/demo/client/tsconfig.json +++ b/demo/client/tsconfig.json @@ -14,6 +14,7 @@ "@xterm/addon-progress": ["../../addons/addon-progress"], "@xterm/addon-search": ["../../addons/addon-search"], "@xterm/addon-serialize": ["../../addons/addon-serialize"], + "@xterm/addon-web-fonts": ["../../addons/addon-web-fonts"], "@xterm/addon-web-links": ["../../addons/addon-web-links"], "@xterm/addon-webgl": ["../../addons/addon-webgl"], "@xterm/addon-unicode11": ["../../addons/addon-unicode11"], diff --git a/demo/client/types.ts b/demo/client/types.ts index bc73ae0e..4790fd4f 100644 --- a/demo/client/types.ts +++ b/demo/client/types.ts @@ -15,10 +15,11 @@ import type { SearchAddon } from '@xterm/addon-search'; import type { SerializeAddon } from '@xterm/addon-serialize'; import type { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; import type { Unicode11Addon } from '@xterm/addon-unicode11'; +import type { WebFontsAddon } from '@xterm/addon-web-fonts'; import type { WebLinksAddon } from '@xterm/addon-web-links'; import type { WebglAddon } from '@xterm/addon-webgl'; -export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webLinks' | 'webgl' | 'ligatures'; +export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures'; export interface IDemoAddon { name: T; @@ -32,11 +33,12 @@ export interface IDemoAddon { T extends 'progress' ? typeof ProgressAddon : 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 : @@ -47,11 +49,12 @@ export interface IDemoAddon { T extends 'progress' ? ProgressAddon : 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 ); } diff --git a/demo/fonts/bpdots.regular.otf b/demo/fonts/bpdots.regular.otf new file mode 100644 index 00000000..e5b5d1dc Binary files /dev/null and b/demo/fonts/bpdots.regular.otf differ diff --git a/demo/fonts/font-licenses.txt b/demo/fonts/font-licenses.txt new file mode 100644 index 00000000..5b1b5ba6 --- /dev/null +++ b/demo/fonts/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/fonts/kongtext.regular.ttf b/demo/fonts/kongtext.regular.ttf new file mode 100644 index 00000000..5e4d65fd Binary files /dev/null and b/demo/fonts/kongtext.regular.ttf differ diff --git a/demo/server/server.ts b/demo/server/server.ts index 97a91243..0f4a79b3 100644 --- a/demo/server/server.ts +++ b/demo/server/server.ts @@ -45,6 +45,8 @@ function startServer(): void { res.sendFile(demoRoot + '/index.css'); }); + app.use('/fonts', express.static(demoRoot + '/fonts')); + app.use('/dist', express.static(demoRoot + '/dist')); app.use('/src', express.static(demoRoot + '/src')); diff --git a/package-lock.json b/package-lock.json index 71dff295..4c218b70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,11 +87,14 @@ "version": "0.10.0", "license": "MIT", "dependencies": { - "font-finder": "^1.1.0", - "font-ligatures": "^1.4.1" + "lru-cache": "^6.0.0", + "opentype.js": "^0.8.0" }, "devDependencies": { + "@types/lru-cache": "^5.1.0", + "@types/opentype.js": "^0.7.0", "axios": "^1.6.0", + "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^2.10.0" }, @@ -99,6 +102,24 @@ "node": ">8.0.0" } }, + "addons/addon-ligatures/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "addons/addon-ligatures/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "addons/addon-progress": { "name": "@xterm/addon-progress", "version": "0.2.0", @@ -124,6 +145,11 @@ "version": "0.9.0", "license": "MIT" }, + "addons/addon-web-fonts": { + "name": "@xterm/addon-web-fonts", + "version": "0.1.0", + "license": "MIT" + }, "addons/addon-web-links": { "name": "@xterm/addon-web-links", "version": "0.12.0", @@ -1701,6 +1727,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mathjs": { "version": "6.0.12", "resolved": "https://registry.npmjs.org/@types/mathjs/-/mathjs-6.0.12.tgz", @@ -1737,6 +1770,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/opentype.js": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-0.7.2.tgz", + "integrity": "sha512-Riz6WyBUBEFs7YqSsJya3SbDHJZ6BmMkY7bzNoue6rtwj+RNilLc+mgOX/eJ0Y0asq16FSU6DatBeOg8ZMy2UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/puppeteer": { "version": "5.4.7", "resolved": "https://registry.npmjs.org/@types/puppeteer/-/puppeteer-5.4.7.tgz", @@ -2258,6 +2298,10 @@ "resolved": "addons/addon-unicode11", "link": true }, + "node_modules/@xterm/addon-web-fonts": { + "resolved": "addons/addon-web-fonts", + "link": true + }, "node_modules/@xterm/addon-web-links": { "resolved": "addons/addon-web-links", "link": true @@ -4228,6 +4272,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/font-finder/-/font-finder-1.1.0.tgz", "integrity": "sha512-wpCL2uIbi6GurJbU7ZlQ3nGd61Ho+dSU6U83/xJT5UPFfN35EeCW/rOtS+5k+IuEZu2SYmHzDIPL9eA5tSYRAw==", + "dev": true, + "license": "MIT", "dependencies": { "get-system-fonts": "^2.0.0", "promise-stream-reader": "^1.0.1" @@ -4236,35 +4282,6 @@ "node": ">8.0.0" } }, - "node_modules/font-ligatures": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/font-ligatures/-/font-ligatures-1.4.1.tgz", - "integrity": "sha512-7W6zlfyhvCqShZ5ReUWqmSd9vBaUudW0Hxis+tqUjtHhsPU+L3Grf8mcZAtCiXHTzorhwdRTId2WeH/88gdFkw==", - "dependencies": { - "font-finder": "^1.0.3", - "lru-cache": "^6.0.0", - "opentype.js": "^0.8.0" - }, - "engines": { - "node": ">8.0.0" - } - }, - "node_modules/font-ligatures/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/font-ligatures/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -4490,6 +4507,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-system-fonts/-/get-system-fonts-2.0.2.tgz", "integrity": "sha512-zzlgaYnHMIEgHRrfC7x0Qp0Ylhw/sHpM6MHXeVBTYIsvGf5GpbnClB+Q6rAPdn+0gd2oZZIo6Tj3EaWrt4VhDQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">8.0.0" } @@ -6700,6 +6719,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz", "integrity": "sha512-Tnxit5trUjBAqqZCGWwjyxhmgMN4hGrtpW3Oc/tRI4bpm/O2+ej72BB08l6JBnGQgVDGCLvHFGjGgQS6vzhwXg==", + "dev": true, + "license": "MIT", "engines": { "node": ">8.0.0" } @@ -6733,10 +6754,11 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" }, diff --git a/package.json b/package.json index e99b5350..ecb60ef4 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,8 @@ "esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch", "test": "npm run test-unit", "posttest": "npm run lint", - "lint": "eslint --max-warnings 0 src/ addons/", - "lint-fix": "eslint --fix src/ addons/", + "lint": "eslint --max-warnings 0 src/ addons/ demo/", + "lint-fix": "eslint --fix src/ addons/ demo/", "lint-api": "eslint --config eslint.config.typings.mjs --max-warnings 0 typings/", "test-unit": "node ./bin/test_unit.js", "test-unit-coverage": "node ./bin/test_unit.js --coverage", diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index d2407062..abc363ed 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -636,8 +636,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // send event to CoreMouseService function sendEvent(ev: MouseEvent | WheelEvent): boolean { - // get mouse coordinates - const pos = self._mouseService!.getMouseReportCoords(ev, self.screenElement!); + // Get mouse coordinates + const pos = self._mouseService?.getMouseReportCoords(ev, self.screenElement!); if (!pos) { return false; } @@ -804,6 +804,16 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // force initial onProtocolChange so we dont miss early mouse requests this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol; + // Ensure document-level listeners are removed on dispose + this._register(toDisposable(() => { + if (requestedEvents.mouseup) { + this._document!.removeEventListener('mouseup', requestedEvents.mouseup); + } + if (requestedEvents.mousedrag) { + this._document!.removeEventListener('mousemove', requestedEvents.mousedrag); + } + })); + /** * "Always on" event listeners. */ diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index c872f5e1..157ceb14 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -103,7 +103,6 @@ export class Terminal extends Disposable implements ITerminalApi { return this._buffer; } public get markers(): ReadonlyArray { - this._checkProposedApi(); return this._core.markers; } public get modes(): IModes { @@ -166,11 +165,9 @@ export class Terminal extends Disposable implements ITerminalApi { return this._core.registerLinkProvider(linkProvider); } public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { - this._checkProposedApi(); return this._core.registerCharacterJoiner(handler); } public deregisterCharacterJoiner(joinerId: number): void { - this._checkProposedApi(); this._core.deregisterCharacterJoiner(joinerId); } public registerMarker(cursorYOffset: number = 0): IMarker { @@ -178,7 +175,6 @@ export class Terminal extends Disposable implements ITerminalApi { return this._core.registerMarker(cursorYOffset); } public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { - this._checkProposedApi(); this._verifyPositiveIntegers(decorationOptions.x ?? 0, decorationOptions.width ?? 0, decorationOptions.height ?? 0); return this._core.registerDecoration(decorationOptions); } diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 1dd1640f..e0e6253c 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1154,7 +1154,10 @@ export class InputHandler extends Disposable implements IInputHandler { * @param respectProtect Whether to respect the protection attribute (DECSCA). */ private _eraseInBufferLine(y: number, start: number, end: number, clearWrap: boolean = false, respectProtect: boolean = false): void { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; + const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y); + if (!line) { + return; + } line.replaceCells( start, end, @@ -1224,7 +1227,10 @@ export class InputHandler extends Disposable implements IInputHandler { this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect); if (this._activeBuffer.x + 1 >= this._bufferService.cols) { // Deleted entire previous line. This next line can no longer be wrapped. - this._activeBuffer.lines.get(j + 1)!.isWrapped = false; + const nextLine = this._activeBuffer.lines.get(j + 1); + if (nextLine) { + nextLine.isWrapped = false; + } } while (j--) { this._resetBufferLine(j, respectProtect); diff --git a/src/common/Platform.ts b/src/common/Platform.ts index 4102f20c..ec34acde 100644 --- a/src/common/Platform.ts +++ b/src/common/Platform.ts @@ -14,7 +14,10 @@ interface INavigator { declare const navigator: INavigator; declare const process: unknown; -export const isNode = (typeof process !== 'undefined' && 'title' in (process as any)) ? true : false; +// navigator.userAgent is also checked here because bundling with the process module can cause +// issues otherwise. Note that navigator exists in Node.js 21+ but the userAgent is +// "Node.js/". +export const isNode = (typeof process !== 'undefined' && 'title' in (process as any) && (typeof navigator === 'undefined' || navigator.userAgent.startsWith('Node.js/'))) ? true : false; const userAgent = (isNode) ? 'node' : navigator.userAgent; const platform = (isNode) ? 'node' : navigator.platform; diff --git a/src/headless/public/Terminal.test.ts b/src/headless/public/Terminal.test.ts index 8beffc69..059b4a50 100644 --- a/src/headless/public/Terminal.test.ts +++ b/src/headless/public/Terminal.test.ts @@ -22,7 +22,7 @@ describe('Headless API Tests', function (): void { it('Proposed API check', async () => { term = new Terminal({ allowProposedApi: false }); - throws(() => term.markers, (error: any) => error.message === 'You must set the allowProposedApi option to true to use proposed API'); + throws(() => term.unicode, (error: any) => error.message === 'You must set the allowProposedApi option to true to use proposed API'); }); it('write', async () => { diff --git a/src/headless/public/Terminal.ts b/src/headless/public/Terminal.ts index 811345b9..e0a47e1f 100644 --- a/src/headless/public/Terminal.ts +++ b/src/headless/public/Terminal.ts @@ -84,7 +84,6 @@ export class Terminal extends Disposable implements ITerminalApi { public get onWriteParsed(): Event { return this._core.onWriteParsed; } public get parser(): IParser { - this._checkProposedApi(); if (!this._parser) { this._parser = new ParserApi(this._core); } @@ -97,14 +96,12 @@ export class Terminal extends Disposable implements ITerminalApi { public get rows(): number { return this._core.rows; } public get cols(): number { return this._core.cols; } public get buffer(): IBufferNamespaceApi { - this._checkProposedApi(); if (!this._buffer) { this._buffer = this._register(new BufferNamespaceApi(this._core)); } return this._buffer; } public get markers(): ReadonlyArray { - this._checkProposedApi(); return this._core.markers; } public get modes(): IModes { @@ -146,7 +143,6 @@ export class Terminal extends Disposable implements ITerminalApi { this._core.resize(columns, rows); } public registerMarker(cursorYOffset: number = 0): IMarker | undefined { - this._checkProposedApi(); this._verifyIntegers(cursorYOffset); return this._core.addMarker(cursorYOffset); } diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index 5deea6d1..dee14aa4 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -25,7 +25,7 @@ test.describe('API Integration Tests', () => { await openTerminal(ctx, { allowProposedApi: false }, { loadUnicodeGraphemesAddon: false }); await ctx.page.evaluate(` try { - window.term.markers; + window.term.unicode; } catch (e) { window.throwMessage = e.message; } diff --git a/tsconfig.all.json b/tsconfig.all.json index 5983deec..b0ed0279 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -17,6 +17,7 @@ { "path": "./addons/addon-serialize" }, { "path": "./addons/addon-unicode11" }, { "path": "./addons/addon-unicode-graphemes" }, + { "path": "./addons/addon-web-fonts" }, { "path": "./addons/addon-web-links" }, { "path": "./addons/addon-webgl" } ] diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 3c018a3b..abc28d08 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -584,21 +584,18 @@ declare module '@xterm/headless' { readonly cols: number; /** - * (EXPERIMENTAL) The terminal's current buffer, this might be either the - * normal buffer or the alt buffer depending on what's running in the - * terminal. + * Access to the terminal's normal and alt buffer. */ readonly buffer: IBufferNamespace; /** - * (EXPERIMENTAL) Get all markers registered against the buffer. If the alt - * buffer is active this will always return []. + * Get all markers registered against the buffer. If the alt buffer is + * active this will always return []. */ readonly markers: ReadonlyArray; /** - * (EXPERIMENTAL) Get the parser interface to register - * custom escape sequence handlers. + * Get the parser interface to register custom escape sequence handlers. */ readonly parser: IParser; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 1084cae8..fb74491c 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -856,8 +856,8 @@ declare module '@xterm/xterm' { readonly buffer: IBufferNamespace; /** - * (EXPERIMENTAL) Get all markers registered against the buffer. If the alt - * buffer is active this will always return []. + * Get all markers registered against the buffer. If the alt buffer is + * active this will always return []. */ readonly markers: ReadonlyArray; @@ -867,8 +867,8 @@ declare module '@xterm/xterm' { readonly parser: IParser; /** - * (EXPERIMENTAL) Get the Unicode handling interface - * to register and switch Unicode version. + * (EXPERIMENTAL) Get the Unicode handling interface to register and switch + * Unicode version. */ readonly unicode: IUnicodeHandling; @@ -1129,9 +1129,9 @@ declare module '@xterm/xterm' { registerLinkProvider(linkProvider: ILinkProvider): IDisposable; /** - * (EXPERIMENTAL) Registers a character joiner, allowing custom sequences of - * characters to be rendered as a single unit. This is useful in particular - * for rendering ligatures and graphemes, among other things. + * Registers a character joiner, allowing custom sequences of characters to + * be rendered as a single unit. This is useful in particular for rendering + * ligatures and graphemes, among other things. * * Each registered character joiner is called with a string of text * representing a portion of a line in the terminal that can be rendered as @@ -1149,8 +1149,6 @@ declare module '@xterm/xterm' { * render together, since they aren't drawn as optimally as individual * characters. * - * NOTE: character joiners are only used by the webgl renderer. - * * @param handler The function that determines character joins. It is called * with a string of text that is eligible for joining and returns an array * where each entry is an array containing the start (inclusive) and end @@ -1160,8 +1158,8 @@ declare module '@xterm/xterm' { registerCharacterJoiner(handler: (text: string) => [number, number][]): number; /** - * (EXPERIMENTAL) Deregisters the character joiner if one was registered. - * NOTE: character joiners are only used by the webgl renderer. + * Deregisters the character joiner if one was registered. Note that + * character joiners are only used by the webgl renderer. * @param joinerId The character joiner's ID (returned after register) */ deregisterCharacterJoiner(joinerId: number): void; @@ -1174,7 +1172,7 @@ declare module '@xterm/xterm' { registerMarker(cursorYOffset?: number): IMarker; /** - * (EXPERIMENTAL) Adds a decoration to the terminal using + * Registers a decoration to the terminal. * @param decorationOptions, which takes a marker and an optional anchor, * width, height, and x offset from the anchor. Returns the decoration or * undefined if the alt buffer is active or the marker has already been