在这篇文章,我们计划操作如下:
- 将字体文件拖入网页,并读取之
- 尽管ttf文件是为C语言读取设计的,但我们仍试图解析之
- 读取文件的字形数目,并定位各个字形轮廓的位置
- 解析每个字形轮廓
- 最后,把这些字形轮廓呈现到网页上
本文由原始文档从零开始解析ttf文件,并获取字形轮廓坐标。如果需要完整解析ttf文件,并获取字体文件的各个属性,以下第三方库可能是更优选择:
Javascript | Python | C | Php |
---|---|---|---|
opentype.js | fonttools | otfcc | php-font-lib |
用Javascript读取文件
这… 好像很危险。 不过,放心吧,只有把文件拖动到网页上,才能用javascript读取它。通过处理dragover(拖入方框)和drop(释放鼠标)事件,我们可以读取拖进方框的文件。
在页面接听到drop事件的时候,可以获取该文件的引用(指针),进而读取该文件。这个操作无需与服务器进行交互。 我们还得处理dragover事件,不然它将不能工作。
var dropTarget = document.getElementById("dropTarget");
dropTarget.ondragover = function(e) {
e.preventDefault();
};
dropTarget.ondrop = function(e) {
e.preventDefault();
if (!e.dataTransfer || !e.dataTransfer.files) {
alert("没有读取到文件");
return;
}
var reader = new FileReader();
reader.readAsArrayBuffer(e.dataTransfer.files[0]);
reader.onload = function(e) {
ShowTtfFile(reader.result);
};
};
HTML5文件对象不太方便后续的操作。要想获取文件的原始数据,只能用FileReader异步读取它。我们可以读取为base64编码的字符串或ArrayBuffer。在这里,我们读取ttf文件为ArrayBuffer类型。
解析C结构体
TrueType文件设计的时候,计算机内存还很小。它的设计思路是,先把硬盘上的字体文件拷贝到运行内存,然后在适当的位置读取。字体文件中甚至直接存入了C结构体。要读取TrueType文件,只要把它加载到内存就可以了。我们将做类似的事情。不过,首先需要一些功能函数,以便在文件适当的位置查找并读取各种数据类型。 这个类可以实现以上目的。
function BinaryReader(arrayBuffer)
{
assert(arrayBuffer instanceof ArrayBuffer);
this.pos = 0;
this.data = new Uint8Array(arrayBuffer);
}
BinaryReader.prototype = {
seek: function(pos) {
assert(pos >=0 && pos <= this.data.length);
var oldPos = this.pos;
this.pos = pos;
return oldPos;
},
tell: function() {
return this.pos;
},
getUint8: function() {//读取单字节无符号整型
assert(this.pos < this.data.length);
return this.data[this.pos++];
},
getUint16: function() {//读取双字节无符号整型
return ((this.getUint8() << 8) | this.getUint8()) >>> 0;
},
getUint32: function() {//读取四字节无符号整型
return this.getInt32() >>> 0;
},
getInt16: function() {//读取双字节有符号整型
var result = this.getUint16();
if (result & 0x8000) {
result -= (1 << 16);
}
return result;
},
getInt32: function() {//读取四字节有符号整型
return ((this.getUint8() << 24) |
(this.getUint8() << 16) |
(this.getUint8() << 8) |
(this.getUint8() ));
},
getFword: function() {
return this.getInt16();
},
get2Dot14: function() {//读取定点数,00.00000000000000
return this.getInt16() / (1 << 14);
},
getFixed: function() {//读取定点数,00.00
return this.getInt32() / (1 << 16);
},
getString: function(length) {//由arraybuffer转字符串(ascii编码)
var result = "";
for(var i = 0; i < length; i++) {
result += String.fromCharCode(this.getUint8());
}
return result;
},
getDate: function() {//读取日期
var macTime = this.getUint32() * 0x100000000 + this.getUint32();
var utcTime = macTime * 1000 + Date.UTC(1904, 0, 1);
return new Date(utcTime);
}
};
定点数
除了无符号及有符号8位、16位和32位整型,字体文件中还需要一些其他数据类型。某些特定位数的小数可以用定点数来表示。类似于定点算术,我们只使用二进制而非十进制。假设我们打算写入十进制数字1.53,由于1.53转换成二进制是循环小数,因此不能精确写入文件,不过我们将其改写为153再存入文件。只要把它再除以100,就可以欲获得原始数据1.53。
有关Javascript中的数据类型
Javascript中的数据类型是变化无常的,它通常是32位整型。只要它认为是必要的,就会从有符号类型自动转换为无符号类型。即使不需要,js也可能把数据转换成64位双精度浮点数(double float)。
不过,可以用无符号右移位运算符(»>)将数据类型强制转换为无符号数。将一个数右移0位,其内部类型就转为无符号整型了。
寻找宝藏
TrueType字体格式的详细说明在苹果公司网站。Truetype文件头是偏移表,记录了其余表在文件中的位置。我们将深入一些表来获取字形轮廓。
每个表有一个校验和,以此保证其正确性。校验和可以通过将该表的所有4字节整数相加模2^32得到。 这段代码用来读取每个表的相对于整个文件的偏移量。
function TrueTypeFont(arrayBuffer)
{
this.file = new BinaryReader(arrayBuffer);
this.tables = this.readOffsetTables(this.file);
this.readHeadTable(this.file);
this.length = this.glyphCount();
}
TrueTypeFont.prototype = {
readOffsetTables: function(file) {
var tables = {};
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') {
assert(this.calculateTableChecksum(file, tables[tag].offset,
tables[tag].length) === tables[tag].checksum);
}
}
return tables;
},
calculateTableChecksum: function(file, offset, length)
{
var old = file.seek(offset);
var sum = 0;
var nlongs = ((length + 3) / 4) | 0;
while( nlongs-- ) {
sum = (sum + file.getUint32() & 0xffffffff) >>> 0;
}
file.seek(old);
return sum;
},
好了,现在我们定位了各个表的位置。不过,接下来我们需要读取“head”表。除了记录字体尺寸,更重要的是它定义了字形索引的格式。
readHeadTable: function(file) {
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();
},
诸如字形之间的水平距离,建议的最小高度,创建日期等属性,可以从许多表得到。不过我们要专注于埋藏的宝藏 – 字形轮廓。 字形轮廓在“glyf”表中。字形是高度压缩的,每个字形的长度也不同。要快速找到某个字形的位置,我们必须先读取“loca”表—字形索引表。
head表的“indexToLocFormat”值决定了“loca”表是一个2字节还是一个4字节值的数组。如果indexToLocFormat为1,那么loca表每个元素占用4个字节,记录了字形在glyf表的位置序号;否则,loca表每个元素占用2个字节,这个元素乘以2就是是字形在glyf表的位置序号。这样的设计不会导致数据的错乱。
getGlyphOffset: function(index) {
assert("loca" in this.tables);
var table = this.tables["loca"];
var file = this.file;
var offset, old;
if (this.indexToLocFormat === 1) {
old = file.seek(table.offset + index * 4);
offset = file.getUint32();
} else {
old = file.seek(table.offset + index * 2);
offset = file.getUint16() * 2;
}
file.seek(old);
return offset + this.tables["glyf"].offset;
},
现在,给定任何字形的索引,就可以定位该字形的位置。不过接下来,有点小麻烦。
如果两个图形彼此重叠,且路径方向不同(一个逆时针,一个顺时针),那么第二个将切掉第一个形状。字体依照这个约定来从轮廓构建形状。例如,字母O需要有两个轮廓 – 一个用于外圆,一个用于内圆。
不过有两种字形。一种是简单字形,由轮廓构成,如上所述;另一种是复合字形,由简单字形复合而成。要绘制复合字形,我们必须把每个简单字形部件放到到正确的位置。这样,复合字形就能处理带重音的字符(如汉语拼音)。正因为此,字母的重音版本占用的空间非常小。
为了专注于获取字体的精华,我们将暂不考虑复合字形。在这里只是提取那些简单字形。
解析轮廓
此函数将解析字形头,然后调用正确的函数来读取字形。
readGlyph: function(index) {
var offset = this.getGlyphOffset(index);
var file = this.file;
if (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 = {
numberOfContours: file.getInt16(),
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;
},
简单字形以压缩格式存储。通过使用一系列单字节标识,可以很好地处理重复点以及邻点之间的变动情况。对每一个XY坐标,每个标识字节指示对应点是存储在一个字节还是两个字节中。标志数组之后是X坐标,最后是Y坐标数组。这样设计的好处是,如果X或Y坐标没改变,那么只需要一个字节就可以存储这个点。
我们读取每一个字形,并把这些点拼成(x,y)坐标数组,并记录对渲染非常重要的标识。
readSimpleGlyph: function(file, glyph) {
var ON_CURVE = 1,
X_IS_BYTE = 2,
Y_IS_BYTE = 4,
REPEAT = 8,
X_DELTA = 16,
Y_DELTA = 32;
glyph.type = "simple";
glyph.contourEnds = [];
var points = 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 = [];
for( i = 0; i < numPoints; i++ ) {
var flag = file.getUint8();
flags.push(flag);
points.push({
onCurve: (flag & ON_CURVE) > 0
});
if ( flag & REPEAT ) {
var repeatCount = file.getUint8();
assert(repeatCount > 0);
i += repeatCount;
while( repeatCount-- ) {
flags.push(flag);
points.push({
onCurve: (flag & ON_CURVE) > 0
});
}
}
}
function readCoords(name, byteFlag, deltaFlag, min, max) {
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);
}
在网页中绘制字形
最后,我们应该为所有的努力展示一些东西 – 绘制字形。我们可以用HTML5画布API绘制。
这个函数用来控制整个流程。首先,从拖放事件中读取数组,并创建TrueType对象;接下来,删除之前绘制的字形;然后,针对每个字形,创建一个canvas元素并缩放字形,使其高度为字母’M’的高度–64像素;最后,由于字体坐标原点在屏幕左下角,但canvas坐标原点在左上角,所以需要垂直翻转一下。
function ShowTtfFile(arrayBuffer)
{
var font = new TrueTypeFont(arrayBuffer);
var width = font.xMax - font.xMin;
var height = font.yMax - font.yMin;
var scale = 64 / font.unitsPerEm;
var container = document.getElementById("font-container");
while(container.firstChild) {
container.removeChild(container.firstChild);
}
for( var i = 0; i < font.length; i++ ) {
var canvas = document.createElement("canvas");
canvas.style.border = "1px solid gray";
canvas.width = width * scale;
canvas.height = height * scale;
var ctx = canvas.getContext("2d");
ctx.scale(scale, -scale);
ctx.translate(-font.xMin, -font.yMin - height);
ctx.fillStyle = "#000000";
ctx.beginPath();
if (font.drawGlyph(i, ctx)) {
ctx.fill();
container.appendChild(canvas);
}
}
}
这里展示了它们是如何绘制的。在此函数中,我们忽略了曲线上的控制点,简单连接了轮廓中的每个点。
drawGlyph: function(index, ctx) {
var glyph = this.readGlyph(index);
if ( glyph === null || glyph.type !== "simple" ) {
return false;
}
var p = 0,
c = 0,
first = 1;
while (p < glyph.points.length) {
var point = glyph.points[p];
if ( first === 1 ) {
ctx.moveTo(point.x, point.y);
first = 0;
} else {
ctx.lineTo(point.x, point.y);
}
if ( p === glyph.contourEnds[c] ) {
c += 1;
first = 1;
}
p += 1;
}
return true;
}