JS解析TTF字体文件

在这篇文章,我们计划操作如下:

  1. 将字体文件拖入网页,并读取之
  2. 尽管ttf文件是为C语言读取设计的,但我们仍试图解析之
  3. 读取文件的字形数目,并定位各个字形轮廓的位置
  4. 解析每个字形轮廓
  5. 最后,把这些字形轮廓呈现到网页上

本文由原始文档从零开始解析ttf文件,并获取字形轮廓坐标。如果需要完整解析ttf文件,并获取字体文件的各个属性,以下第三方库可能是更优选择:

JavascriptPythonCPhp
opentype.jsfonttoolsotfccphp-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;
    }

源码