I tried anything to avoid to read TrueType files. This is a very compact format with cryptic tables. Furthermore, even when we get the glyphs, we will have to deal with quadratic curves.
But we have seen that both text based fonts PostScript Type 3 and SVG have show stopping limits.
We will try here to read TrueType files and get the metrics and the glyphs in a format we can deploy it in our workflow.
Fortunately, we are not the first ones to do this. We will use two sources:
If computers can read file formats, they cannot be too complicated.
We need first a class to read all binary datatypes. There are many
We try it out to read the header. We ignore values we do not need.
Run
rpnTTF = class {
constructor(path) {
const file = readSyncURL(path);
this.reader = new rpnBinary(file);
this.reader.getUint32() // scalarType
const numTables = this.reader.getUint16()
this.reader.getUint16() // searchRange
this.reader.getUint16() // entrySelector
this.reader.getUint16() // rangeShift
this.tables = {};
for (var i = 0; i < numTables; i++) {
const tag = this.reader.getString(4)
this.tables[tag] = {
checksum: this.reader.getUint32(),
offset: this.reader.getUint32(),
length: this.reader.getUint32(),
}
}
this.reader.setPosition(this.tables.head.offset);
this.head = {
majorVersion: this.reader.getUint16(),
minorVersion: this.reader.getUint16(),
fontRevision: this.reader.getFixed(),
checksumAdjustment: this.reader.getUint32(),
magicNumber: this.reader.getUint32(),
flags: this.reader.getUint16(),
unitsPerEm: this.reader.getUint16(),
created: this.reader.getDate(),
modified: this.reader.getDate(),
xMin: this.reader.getFWord(),
yMin: this.reader.getFWord(),
xMax: this.reader.getFWord(),
yMax: this.reader.getFWord(),
macStyle: this.reader.getUint16(),
lowestRecPPEM: this.reader.getUint16(),
fontDirectionHint: this.reader.getInt16(),
indexToLocFormat: this.reader.getInt16(),
glyphDataFormat: this.reader.getInt16()
};
}
}
font = new rpnTTF("site/files/CMUSerif-Italic.ttf")
console.log(JSON.stringify(font.tables));
console.log(JSON.stringify(font.head));
Seems to work, so we continue to read
Run
rpnTTF = class {
constructor(path) {
const file = readSyncURL(path);
this.reader = new rpnBinary(file);
this.reader.getUint32() // scalarType
const numTables = this.reader.getUint16()
this.reader.getUint16() // searchRange
this.reader.getUint16() // entrySelector
this.reader.getUint16() // rangeShift
this.tables = {};
for (var i = 0; i < numTables; i++) {
const tag = this.reader.getString(4)
this.tables[tag] = {
checksum: this.reader.getUint32(),
offset: this.reader.getUint32(),
length: this.reader.getUint32(),
}
}
this.reader.setPosition(this.tables.head.offset);
this.head = {
majorVersion: this.reader.getUint16(),
minorVersion: this.reader.getUint16(),
fontRevision: this.reader.getFixed(),
checksumAdjustment: this.reader.getUint32(),
magicNumber: this.reader.getUint32(),
flags: this.reader.getUint16(),
unitsPerEm: this.reader.getUint16(),
created: this.reader.getDate(),
modified: this.reader.getDate(),
xMin: this.reader.getFWord(),
yMin: this.reader.getFWord(),
xMax: this.reader.getFWord(),
yMax: this.reader.getFWord(),
macStyle: this.reader.getUint16(),
lowestRecPPEM: this.reader.getUint16(),
fontDirectionHint: this.reader.getInt16(),
indexToLocFormat: this.reader.getInt16(),
glyphDataFormat: this.reader.getInt16()
};
this.reader.setPosition(this.tables.maxp.offset);
this.maxp = {
version: this.reader.getFixed(),
numGlyphs: this.reader.getUint16(),
maxPoints: this.reader.getUint16(),
maxContours: this.reader.getUint16(),
maxCompositePoints: this.reader.getUint16(),
maxCompositeContours: this.reader.getUint16(),
maxZones: this.reader.getUint16(),
maxTwilightPoints: this.reader.getUint16(),
maxStorage: this.reader.getUint16(),
maxFunctionDefs: this.reader.getUint16(),
maxInstructionDefs: this.reader.getUint16(),
maxStackElements: this.reader.getUint16(),
maxSizeOfInstructions: this.reader.getUint16(),
maxComponentElements: this.reader.getUint16(),
maxComponentDepth: this.reader.getUint16()
}
this.reader.setPosition(this.tables.hhea.offset);
this.hhea = {
version: this.reader.getFixed(),
ascent: this.reader.getFWord(),
descent: this.reader.getFWord(),
lineGap: this.reader.getFWord(),
advanceWidthMax: this.reader.getUFWord(),
minLeftSideBearing: this.reader.getFWord(),
minRightSideBearing: this.reader.getFWord(),
xMaxExtent: this.reader.getFWord(),
caretSlopeRise: this.reader.getInt16(),
caretSlopeRun: this.reader.getInt16(),
caretOffset: this.reader.getFWord(),
reserved0: this.reader.getInt16(),
reserved1: this.reader.getInt16(),
reserved2: this.reader.getInt16(),
reserved3: this.reader.getInt16(),
metricDataFormat: this.reader.getInt16(),
numOfLongHorMetrics: this.reader.getInt16()
}
this.reader.setPosition(this.tables.hmtx.offset);
const hMetrics = [];
for (var i = 0; i < this.hhea.numOfLongHorMetrics; i++) {
hMetrics.push({
advanceWidth: this.reader.getUint16(),
leftSideBearing: this.reader.getInt16()
})
}
const leftSideBearing = [];
for (var i = 0; i < this.maxp.numGlyphs - this.hhea.numOfLongHorMetrics; i++) {
leftSideBearing.push(reader.getFWord())
}
this.hmtx = {
hMetrics,
leftSideBearing
}
}
}
font = new rpnTTF("site/files/CMUSerif-Italic.ttf")
console.log(JSON.stringify(font.hmtx));
Now lets move to the glyphs table
Run
rpnTTF = class {
constructor(path) {
const file = readSyncURL(path);
this.reader = new rpnBinary(file);
this.reader.getUint32() // scalarType
const numTables = this.reader.getUint16()
this.reader.getUint16() // searchRange
this.reader.getUint16() // entrySelector
this.reader.getUint16() // rangeShift
this.tables = {};
for (var i = 0; i < numTables; i++) {
const tag = this.reader.getString(4)
this.tables[tag] = {
checksum: this.reader.getUint32(),
offset: this.reader.getUint32(),
length: this.reader.getUint32(),
}
}
this.reader.setPosition(this.tables.head.offset);
this.head = {
majorVersion: this.reader.getUint16(),
minorVersion: this.reader.getUint16(),
fontRevision: this.reader.getFixed(),
checksumAdjustment: this.reader.getUint32(),
magicNumber: this.reader.getUint32(),
flags: this.reader.getUint16(),
unitsPerEm: this.reader.getUint16(),
created: this.reader.getDate(),
modified: this.reader.getDate(),
xMin: this.reader.getFWord(),
yMin: this.reader.getFWord(),
xMax: this.reader.getFWord(),
yMax: this.reader.getFWord(),
macStyle: this.reader.getUint16(),
lowestRecPPEM: this.reader.getUint16(),
fontDirectionHint: this.reader.getInt16(),
indexToLocFormat: this.reader.getInt16(),
glyphDataFormat: this.reader.getInt16()
};
this.reader.setPosition(this.tables.maxp.offset);
this.maxp = {
version: this.reader.getFixed(),
numGlyphs: this.reader.getUint16(),
maxPoints: this.reader.getUint16(),
maxContours: this.reader.getUint16(),
maxCompositePoints: this.reader.getUint16(),
maxCompositeContours: this.reader.getUint16(),
maxZones: this.reader.getUint16(),
maxTwilightPoints: this.reader.getUint16(),
maxStorage: this.reader.getUint16(),
maxFunctionDefs: this.reader.getUint16(),
maxInstructionDefs: this.reader.getUint16(),
maxStackElements: this.reader.getUint16(),
maxSizeOfInstructions: this.reader.getUint16(),
maxComponentElements: this.reader.getUint16(),
maxComponentDepth: this.reader.getUint16()
}
this.reader.setPosition(this.tables.cmap.offset);
this.cmap = {
version: this.reader.getUint16(),
numTables: this.reader.getUint16(),
encodingRecords: [],
glyphIndexMap: {},
}
if (this.cmap.version) throw "truetype error camp version not 0";
for (var i = 0; i < this.cmap.numTables; i++) {
this.cmap.encodingRecords.push({
platformID: this.reader.getUint16(),
encodingID: this.reader.getUint16(),
offset: this.reader.getOffset32(),
})
}
// we only support platform 0 and and encoding 3
var encodingsOffset = -1;
for (var i = 0; i < this.cmap.encodingRecords.length; i++) {
const { platformID, encodingID, offset } = this.cmap. encodingRecords[i];
if ( platformID == 0 && encodingID == 3 ) encodingsOffset = offset; // unicode
if ( platformID == 3 && encodingID == 1 ) encodingsOffset = offset; // windows
if (encodingsOffset > -1 ) break;
}
if (encodingsOffset == -1 ) throw "truetype unsupported encoding";
this.reader.setPosition(this.tables.cmap.offset + encodingsOffset);
const format = this.reader.getUint16();
if (!(format == 4) ) throw "truetype unsupported format";
this.cmap.format = {
format: 4,
length: this.reader.getUint16(),
language: this.reader.getUint16(),
segCountX2: this.reader.getUint16(),
searchRange: this.reader.getUint16(),
entrySelector: this.reader.getUint16(),
rangeShift: this.reader.getUint16(),
endCode: [],
startCode: [],
idDelta: [],
idRangeOffset: [],
glyphIndexMap: {}, // This one is my addition, contains final unicode->index mapping
}
const segCount = this.cmap.format.segCountX2 / 2;
for (var i = 0; i < segCount; i++) {
this.cmap.format.endCode.push(this.reader.getUint16())
}
this.reader.getUint16() // Reserved pad.
for (var i = 0; i < segCount; i++) {
this.cmap.format.startCode.push(this.reader.getUint16())
}
for (var i = 0; i < segCount; i++) {
this.cmap.format.idDelta.push(this.reader.getInt16())
}
const idRangeOffsetsStart = this.reader.getPosition()
for (var i = 0; i < segCount; i++) {
this.cmap.format.idRangeOffset.push(this.reader.getUint16())
}
this.reader.setPosition(this.tables.hhea.offset);
this.hhea = {
version: this.reader.getFixed(),
ascent: this.reader.getFWord(),
descent: this.reader.getFWord(),
lineGap: this.reader.getFWord(),
advanceWidthMax: this.reader.getUFWord(),
minLeftSideBearing: this.reader.getFWord(),
minRightSideBearing: this.reader.getFWord(),
xMaxExtent: this.reader.getFWord(),
caretSlopeRise: this.reader.getInt16(),
caretSlopeRun: this.reader.getInt16(),
caretOffset: this.reader.getFWord(),
reserved0: this.reader.getInt16(),
reserved1: this.reader.getInt16(),
reserved2: this.reader.getInt16(),
reserved3: this.reader.getInt16(),
metricDataFormat: this.reader.getInt16(),
numOfLongHorMetrics: this.reader.getInt16()
}
this.reader.setPosition(this.tables.hmtx.offset);
const hMetrics = [];
for (var i = 0; i < this.hhea.numOfLongHorMetrics; i++) {
hMetrics.push({
advanceWidth: this.reader.getUint16(),
leftSideBearing: this.reader.getInt16()
})
}
const leftSideBearing = [];
for (var i = 0; i < this.maxp.numGlyphs - this.hhea.numOfLongHorMetrics; i++) {
leftSideBearing.push(reader.getFWord())
}
this.hmtx = {
hMetrics,
leftSideBearing
}
this.reader.setPosition(this.tables.loca.offset);
const loca = [];
for (let i = 0; i < this.maxp.numGlyphs + 1; i++) {
if (this.head.indexToLocFormat)
loca.push(this.reader.getOffset32());
else
loca.push(this.reader.getOffset16());
}
this.glyphs = [];
for (var i = 0; i < loca.length - 1; i++) {
const multiplier = this.head.indexToLocFormat === 0 ? 2 : 1
const locaOffset = loca[i] * multiplier;
this.reader.setPosition(this.tables.glyf.offset + locaOffset)
const glyph = {
numberOfContours: this.reader.getInt16(),
xMin: this.reader.getInt16(),
yMin: this.reader.getInt16(),
xMax: this.reader.getInt16(),
yMax: this.reader.getInt16()
};
glyph.endPtsOfContours = []
for (var j = 0; j < glyph.numberOfContours; j++) {
glyph.endPtsOfContours.push(this.reader.getUint16());
}
glyph.instructionLength = this.reader.getUint16();
glyph.instructions = [];
for (var j = 0; j < glyph.instructionLength; j++) {
glyph.instructions.push(this.reader.getUint8());
}
const numPoints = Math.max(glyph.endPtsOfContours)+1;
const flags = [];
glyph.points = [];
for (var j = 0; j < numPoints; j++) {
const flag = this.reader.getUint8();
flags.push(flag);
glyph.points.push( { x: 0, y: 0, onCurve: flag & 1 });
if (flag & 8) {
var repeatCount = this.reader.getUint8();
j += repeatCount;
while (repeatCount--) {
flags.push(flag);
glyph.points.push( { x: 0, y: 0, onCurve: flag & 1 });
}
}
}
function readCoords(name, byteFlag, deltaFlag, reader) {
var value = 0;
for (var i = 0; i < numPoints; i++) {
var flag = flags[i];
if (flag & byteFlag) {
if (flag & deltaFlag) {
value += reader.getUint8();
} else {
value -= reader.getUint8();
}
} else if (~flag & deltaFlag) {
value += reader.getInt16();
} else {
// value is unchanged.
}
glyph.points[i][name] = value;
}
}
readCoords("x", 2, 16, this.reader);
readCoords("y", 4, 32, this.reader);
this.glyphs.push(glyph);
}
}
unicodeOffset(unicode) {
for(var i = 0; i < this.cmap.format.segCountX2 / 2 ; i++) {
if (unicode >= this.cmap.format.startCode[i] && unicode <= this.cmap.format.endCode[i] ) {
return unicode + this.cmap.format.idDelta[i];
}
}
return 0; //missing glyph
}
glyphMetrics(g) {
}
}
console.log(JSON.stringify(font.cmap));
console.log(JSON.stringify(font.glyphs));
We are close. Can now try to create PostScript code from it
Run
font = new rpnTTF("site/files/CMUSerif-Italic.ttf")
operators.showttf = function(context) {
const [s] = context.pop("string");
if (!context.graphics.current.length) {
context.stack.push(new rpnError("nocurrentpoint"));
return context;
}
if (!s) return context;
const scale = 60 / font.head.unitsPerEm;
var ps = " currentpoint currentpoint translate gsave " + scale + " " + scale + " scale ";
for(var i = 0; i < s.value.length; i++) {
const c = s.value.charCodeAt(i);
const gi = font.unicodeOffset(c);
const width = font.hmtx.hMetrics[gi].advanceWidth;
const glyph = font.glyphs[gi];
if (glyph.points.length) {
var p = glyph.points[0];
ps += p["x"] + " " + p["y"] + " moveto ";
for (var j = 1; j < glyph.points.length; j++) {
p = glyph.points[j];
if (p.onCurve)
ps += " " + p["x"] + " " + p["y"] + " lineto ";
}
ps += " closepath fill "
}
ps += width + " 0 translate ";
}
ps += " grestore neg exch neg exch translate "; console.log(ps)
context = rpn(ps, context);
return context;
}
showttf connects all points on curve. It does not yet handle the control points and not handle characters using multiple subpaths. But this sounds promising. We will get there in the next chapter
Run
postScriptEditor(`
10 dict begin /rawurl 1 def currentdict setpagedevice end
0 270 moveto (ABCDEFGHIJKLMN) showttf
0 220 moveto (OPQRSTUVWXYZ) showttf
0 170 moveto (abcdefghijklmn) showttf
0 120 moveto (opqrstuvwxyz) showttf
0 70 moveto (0123456789) showttf
0 20 moveto (.,;?!-+/()%) showttf
showpage
`);
My Journey to PostScript