263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
import * as opentype from 'opentype.js';
|
|
import { LRUCache } from '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<string, ILigatureData | [number, number][]>;
|
|
|
|
constructor(font: opentype.Font, options: Required<IOptions>) {
|
|
this._font = font;
|
|
|
|
if (options.cacheSize > 0) {
|
|
this._cache = new LRUCache({
|
|
maxSize: options.cacheSize,
|
|
sizeCalculation: ((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 };
|