diff --git a/addons/addon-ligatures/.gitignore b/addons/addon-ligatures/.gitignore index f172c3a1d5..7b48eed8a4 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 b442934b7d..b7ca5139ad 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 0000000000..e7a9fda69d 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 0000000000..a09e9faff2 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 0000000000..fdd309d716 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 0000000000..963cbe2a65 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 85a19c36e0..5150f3838d 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 ed7910cf47..196651ac4f 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 0000000000..843f9acf43 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/flatten.ts @@ -0,0 +1,35 @@ +import { ILookupTree, IFlattenedLookupTree, ILookupTreeEntry, IFlattenedLookupTreeEntry } from './types'; + +export default function flatten(tree: ILookupTree): IFlattenedLookupTree { + const result: IFlattenedLookupTree = {}; + for (const [glyphId, entry] of Object.entries(tree.individual)) { + result[glyphId] = flattenEntry(entry); + } + + for (const { range, entry } of tree.range) { + const flattened = flattenEntry(entry); + for (let glyphId = range[0]; glyphId < range[1]; glyphId++) { + result[glyphId] = flattened; + } + } + + return result; +} + +function flattenEntry(entry: ILookupTreeEntry): IFlattenedLookupTreeEntry { + const result: IFlattenedLookupTreeEntry = {}; + + if (entry.forward) { + result.forward = flatten(entry.forward); + } + + if (entry.reverse) { + result.reverse = flatten(entry.reverse); + } + + 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 0000000000..7f4949b128 --- /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 0000000000..d65e73bc4b --- /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 0000000000..d4e138a8d1 --- /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 0000000000..3e12704809 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/merge.ts @@ -0,0 +1,372 @@ +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: [] + }; + + for (const tree of trees) { + mergeSubtree(result, tree); + } + + 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 + */ +function mergeSubtree(mainTree: ILookupTree, mergeTree: ILookupTree): 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); + } 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)); + + // 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)); + + 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)); + + // 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)); + + // Update the remaining ranges + remainingRanges.splice(remainingIndex, 1, ...overlap.second); + break; + } else { + if (Number(glyphId) === remainingRange) { + mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry)); + 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 + */ +function mergeTreeEntry(mainTree: ILookupTreeEntry, mergeTree: ILookupTreeEntry): void { + 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); + } + } + + if (mergeTree.reverse) { + if (!mainTree.reverse) { + mainTree.reverse = mergeTree.reverse; + } else { + mergeSubtree(mainTree.reverse, mergeTree.reverse); + } + } +} + +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 + */ +function cloneEntry(entry: ILookupTreeEntry): ILookupTreeEntry { + const result: ILookupTreeEntry = {}; + + if (entry.forward) { + result.forward = cloneTree(entry.forward); + } + + if (entry.reverse) { + result.reverse = cloneTree(entry.reverse); + } + + 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 + */ +function cloneTree(tree: ILookupTree): ILookupTree { + const individual: { [glyphId: string]: ILookupTreeEntry } = {}; + for (const [glyphId, entry] of Object.entries(tree.individual)) { + individual[glyphId] = cloneEntry(entry); + } + + return { + individual, + range: tree.range.map(({ range, entry }) => ({ + range: range.slice() as [number, number], + entry: cloneEntry(entry) + })) + }; +} 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 0000000000..790ea666b6 --- /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 0000000000..ec53050830 --- /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 0000000000..c7f03b53b8 --- /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 0000000000..f39682423b --- /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 0000000000..334e6a566c --- /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 0000000000..536b38c749 --- /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 0000000000..5dac85a159 --- /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 0000000000..b287a2f726 --- /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 0000000000..b05e1e2e02 --- /dev/null +++ b/addons/addon-ligatures/src/fontLigatures/processors/helper.ts @@ -0,0 +1,163 @@ +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[] = []; + for (const currentEntry of currentEntries) { + for (const glyph of glyphs) { + const entry: ILookupTreeEntry = {}; + if (!currentEntry.entry.forward) { + currentEntry.entry.forward = { + individual: {}, + range: [] + }; + } + nextEntries.push({ + entry, + substitutions: currentEntry.substitutions + }); + + if (Array.isArray(glyph)) { + currentEntry.entry.forward.range.push({ + entry, + range: glyph + }); + } else { + currentEntry.entry.forward.individual[glyph] = entry; + } + } + } + + return nextEntries; +} + +export function processBacktrackPosition( + glyphs: (number | [number, number])[], + currentEntries: IEntryMeta[] +): IEntryMeta[] { + const nextEntries: IEntryMeta[] = []; + for (const currentEntry of currentEntries) { + for (const glyph of glyphs) { + const entry: ILookupTreeEntry = {}; + if (!currentEntry.entry.reverse) { + currentEntry.entry.reverse = { + individual: {}, + range: [] + }; + } + nextEntries.push({ + entry, + substitutions: currentEntry.substitutions + }); + + if (Array.isArray(glyph)) { + currentEntry.entry.reverse.range.push({ + entry, + range: glyph + }); + } else { + currentEntry.entry.reverse.individual[glyph] = entry; + } + } + } + + 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 0000000000..5eae247614 --- /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 0000000000..a2433dbfac --- /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 0000000000..4d6f44a0fd --- /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 0000000000..cfcf12526a --- /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 bd8ff215f3..0c67f510b0 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 d6c56d2cde..d2150e9dcb 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 cc9a9befa9..f54111a33e 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/package-lock.json b/package-lock.json index 71dff295c6..e48e512cce 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", @@ -1701,6 +1722,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 +1765,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", @@ -4228,6 +4263,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 +4273,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 +4498,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 +6710,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" }