diff --git a/docs/font-inspector.html b/docs/font-inspector.html index 52e1ea14..109965b2 100644 --- a/docs/font-inspector.html +++ b/docs/font-inspector.html @@ -96,6 +96,17 @@

Free Software

var font = null; const fontSize = 32; const textToRender = 'Grumpy wizards make toxic brew for the evil Queen and Jack.'; +const drawOptions = { + kerning: true, + features: [ + /** + * these 4 features are required to render Arabic text properly + * and shouldn't be turned off when rendering arabic text. + */ + { script: 'arab', tags: ['init', 'medi', 'fina', 'rlig'] }, + { script: 'latn', tags: ['liga', 'rlig'] } + ] +}; function escapeHtml(unsafe) { return unsafe @@ -127,17 +138,7 @@

Free Software

var previewCanvas = document.getElementById('preview'); var previewCtx = previewCanvas.getContext("2d"); previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - font.draw(previewCtx, textToRender, 0, 32, fontSize, { - kerning: true, - features: [ - /** - * these 4 features are required to render Arabic text properly - * and shouldn't be turned off when rendering arabic text. - */ - { script: 'arab', tags: ['init', 'medi', 'fina', 'rlig'] }, - { script: 'latn', tags: ['liga', 'rlig'] } - ] - }); + font.draw(previewCtx, textToRender, 0, 32, fontSize, drawOptions); } function showErrorMessage(message) { @@ -234,9 +235,18 @@

Free Software

} function onFontLoaded(font) { + if (window.font) { + window.font.onGlyphUpdated = null; + } window.font = font; renderText(font); displayFontData(font); + font.onGlyphUpdated = (glyphId) => { + const glyphIds = font.stringToGlyphIndexes(textToRender, drawOptions); + if (glyphIds.includes(glyphId)) { + renderText(font); + } + }; } var tableHeaders = document.getElementById('font-data').getElementsByTagName('h3'); diff --git a/docs/glyph-inspector.html b/docs/glyph-inspector.html index 60732306..a6048fde 100644 --- a/docs/glyph-inspector.html +++ b/docs/glyph-inspector.html @@ -340,6 +340,9 @@

Free Software

} function onFontLoaded(font) { + if (window.font) { + window.font.onGlyphUpdated = null + } window.font = font; var w = cellWidth - cellMarginLeftRight * 2, @@ -371,6 +374,13 @@

Free Software

displayGlyphPage(0); displayGlyph(-1); displayGlyphData(-1); + + font.onGlyphUpdated = (glyphId) => { + const firstGlyph = pageSelected * cellCount; + if (firstGlyph <= glyphId && glyphId < firstGlyph + cellCount) { + renderGlyphItem(document.getElementById('g'+(glyphId - firstGlyph)), glyphId); + } + }; } function cellSelect(event) { diff --git a/docs/index.html b/docs/index.html index ed92e27e..6ad6f692 100755 --- a/docs/index.html +++ b/docs/index.html @@ -109,14 +109,7 @@

Free Software

if (!font) return; const textToRender = form.textField.value; var previewCtx = document.getElementById('preview').getContext('2d'); - var options = { - kerning: form.kerning.checked, - hinting: form.hinting.checked, - features: { - liga: form.ligatures.checked, - rlig: form.ligatures.checked - } - }; + var options = getDrawOptions(); previewCtx.clearRect(0, 0, 940, 300); const fontSize = +form.fontsize.value; font.draw(previewCtx, textToRender, 0, 200, fontSize, options); @@ -134,6 +127,17 @@

Free Software

snapPath.draw(snapCtx); } +function getDrawOptions() { + return { + kerning: form.kerning.checked, + hinting: form.hinting.checked, + features: { + liga: form.ligatures.checked, + rlig: form.ligatures.checked + } + }; +} + function enableHighDPICanvas(canvas) { if (typeof canvas === 'string') { canvas = document.getElementById(canvas); @@ -176,6 +180,9 @@

Free Software

} function onFontLoaded(font) { + if (window.font) { + window.font.onGlyphUpdated = null + } window.font = font; // Show the first 100 glyphs. @@ -186,9 +193,11 @@

Free Software

const x = 50; const y = 120; const fontSize = 72; + const ctxs = new Array(amount); for (let i = 0; i < amount; i++) { const glyph = font.glyphs.get(i); const ctx = createGlyphCanvas(glyph, 150); + ctxs[i] = ctx; glyph.draw(ctx, x, y, fontSize); glyph.drawPoints(ctx, x, y, fontSize); glyph.drawMetrics(ctx, x, y, fontSize); @@ -206,6 +215,23 @@

Free Software

} renderText(); + + font.onGlyphUpdated = (glyphId) => { + if (0 <= glyphId && glyphId < amount) { + const glyph = font.glyphs.get(glyphId); + const ctx = ctxs[glyphId]; + glyph.draw(ctx, x, y, fontSize); + glyph.drawPoints(ctx, x, y, fontSize); + glyph.drawMetrics(ctx, x, y, fontSize); + } + + const textToRender = form.textField.value; + const drawOptions = getDrawOptions(); + const glyphIds = font.stringToGlyphIndexes(textToRender, drawOptions); + if (glyphIds.includes(glyphId)) { + renderText(); + } + }; } const loadScript = (src) => new Promise((onload) => document.head.append(Object.assign(document.createElement('script'), {src, onload}))); async function display(file, name) { diff --git a/src/glyph.js b/src/glyph.js index ff38f6b3..d16089ee 100644 --- a/src/glyph.js +++ b/src/glyph.js @@ -36,6 +36,7 @@ function getPathDefinition(glyph, path) { * @property {number} [yMax] * @property {number} [advanceWidth] * @property {number} [leftSideBearing] + * @property {import('./tables/svg.js').SVGImage | Promise | Error} [svgImage] */ // A Glyph is an individual mark that often corresponds to a character. @@ -147,6 +148,19 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { let yScale = options.yScale; const scale = 1 / (this.path.unitsPerEm || 1000) * fontSize; + const svgImage = this.svgImage; + if (svgImage && svgImage.image) { + const path = new Path(); + path.drawImage( + svgImage.image, + x + svgImage.leftSideBearing * scale, + y - svgImage.baseline * scale, + svgImage.image.width * scale, + svgImage.image.height * scale, + ); + return path; + } + if (options.hinting && font && font.hinting) { // in case of hinting, the hinting engine takes care // of scaling the points (not the path) before hinting. diff --git a/src/glyphset.js b/src/glyphset.js index f4acc393..02d9c095 100644 --- a/src/glyphset.js +++ b/src/glyphset.js @@ -1,6 +1,7 @@ // The GlyphSet object import Glyph from './glyph.js'; +import svg from './tables/svg.js'; // Define a property on the glyph that depends on the path being loaded. function defineDependentProperty(glyph, externalName, internalName) { @@ -103,6 +104,39 @@ GlyphSet.prototype.push = function(index, loader) { this.length++; }; +function defineSvgImageLoader(glyph, font) { + glyph._svgImage = function () { + return font.tables.svg + ? svg.imageLoader(font.tables.svg, glyph.index) + : Promise.resolve(); + }; + + Object.defineProperty(glyph, 'svgImage', { + get: function() { + if ( typeof glyph._svgImage === 'function' ) { + glyph._svgImage = glyph._svgImage() + .then(svgImage => { + glyph._svgImage = svgImage; + if (svgImage && font.onGlyphUpdated) { + font.onGlyphUpdated(glyph.index); + } + return svgImage; + }) + .catch(error => { + glyph._svgImage = error; + throw error; + }); + } + return glyph._svgImage; + }, + set: function(newValue) { + glyph._svgImage = newValue; + }, + enumerable: true, + configurable: true + }); +} + /** * @alias opentype.glyphLoader * @param {opentype.Font} font @@ -142,6 +176,8 @@ function ttfGlyphLoader(font, index, parseGlyph, data, position, buildPath) { defineDependentProperty(glyph, 'yMin', '_yMin'); defineDependentProperty(glyph, 'yMax', '_yMax'); + defineSvgImageLoader(glyph, font); + return glyph; }; } @@ -163,6 +199,8 @@ function cffGlyphLoader(font, index, parseCFFCharstring, charstring, version) { return path; }; + defineSvgImageLoader(glyph, font); + return glyph; }; } diff --git a/src/opentype.js b/src/opentype.js index 0fd00854..4c3cade2 100644 --- a/src/opentype.js +++ b/src/opentype.js @@ -35,6 +35,7 @@ import os2 from './tables/os2.js'; import post from './tables/post.js'; import meta from './tables/meta.js'; import gasp from './tables/gasp.js'; +import svg from './tables/svg.js'; /** * The opentype library. * @namespace opentype @@ -390,6 +391,10 @@ function parseBuffer(buffer, opt={}) { table = uncompressTable(data, tableEntry); font.tables.gasp = gasp.parse(table.data, table.offset); break; + case 'SVG ': + table = uncompressTable(data, tableEntry); + font.tables.svg = svg.parse(table.data, table.offset); + break; } } diff --git a/src/path.js b/src/path.js index 02f9bdff..e12167f8 100644 --- a/src/path.js +++ b/src/path.js @@ -413,6 +413,21 @@ Path.prototype.quadTo = Path.prototype.quadraticCurveTo = function(x1, y1, x, y) }); }; +/** + * @function drawImage + * @memberof opentype.Path.prototype + */ +Path.prototype.drawImage = function(image, x, y, width, height) { + this.commands.push({ + type: 'I', + image, + x, + y, + width, + height + }); +}; + /** * Closes the path * @function closePath @@ -488,6 +503,14 @@ Path.prototype.getBoundingBox = function() { prevX = startX; prevY = startY; break; + case 'I': + box.addPoint(cmd.x, cmd.y); + box.addPoint(cmd.x + cmd.width, cmd.y); + box.addPoint(cmd.x + cmd.width, cmd.y + cmd.height); + box.addPoint(cmd.x, cmd.y + cmd.height); + startX = prevX = cmd.x; + startY = prevY = cmd.y; + break; default: throw new Error('Unexpected path command ' + cmd.type); } @@ -516,6 +539,8 @@ Path.prototype.draw = function(ctx) { ctx.quadraticCurveTo(cmd.x1, cmd.y1, cmd.x, cmd.y); } else if (cmd.type === 'Z') { ctx.closePath(); + } else if (cmd.type === 'I') { + ctx.drawImage(cmd.image, cmd.x, cmd.y, cmd.width, cmd.height); } } diff --git a/src/tables/sfnt.js b/src/tables/sfnt.js index 8abdb844..2a10fc69 100644 --- a/src/tables/sfnt.js +++ b/src/tables/sfnt.js @@ -25,6 +25,7 @@ import fvar from './fvar.js'; import stat from './stat.js'; import avar from './avar.js'; import gasp from './gasp.js'; +import svg from './svg.js'; function log2(v) { return Math.log(v) / Math.log(2) | 0; @@ -361,7 +362,8 @@ function fontToSfntTable(font) { cpal, colr, stat, - avar + avar, + svg, }; const optionalTableArgs = { diff --git a/src/tables/svg.js b/src/tables/svg.js new file mode 100644 index 00000000..33341dfd --- /dev/null +++ b/src/tables/svg.js @@ -0,0 +1,254 @@ +import { Parser } from '../parse.js'; +import table from '../table.js'; +import { isGzip, unGzip } from '../util.js'; + +// https://learn.microsoft.com/en-us/typography/opentype/spec/svg + +/** + * @typedef {Map} SVGTable + */ + +/** + * @param {DataView} data + * @param {number} offset + * @returns {SVGTable} + */ +function parseSvgTable(data, offset) { + const svgTable = new Map(); + const buf = data.buffer; + const p = new Parser(data, offset); + const version = p.parseUShort(); + if (version !== 0) return svgTable; + + p.relativeOffset = p.parseOffset32(); + const svgDocumentListOffset = data.byteOffset + offset + p.relativeOffset; + const numEntries = p.parseUShort(); + const svgDocMap = new Map(); + for (let i = 0; i < numEntries; i++) { + const startGlyphID = p.parseUShort(); + const endGlyphID = p.parseUShort(); + const svgDocOffset = svgDocumentListOffset + p.parseOffset32(); + const svgDocLength = p.parseULong(); + let svgDoc = svgDocMap.get(svgDocOffset); + if (svgDoc === undefined) { + svgDoc = new Uint8Array(buf, svgDocOffset, svgDocLength); + svgDocMap.set(svgDocOffset, svgDoc); + } + for (let i = startGlyphID; i <= endGlyphID; i++) { + svgTable.set(i, svgDoc); + } + } + return svgTable; +} + +/** + * @param {SVGTable} svgTable + * @returns {opentype.Table} + */ +function makeSvgTable(svgTable) { + const glyphIds = Array.from(svgTable.keys()).sort(); + const documentRecords = []; + const documentBuffers = []; + const documentOffsets = new Map(); + let nextSvgDocOffset = 0; + let record = { endGlyphID: null }; + for (let i = 0, l = glyphIds.length; i < l; i++) { + const glyphId = glyphIds[i]; + const svgDoc = svgTable.get(glyphId); + let svgDocOffset = documentOffsets.get(svgDoc); + if (svgDocOffset === undefined) { + svgDocOffset = nextSvgDocOffset; + documentBuffers.push(svgDoc); + documentOffsets.set(svgDoc, svgDocOffset); + nextSvgDocOffset += svgDoc.byteLength; + } + if (glyphId - 1 === record.endGlyphID && svgDocOffset === record.svgDocOffset) { + record.endGlyphID = glyphId; + } else { + record = { + startGlyphID: glyphId, + endGlyphID: glyphId, + svgDocOffset, + svgDocLength: svgDoc.byteLength, + }; + documentRecords.push(record); + } + } + + const numEntries = documentRecords.length; + const numDocuments = documentBuffers.length; + const documentsOffset = 2 + numEntries * (2 + 2 + 4 + 4); + const fields = new Array(3 + 1 + numEntries * 4 + numDocuments); + let fieldIndex = 0; + + // SVG Table Header + fields[fieldIndex++] = { name: 'version', type: 'USHORT', value: 0 }; + fields[fieldIndex++] = { name: 'svgDocumentListOffset', type: 'ULONG', value: 2 + 4 + 4 }; + fields[fieldIndex++] = { name: 'reserved', type: 'ULONG', value: 0 }; + + // SVG Document List + fields[fieldIndex++] = { name: 'numEntries', type: 'USHORT', value: numEntries }; + for (let i = 0; i < numEntries; i++) { + const namePrefix = 'documentRecord_' + i; + const { startGlyphID, endGlyphID, svgDocOffset, svgDocLength } = documentRecords[i]; + fields[fieldIndex++] = { name: namePrefix + '_startGlyphID', type: 'USHORT', value: startGlyphID }; + fields[fieldIndex++] = { name: namePrefix + '_endGlyphID', type: 'USHORT', value: endGlyphID }; + fields[fieldIndex++] = { name: namePrefix + '_svgDocOffset', type: 'ULONG', value: documentsOffset + svgDocOffset }; + fields[fieldIndex++] = { name: namePrefix + '_svgDocLength', type: 'ULONG', value: svgDocLength }; + } + + // SVG Documents + for (let i = 0; i < numDocuments; i++) { + fields[fieldIndex++] = { name: 'svgDoc_' + i, type: 'LITERAL', value: documentBuffers[i] }; + } + + return new table.Table('SVG ', fields); +} + +/** + * @param {Uint8Array} buf + * @returns {Promise} + */ +function decodeSvgDocumentWithTinyInflate(buf) { + try { + return Promise.resolve(new TextDecoder().decode(isGzip(buf) ? unGzip(buf) : buf)); + } catch (error) { + return Promise.reject(error); + } +} +/** + * @param {Uint8Array} buf + * @returns {Promise} + */ +function decodeSvgDocumentWithDecompressionStream(buf) { + if (isGzip(buf)) { + return new Response(new Response(buf).body.pipeThrough(new DecompressionStream('gzip'))).text(); + } + try { + return Promise.resolve(new TextDecoder().decode(buf)); + } catch (error) { + return Promise.reject(error); + } +} +/** + * @param {Uint8Array} buf + * @returns {Promise} + */ +const decodeSvgDocument = globalThis.DecompressionStream + ? decodeSvgDocumentWithDecompressionStream + : decodeSvgDocumentWithTinyInflate; + +/** + * @typedef {string | [string, string, string, string, string, string, string]} SVGTemplate + */ + +/** + * @typedef {Object} SVGImage + * @prop {number} leftSideBearing + * @prop {number} baseline + * @prop {HTMLImageElement} image + */ + +/** @type {WeakMap>} */ +const svgTemplateCache = new WeakMap(); + +/** + * @param {SVGTable} svgTable + * @param {number} glyphId + * @returns {Promise | undefined} + */ +function svgImageLoader(svgTable, glyphId) { + const svgBuf = svgTable.get(glyphId); + if (svgBuf === undefined) return Promise.resolve(); + let svgTemplatePromise = svgTemplateCache.get(svgBuf); + if (svgTemplatePromise === undefined) { + svgTemplatePromise = decodeSvgDocument(svgBuf).then(makeSvgTemplate); + svgTemplateCache.set(svgBuf, svgTemplatePromise); + } + return svgTemplatePromise.then((svgTemplate) => { + let svgText; + if (typeof svgTemplate === 'string') { + svgText = svgTemplate; + } else { + svgTemplate[4] = glyphId; + svgText = svgTemplate.join(''); + } + const svgImage = makeSvgImage(svgText); + return svgImage.image.decode().then(() => svgImage); + }); +} + +/** + * https://learn.microsoft.com/en-us/typography/opentype/spec/svg#glyph-identifiers + * @param {string} text + * @returns {SVGTemplate} + */ +function makeSvgTemplate(text) { + const documentStart = text.indexOf('', documentStart + 4) + 1; + + if (/ id=['"]glyph\d+['"]/.test(text.substring(documentStart, contentStart))) { + return text; + } + + const contentEnd = text.lastIndexOf(''); + return [ + text.substring(0, contentStart), + '', + text.substring(contentStart, contentEnd), + '', + text.substring(contentEnd), + ]; +} + +/** + * @param {string} text + * @returns {SVGImage} + */ +function makeSvgImage(text) { + const svgDocument = new DOMParser().parseFromString(text, 'image/svg+xml'); + /** @type {SVGSVGElement} */ + const svg = svgDocument.documentElement; + const viewBoxVal = svg.viewBox.baseVal; + const widthVal = svg.width.baseVal; + const heightVal = svg.height.baseVal; + let xScale = 1; + let yScale = 1; + if (viewBoxVal.width > 0 && viewBoxVal.height > 0) { + if (widthVal.unitType === 1) { + xScale = widthVal.valueInSpecifiedUnits / viewBoxVal.width; + yScale = heightVal.unitType === 1 ? heightVal.valueInSpecifiedUnits / viewBoxVal.height : xScale; + } else if (heightVal.unitType === 1) { + yScale = heightVal.valueInSpecifiedUnits / viewBoxVal.height; + xScale = yScale; + } + } + + const div = document.createElement('div'); + div.style.position = 'fixed'; + div.style.visibility = 'hidden'; + div.appendChild(svg); + document.body.appendChild(div); + const bbox = svg.getBBox(); + document.body.removeChild(div); + + const leftSideBearing = (bbox.x - viewBoxVal.x) * xScale; + const baseline = (viewBoxVal.y - bbox.y) * yScale; + const width = bbox.width * xScale; + const height = bbox.height * yScale; + + svg.setAttribute('viewBox', [bbox.x, bbox.y, bbox.width, bbox.height].join(' ')); + if (xScale !== 1) svg.setAttribute('width', width); + if (yScale !== 1) svg.setAttribute('height', height); + + const image = new Image(width, height); + image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.outerHTML); + return { leftSideBearing, baseline, image }; +} + +export default { + make: makeSvgTable, + parse: parseSvgTable, + decodeDocument: decodeSvgDocument, + imageLoader: svgImageLoader, +}; diff --git a/src/util.js b/src/util.js index 0757513f..ae5666c3 100644 --- a/src/util.js +++ b/src/util.js @@ -1,3 +1,5 @@ +import { tinf_uncompress as inflate } from './tiny-inflate@1.0.3.esm.js'; + function isBrowser() { return ( typeof window !== 'undefined' || @@ -39,4 +41,48 @@ function objectsEqual(obj1, obj2) { return arraysEqual(val1, val2) && arraysEqual(keys1, keys2); } -export { isBrowser, isNode, checkArgument, arraysEqual, objectsEqual }; +/** + * [GZIP File Format Specification](https://datatracker.ietf.org/doc/html/rfc1952#section-2.3) + * @param {Uint8Array} buf + * @returns {boolean} + */ +function isGzip(buf) { + return buf[0] === 31 && buf[1] === 139 && buf[2] === 8; +} + +/** + * [GZIP File Format Specification](https://datatracker.ietf.org/doc/html/rfc1952#section-2.3) + * @param {Uint8Array} gzip + * @returns {Uint8Array} + */ +function unGzip(gzip) { + const data = new DataView(gzip.buffer, gzip.byteOffset, gzip.byteLength); + + let start = 10; + const end = gzip.byteLength - 8; + const flg = data.getInt8(3); + // FEXTRA + if (flg & 0b00000100) { + start += 2 + data.getUint16(start, true); + } + // FNAME + if (flg & 0b00001000) { + while (start < end) if (gzip[start++] === 0) break; + } + // FCOMMENT + if (flg & 0b00010000) { + while (start < end) if (gzip[start++] === 0) break; + } + // FHCRC + if (flg & 0b00000010) { + start += 2; + } + + if (start >= end) throw new Error('Can\'t find compressed blocks'); + + const isize = data.getUint32(data.byteLength - 4, true); + + return inflate(gzip.subarray(start, end), new Uint8Array(isize)); +} + +export { isBrowser, isNode, checkArgument, arraysEqual, objectsEqual, isGzip, unGzip }; diff --git a/test/fonts/Colortube-lWAd.otf b/test/fonts/Colortube-lWAd.otf new file mode 100644 index 00000000..175fc646 Binary files /dev/null and b/test/fonts/Colortube-lWAd.otf differ diff --git a/test/svg.js b/test/svg.js new file mode 100644 index 00000000..0462796e --- /dev/null +++ b/test/svg.js @@ -0,0 +1,52 @@ +import assert from 'assert'; +import { loadSync } from '../src/opentype.js'; +import svg from '../src/tables/svg.js'; + +/** @typedef {import('../src/tables/svg.js').SVGTable} SVGTable */ + +describe('tables/svg.js', () => { + /** + * https://www.fontspace.com/colortube-font-f28146 + * @type {SVGTable} + */ + const svgTable = loadSync('./test/fonts/Colortube-lWAd.otf').tables.svg; + const glyphIds = Array.from(svgTable.keys()).sort(); + + /** + * @param {SVGTable} svgTable + * @param {number} recordIndex + */ + async function checkDocument(svgTable, glyphId) { + const svgDocBytes = svgTable.get(glyphId); + const svgDocText = await svg.decodeDocument(svgDocBytes); + assert(svgDocText.startsWith('')); + } + + it('can parse SVG table', async () => { + await checkDocument(svgTable, glyphIds[0]); + await checkDocument(svgTable, glyphIds[glyphIds.length - 1]); + }); + + it('can make SVG table', () => { + const bytes = new Uint8Array(svg.make(svgTable).encode()); + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + assert.deepStrictEqual(svg.parse(data, 0), svgTable); + }); + + it('can decode compressed SVG document', async () => { + const glyphId = glyphIds[0]; + const docStream = new Response(svgTable.get(glyphId)).body; + const gzipStream = docStream.pipeThrough(new CompressionStream('gzip')); + const gzipBuffer = await new Response(gzipStream).arrayBuffer(); + + const svgTableCopy = new Map(svgTable); + svgTableCopy.set(glyphId, new Uint8Array(gzipBuffer)); + await checkDocument(svgTableCopy, glyphId); + }); +});