diff --git a/README.md b/README.md index 7e9cd746..50ed9398 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/src/glyph.mjs b/src/glyph.mjs index 3b9da2b7..cea7a1b4 100644 --- a/src/glyph.mjs +++ b/src/glyph.mjs @@ -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. @@ -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; } @@ -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)) { @@ -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; }; @@ -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); }; diff --git a/src/glyphset.mjs b/src/glyphset.mjs index 4aaa6871..a292b12e 100644 --- a/src/glyphset.mjs +++ b/src/glyphset.mjs @@ -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](); diff --git a/src/opentype.mjs b/src/opentype.mjs index 7a3101e4..66c6bd89 100644 --- a/src/opentype.mjs +++ b/src/opentype.mjs @@ -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'; @@ -189,6 +191,7 @@ function parseBuffer(buffer, opt={}) { let gsubTableEntry; let hmtxTableEntry; let hvarTableEntry; + let vmtxTableEntry; let kernTableEntry; let locaTableEntry; let nameTableEntry; @@ -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); @@ -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) { diff --git a/src/tables/vhea.mjs b/src/tables/vhea.mjs new file mode 100644 index 00000000..9a683fa4 --- /dev/null +++ b/src/tables/vhea.mjs @@ -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 }; diff --git a/src/tables/vmtx.mjs b/src/tables/vmtx.mjs new file mode 100644 index 00000000..8f96d20f --- /dev/null +++ b/src/tables/vmtx.mjs @@ -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 }; diff --git a/test/fonts/NotoSansJP-Medium.ttf b/test/fonts/NotoSansJP-Medium.ttf new file mode 100644 index 00000000..1d89aefe Binary files /dev/null and b/test/fonts/NotoSansJP-Medium.ttf differ diff --git a/test/tables/vhea.spec.mjs b/test/tables/vhea.spec.mjs new file mode 100644 index 00000000..e9034a78 --- /dev/null +++ b/test/tables/vhea.spec.mjs @@ -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); + }); +}); \ No newline at end of file diff --git a/test/tables/vmtx.spec.mjs b/test/tables/vmtx.spec.mjs new file mode 100644 index 00000000..91bff753 --- /dev/null +++ b/test/tables/vmtx.spec.mjs @@ -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); + }); +}); \ No newline at end of file