Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support vertical fonts (vmtx / vhea table parsing) #728

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ A Font represents a loaded OpenType font file. It contains a set of glyphs and m
* `unitsPerEm`: X/Y coordinates in fonts are stored as integers. This value determines the size of the grid. Common values are `2048` and `4096`.
* `ascender`: Distance from baseline of highest ascender. In font units, not pixels.
* `descender`: Distance from baseline of lowest descender. In font units, not pixels.
* `vertTypoAscender`: Similar to "ascender", except this is used when glyphs are drawn vertically. In font units, not pixels.
* `vertTypoDescender`: Similar to "descender", except this is used when glyphs are drawn vertically. In font units, not pixels.

#### `Font.getPath(text, x, y, fontSize, options)`
Create a Path that represents the given text.
Expand Down Expand Up @@ -442,8 +444,10 @@ A Glyph is an individual mark that often corresponds to a character. Some glyphs
* `unicode`: The primary unicode value of this glyph (can be `undefined`).
* `unicodes`: The list of unicode values for this glyph (most of the time this will be `1`, can also be empty).
* `index`: The index number of the glyph.
* `advanceWidth`: The width to advance the pen when drawing this glyph.
* `advanceWidth`: The width to advance the pen when drawing this glyph horizontally.
* `leftSideBearing`: The horizontal distance from the previous character to the origin (`0, 0`); a negative value indicates an overhang
* `advanceHeight`: The height to advance the pen when drawing this glyph vertically.
* `topSideBearing`: The vertical distance from the previous character to the origin (`0, 0`); a negative value indicates an overhang
* `xMin`, `yMin`, `xMax`, `yMax`: The bounding box of the glyph.
* `path`: The raw, unscaled path of the glyph.

Expand Down
18 changes: 16 additions & 2 deletions src/glyph.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function getPathDefinition(glyph, path) {
* @property {number} [yMax]
* @property {number} [advanceWidth]
* @property {number} [leftSideBearing]
* @property {number} [advanceHeight]
* @property {number} [topSideBearing]
*/

// A Glyph is an individual mark that often corresponds to a character.
Expand Down Expand Up @@ -103,6 +105,14 @@ Glyph.prototype.bindConstructorValues = function(options) {
this.leftSideBearing = options.leftSideBearing;
}

if ('advanceHeight' in options) {
this.advanceHeight = options.advanceHeight;
}

if ('topSideBearing' in options) {
this.topSideBearing = options.topSideBearing;
}

if ('points' in options) {
this.points = options.points;
}
Expand Down Expand Up @@ -324,7 +334,8 @@ Glyph.prototype.getMetrics = function() {
yMin: Math.min.apply(null, yCoords),
xMax: Math.max.apply(null, xCoords),
yMax: Math.max.apply(null, yCoords),
leftSideBearing: this.leftSideBearing
leftSideBearing: this.leftSideBearing,
topSideBearing: this.topSideBearing
};

if (!isFinite(metrics.xMin)) {
Expand All @@ -344,6 +355,9 @@ Glyph.prototype.getMetrics = function() {
}

metrics.rightSideBearing = this.advanceWidth - metrics.leftSideBearing - (metrics.xMax - metrics.xMin);
metrics.bottomSideBearing = metrics.topSideBearing != null
? this.advanceHeight - metrics.topSideBearing - (metrics.yMax - metrics.yMin)
: undefined;
return metrics;
};

Expand All @@ -357,7 +371,7 @@ Glyph.prototype.getMetrics = function() {
* @param {opentype.Font} font - if hinting is to be used, or CPAL/COLR / variation needs to be rendered, the font
*/
Glyph.prototype.draw = function(ctx, x, y, fontSize, options, font) {
options = Object.assign({}, font.defaultRenderOptions, options);
options = Object.assign({}, font && font.defaultRenderOptions, options);
const path = this.getPath(x, y, fontSize, options, font);
path.draw(ctx);
};
Expand Down
4 changes: 4 additions & 0 deletions src/glyphset.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ GlyphSet.prototype.get = function(index) {

this.glyphs[index].advanceWidth = this.font._hmtxTableData[index].advanceWidth;
this.glyphs[index].leftSideBearing = this.font._hmtxTableData[index].leftSideBearing;
if (this.font._vmtxTableData) {
this.glyphs[index].advanceHeight = this.font._vmtxTableData[index].advanceHeight;
this.glyphs[index].topSideBearing = this.font._vmtxTableData[index].topSideBearing;
}
} else {
if (typeof this.glyphs[index] === 'function') {
this.glyphs[index] = this.glyphs[index]();
Expand Down
23 changes: 21 additions & 2 deletions src/opentype.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import gpos from './tables/gpos.mjs';
import gsub from './tables/gsub.mjs';
import head from './tables/head.mjs';
import hhea from './tables/hhea.mjs';
import vhea from './tables/vhea.mjs';
import hmtx from './tables/hmtx.mjs';
import vmtx from './tables/vmtx.mjs';
import kern from './tables/kern.mjs';
import ltag from './tables/ltag.mjs';
import loca from './tables/loca.mjs';
Expand Down Expand Up @@ -189,6 +191,7 @@ function parseBuffer(buffer, opt={}) {
let gsubTableEntry;
let hmtxTableEntry;
let hvarTableEntry;
let vmtxTableEntry;
let kernTableEntry;
let locaTableEntry;
let nameTableEntry;
Expand Down Expand Up @@ -248,6 +251,16 @@ function parseBuffer(buffer, opt={}) {
case 'hmtx':
hmtxTableEntry = tableEntry;
break;
case 'vhea':
table = uncompressTable(data, tableEntry);
font.tables.vhea = vhea.parse(table.data, table.offset);
font.vertTypoAscender = font.tables.vhea.vertTypoAscender;
font.vertTypoDescender = font.tables.vhea.vertTypoDescender;
font.numOfLongVerMetrics = font.tables.vhea.numOfLongVerMetrics;
break;
case 'vmtx':
vmtxTableEntry = tableEntry;
break;
case 'ltag':
table = uncompressTable(data, tableEntry);
ltagTable = ltag.parse(table.data, table.offset);
Expand Down Expand Up @@ -343,8 +356,14 @@ function parseBuffer(buffer, opt={}) {
throw new Error('Font doesn\'t contain TrueType, CFF or CFF2 outlines.');
}

const hmtxTable = uncompressTable(data, hmtxTableEntry);
hmtx.parse(font, hmtxTable.data, hmtxTable.offset, font.numberOfHMetrics, font.numGlyphs, font.glyphs, opt);
if (hmtxTableEntry) {
const hmtxTable = uncompressTable(data, hmtxTableEntry);
hmtx.parse(font, hmtxTable.data, hmtxTable.offset, font.numberOfHMetrics, font.numGlyphs, font.glyphs, opt);
}
if (vmtxTableEntry) {
const vmtxTable = uncompressTable(data, vmtxTableEntry);
vmtx.parse(font, vmtxTable.data, vmtxTable.offset, font.numOfLongVerMetrics, font.numGlyphs, font.glyphs, opt);
}
addGlyphNames(font, opt);

if (kernTableEntry) {
Expand Down
53 changes: 53 additions & 0 deletions src/tables/vhea.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// The `vhea` table contains information for vertical layout.
// https://learn.microsoft.com/en-us/typography/opentype/spec/vhea

import parse from '../parse.mjs';
import table from '../table.mjs';

// Parse the vertical header `vhea` table
function parseVheaTable(data, start) {
const vhea = {};
const p = new parse.Parser(data, start);
vhea.version = p.parseVersion();
vhea.ascent = p.parseShort(); // v1.0
vhea.vertTypoAscender = vhea.ascent; // v1.1
vhea.descent = p.parseShort(); // v1.0
vhea.vertTypoDescender = vhea.descent; // v1.1
vhea.lineGap = p.parseShort(); // v1.0
vhea.vertTypoLineGap = vhea.lineGap; // v1.1
vhea.advanceHeightMax = p.parseUShort();
vhea.minTopSideBearing = p.parseShort();
vhea.minBottomSideBearing = p.parseShort();
vhea.yMaxExtent = p.parseShort();
vhea.caretSlopeRise = p.parseShort();
vhea.caretSlopeRun = p.parseShort();
vhea.caretOffset = p.parseShort();
p.relativeOffset += 8;
vhea.metricDataFormat = p.parseShort();
vhea.numOfLongVerMetrics = p.parseUShort();
return vhea;
}

function makeVheaTable(options) {
return new table.Table('vhea', [
{name: 'version', type: 'FIXED', value: 0x00010000},
{name: 'ascent', type: 'FWORD', value: 0},
{name: 'descent', type: 'FWORD', value: 0},
{name: 'lineGap', type: 'FWORD', value: 0},
{name: 'advanceHeightMax', type: 'UFWORD', value: 0},
{name: 'minTopSideBearing', type: 'FWORD', value: 0},
{name: 'minBottomSideBearing', type: 'FWORD', value: 0},
{name: 'yMaxExtent', type: 'FWORD', value: 0},
{name: 'caretSlopeRise', type: 'SHORT', value: 1},
{name: 'caretSlopeRun', type: 'SHORT', value: 0},
{name: 'caretOffset', type: 'SHORT', value: 0},
{name: 'reserved1', type: 'SHORT', value: 0},
{name: 'reserved2', type: 'SHORT', value: 0},
{name: 'reserved3', type: 'SHORT', value: 0},
{name: 'reserved4', type: 'SHORT', value: 0},
{name: 'metricDataFormat', type: 'SHORT', value: 0},
{name: 'numOfLongVerMetrics', type: 'USHORT', value: 0}
], options);
}

export default { parse: parseVheaTable, make: makeVheaTable };
66 changes: 66 additions & 0 deletions src/tables/vmtx.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// The `vmtx` table contains the vertical metrics for all glyphs.
// https://learn.microsoft.com/en-us/typography/opentype/spec/vmtx

import parse from '../parse.mjs';
import table from '../table.mjs';

function parseVmtxTableAll(data, start, numMetrics, numGlyphs, glyphs) {
let advanceHeight;
let topSideBearing;
const p = new parse.Parser(data, start);
for (let i = 0; i < numGlyphs; i += 1) {
// If the font is monospaced, only one entry is needed. This last entry applies to all subsequent glyphs.
if (i < numMetrics) {
advanceHeight = p.parseUShort();
topSideBearing = p.parseShort();
}

const glyph = glyphs.get(i);
glyph.advanceHeight = advanceHeight;
glyph.topSideBearing = topSideBearing;
}
}

function parseVmtxTableOnLowMemory(font, data, start, numMetrics, numGlyphs) {
font._vmtxTableData = {};

let advanceHeight;
let topSideBearing;
const p = new parse.Parser(data, start);
for (let i = 0; i < numGlyphs; i += 1) {
// If the font is monospaced, only one entry is needed. This last entry applies to all subsequent glyphs.
if (i < numMetrics) {
advanceHeight = p.parseUShort();
topSideBearing = p.parseShort();
}

font._vmtxTableData[i] = {
advanceHeight: advanceHeight,
topSideBearing: topSideBearing
};
}
}

// Parse the `vmtx` table, which contains the horizontal metrics for all glyphs.
// This function augments the glyph array, adding the advanceHeight and topSideBearing to each glyph.
function parseVmtxTable(font, data, start, numMetrics, numGlyphs, glyphs, opt) {
if (opt.lowMemory)
parseVmtxTableOnLowMemory(font, data, start, numMetrics, numGlyphs);
else
parseVmtxTableAll(data, start, numMetrics, numGlyphs, glyphs);
}

function makeVmtxTable(glyphs) {
const t = new table.Table('vmtx', []);
for (let i = 0; i < glyphs.length; i += 1) {
const glyph = glyphs.get(i);
const advanceHeight = glyph.advanceHeight || 0;
const topSideBearing = glyph.topSideBearing || 0;
t.fields.push({name: 'advanceHeight_' + i, type: 'USHORT', value: advanceHeight});
t.fields.push({name: 'topSideBearing_' + i, type: 'SHORT', value: topSideBearing});
}

return t;
}

export default { parse: parseVmtxTable, make: makeVmtxTable };
Binary file added test/fonts/NotoSansJP-Medium.ttf
Binary file not shown.
35 changes: 35 additions & 0 deletions test/tables/vhea.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'assert';
import { parse } from '../../src/opentype.mjs';
import { readFileSync } from 'fs';
const loadSync = (url, opt) => parse(readFileSync(url), opt);

describe('tables/vhea.mjs', function() {
const fonts = {
notoSansJp: loadSync('./test/fonts/NotoSansJP-Medium.ttf'),
};
it('correctly parses the vertical header table', function() {
// tests for all fonts
const { notoSansJp } = fonts;
assert.equal(notoSansJp.tables.vhea.version, 1.1);
assert.equal(notoSansJp.tables.vhea.ascent, 500);
assert.equal(notoSansJp.tables.vhea.vertTypoAscender, 500);
assert.equal(notoSansJp.tables.vhea.descent, -500);
assert.equal(notoSansJp.tables.vhea.vertTypoDescender, -500);
assert.equal(notoSansJp.tables.vhea.lineGap, 0);
assert.equal(notoSansJp.tables.vhea.vertTypoLineGap, 0);
assert.equal(notoSansJp.tables.vhea.advanceHeightMax, 3000);
assert.equal(notoSansJp.tables.vhea.minTopSideBearing, -224);
assert.equal(notoSansJp.tables.vhea.minBottomSideBearing, -689);
assert.equal(notoSansJp.tables.vhea.yMaxExtent, 2927);
assert.equal(notoSansJp.tables.vhea.caretSlopeRise, 0);
assert.equal(notoSansJp.tables.vhea.caretSlopeRun, 1);
assert.equal(notoSansJp.tables.vhea.caretOffset, 0);
assert.equal(notoSansJp.tables.vhea.metricDataFormat, 0);
assert.equal(notoSansJp.tables.vhea.numOfLongVerMetrics, 17481);

// Directly exposed equivalents to ascender, descender, numberOfHMetrics
assert.equal(notoSansJp.vertTypoAscender, 500);
assert.equal(notoSansJp.vertTypoDescender, -500);
assert.equal(notoSansJp.numOfLongVerMetrics, 17481);
});
});
35 changes: 35 additions & 0 deletions test/tables/vmtx.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'assert';
import { parse } from '../../src/opentype.mjs';
import { readFileSync } from 'fs';
const loadSync = (url, opt) => parse(readFileSync(url), opt);

describe('tables/vmtx.mjs', function() {
const fonts = {
notoSansJp: loadSync('./test/fonts/NotoSansJP-Medium.ttf'),
notoSansJpLowMemory: loadSync('./test/fonts/NotoSansJP-Medium.ttf', { lowMemory: true }),
};

it('correctly parses the vertical metrics table - high memory', function() {
// tests for all fonts
const { notoSansJp } = fonts;

const a = notoSansJp.charToGlyph('あ');
assert.equal(a.topSideBearing, 80);
assert.equal(a.advanceHeight, 1000);
const aMetrics = a.getMetrics();
assert.equal(aMetrics.topSideBearing, 80);
assert.equal(aMetrics.bottomSideBearing, 64);
});

it('correctly parses the vertical metrics table - low memory', function() {
// tests for all fonts
const { notoSansJpLowMemory } = fonts;

const a = notoSansJpLowMemory.charToGlyph('あ');
assert.equal(a.topSideBearing, 80);
assert.equal(a.advanceHeight, 1000);
const aMetrics = a.getMetrics();
assert.equal(aMetrics.topSideBearing, 80);
assert.equal(aMetrics.bottomSideBearing, 64);
});
});
Loading