Skip to content

Instantly share code, notes, and snippets.

@smhanov
Last active December 17, 2023 04:58
Show Gist options
  • Star 24 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save smhanov/f009a02c00eb27d99479a1e37c1b3354 to your computer and use it in GitHub Desktop.
Save smhanov/f009a02c00eb27d99479a1e37c1b3354 to your computer and use it in GitHub Desktop.
Here is my implementation of a TrueType font reader in Typescript. You can read a font directly from an ArrayBuffer, and then call drawText() to draw it. See my article http://stevehanov.ca/blog/index.php?id=143. The second file, OpenType.ts is the same thing but it handles more TrueType files. It is also more coplex
// To see this run, you first stub out these imports. Then put the file in a Uint8Array.
// let slice = new Slice(array);
// let font = new OTFFont(slice);
// Then you can call methods like font.drawText(canvasContext, )
//
//
import { ICanvasContext } from "./ICanvasContext"
import { log as Log } from "./log"
const log = Log.create("OPENTYPE");
function assert(condition: boolean, message = "Assertion failed.") {
if (!condition) {
console.log(new Error(message))
throw new Error(message);
}
}
// Represents a CFF instruction. They can have a opcode, plus
// any number of floating point arguments. Some opcodes begin with 12. In this case
// they are two bytes, with the high byte 0x0c.
class OpCode {
constructor(public code: number, public operands: number[]) { }
toString() {
let str: string;
if (this.code > 255) {
str = `code=12 ${this.code & 0xff}`
} else {
str = `${this.code}`
}
return str + " " + JSON.stringify(this.operands);
}
}
// This slice lets us seek around a file and read various parts of it.
class Slice {
private pos = 0;
constructor(private arr: Uint8Array, private start = 0, readonly length = arr.length - start) { }
getUint(len: number) {
let result = 0;
while (len--) {
result = result * 256 + this.arr[this.pos++ + this.start];
}
return result;
}
getInt16() {
let result = this.getUint(2);
if (result & 0x8000) {
result -= (1 << 16);
}
return result;
}
getString(length: number, width: number = 1) {
var result = "";
for (var i = 0; i < length; i += width) {
result += String.fromCharCode(this.getUint(width));
}
return result;
}
get2Dot14() {
return this.getInt16() / (1 << 14);
}
seek(pos: number) {
if (pos > this.length) {
throw new Error("Seek to invalid location");
}
let ret = this.pos;
this.pos = pos;
return ret;
}
tell() {
return this.pos;
}
slice(start: number, length = this.length - start) {
return new Slice(this.arr, start + this.start, length);
}
indexSkip(start: number = this.tell()) {
this.seek(start);
const count = this.getUint(2);
if (count > 0) {
const offSize = this.getUint(1);
this.seek(start + 3 + offSize * count);
const elemOffset = this.getUint(offSize);
this.seek(start + 3 + (count + 1) * offSize - 1 + elemOffset);
}
return start;
}
// CFF
getIndexCount(start: number) {
this.seek(start);
let count = this.getUint(2);
let offsetSize = this.getUint(1);
if (offsetSize > 4) {
throw new Error("Error: This is not an index; offsetSize=" + offsetSize);
}
return count;
}
/** Seek to the CFF index given at start, and then return a slice of that entry */
indexSeek(start: number, n: number): Slice {
this.seek(start);
const count = this.getUint(2);
if (n >= count) {
throw new Error("Tried to read past end of index");
}
const offSize = this.getUint(1);
this.seek(start + 3 + offSize * n);
let elemOffset = this.getUint(offSize);
//log("count=%s offSize=%s elemOffset=%s", count, offSize, elemOffset);
let nextOffset = this.getUint(offSize);
let offset = start + 3 + (count + 1) * offSize - 1 + elemOffset;
return this.slice(offset, nextOffset - elemOffset);
}
eof() {
return this.pos >= this.length;
}
getInstruction(type2 = false, stack: number[] = []): OpCode {
let ret = new OpCode(0, stack);
for (; ;) {
if (this.eof()) {
throw new Error("Unexpected end of charstring");
}
const b0 = this.getUint(1);
//log("Read b0=%s", b0);
if (b0 === 12) {
ret.code = 0x0c00 | this.getUint(1);
return ret;
} else if (b0 < 28 || type2 && b0 >= 29 && b0 <= 31) {
ret.code = b0;
return ret;
} else if (b0 === 30) {
// real
let str = "";
outer:
for (; ;) {
let b = this.getUint(1);
for (let i = 4; i >= 0; i -= 4) {
let code = (b >> i) & 0x0f;
switch (code) {
case 0xa: str += "."; break;
case 0xb: str += "E"; break;
case 0xc: str += "E-"; break;
case 0xe: str += "-"; break;
case 0xf: break outer;
case 0xe: /* reserved */ break;
default:
str += code.toString();
}
}
}
ret.operands.push(parseFloat(str));
} else if (b0 === 29) {
const b1 = this.getUint(1);
const b2 = this.getUint(1);
const b3 = this.getUint(1);
const b4 = this.getUint(1);
ret.operands.push((b1 << 24) | (b2 << 16) | (b3 << 8) | b4);
} else if (b0 === 28) {
const b1 = this.getUint(1);
const b2 = this.getUint(1);
ret.operands.push((b1 << 8) | b2);
} else if (type2 && b0 === 255) {
ret.operands.push(this.getUint(4) / 0x10000);
} else if (b0 >= 251) {
const b1 = this.getUint(1);
ret.operands.push(-(b0 - 251) * 256 - b1 - 108);
} else if (b0 >= 247) {
const b1 = this.getUint(1);
ret.operands.push((b0 - 247) * 256 + b1 + 108);
} else {
ret.operands.push(b0 - 139);
}
}
}
}
// ------------------------------------------------------------------------------
// This is a tokenizer, part of the byte parsing. I wanted to have something
// where I can just write out the font structures and have code automatically
// read them in. This is the tokenizer for the mini-language I created for this
// purpose.
//
// The decode function takes the structure description, and a Slice, and
// parses the data into a JSON structure.
class Tokens {
private tokens: string[] = []
n = 0
constructor(input: string) {
const regex = /([:\[\]\(\)\-\.{}]|\d+)|\s+/;
for (let item of input.split(regex)) {
if (item && item.length) {
this.tokens.push(item);
}
}
}
next() {
return this.tokens[this.n++];
}
peek() {
return this.tokens[this.n];
}
match(tok: string) {
if (this.tokens[this.n] === tok) {
this.n += 1;
return true;
}
return false;
}
value(context: any) {
let value = this.next();
let num = parseFloat(value);
if (isNaN(num)) {
return context[value];
}
return num;
}
fail(oldPos: number) {
this.n = oldPos;
return false;
}
}
function parseData(tokens: Tokens, input: Slice, obj: any, skipping: boolean) {
/** data = '{' namedValue* '}' | value */
if (tokens.match("{")) {
obj = {};
while (parseNamedValue(tokens, input, obj, skipping)) { }
tokens.match("}");
return obj;
}
return parseValue(tokens, input, obj, skipping);
}
function parseNamedValue(tokens: Tokens, input: Slice, obj: any, skipping: boolean) {
// namedValue = 'push' expr namedValue* pop | name ':' value
const at = tokens.n;
if (tokens.match("push")) {
const offset = parseExpr(tokens, obj);
input = input.slice(offset);
while (parseNamedValue(tokens, input, obj, skipping)) { }
if (!tokens.match("pop")) {
return false
}
return true;
}
let name = tokens.next();
if (tokens.match(":")) {
const value = parseValue(tokens, input, obj, skipping);
if (name !== '_') {
obj[name] = value;
}
return true;
}
return tokens.fail(at);
}
function parseValue(tokens: Tokens, input: Slice, obj: any, skipping: boolean) {
const at = tokens.n;
// value = ("[" expr "]")? type
if (tokens.match("[")) {
let count = parseExpr(tokens, obj);
let tag = "";
let ret: any = [];
if (tokens.match('.')) {
tag = tokens.next();
ret = {};
}
if (!tokens.match("]")) {
return tokens.fail(at);
}
const start = tokens.n;
for (let i = 0; i < count; i++) {
tokens.n = start;
let result = parseData(tokens, input, obj, skipping);
if (tag === "") {
ret.push(result);
} else {
ret[result[tag]] = result;
}
}
if (count === 0) { // skip over description
parseData(tokens, input, obj, true);
}
return ret;
}
if (tokens.match("(")) {
let num = parseExpr(tokens, obj);
tokens.match(")");
return num;
} else if (tokens.match("uint")) {
let num = tokens.value(obj) / 8;
return skipping ? 0 : input.getUint(num);
} else if (tokens.match("int")) {
tokens.next(); // skip '16'
return skipping ? 0 : input.getInt16();
} else if (tokens.match("offset")) {
return input.tell();
} else if (tokens.match("date")) {
const macTime = input.getUint(4) * 0x100000000 + input.getUint(4);
const utcTime = macTime * 1000 + Date.UTC(1904, 1, 1);
return new Date(utcTime);
} else if (tokens.match("fixed")) {
return skipping ? 0 : input.getUint(4) / (1 << 16);
} else if (tokens.match("string")) {
if (!tokens.match("(")) {
return tokens.fail(at);
}
const length = parseExpr(tokens, obj);
if (!tokens.match(")")) {
return tokens.fail(at);
}
return skipping ? 0 : input.getString(length);
}
return tokens.fail(at);
}
function parseExpr(tokens: Tokens, obj: any) {
// expr = num - num | num
let value = tokens.value(obj);
if (tokens.match("-")) {
value -= tokens.value(obj);
} else if (tokens.match(":")) { // item:bit#
const bit = tokens.value(obj);
value = (value >> bit) & 1;
}
return value;
}
// This is the magic function that takes a structure description and
// parses it into JSON.
function decode(input: Slice, spec: string): any {
return parseData(new Tokens(spec), input, null, false);
}
const TTC_HEADER = `{
ttcTag:string(4)
majorVersion:uint16
minorVersion:uint16
numFonts:uint32
offsetTable:[numFonts] uint32
}`
const OFFSET_TABLES = `{
scalarType:uint32
numTables:uint16
searchRange:uint16
entrySelector:uint16
rangeShift:uint16
tables:[numTables.tag] {
tag:string(4)
checksum:uint32
offset:uint32
length:uint32
}
}`
const HEAD_TABLE = `{
version:fixed
fontRevision:fixed
checksumAdjustment:uint32
magicNumber:uint32
flags:uint16
unitsPerEm:uint16
created:date
modified:date
xMin:int16
yMmin:int16
xMax:int16
yMax:int16
macStyle:uint16
lowestRectPPEM:uint16
fontDirectionHint:uint16
indexToLocFormat:uint16
glyphDataFormat:uint16
bold:(macStyle:0)
italic:(macStyle:1)
}`
const CMAP_TABLE = `{
offset:offset
version:uint16
numberSubtables:uint16
subTables:[numberSubtables] {
platformID:uint16
platformSpecificID:uint16
offset:uint32
}
}`
const NAME_TABLE = `{
offset:offset
format:uint16
count:uint16
stringOffset:uint16
names:[count] {
platformID:uint16
platformSpecificID:uint16
languageID:uint16
nameID:uint16
length:uint16
offset:uint16
}
}`
const HHEA_TABLE = `{
version:fixed
ascent:int16
descent:int16
lineGap:int16
advanceWidthMax:uint16
minLeftSideBearing:int16
minRightSideBearing:int16
xMaxExtent:int16
caretSlopeRise:int16
caretSlopeRun:uint16
caretOffset:int16
reserved:uint64
metricDataFormat:int16
numOfLongHorMetrics:uint16
}`
const OS2_TABLE = `{
version:uint16
xAvgCharWidth:int16
usWeightClass:uint16
usWidthClass:uint16
}`
const KERN_TABLE = `{
version:uint16
nTables:uint16
tables:[nTables] {
version:uint16
length:uint16
coverage:uint16
offset:offset
_:[length-6] uint8
}
}`
const MAXP_TABLE = `{
version:fixed
numGlyphs:uint16
}`
const CFF_TABLE = `{
offset:offset
major:uint8
minor:uint8
}`
// A CMAP is any class that maps a unicode codepoint to a glyph index.
interface CMap {
map(charCode: number): number; // or -1
}
interface Point {
x: number;
y: number;
onCurve: boolean;
}
interface Glyph {
contourEnds: number[];
numberOfContours: number;
points: Point[];
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}
// In OpenType, a font file can contain many different fonts. Opening with the
// Font Collection will let you access them.
export class FontCollection {
private fonts: OTFFont[] = []
async add(url: string) {
let response = await fetch(url);
let buffer = await response.arrayBuffer();
return this.openSync(new Uint8Array(buffer));
}
removeAll() {
this.fonts.length = 0;
}
get(name: string): OTFFont | null {
for (let font of this.fonts) {
if (font.fullName === name || font.fontFamily === name) {
return font;
}
}
return null;
}
openSync(data: Uint8Array) {
const f = new Slice(data);
const magic = f.getUint(4);
f.seek(0);
let fonts: OTFFont[]
if (magic === 0x74746366) { // ttcf
// it's a font collection
fonts = this.addOTCFile(f);
} else { // it's a font file.
fonts = [new OTFFont(f)];
}
outer:
for (let font of fonts) {
for (let have of this.fonts) {
if (have.fullName === font.fullName) {
log("Not adding %s; already have it.");
continue outer;
}
}
log("Opened font: %s weight=%s italic=%s", font.fullName, font.weight, font.italic);
console.log(font);
this.fonts.push(font);
}
return fonts;
}
private addOTCFile(f: Slice) {
const header = decode(f, TTC_HEADER);
log(JSON.stringify(header, null, 2));
log("Collection contains %s fonts", header["numFonts"]);
const ret = [];
for (let offset of header["offsetTable"]) {
ret.push(new OTFFont(f, offset))
}
return ret;
}
}
export class OTFFont {
private offsetTables: any;
private tables: { [name: string]: any } = {}
fontFamily = "";
fontSubFamily = "";
fullName = "";
postscriptName = "";
weight = 0;
italic = false;
bold = false;
private cmaps: CMap[] = []
private cff: CFFString | null = null;
private kerners: KernAdjuster[] = [];
constructor(private f: Slice, offset = 0) {
f.seek(offset);
this.offsetTables = decode(f, OFFSET_TABLES);
log(JSON.stringify(this.offsetTables, null, 2));
for (let name in this.offsetTables["tables"]) {
log("Table: %s", name);
}
//console.log("offset", JSON.stringify(this.offsetTables, null, 2));
this.decodeTable("head", HEAD_TABLE);
this.decodeTable("name", NAME_TABLE);
this.decodeTable("cmap", CMAP_TABLE);
this.decodeTable("hhea", HHEA_TABLE);
this.decodeTable("kern", KERN_TABLE);
this.decodeTable("maxp", MAXP_TABLE);
this.decodeTable("OS/2", OS2_TABLE);
this.decodeTable("CFF ", CFF_TABLE);
this.parseNameTable(f, this.tables["name"]);
this.parseCmap(f, this.tables["cmap"]);
this.parseGPOS(f);
this.parseKern(f, this.tables["kern"]);
this.italic = !!this.tables["head"]["italic"];
this.bold = !!this.tables["head"]["bold"];
this.weight = this.tables["OS/2"]["usWeightClass"];
if ("CFF " in this.tables) {
this.cff = new CFFString(f.slice(this.tables["CFF "]["offset"]));
}
}
private decodeTable(name: string, spec: string) {
if (name in this.offsetTables["tables"]) {
log("Decoding %s", name);
let offset = this.offsetTables["tables"][name]["offset"];
this.f.seek(offset);
this.tables[name] = decode(this.f, spec);
log(`${name} table:`, JSON.stringify(this.tables[name], null, 2));
}
}
private parseNameTable(f: Slice, nameTable: any) {
for (let item of nameTable["names"]) {
let name: string
f.seek(nameTable["offset"] + nameTable["stringOffset"] + item["offset"]);
if (item["platformID"] === 0 || item["platformID"] === 3) {
name = f.getString(item["length"], 2); // UCS-2
} else {
name = f.getString(item["length"]); // ASCII
}
switch (item["nameID"]) {
case 1: this.fontFamily = name; break;
case 2: this.fontSubFamily = name; break;
case 4: this.fullName = name; break;
case 6: this.postscriptName = name; break;
}
}
}
private parseCmap(f: Slice, cmapTable: any) {
for (let cmap of cmapTable["subTables"]) {
let platformID = cmap["platformID"];
let platformSpecificID = cmap["platformSpecificID"];
let offset = cmapTable["offset"] + cmap["offset"];
f.seek(offset);
const format = f.getUint(2);
const length = f.getUint(2);
log(`CMAP platform ${platformID} ${platformSpecificID} format ${format} length ${length} at ${offset}`)
switch (format) {
case 0:
this.cmaps.push(new CMap0(f.slice(offset, length)));
break;
case 4:
this.cmaps.push(new CMap4(f.slice(offset, length)));
break;
}
}
}
private parseGPOS(f: Slice) {
let table = this.offsetTables["tables"]["GPOS"];
if (table) {
this.kerners.push(new GPOS(f.slice(table["offset"], table["length"])));
}
}
private parseKern(f: Slice, kernTable: any) {
if (!kernTable) return;
for (let table of kernTable["tables"]) {
if ((table["coverage"] >> 8) === 0) {
log("Format 0 kern table detected");
this.kerners.push(new Kern0(f.slice(table["offset"], table["length"])))
}
}
}
getGlyphCount() {
return this.tables["maxp"]["numGlyphs"];
}
drawSingleGlyph(ctx: ICanvasContext, glyphIndex: number,
x: number, y: number, size: number) {
ctx.save();
ctx.translate(x, y);
this.transform(ctx, size);
this.drawGlyph(ctx, glyphIndex, 0, 0);
ctx.restore();
}
transform(ctx: ICanvasContext, size: number) {
let scale = this.getScale(size);
ctx.scale(scale, -scale);
}
private drawGlyph(ctx: ICanvasContext, index: number, x: number, y: number) {
if (this.cff) {
this.cff.drawGlyph(ctx, index, x, y);
return;
}
var glyph = this.readGlyph(index);
//log("Draw GLyph index %s", index);
if (glyph === null) {
return false;
}
var s = 0,
p = 0,
c = 0,
contourStart = 0,
prev;
for (; p < glyph.points.length; p++) {
var point = glyph.points[p];
if (s === 0) {
ctx.moveTo(point.x + x, point.y + y);
s = 1;
} else if (s === 1) {
if (point.onCurve) {
ctx.lineTo(point.x + x, point.y + y);
} else {
s = 2;
}
} else {
prev = glyph.points[p - 1];
if (point.onCurve) {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
point.x + x, point.y + y);
s = 1;
} else {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
(prev.x + point.x) / 2 + x,
(prev.y + point.y) / 2 + y);
}
}
if (p === glyph.contourEnds[c]) {
if (s === 2) { // final point was off-curve. connect to start
prev = point;
point = glyph.points[contourStart];
if (point.onCurve) {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
point.x + x, point.y + y);
} else {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
(prev.x + point.x) / 2 + x,
(prev.y + point.y) / 2 + y);
}
}
contourStart = p + 1;
c += 1;
s = 0;
}
}
return true;
}
private readGlyph(index: number): Glyph | null {
var offset = this.getGlyphOffset(index);
var file = this.f;
let table = this.offsetTables["tables"]["glyf"];
if (offset === 0 ||
offset >= table["offset"] + table["length"]) {
return null;
}
assert(offset >= table["offset"]);
assert(offset < table["offset"] + table["length"]);
file.seek(offset);
var glyph = {
contourEnds: [],
numberOfContours: file.getInt16(),
points: [],
xMin: file.getInt16(),
yMin: file.getInt16(),
xMax: file.getInt16(),
yMax: file.getInt16()
};
assert(glyph.numberOfContours >= -1);
if (glyph.numberOfContours === -1) {
this.readCompoundGlyph(file, glyph);
} else {
this.readSimpleGlyph(file, glyph);
}
return glyph;
}
private getGlyphOffset(index: number) {
assert("loca" in this.offsetTables["tables"]);
var table = this.offsetTables["tables"]["loca"];
var file = this.f;
var offset, old, next;
if (this.tables["head"]["indexToLocFormat"] === 1) {
old = file.seek(table["offset"] + index * 4);
offset = file.getUint(4);
next = file.getUint(4);
} else {
old = file.seek(table["offset"] + index * 2);
offset = file.getUint(2) * 2;
next = file.getUint(2) * 2;
}
file.seek(old);
if (offset === next) {
// indicates glyph has no outline( eg space)
return 0;
}
//log("Offset for glyph index %s is %s", index, offset);
return offset + this.offsetTables["tables"]["glyf"].offset;
}
private readCompoundGlyph(file: Slice, glyph: Glyph) {
var ARG_1_AND_2_ARE_WORDS = 1,
ARGS_ARE_XY_VALUES = 2,
//ROUND_XY_TO_GRID = 4,
WE_HAVE_A_SCALE = 8,
// RESERVED = 16
MORE_COMPONENTS = 32,
WE_HAVE_AN_X_AND_Y_SCALE = 64,
WE_HAVE_A_TWO_BY_TWO = 128,
WE_HAVE_INSTRUCTIONS = 256;
//USE_MY_METRICS = 512,
//OVERLAP_COMPONENT = 1024;
var flags = MORE_COMPONENTS;
var component;
glyph.contourEnds = [];
glyph.points = [];
while (flags & MORE_COMPONENTS) {
var arg1, arg2;
flags = file.getUint(2);
component = {
glyphIndex: file.getUint(2),
matrix: {
a: 1, b: 0, c: 0, d: 1, e: 0, f: 0
},
destPointIndex: 0,
srcPointIndex: 0
};
if (flags & ARG_1_AND_2_ARE_WORDS) {
arg1 = file.getInt16();
arg2 = file.getInt16();
} else {
arg1 = file.getUint(1);
arg2 = file.getUint(1);
}
if (flags & ARGS_ARE_XY_VALUES) {
component.matrix.e = arg1;
component.matrix.f = arg2;
} else {
component.destPointIndex = arg1;
component.srcPointIndex = arg2;
}
if (flags & WE_HAVE_A_SCALE) {
component.matrix.a = file.get2Dot14();
component.matrix.d = component.matrix.a;
} else if (flags & WE_HAVE_AN_X_AND_Y_SCALE) {
component.matrix.a = file.get2Dot14();
component.matrix.d = file.get2Dot14();
} else if (flags & WE_HAVE_A_TWO_BY_TWO) {
component.matrix.a = file.get2Dot14();
component.matrix.b = file.get2Dot14();
component.matrix.c = file.get2Dot14();
component.matrix.d = file.get2Dot14();
}
//log("Read component glyph index %s", component.glyphIndex);
//log("Transform: [%s %s %s %s %s %s]", component.matrix.a, component.matrix.b,
// component.matrix.c, component.matrix.d, component.matrix.e, component.matrix.f);
var old = file.tell();
var simpleGlyph = this.readGlyph(component.glyphIndex);
if (simpleGlyph) {
var pointOffset = glyph.points.length;
for (var i = 0; i < simpleGlyph.contourEnds.length; i++) {
glyph.contourEnds.push(simpleGlyph.contourEnds[i] +
pointOffset);
}
for (i = 0; i < simpleGlyph.points.length; i++) {
var x = simpleGlyph.points[i].x;
var y = simpleGlyph.points[i].y;
x = component.matrix.a * x + component.matrix.b * y +
component.matrix.e;
y = component.matrix.c * x + component.matrix.d * y +
component.matrix.f;
glyph.points.push({
x: x, y: y, onCurve:
simpleGlyph.points[i].onCurve
});
}
}
file.seek(old);
}
glyph.numberOfContours = glyph.contourEnds.length;
if (flags & WE_HAVE_INSTRUCTIONS) {
file.seek(file.getUint(2) + file.tell());
}
}
private readSimpleGlyph(file: Slice, glyph: Glyph) {
var ON_CURVE = 1,
X_IS_BYTE = 2,
Y_IS_BYTE = 4,
REPEAT = 8,
X_DELTA = 16,
Y_DELTA = 32;
glyph.contourEnds = [];
var points: Point[] = glyph.points = [];
for (var i = 0; i < glyph.numberOfContours; i++) {
glyph.contourEnds.push(file.getUint(2));
}
// skip over intructions
file.seek(file.getUint(2) + file.tell());
if (glyph.numberOfContours === 0) {
return;
}
var numPoints = Math.max.apply(null, glyph.contourEnds) + 1;
var flags: number[] = [];
for (i = 0; i < numPoints; i++) {
var flag = file.getUint(1);
flags.push(flag);
points.push({
x: 0,
y: 0,
onCurve: (flag & ON_CURVE) > 0
});
if (flag & REPEAT) {
var repeatCount = file.getUint(1);
assert(repeatCount > 0);
i += repeatCount;
while (repeatCount--) {
flags.push(flag);
points.push({
x: 0,
y: 0,
onCurve: (flag & ON_CURVE) > 0
});
}
}
}
function readCoords(name: "x" | "y", byteFlag: number,
deltaFlag: number) {
var value = 0;
for (var i = 0; i < numPoints; i++) {
var flag = flags[i];
if (flag & byteFlag) {
if (flag & deltaFlag) {
value += file.getUint(1);
} else {
value -= file.getUint(1);
}
} else if (~flag & deltaFlag) {
value += file.getInt16();
} else {
// value is unchanged.
}
points[i][name] = value;
}
}
readCoords("x", X_IS_BYTE, X_DELTA);
readCoords("y", Y_IS_BYTE, Y_DELTA);
}
getScale(fontHeight: number) {
return fontHeight / this.tables["head"]["unitsPerEm"];
}
getHorizontalMetrics(glyphIndex: number) {
//console.log("Offset tables: ", this.offsetTables["tables"])
assert("hmtx" in this.offsetTables["tables"]);
var file = this.f;
var offset = this.offsetTables["tables"]["hmtx"].offset;
var old = file.seek(offset + 4);
let advanceWidth, leftSideBearing;
let numOfLongHorMetrics = this.tables["hhea"]["numOfLongHorMetrics"];
if (glyphIndex < numOfLongHorMetrics) {
offset += glyphIndex * 4;
old = this.f.seek(offset);
advanceWidth = file.getUint(2);
leftSideBearing = file.getInt16();
} else {
// read the last entry of the hMetrics array
old = file.seek(offset + (numOfLongHorMetrics - 1) * 4);
advanceWidth = file.getUint(2);
file.seek(offset + numOfLongHorMetrics * 4 +
2 * (glyphIndex - numOfLongHorMetrics));
leftSideBearing = file.getInt16();
}
file.seek(old);
return {
advanceWidth: advanceWidth,
leftSideBearing: leftSideBearing
};
}
getGlyphIndex(charCode: number) {
var index = 0;
// start at the last CMAP first, which is typically the full featured one that supports unicode.
for (var i = this.cmaps.length - 1; i >= 0; i--) {
var cmap = this.cmaps[i];
index = cmap.map(charCode);
if (index) {
break;
}
}
return index;
}
drawText(ctx: ICanvasContext, text: string, x: number, y: number,
size: number) {
ctx.save();
ctx.translate(x, y);
this.transform(ctx, size);
let glyphs: KernAdjustment[] = [];
for (var i = 0; i < text.length; i++) {
glyphs.push(new KernAdjustment(this.getGlyphIndex(text.charCodeAt(i))));
}
for (let kerner of this.kerners) {
kerner.kern(glyphs);
}
x = 0;
y = 0;
for (let i = 0; i < glyphs.length; i++) {
let glyph = glyphs[i];
var metrics = this.getHorizontalMetrics(glyph.glyph);
log("Metrics for %s code %s index %s: %s %s", text.charAt(i),
text.charCodeAt(i), glyph.glyph, metrics.advanceWidth, metrics.leftSideBearing);
this.drawGlyph(ctx, glyph.glyph, x + glyph.placement.x, y + glyph.placement.y);
x += metrics.advanceWidth + glyph.advance.x;
y += glyph.advance.y;
}
ctx.restore();
}
}
// Implements CMAP0 format, mapping unicode to a glyph index using an array.
class CMap0 implements CMap {
constructor(private f: Slice) { }
map(charCode: number) {
if (charCode >= 0 && charCode <= 255) {
this.f.seek(charCode + 6);
log("charCode %s maps to %s", charCode, this.f.getUint(1));
this.f.seek(charCode + 6);
return this.f.getUint(1);
}
return 0;
}
}
// Implements CMAP4 format, mapping unicode to a glyph index using a list of
// numerical ranges.
class CMap4 implements CMap {
private segCount: number;
constructor(private f: Slice) {
f.seek(6);
this.segCount = f.getUint(2) / 2;
}
map(charCode: number) {
log("Try to map charcode %s using %s ranges", charCode, this.segCount);
let f = this.f, start = 0, result = 0;
let i = bsearch(this.segCount - 1, (i) => {
f.seek(14 + i * 2);
let end = f.getUint(2);
f.seek(14 + this.segCount * 2 + 2 + i * 2);
start = f.getUint(2);
if (charCode < start) {
return -1;
} else if (charCode > end) {
return 1;
}
return 0;
})
if (i >= 0) {
f.seek(14 + this.segCount * 3 * 2 + 2 + i * 2);
const rangeOffset = f.getUint(2);
f.seek(14 + this.segCount * 2 * 2 + 2 + i * 2);
const delta = f.getInt16();
if (rangeOffset === 0) {
result = (delta + charCode) & 0xffff;
} else {
let ptr = 14 + this.segCount * 3 * 2 + 2 + i * 2;
ptr += (charCode - start) * 2 + rangeOffset;
f.seek(ptr);
result = f.getUint(2);
if (result !== 0) {
result = (result + delta) & 0xffff;
}
}
}
return result;
}
}
/** @param cmp returns target - i */
function bsearch(count: number, cmp: (i: number) => number): number {
let high = count, low = -1, probe: number;
while (high - low > 1) {
probe = (high + low) >> 1;
const match = cmp(probe);
if (match == 0) {
return probe;
} else if (match > 0) {
low = probe;
} else {
high = probe;
}
}
return -1;
}
/**
CFF string is a collection of machine code instructions to render a font. They
can call into subroutines marked "local" or "global". This reads the headers
containing the description of the font, all of the instructions. It implements
the drawGlyph function for CFF fonts.
*/
class CFFString {
dicts: number;
strings: number; // string index
subs: number;
topDict: any;
numGlyphs: number;
fdSelect: FDSelect | null = null;
fdDicts: any[] = [];
constructor(private f: Slice) {
const major = f.getUint(1);
const minor = f.getUint(1);
const hdrSize = f.getUint(1);
log(`CFF Version ${major}.${minor}`);
f.seek(hdrSize);
f.indexSkip(); // name index
this.dicts = f.indexSkip(); // font dicts
this.strings = f.indexSkip(); // string index
this.subs = f.tell();
let topDict = this.topDict = this.readDict(f.indexSeek(this.dicts, 0));
this.numGlyphs = f.getIndexCount(topDict["CharStrings"]);
log(`CFF Font contains ${this.numGlyphs} glyphs`);
if (this.topDict["FDSelect"]) {
this.fdSelect = new FDSelect(f.slice(topDict["FDSelect"]));
this.readFDArray(f, topDict);
} else {
this.fdDicts.push(topDict);
}
}
readDict(f: Slice) {
let dict = ReadCFFDict(f, {}, this);
if (dict["Private"]) {
let length = dict["Private"][0];
let offset = dict["Private"][1];
ReadCFFDict(this.f.slice(offset, length), dict, this);
if (dict["Subrs"]) {
dict["Subrs"] += offset;
}
}
return dict;
}
getString(n: number) {
if (n < STDSTRINGS.length) {
return STDSTRINGS[n];
}
const slice = this.f.indexSeek(this.strings, n - STDSTRINGS.length);
return slice.getString(slice.length);
}
readFDArray(f: Slice, topDict: any) {
let fdindex = topDict["FDArray"];
let count = f.getIndexCount(fdindex);
log("There are %s items in FDArray", count);
for (let i = 0; i < count; i++) {
this.fdDicts.push(this.readDict(f.indexSeek(fdindex, i)));
}
}
/** @param subNumber Is the unbiased number of the subroutine
* @param fontIndex is -1 for a global sub, or the font glyphs FD index
* for a local sub.
*/
getSubr(subNumber: number, fontIndex: number = -1): Slice {
let subIndex: number;
let charStringType: number;
if (fontIndex === -1) {
subIndex = this.subs;
charStringType = this.topDict["CharstringType"];
} else {
let dict = this.fdDicts[fontIndex];
subIndex = dict["Subrs"];
charStringType = dict["CharstringType"];
}
let count = this.f.getIndexCount(subIndex);
subNumber += GetBias(charStringType, count);
//log("Subindex for font %s has %s entries. Call sub# %s", fontIndex, count, subNumber);
return this.f.indexSeek(subIndex, subNumber);
}
getFontNumber(glyph: number) {
if (this.fdSelect) {
return this.fdSelect.lookup(glyph);
}
return 0; // top dict in 0th entry of fdDicts
}
drawGlyph(ctx: ICanvasContext, glyphIndex: number, x: number, y: number) {
ctx.translate(x, y);
let index = this.topDict["CharStrings"];
let fontNumber = this.getFontNumber(glyphIndex);
DrawCFFGlyph(this.f.indexSeek(index, glyphIndex), ctx, fontNumber, this);
ctx.translate(-x, -y);
}
}
type OpType = number;
const SID = 0;
const BOOLEAN = 1;
const NUMBER = 2;
const ARRAY = 3;
interface Instruction {
type: OpType[];
name: string;
def: any;
fn: (dict: any, args: number[]) => void;
}
const DICT_INSTRUCTIONS: { [code: number]: Instruction } = {};
function assign(name: string, code: number, typeIn: OpType | OpType[], def?: any) {
const array = typeIn instanceof Array;
DICT_INSTRUCTIONS[code] = {
name: name,
def: def,
type: (array ? typeIn : [typeIn]) as OpType[],
fn: (dict: any, args: any[]) => {
//console.log(`Assign ${name}=${JSON.stringify(args)}`);
dict[name] = array || typeIn === ARRAY ? args : args[0];
}
}
};
assign("version", 0, SID);
assign("Notice", 1, SID);
assign("Copyright", 0xC | 0, SID);
assign("FullName", 2, SID);
assign("FamilyName", 3, SID);
assign("Weight", 4, SID);
assign("isFixedPitch", 0xc00 | 1, BOOLEAN, false);
assign("ItalicAngle", 0xc00 | 2, NUMBER, 0);
assign("UnderlinePosition", 0xc00 | 3, NUMBER, -100);
assign("UnderlineThickness", 0xc00 | 4, NUMBER, 50);
assign("PaintType", 0xc00 | 5, NUMBER, 0);
assign("CharstringType", 0xc00 | 6, NUMBER, 2);
assign("FontMatrix", 0xc00 | 7, ARRAY, [0.001, 0, 0, 0.001, 0, 0]);
assign("UniqueID", 13, NUMBER);
assign("FontBBox", 5, ARRAY, [0, 0, 0, 0]);
assign("StrokeWidth", 0xc00 | 8, NUMBER, 0);
assign("XUID", 14, ARRAY);
assign("charset", 15, NUMBER, 0);
assign("Encoding", 16, NUMBER, 0)
assign("CharStrings", 17, NUMBER, 0);
assign("Private", 18, [NUMBER, NUMBER]);
assign("SyntheticBase", 0xc00 | 20, NUMBER);
assign("PostSCript", 0xc00 | 21, SID);
assign("BaseFontName", 0xc00 | 22, SID);
assign("BaseFontBlend", 0xc00 | 23, SID);
assign("ROS", 0xc00 | 30, [SID, SID]);
assign("CIDFontVersion", 0xc00 | 31, NUMBER, 0);
assign("CIDFontRevision", 0xc00 | 32, NUMBER, 0);
assign("CIDFontType", 0xc00 | 33, NUMBER, 0);
assign("CIDCount", 0xc00 | 34, NUMBER);
assign("FDArray", 0xc00 | 36, NUMBER);
assign("FDSelect", 0xc00 | 37, NUMBER);
assign("FontName", 0xc00 | 38, SID);
// private
assign("BlueValues", 6, NUMBER);
assign("OtherBlues", 7, NUMBER);
assign("FamilyBlues", 8, NUMBER);
assign("FamilyOtherBlues", 9, NUMBER);
assign("BlueScale", 0xc09, NUMBER, 0.039625);
assign("BlueShift", 0xc0a, 8);
assign("BlueFuzz", 0xc01, 1);
assign("StdHW", 10, NUMBER);
assign("StdVW", 11, NUMBER);
assign("StemSnapH", 0xc0c, NUMBER);
assign("StemSnapV", 0xc0d, NUMBER);
assign("ForceBold", 0xc0e, BOOLEAN, false);
assign("LanguageGroup", 0xc11, NUMBER, 0);
assign("ExpansionFactor", 0xc12, NUMBER, 0.6);
assign("initialRandomSeed", 0xc13, NUMBER, 0);
assign("Subrs", 19, NUMBER);
assign("defaultWidthX", 20, NUMBER);
assign("NominalWidthX", 21, NUMBER);
interface StringGetter {
getString(n: number): string;
}
function ReadCFFDict(f: Slice, dict: any, strings: StringGetter) {
while (!f.eof()) {
const op = f.getInstruction();
if (op.code in DICT_INSTRUCTIONS) {
let instr = DICT_INSTRUCTIONS[op.code];
let args: any[] = [];
outer:
for (let i = 0; i < instr.type.length; i++) {
let type = instr.type[i];
if (i >= op.operands.length) {
throw new Error("Not enough operands for instruction:" + op);
}
let arg = op.operands[i];
switch (type) {
case SID:
args.push(strings.getString(arg));
break;
case BOOLEAN:
args.push(!!arg);
break;
case ARRAY:
args = op.operands;
break outer;
case NUMBER:
args.push(arg);
break;
}
}
instr.fn(dict, args);
} else {
log("Uknown dict opcode: %s", op.code);
}
}
for (let key in DICT_INSTRUCTIONS) {
let instr = DICT_INSTRUCTIONS[key];
if (instr.def && !(instr.name in dict)) {
dict[instr.name] = instr.def;
}
}
return dict;
}
interface SubGetter {
getSubr(subIndex: number, fontNumber: number): Slice;
}
interface MachineState {
first: boolean; // has first stack-clearing instruction been executed?
width: number;
numHints: number;
stack: number[];
x: number;
y: number;
started: boolean;
startX: number;
startY: number;
}
/*
let DRAWCODES: { [key: number]: string } = {
7: "vlineto",
10: "callsubr",
18: "hstemhm",
19: "hintmask",
21: "rmoveto",
26: "vvcurveto",
5: "rlineto",
30: "vhcurveto",
31: "hcurveto",
14: "endchar",
}
*/
function DrawCFFGlyph(f: Slice, ctx: ICanvasContext, fontNumber: number, subs: SubGetter, s?: MachineState) {
s = s || {
first: true,
width: 0,
numHints: 0,
stack: [],
x: 0,
y: 0,
started: false,
startX: 0,
startY: 0,
};
while (!f.eof()) {
let op = f.getInstruction(true, s.stack);
//log("Exec opcode %s [%s] %s", op.code, DRAWCODES[op.code], JSON.stringify(s.stack));
switch (op.code) {
case 1: // hstem
case 3: // vstem
if (s.stack.length & 1) getWidth(s);
break;
case 4: // vmoveto
if (s.stack.length > 1) getWidth(s);
s.y += s.stack[0];
moveto(s, ctx);
break;
case 5: // rlineto
for (let i = 0; i + 1 < s.stack.length; i += 2) {
s.x += s.stack[i];
s.y += s.stack[i + 1];
ctx.lineTo(s.x, s.y);
}
break;
case 6: // hlineto
for (let i = 0; i < s.stack.length; i += 2) {
s.x += s.stack[i];
ctx.lineTo(s.x, s.y);
if (i + 1 < s.stack.length) {
s.y += s.stack[i + 1];
ctx.lineTo(s.x, s.y);
}
}
break;
case 7: // vlineto
for (let i = 0; i < s.stack.length; i += 2) {
s.y += s.stack[i];
ctx.lineTo(s.x, s.y);
if (i + 1 < s.stack.length) {
s.x += s.stack[i + 1];
ctx.lineTo(s.x, s.y);
}
}
break;
case 8: // rrcurveto
rrcurve(s, ctx);
break;
case 10: // callsubr
DrawCFFGlyph(subs.getSubr(s.stack.pop()!, fontNumber), ctx, fontNumber, subs, s);
continue; // don't clear stack
case 11: // return
return;
case 14: // endchar
if (s.stack.length) getWidth(s);
endpath(s, ctx);
break;
case 18: // hstemhm
case 23: // vstemhm
if (s.stack.length & 1) getWidth(s);
s.numHints += s.stack.length >> 1;
break;
case 19: // hintmask
case 20: // cntrmask
if (s.stack.length & 1) getWidth(s);
s.numHints += s.stack.length >> 1; // optional vstem values
const numSkip = (s.numHints + 7) >> 3;
for (let i = 0; i < numSkip; i++) {
f.getUint(1);
}
break;
case 21: // rmoveto
if (s.stack.length & 1) getWidth(s);
s.x += s.stack[0];
s.y += s.stack[1];
moveto(s, ctx);
break;
case 22: // hmoveto
if (s.stack.length === 2) getWidth(s);
s.x += s.stack[0];
moveto(s, ctx);
break;
case 24: // rcurveline
rrcurve(s, ctx);
s.y += s.stack.pop()!;
s.x += s.stack.pop()!;
ctx.lineTo(s.x, s.y);
break;
case 25: { // rlinecurve
for (var i = 0; i < s.stack.length - 6; i += 2) {
s.x += s.stack[i];
s.y += s.stack[i + 1];
ctx.lineTo(s.x, s.y);
}
rrcurve(s, ctx, i);
break;
}
case 26: { // vvcurveto
let i = s.stack.length & 1;
if (i) {
s.x += s.stack[0];
}
for (; i < s.stack.length; i += 4) {
const dxa = s.x;
const dya = s.y += s.stack[i + 0];
const dxb = s.x += s.stack[i + 1];
const dyb = s.y += s.stack[i + 2];
const dxc = s.x;
const dyc = s.y += s.stack[i + 3];
ctx.bezierCurveTo(dxa, dya, dxb, dyb, dxc, dyc);
}
break;
}
case 27: { // hhcurveto
let i = s.stack.length & 1;
if (i) {
s.y += s.stack[0];
}
for (; i < s.stack.length; i += 4) {
const dxa = s.x += s.stack[i + 0];
const dya = s.y;
const dxb = s.x += s.stack[i + 1];
const dyb = s.y += s.stack[i + 2];
const dxc = s.x += s.stack[i + 3];
const dyc = s.y;
ctx.bezierCurveTo(dxa, dya, dxb, dyb, dxc, dyc);
}
break;
}
case 29: // callgsubr
DrawCFFGlyph(subs.getSubr(s.stack.pop()!, -1), ctx, fontNumber, subs, s);
continue;
case 30: // vhcurveto
xxcurveto(s, false, ctx);
break;
case 31: // hvcurveto
xxcurveto(s, true, ctx);
break;
case 0xc03: // and
case 0xc04: // or
case 0xc05: // not
case 0xc09: // abs
case 0xc0a: // add
case 0xc0b: // sub
case 0xc0c: // div
case 0xc0e: // neg
case 0xc0f: // eq
case 0xc12: // drop
case 0xc14: // put
case 0xc15: // get
case 0xc16: // ifelse
case 0xc17: // random
case 0xc18: // mul
case 0xc1a: // sqrt
case 0xc1b: // dup
case 0xc1c: // exch
case 0xc1d: // index
case 0xc1e: // roll
case 0xc22: // hflex
case 0xc23: // flex
case 0xc24: // hflex1
case 0xc25: // flex1
todo();
break;
}
s.stack.length = 0;
}
}
/**
CFF font outlines don't end at their start. So before we move to another
position, close off any existing paths.
*/
function moveto(s: MachineState, ctx: ICanvasContext) {
endpath(s, ctx);
s.startX = s.x;
s.startY = s.y;
s.started = true;
ctx.moveTo(s.x, s.y);
}
function endpath(s: MachineState, ctx: ICanvasContext) {
if (s.started) {
ctx.lineTo(s.startX, s.startY);
}
}
function rrcurve(s: MachineState, ctx: ICanvasContext, i = 0) {
for (; i + 5 < s.stack.length; i += 6) {
const dxa = s.x += s.stack[i + 0];
const dya = s.y += s.stack[i + 1];
const dxb = s.x += s.stack[i + 2];
const dyb = s.y += s.stack[i + 3];
const dxc = s.x += s.stack[i + 4];
const dyc = s.y += s.stack[i + 5];
ctx.bezierCurveTo(dxa, dya, dxb, dyb, dxc, dyc);
}
}
function xxcurveto(s: MachineState, h: boolean, ctx: ICanvasContext) {
for (let i = 0; i + 1 < s.stack.length; i += 4) {
const last = i == s.stack.length - 5;
if (h) {
const dxa = s.x += s.stack[i + 0];
const dya = s.y;
const dxb = s.x += s.stack[i + 1];
const dyb = s.y += s.stack[i + 2];
const dxc = last ? s.x += s.stack[i + 4] : s.x;
const dyc = s.y += s.stack[i + 3];
ctx.bezierCurveTo(dxa, dya, dxb, dyb, dxc, dyc);
} else {
const dxa = s.x;
const dya = s.y += s.stack[i + 0];
const dxb = s.x += s.stack[i + 1];
const dyb = s.y += s.stack[i + 2];
const dxc = s.x += s.stack[i + 3];
const dyc = last ? s.y += s.stack[i + 4] : s.y;
ctx.bezierCurveTo(dxa, dya, dxb, dyb, dxc, dyc);
}
h = !h;
}
}
function todo() {
throw new Error("Not implemented.")
}
function getWidth(s: MachineState) {
if (!s.first) {
log("Warning; already have width");
s.stack.shift();
return;
//throw new Error("Error: Already got width arg.");
}
//log("Got width: " + s.stack[0]);
s.width = s.stack.shift()!;
s.first = false;
}
class FDSelect {
/** @param f Already sliced binary reader */
format: number;
nRanges = 0;
constructor(private f: Slice) {
this.format = f.getUint(1);
log("Reading FDSelect format %s", this.format);
if (this.format !== 3 && this.format !== 0) {
throw new Error(`Can't read format ${this.format} FDSelect`);
}
if (this.format === 3) {
this.nRanges = f.getUint(2);
}
}
lookup(target: number) {
if (this.format === 0) {
this.f.seek(1 + target);
return this.f.getUint(1);
}
let fd = -1;
bsearch(this.nRanges, (n) => {
this.f.seek(3 + 3 * n);
let first = this.f.getUint(2);
fd = this.f.getUint(1);
let next = this.f.getUint(2);
if (target < first) {
return -1;
} else if (target >= next) {
return 1;
}
return 0;
});
return fd;
}
}
/**
To save space, CFF font subroutine numbers are offset by a certain fixed number.
This adds that number back in.
*/
function GetBias(charStringType: number, count: number): number {
if (charStringType === 0) {
return 0;
} else if (count < 1240) {
return 107;
} else if (count < 33900) {
return 1131;
}
return 32768;
}
const STDSTRINGS = `.notdef space exclam quotedbl numbersign dollar percent ampersand quoteright
parenleft parenright asterisk plus comma hyphen period slash zero one two three four five six seven
eight nine colon semicolon less equal greater question at A B C D E F G H I J K L M N O P Q R S T U
V W X Y Z bracketleft backslash bracketright asciicircum underscore quoteleft a b c d e f g h i j k
l m n o p q r s t u v w x y z braceleft bar braceright asciitilde exclamdown cent sterling fraction
yen florin section currency quotesingle quotedblleft guillemotleft guilsinglleft guilsinglright fi
fl endash dagger daggerdbl periodcentered paragraph bullet quotesinglbase quotedblbase quotedblright
guillemotright ellipsis perthousand questiondown grave acute circumflex tilde macron breve dotaccent
dieresis ring cedilla hungarumlaut ogonek caron emdash AE ordfeminine Lslash Oslash OE ordmasculine
ae dotlessi lslash oslash oe germandbls onesuperior logicalnot mu trademark Eth onehalf plusminus
Thorn onequarter divide brokenbar degree thorn threequarters twosuperior registered minus eth
multiply threesuperior copyright Aacute Acircumflex Adieresis Agrave Aring Atilde Ccedilla Eacute
Ecircumflex Edieresis Egrave Iacute Icircumflex Idieresis Igrave Ntilde Oacute Ocircumflex
Odieresis Ograve Otilde Scaron Uacute Ucircumflex Udieresis Ugrave Yacute Ydieresis Zcaron aacute
acircumflex adieresis agrave aring atilde ccedilla eacute ecircumflex edieresis egrave iacute
icircumflex idieresis igrave ntilde oacute ocircumflex odieresis ograve otilde scaron uacute
ucircumflex udieresis ugrave yacute ydieresis zcaron exclamsmall Hungarumlautsmall dollaroldstyle
dollarsuperior ampersandsmall Acutesmall parenleftsuperior parenrightsuperior twodotenleader
onedotenleader zerooldstyle oneoldstyle twooldstyle threeoldstyle fouroldstyle fiveoldstyle
sixoldstyle sevenoldstyle eightoldstyle nineoldstyle commasuperior threequartersemdash
periodsuperior questionsmall asuperior bsuperior centsuperior dsuperior esuperior isuperior
lsuperior msuperior nsuperior osuperior rsuperior ssuperior tsuperior ff ffi ffl parenleftinferior
parenrightinferior Circumflexsmall hyphensuperior Gravesmall Asmall Bsmall Csmall Dsmall Esmall
Fsmall Gsmall Hsmall Ismall Jsmall Ksmall Lsmall Msmall Nsmall Osmall Psmall Qsmall Rsmall Ssmall
Tsmall Usmall Vsmall Wsmall Xsmall Ysmall Zsmall colonmonetary onefitted rupiah Tildesmall
exclamdownsmall centoldstyle Lslashsmall Scaronsmall Zcaronsmall Dieresissmall Brevesmall Caronsmall
Dotaccentsmall Macronsmall figuredash hypheninferior Ogoneksmall Ringsmall Cedillasmall
questiondownsmall oneeighth threeeighths fiveeighths seveneighths onethird twothirds zerosuperior
foursuperior fivesuperior sixsuperior sevensuperior eightsuperior ninesuperior zeroinferior
oneinferior twoinferior threeinferior fourinferior fiveinferior sixinferior seveninferior
eightinferior nineinferior centinferior dollarinferior periodinferior commainferior Agravesmall
Aacutesmall Acircumflexsmall Atildesmall Adieresissmall Aringsmall AEsmall Ccedillasmall Egravesmall
Eacutesmall Ecircumflexsmall Edieresissmall Igravesmall Iacutesmall Icircumflexsmall Idieresissmall
Ethsmall Ntildesmall Ogravesmall Oacutesmall Ocircumflexsmall Otildesmall Odieresissmall OEsmall
Oslashsmall Ugravesmall Uacutesmall Ucircumflexsmall Udieresissmall Yacutesmall Thornsmall
Ydieresissmall 001.000 001.001 001.002 001.003 Black Bold Book Light Medium Regular Roman Semibold`.
split(/\s+/);
const GPOS_TABLE = `{
majorVersion:uint16
minorVersion:uint16
scriptListOffset:uint16
featureListOffset:uint16
lookupListOffset:uint16
featureVariationsOffset:uint16
push scriptListOffset
scriptCount:uint16
scriptRecords:[scriptCount] {
tag:string(4)
scriptOffset:uint16
push scriptOffset
defaultLangSys:uint16
langSysCount:uint16
langSysRecords:[langSysCount] {
langSysTag:string(4)
langSysOffset:uint16
push langSysOffset
lookupOrder:uint16
requiredFeatureIndex:uint16
featureIndexCount:uint16
featureIndices:[featureIndexCount] uint16
pop
}
pop
}
pop
push lookupListOffset
lookupCount:uint16
lookups:[lookupCount] {
offset:uint16
push offset
lookupType:uint16
lookupFlag:uint16
subTableCount:uint16
subTables:[subTableCount] {
offset:uint16
push offset
posFormat:uint16
coverageOffset:uint16
}
pop
}
pop
push featureListOffset
featureCount:uint16
featureRecords[featureCount] {
featureTag:string(4)
featureOffset:uint16
push featureOffset
featureParams:uint16
lookupIndexCount:uint16
lookupListIndicies:[lookupIndexCount] uint16
pop
}
pop
}`
class KernAdjustment {
constructor(public glyph: number) { }
placement = { x: 0, y: 0 };
advance = { x: 0, y: 0 };
}
interface KernAdjuster {
kern(glyphs: KernAdjustment[]): void;
}
class GPOS implements KernAdjuster {
header: any;
adjusters: KernAdjuster[] = [];
constructor(input: Slice) {
this.header = decode(input, GPOS_TABLE);
let offset = this.header["lookupListOffset"];
for (let lookup of this.header["lookups"]) {
let loffset = offset + lookup["offset"];
let type = lookup["lookupType"];
log("Lookup type %s has %s subtables", type, lookup["subTables"].length);
for (let subtable of lookup["subTables"]) {
let soffset = loffset + subtable["offset"];
if (type === 2 && subtable["posFormat"] === 1) {
this.adjusters.push(new LookupType2_1(input.slice(soffset)));
} else if (type === 2 && subtable["posFormat"] === 2) {
this.adjusters.push(new LookupType2_2(input.slice(soffset)));
}
}
}
}
kern(glyphs: KernAdjustment[]) {
for (let adjuster of this.adjusters) {
adjuster.kern(glyphs);
}
}
}
class CoverageTable {
format: number;
count: number;
headersize = 4;
constructor(private f: Slice) {
this.format = f.getUint(2);
this.count = f.getUint(2);
log("CoverageTable format %s count %s", this.format, this.count);
}
getCoverageIndex(glyph: number) {
let index = -1;
if (this.format === 1) {
bsearch(this.count, (i: number) => {
this.f.seek(this.headersize + i * 2);
let j = this.f.getUint(2);
if (glyph < j) {
return -1;
} else if (glyph > j) {
return 1;
}
index = j;
return 0;
});
} else {
bsearch(this.count, (i: number) => {
this.f.seek(this.headersize + i * 6);
let start = this.f.getUint(2);
this.f.getUint(2); // end
let first = this.f.getUint(2);
if (glyph < start) {
return -1;
} else if (glyph > start) {
return 1;
}
index = first + glyph - start;
return 0;
})
}
return index;
}
}
// Since this is so similar to the Coverage table formats
// we can reuse the code.
class ClassDefTable extends CoverageTable {
startGlyph = 0
constructor(f: Slice) {
super(f)
if (this.format === 1) {
this.startGlyph = this.count;
this.count = f.getUint(2);
this.headersize += 2;
}
}
getClass(glyph: number) {
return Math.max(0, this.getCoverageIndex(glyph - this.startGlyph));
}
}
function readValueRecord(f: Slice, kern: KernAdjustment, format: number) {
if (format & 1) kern.placement.x = f.getInt16();
if (format & 2) kern.placement.y = f.getInt16();
if (format & 4) kern.advance.x = f.getInt16();
if (format & 8) kern.advance.y = f.getInt16();
if (format & 16) f.getInt16();
if (format & 32) f.getInt16();
if (format & 64) f.getInt16();
if (format & 128) f.getInt16();
}
abstract class PairKerner {
kern(glyphs: KernAdjustment[]) {
for (let i = 0; i < glyphs.length - 1; i++) {
this.lookup(glyphs[i], glyphs[i + 1]);
}
}
abstract lookup(glyph1: KernAdjustment, glyph2: KernAdjustment): number
}
// Pair adjustment positioning pos format 1
class LookupType2_1 extends PairKerner {
coverage: CoverageTable
valueFormat1: number;
valueFormat2: number;
count: number;
recordLength: number;
constructor(private f: Slice) {
super()
f.getUint(2); // format = 1
let coverage = f.getUint(2);
this.coverage = new CoverageTable(f.slice(coverage));
this.valueFormat1 = f.getUint(2);
this.valueFormat2 = f.getUint(2);
this.count = f.getUint(2);
this.recordLength = bitCount(this.valueFormat1) * 2 + bitCount(this.valueFormat2) * 2 + 2;
}
lookup(glyph1: KernAdjustment, glyph2: KernAdjustment) {
// first, find glyph1 in coverage
let glyph1Coverage = this.coverage.getCoverageIndex(glyph1.glyph);
if (glyph1Coverage === -1) {
return 0;
}
// now seek to its pairset table
this.f.seek(10 + 2 * glyph1Coverage);
this.f.seek(this.f.getUint(2));
const at = this.f.tell();
let count = this.f.getUint(2);
let found = bsearch(count, (i) => {
this.f.seek(at + i * this.recordLength);
let secondGlyph = this.f.getUint(2);
if (secondGlyph < glyph2.glyph) {
return -1;
} else if (secondGlyph > glyph2.glyph) {
return 1;
}
readValueRecord(this.f, glyph1, this.valueFormat1);
readValueRecord(this.f, glyph2, this.valueFormat2);
return 0;
});
return found >= 0 ? 1 : 0;
}
}
class LookupType2_2 extends PairKerner {
coverage: CoverageTable
classDef1: ClassDefTable
classDef2: ClassDefTable
class1Count: number
class2Count: number
valueFormat1: number
valueFormat2: number
recordSize: number;
constructor(private f: Slice) {
super();
f.getUint(2); // format
this.coverage = new CoverageTable(f.slice(f.getUint(2)));
this.valueFormat1 = f.getUint(2);
this.valueFormat2 = f.getUint(2);
this.classDef1 = new ClassDefTable(f.slice(f.getUint(2)));
this.classDef2 = new ClassDefTable(f.slice(f.getUint(2)));
this.class1Count = f.getUint(2);
this.class2Count = f.getUint(2);
this.recordSize = bitCount(this.valueFormat1) * 2 + bitCount(this.valueFormat2) * 2;
}
lookup(glyph1: KernAdjustment, glyph2: KernAdjustment) {
let glyph1Coverage = this.coverage.getCoverageIndex(glyph1.glyph);
if (glyph1Coverage === -1) return 0;
let glyph1Class = this.classDef1.getClass(glyph1.glyph);
let glyph2Class = this.classDef2.getClass(glyph2.glyph);
this.f.seek(16 + glyph1Class * this.recordSize * this.class2Count + glyph2Class * this.recordSize);
readValueRecord(this.f, glyph1, this.valueFormat1);
readValueRecord(this.f, glyph2, this.valueFormat2);
return 1;
}
}
class Kern0 extends PairKerner {
constructor(
private f: Slice,
private count = f.getUint(2)
) {
super();
log("Kern table has %s entries", this.count);
}
lookup(glyph1: KernAdjustment, glyph2: KernAdjustment) {
let key = (glyph1.glyph << 16) | glyph2.glyph;
let ret = 0;
bsearch(this.count, (i) => {
this.f.seek(8 + 6 * i);
let rec = this.f.getUint(4);
if (key < rec) {
return -1;
} else if (key > rec) {
return 1;
}
let shift = this.f.getInt16();
glyph2.placement.x += shift;
glyph2.advance.x += shift;
ret = 1;
return 0;
})
return ret;
}
}
function bitCount(n: number) {
n = n - ((n >> 1) & 0x55555555)
n = (n & 0x33333333) + ((n >> 2) & 0x33333333)
return ((n + (n >> 4) & 0xF0F0F0F) * 0x1010101) >> 24
}
// By Steve Hanov
// steve.hanov@gmail.com
// Released to the public domain on April 18, 2020
//import { log } from "./log"
// Usage:
/*
declare let arrayBuffer:ArrayBuffer;
let font = new TrueTypeFont(arrayBuffer);
// Draw 15 pixel high "hello world" at x, y on canvas context ctx
font.drawText(ctx, "hello world", x, y, 15);
*/
// This is a stand-in for my own logging framework. Replace with yours.
let log = {
create(prefix: string) {
return (...args:any[]) => {
console.log(...args);
}
}
};
function assert(condition: boolean, message = "Assertion failed.") {
if (!condition) {
throw new Error(message);
}
}
class BinaryReader {
private pos = 0;
getUint8: () => number;
readonly length: number;
constructor(dataIn: string | ArrayBuffer) {
if (typeof dataIn === "string") {
this.length = dataIn.length;
this.getUint8 = () => {
assert(this.pos < dataIn.length);
return dataIn.charCodeAt(this.pos++) & 0xff;
};
} else {
let data = new Uint8Array(dataIn);
this.length = data.length;
this.getUint8 = () => {
assert(this.pos < data.length);
return data[this.pos++];
}
}
}
log = log.create("BinaryReader");
seek(pos: number) {
assert(pos >= 0 && pos <= this.length);
var oldPos = this.pos;
this.pos = pos;
return oldPos;
}
tell() {
return this.pos;
}
getUint16() {
return ((this.getUint8() << 8) | this.getUint8()) >>> 0;
}
getUint32() {
return this.getInt32() >>> 0;
}
getInt16() {
var result = this.getUint16();
if (result & 0x8000) {
result -= (1 << 16);
}
return result;
}
getInt32() {
return ((this.getUint8() << 24) |
(this.getUint8() << 16) |
(this.getUint8() << 8) |
(this.getUint8()));
}
getFword() {
return this.getInt16();
}
getUFword() {
return this.getUint16();
}
get2Dot14() {
return this.getInt16() / (1 << 14);
}
getFixed() {
return this.getInt32() / (1 << 16);
}
getString(length: number) {
var result = "";
for (var i = 0; i < length; i++) {
result += String.fromCharCode(this.getUint8());
}
return result;
}
getUnicodeString(length: number) {
var result = "";
for (var i = 0; i < length; i += 2) {
result += String.fromCharCode(this.getUint16());
}
return result;
}
getDate() {
var macTime = this.getUint32() * 0x100000000 + this.getUint32();
var utcTime = macTime * 1000 + Date.UTC(1904, 1, 1);
return new Date(utcTime);
}
}
interface CMap {
readonly format: number;
map(charCode: number): number;
}
/**
Cmap format 0 is just a direct mapping from one byte to the glyph index.
*/
class TrueTypeCmap0 implements CMap {
format = 0;
array: number[] = [];
constructor(file: BinaryReader, length: number) {
for (var i = 0; i < 256; i++) {
var glyphIndex = file.getUint8();
this.log(" Glyph[%s] = %s", i, glyphIndex);
this.array.push(glyphIndex);
}
}
log = log.create("CMAP0");
map(charCode: number) {
if (charCode >= 0 && charCode <= 255) {
//this.log("charCode %s maps to %s", charCode, this.array[charCode]);
return this.array[charCode];
}
return 0;
}
}
interface Segment {
idRangeOffset: number;
startCode: number;
endCode: number;
idDelta: number;
}
/**
Cmap format 4 is a list of segments which can possibly include gaps
*/
class TrueTypeCmap4 implements CMap {
format = 4;
cache: { [key: number]: number } = {};
segments: Segment[];
constructor(private file: BinaryReader, length: number) {
var i, segments: Segment[] = [];
// 2x segcount
var segCount = file.getUint16() / 2;
// 2 * (2**floor(log2(segCount)))
var searchRange = file.getUint16();
// log2(searchRange)
var entrySelector = file.getUint16();
// (2*segCount) - searchRange
var rangeShift = file.getUint16();
// Ending character code for each segment, last is 0xffff
for (i = 0; i < segCount; i++) {
segments.push({
idRangeOffset: 0,
startCode: 0,
endCode: file.getUint16(),
idDelta: 0
});
}
// reservePAd
file.getUint16();
// starting character code for each segment
for (i = 0; i < segCount; i++) {
segments[i].startCode = file.getUint16();
}
// Delta for all character codes in segment
for (i = 0; i < segCount; i++) {
segments[i].idDelta = file.getUint16();
}
// offset in bytes to glyph indexArray, or 0
for (i = 0; i < segCount; i++) {
var ro = file.getUint16();
if (ro) {
segments[i].idRangeOffset = file.tell() - 2 + ro;
} else {
segments[i].idRangeOffset = 0;
}
}
/*
for(i = 0; i < segCount; i++) {
var seg = segments[i];
this.log("segment[%s] = %s %s %s %s", i,
seg.startCode, seg.endCode, seg.idDelta, seg.idRangeOffset);
}
*/
this.segments = segments;
}
log = log.create("CMAP4");
map(charCode: number) {
if (!(charCode in this.cache)) {
for (var j = 0; j < this.segments.length; j++) {
var segment = this.segments[j];
if (segment.startCode <= charCode && segment.endCode >=
charCode) {
var index, glyphIndexAddress;
if (segment.idRangeOffset) {
glyphIndexAddress = segment.idRangeOffset + 2 *
(charCode - segment.startCode);
this.file.seek(glyphIndexAddress);
index = this.file.getUint16();
} else {
index = (segment.idDelta + charCode) & 0xffff;
}
this.log("Charcode %s is between %s and %s; maps to %s (%s) roffset=%s",
charCode, segment.startCode, segment.endCode,
glyphIndexAddress, index, segment.idRangeOffset);
this.cache[charCode] = index;
break;
}
}
if (j === this.segments.length) {
this.cache[charCode] = 0;
}
}
return this.cache[charCode];
}
};
class Kern0Table {
swap: boolean;
offset: number;
nPairs: number;
map: { [key: number]: number } = {};
oldIndex = -1;
constructor(
private file: BinaryReader,
vertical: boolean,
cross: boolean) {
this.swap = vertical && !cross || !vertical && cross;
this.file = file;
this.offset = file.tell();
this.nPairs = file.getUint16();
file.getUint16(); // searchRange
file.getUint16(); // entrySelector
file.getUint16(); // rangeShift
for (var i = 0; i < this.nPairs; i++) {
var left = file.getUint16();
var right = file.getUint16();
var value = file.getFword();
this.map[(left << 16) | right] = value;
//this.log("Kern %s/%s->%s", left, right, value);
}
this.reset();
}
log = log.create("KERN0");
reset() {
this.oldIndex = -1;
}
get(glyphIndex: number) {
var x = 0;
if (this.oldIndex >= 0) {
var ch = (this.oldIndex << 16) | glyphIndex;
if (ch in this.map) {
x = this.map[ch];
}
//this.log("Lookup kern pair %s/%s -> %s (%s)",
// this.oldIndex, glyphIndex, x, ch);
}
this.oldIndex = glyphIndex;
if (this.swap) {
return {
x: 0,
y: x
};
} else {
return {
x: x,
y: 0
};
}
}
};
interface Table {
checksum: number;
offset: number;
length: number;
}
interface Point {
x: number;
y: number;
onCurve: boolean;
}
class GylphComponent {
points: Point[] = [];
}
interface Glyph {
contourEnds: number[];
numberOfContours: number;
points: Point[];
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}
export class TrueTypeFont {
private file: BinaryReader;
private cmaps: CMap[] = [];
private kern: Kern0Table[] = [];
private tables: { [key: string]: Table };
private length: number;
private scalarType = 0;
private searchRange = 0;
private entrySelector = 0;
private rangeShift = 0;
private version = 0;
private fontRevision = 0;
private checksumAdjustment = 0;
private magicNumber = 0;
private flags = 0;
private unitsPerEm = 0;
private created = new Date();
private modified = new Date();
private xMin = 0;
private yMin = 0;
private xMax = 0;
private yMax = 0;
private macStyle = 0;
private lowestRecPPEM = 0;
private fontDirectionHint = 0;
private indexToLocFormat = 0;
private glyphDataFormat = 0;
public fullName = "";
public fontFamily = "";
private fontSubFamily = "";
public postscriptName = "";
public ascent = 0;
public descent = 0;
public lineGap = 0;
public advanceWidthMax = 0;
public minLeftSideBearing = 0;
public minRightSideBearing = 0;
public xMaxExtent = 0;
public caretSlopeRise = 0;
public caretSlopeRun = 0;
public caretOffset = 0;
public metricDataFormat = 0;
public numOfLongHorMetrics = 0;
constructor(data: ArrayBuffer | string) {
this.file = new BinaryReader(data);
this.tables = this.readOffsetTables(this.file);
this.readHeadTable(this.file);
this.readNameTable(this.file);
this.readCmapTable(this.file);
this.readHheaTable(this.file);
this.readKernTable(this.file);
this.length = this.glyphCount();
}
log = log.create("TrueType");
readOffsetTables(file: BinaryReader) {
/**
Mandatory tables:
- cmap
- glyf
- head
- hhead
- hmtx
- loca
- maxp
- name
- post
*/
var tables: { [key: string]: Table } = {};
this.scalarType = file.getUint32();
var numTables = file.getUint16();
this.searchRange = file.getUint16();
this.entrySelector = file.getUint16();
this.rangeShift = file.getUint16();
for (var i = 0; i < numTables; i++) {
var tag = file.getString(4);
tables[tag] = {
checksum: file.getUint32(),
offset: file.getUint32(),
length: file.getUint32()
};
if (tag !== 'head') {
this.log("Table %s has checksum 0x%s", tag,
tables[tag].checksum.toString(16));
//assert(this.calculateTableChecksum(file, tables[tag].offset,
// tables[tag].length) === tables[tag].checksum);
}
}
return tables;
}
calculateTableChecksum(file: BinaryReader, offset: number,
length: number) {
var old = file.seek(offset);
var sum = 0;
var nlongs = ((length + 3) / 4) >>> 0;
this.log("nlongs=%s length=%s", nlongs, length);
while (nlongs--) {
sum = (sum + file.getUint32()) >>> 0;
}
file.seek(old);
this.log("Checksum calculated is 0x%s", sum.toString(16));
return sum;
}
readHeadTable(file: BinaryReader) {
assert("head" in this.tables);
file.seek(this.tables["head"].offset);
this.version = file.getFixed();
this.fontRevision = file.getFixed();
this.checksumAdjustment = file.getUint32();
this.magicNumber = file.getUint32();
assert(this.magicNumber === 0x5f0f3cf5);
this.flags = file.getUint16();
this.unitsPerEm = file.getUint16();
this.created = file.getDate();
this.modified = file.getDate();
this.xMin = file.getFword();
this.yMin = file.getFword();
this.xMax = file.getFword();
this.yMax = file.getFword();
this.macStyle = file.getUint16();
this.lowestRecPPEM = file.getUint16();
this.fontDirectionHint = file.getInt16();
this.indexToLocFormat = file.getInt16();
this.glyphDataFormat = file.getInt16();
}
readCmapTable(file: BinaryReader) {
assert("cmap" in this.tables);
var tableOffset = this.tables["cmap"].offset;
file.seek(tableOffset);
var version = file.getUint16(); // must be 0
var numberSubtables = file.getUint16();
// tables must be sorted by platform id and then platform specific
// encoding.
for (var i = 0; i < numberSubtables; i++) {
// platforms are:
// 0 - Unicode -- use specific id 6 for full coverage. 0/4 common.
// 1 - MAcintosh (Discouraged)
// 2 - reserved
// 3 - Microsoft
var platformID = file.getUint16();
var platformSpecificID = file.getUint16();
var offset = file.getUint32();
this.log("CMap platformid=%s specificid=%s offset=%s", platformID,
platformSpecificID, offset);
if (platformID === 3 && (platformSpecificID <= 1)) {
this.readCmap(file, tableOffset + offset);
}
}
// use format 0 table preferably.
//this.cmaps.sort(function(a, b) {
// return a.format - b.format;
//});
}
readCmap(file: BinaryReader, offset: number) {
var oldPos = file.seek(offset);
var format = file.getUint16();
var length = file.getUint16();
var language = file.getUint16();
var cmap;
this.log(" Cmap format %s length %s", format, length);
if (format === 0) {
cmap = new TrueTypeCmap0(file, length);
} else if (format === 4) {
cmap = new TrueTypeCmap4(file, length);
}
if (cmap) {
this.cmaps.push(cmap);
}
file.seek(oldPos);
}
readKernTable(file: BinaryReader) {
if (!("kern" in this.tables)) {
return;
}
var tableOffset = this.tables["kern"].offset;
file.seek(tableOffset);
var version = file.getUint16(); // version 0
var nTables = file.getUint16();
this.log("Kern Table version: %s", version);
this.log("Kern nTables: %s", nTables);
for (var i = 0; i < nTables; i++) {
version = file.getUint16(); // subtable version
var length = file.getUint16();
var coverage = file.getUint16();
var format = coverage >> 8;
var cross = coverage & 4;
var vertical = (coverage & 0x1) === 0;
this.log("Kerning subtable version %s format %s length %s coverage: %s",
version, format, length, coverage);
var kern = null;
if (format === 0) {
kern = new Kern0Table(file, vertical, cross != 0);
} else {
this.log("Unknown format -- skip");
file.seek(file.tell() + length);
}
if (kern) {
this.kern.push(kern);
}
}
}
readNameTable(file: BinaryReader) {
assert("name" in this.tables);
var tableOffset = this.tables["name"].offset;
file.seek(tableOffset);
var format = file.getUint16(); // must be 0
var count = file.getUint16();
var stringOffset = file.getUint16();
for (var i = 0; i < count; i++) {
var platformID = file.getUint16();
var platformSpecificID = file.getUint16();
var languageID = file.getUint16();
var nameID = file.getUint16();
var length = file.getUint16();
var offset = file.getUint16();
var old = file.seek(tableOffset + stringOffset + offset);
var name;
if (platformID === 0 || platformID === 3) {
name = file.getUnicodeString(length);
} else {
name = file.getString(length);
}
this.log("Name %s/%s id %s language %s: %s",
platformID, platformSpecificID, nameID, languageID, name);
file.seek(old);
switch (nameID) {
case 1:
this.fontFamily = name;
break;
case 2:
this.fontSubFamily = name;
break;
case 4:
this.fullName = name;
break;
case 6:
this.postscriptName = name;
break;
}
}
}
readHheaTable(file: BinaryReader) {
assert("hhea" in this.tables);
var tableOffset = this.tables["hhea"].offset;
file.seek(tableOffset);
var version = file.getFixed(); // 0x00010000
this.ascent = file.getFword();
this.descent = file.getFword();
this.lineGap = file.getFword();
this.advanceWidthMax = file.getUFword();
this.minLeftSideBearing = file.getFword();
this.minRightSideBearing = file.getFword();
this.xMaxExtent = file.getFword();
this.caretSlopeRise = file.getInt16();
this.caretSlopeRun = file.getInt16();
this.caretOffset = file.getFword();
file.getInt16(); // reserved
file.getInt16(); // reserved
file.getInt16(); // reserved
file.getInt16(); // reserved
this.metricDataFormat = file.getInt16();
this.numOfLongHorMetrics = file.getUint16();
}
getHorizontalMetrics(glyphIndex: number) {
assert("hmtx" in this.tables);
var file = this.file;
var old = file.seek(this.tables["hmtx"].offset + 4);
var offset = this.tables["hmtx"].offset;
let advanceWidth, leftSideBearing;
if (glyphIndex < this.numOfLongHorMetrics) {
offset += glyphIndex * 4;
old = this.file.seek(offset);
advanceWidth = file.getUint16();
leftSideBearing = file.getInt16();
} else {
// read the last entry of the hMetrics array
old = file.seek(offset + (this.numOfLongHorMetrics - 1) * 4);
advanceWidth = file.getUint16();
file.seek(offset + this.numOfLongHorMetrics * 4 +
2 * (glyphIndex - this.numOfLongHorMetrics));
leftSideBearing = file.getFword();
}
this.file.seek(old);
return {
advanceWidth: advanceWidth,
leftSideBearing: leftSideBearing
};
}
glyphCount() {
assert("maxp" in this.tables);
var old = this.file.seek(this.tables["maxp"].offset + 4);
var count = this.file.getUint16();
this.file.seek(old);
return count;
}
getGlyphOffset(index: number) {
assert("loca" in this.tables);
var table = this.tables["loca"];
var file = this.file;
var offset, old, next;
if (this.indexToLocFormat === 1) {
old = file.seek(table.offset + index * 4);
offset = file.getUint32();
next = file.getUint32();
} else {
old = file.seek(table.offset + index * 2);
offset = file.getUint16() * 2;
next = file.getUint16() * 2;
}
file.seek(old);
if (offset === next) {
// indicates glyph has no outline( eg space)
return 0;
}
//this.log("Offset for glyph index %s is %s", index, offset);
return offset + this.tables["glyf"].offset;
}
readGlyph(index: number): Glyph | null {
var offset = this.getGlyphOffset(index);
var file = this.file;
if (offset === 0 ||
offset >= this.tables["glyf"].offset + this.tables["glyf"].length) {
return null;
}
assert(offset >= this.tables["glyf"].offset);
assert(offset < this.tables["glyf"].offset + this.tables["glyf"].length);
file.seek(offset);
var glyph = {
contourEnds: [],
numberOfContours: file.getInt16(),
points: [],
xMin: file.getFword(),
yMin: file.getFword(),
xMax: file.getFword(),
yMax: file.getFword()
};
assert(glyph.numberOfContours >= -1);
if (glyph.numberOfContours === -1) {
this.readCompoundGlyph(file, glyph);
} else {
this.readSimpleGlyph(file, glyph);
}
return glyph;
}
readSimpleGlyph(file: BinaryReader, glyph: Glyph) {
var ON_CURVE = 1,
X_IS_BYTE = 2,
Y_IS_BYTE = 4,
REPEAT = 8,
X_DELTA = 16,
Y_DELTA = 32;
glyph.contourEnds = [];
var points: Point[] = glyph.points = [];
for (var i = 0; i < glyph.numberOfContours; i++) {
glyph.contourEnds.push(file.getUint16());
}
// skip over intructions
file.seek(file.getUint16() + file.tell());
if (glyph.numberOfContours === 0) {
return;
}
var numPoints = Math.max.apply(null, glyph.contourEnds) + 1;
var flags: number[] = [];
for (i = 0; i < numPoints; i++) {
var flag = file.getUint8();
flags.push(flag);
points.push({
x: 0,
y: 0,
onCurve: (flag & ON_CURVE) > 0
});
if (flag & REPEAT) {
var repeatCount = file.getUint8();
assert(repeatCount > 0);
i += repeatCount;
while (repeatCount--) {
flags.push(flag);
points.push({
x: 0,
y: 0,
onCurve: (flag & ON_CURVE) > 0
});
}
}
}
function readCoords(name: "x" | "y", byteFlag: number,
deltaFlag: number, min: number, max: number) {
var value = 0;
for (var i = 0; i < numPoints; i++) {
var flag = flags[i];
if (flag & byteFlag) {
if (flag & deltaFlag) {
value += file.getUint8();
} else {
value -= file.getUint8();
}
} else if (~flag & deltaFlag) {
value += file.getInt16();
} else {
// value is unchanged.
}
points[i][name] = value;
}
}
readCoords("x", X_IS_BYTE, X_DELTA, glyph.xMin, glyph.xMax);
readCoords("y", Y_IS_BYTE, Y_DELTA, glyph.yMin, glyph.yMax);
}
readCompoundGlyph(file: BinaryReader, glyph: Glyph) {
var ARG_1_AND_2_ARE_WORDS = 1,
ARGS_ARE_XY_VALUES = 2,
ROUND_XY_TO_GRID = 4,
WE_HAVE_A_SCALE = 8,
// RESERVED = 16
MORE_COMPONENTS = 32,
WE_HAVE_AN_X_AND_Y_SCALE = 64,
WE_HAVE_A_TWO_BY_TWO = 128,
WE_HAVE_INSTRUCTIONS = 256,
USE_MY_METRICS = 512,
OVERLAP_COMPONENT = 1024;
var flags = MORE_COMPONENTS;
var component;
glyph.contourEnds = [];
glyph.points = [];
while (flags & MORE_COMPONENTS) {
var arg1, arg2;
flags = file.getUint16();
component = {
glyphIndex: file.getUint16(),
matrix: {
a: 1, b: 0, c: 0, d: 1, e: 0, f: 0
},
destPointIndex: 0,
srcPointIndex: 0
};
if (flags & ARG_1_AND_2_ARE_WORDS) {
arg1 = file.getInt16();
arg2 = file.getInt16();
} else {
arg1 = file.getUint8();
arg2 = file.getUint8();
}
if (flags & ARGS_ARE_XY_VALUES) {
component.matrix.e = arg1;
component.matrix.f = arg2;
} else {
component.destPointIndex = arg1;
component.srcPointIndex = arg2;
}
if (flags & WE_HAVE_A_SCALE) {
component.matrix.a = file.get2Dot14();
component.matrix.d = component.matrix.a;
} else if (flags & WE_HAVE_AN_X_AND_Y_SCALE) {
component.matrix.a = file.get2Dot14();
component.matrix.d = file.get2Dot14();
} else if (flags & WE_HAVE_A_TWO_BY_TWO) {
component.matrix.a = file.get2Dot14();
component.matrix.b = file.get2Dot14();
component.matrix.c = file.get2Dot14();
component.matrix.d = file.get2Dot14();
}
this.log("Read component glyph index %s", component.glyphIndex);
this.log("Transform: [%s %s %s %s %s %s]", component.matrix.a, component.matrix.b,
component.matrix.c, component.matrix.d, component.matrix.e, component.matrix.f);
var old = file.tell();
var simpleGlyph = this.readGlyph(component.glyphIndex);
if (simpleGlyph) {
var pointOffset = glyph.points.length;
for (var i = 0; i < simpleGlyph.contourEnds.length; i++) {
glyph.contourEnds.push(simpleGlyph.contourEnds[i] +
pointOffset);
}
for (i = 0; i < simpleGlyph.points.length; i++) {
var x = simpleGlyph.points[i].x;
var y = simpleGlyph.points[i].y;
x = component.matrix.a * x + component.matrix.b * y +
component.matrix.e;
y = component.matrix.c * x + component.matrix.d * y +
component.matrix.f;
glyph.points.push({
x: x, y: y, onCurve:
simpleGlyph.points[i].onCurve
});
}
}
file.seek(old);
}
glyph.numberOfContours = glyph.contourEnds.length;
if (flags & WE_HAVE_INSTRUCTIONS) {
file.seek(file.getUint16() + file.tell());
}
}
drawGlyph(ctx: CanvasRenderingContext2D, index: number, x: number, y: number) {
var glyph = this.readGlyph(index);
//this.log("Draw GLyph index %s", index);
if (glyph === null) {
return false;
}
var s = 0,
p = 0,
c = 0,
contourStart = 0,
prev;
for (; p < glyph.points.length; p++) {
var point = glyph.points[p];
if (s === 0) {
ctx.moveTo(point.x + x, point.y + y);
s = 1;
} else if (s === 1) {
if (point.onCurve) {
ctx.lineTo(point.x + x, point.y + y);
} else {
s = 2;
}
} else {
prev = glyph.points[p - 1];
if (point.onCurve) {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
point.x + x, point.y + y);
s = 1;
} else {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
(prev.x + point.x) / 2 + x,
(prev.y + point.y) / 2 + y);
}
}
if (p === glyph.contourEnds[c]) {
if (s === 2) { // final point was off-curve. connect to start
prev = point;
point = glyph.points[contourStart];
if (point.onCurve) {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
point.x + x, point.y + y);
} else {
ctx.quadraticCurveTo(prev.x + x, prev.y + y,
(prev.x + point.x) / 2 + x,
(prev.y + point.y) / 2 + y);
}
}
contourStart = p + 1;
c += 1;
s = 0;
}
}
return true;
}
transform(ctx: CanvasRenderingContext2D, size: number) {
ctx.scale(size / this.unitsPerEm, -size / this.unitsPerEm);
}
drawText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number,
size: number) {
ctx.save();
ctx.translate(x, y);
this.transform(ctx, size);
x = 0;
y = 0;
this.resetKern();
for (var i = 0; i < text.length; i++) {
var index = this.mapCode(text.charCodeAt(i));
var metrics = this.getHorizontalMetrics(index);
var kern = this.nextKern(index);
this.log("Metrics for %s code %s index %s: %s %s kern: %s,%s", text.charAt(i),
text.charCodeAt(i), index, metrics.advanceWidth, metrics.leftSideBearing,
kern.x, kern.y);
this.drawGlyph(ctx, index, x + kern.x,//- metrics.leftSideBearing,
y + kern.y);
x += metrics.advanceWidth;
}
ctx.restore();
}
drawSingleGlyph(ctx: CanvasRenderingContext2D, glyphIndex: number,
x: number, y: number, size: number) {
ctx.save();
ctx.translate(x, y);
this.transform(ctx, size);
this.drawGlyph(ctx, glyphIndex, 0, 0);
ctx.restore();
}
mapCode(charCode: number) {
var index = 0;
for (var i = 0; i < this.cmaps.length; i++) {
var cmap = this.cmaps[i];
index = cmap.map(charCode);
if (index) {
break;
}
}
return index;
}
resetKern() {
for (var i = 0; i < this.kern.length; i++) {
this.kern[i].reset();
}
}
nextKern(glyphIndex: number) {
var pt, x = 0, y = 0;
for (var i = 0; i < this.kern.length; i++) {
pt = this.kern[i].get(glyphIndex);
x += pt.x;
y += pt.y;
}
return { x: x, y: y };
}
}
@smhanov
Copy link
Author

smhanov commented Aug 16, 2022

Some people are using this as a basis for their projects, so I have now posted the version I am using now. It includes support for OpenType and CFF (Adobe type 1) fonts and more CMAP types, so it will handle the majority of Truetype font files. (Still no .woff though). It is in typescript and imports some logging methods so you would have to stub them out to see it run.

@jeancolasp
Copy link

Absolutely love this example. Thanks for having put it together).

@dashxdr
Copy link

dashxdr commented Jul 29, 2023

I have a minor fix for the code inside the composite glyph handler, byte offset values are not handled correctly, they are treated as unsigned. A font I use called cleanFont.ttf has a glyph with a lower case a and above it is a little tilda, but the tilda was way to the right of where it should be. This fixes the problem:

           if (flags & ARGS_ARE_XY_VALUES) {
                component.matrix.e = arg1;
                component.matrix.f = arg2;
            } else {

ought to be

            if (flags & ARGS_ARE_XY_VALUES) {
                if(~flags & ARG_1_AND_2_ARE_WORDS) {
                    if(arg1>127) arg1-=256;
                    if(arg2>127) arg2-=256;
                }
                component.matrix.e = arg1;
                component.matrix.f = arg2;
            } else {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment