diff --git a/docs/font-inspector.html b/docs/font-inspector.html index b515dd5e..e5ac87ff 100644 --- a/docs/font-inspector.html +++ b/docs/font-inspector.html @@ -103,6 +103,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 @@ -134,18 +145,7 @@

Free Software

var previewCanvas = document.getElementById('preview'); var previewCtx = previewCanvas.getContext("2d"); previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - var options = { - 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'] } - ] - }; - options = Object.assign({}, font.defaultRenderOptions, options); + var options = Object.assign({}, font.defaultRenderOptions, drawOptions); font.draw(previewCtx, textToRender, 0, 32, fontSize, options); } @@ -261,9 +261,19 @@

Free Software

} function onFontLoaded(font) { + if (window.font) { + window.font.onGlyphUpdated = null; + } window.font = font; renderText(font); displayFontData(font); + font.onGlyphUpdated = (glyphId) => { + const options = Object.assign({}, font.defaultRenderOptions, drawOptions); + const glyphIds = font.stringToGlyphIndexes(textToRender, options); + if (glyphIds.includes(glyphId)) { + renderText(font); + } + }; } document.getElementById('update').addEventListener('click', () => displayFontData(window.font)); diff --git a/docs/glyph-inspector.html b/docs/glyph-inspector.html index c44680fc..07e25fd4 100644 --- a/docs/glyph-inspector.html +++ b/docs/glyph-inspector.html @@ -490,6 +490,9 @@

Free Software

}; function onFontLoaded(font) { + if (window.font) { + window.font.onGlyphUpdated = null + } window.font = font; options = Object.assign({}, window.font.defaultRenderOptions); window.fontOptions = options; @@ -525,6 +528,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 2cadad0f..54a08a75 100755 --- a/docs/index.html +++ b/docs/index.html @@ -116,15 +116,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 - } - }; - options = Object.assign({}, font.defaultRenderOptions, options); + var options = Object.assign({}, font.defaultRenderOptions, getDrawOptions()); previewCtx.clearRect(0, 0, 940, 300); const fontSize = +form.fontsize.value; font.draw(previewCtx, textToRender, 0, 200, fontSize, options); @@ -142,6 +134,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); @@ -184,6 +187,9 @@

Free Software

} function onFontLoaded(font) { + if (window.font) { + window.font.onGlyphUpdated = null + } window.font = font; // Show the first 100 glyphs. @@ -194,9 +200,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, {}, font); glyph.drawPoints(ctx, x, y, fontSize); glyph.drawMetrics(ctx, x, y, fontSize); @@ -214,6 +222,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, {}, font); + glyph.drawPoints(ctx, x, y, fontSize); + glyph.drawMetrics(ctx, x, y, fontSize); + } + + const textToRender = form.textField.value; + const options = Object.assign({}, font.defaultRenderOptions, getDrawOptions()); + const glyphIds = font.stringToGlyphIndexes(textToRender, options); + 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/font.js b/src/font.js index e0963a5d..f4591dcd 100644 --- a/src/font.js +++ b/src/font.js @@ -8,6 +8,7 @@ import Position from './position.js'; import Substitution from './substitution.js'; import { PaletteManager } from './palettes.js'; import { LayerManager } from './layers.js'; +import { SVGImageManager } from './svgimages.js'; import { isBrowser, checkArgument } from './util.js'; import HintingTrueType from './hintingtt.js'; import Bidi from './bidi.js'; @@ -142,6 +143,7 @@ function Font(options) { this.tables = this.tables || {}; this.palettes = new PaletteManager(this); this.layers = new LayerManager(this); + this.svgImages = new SVGImageManager(this); // needed for low memory mode only. this._push = null; @@ -333,6 +335,7 @@ Font.prototype.getKerningValue = function(leftGlyph, rightGlyph) { * @property {boolean} [hinting=false] - whether to apply font hinting to the outlines * @property {integer} [usePalette=0] For COLR/CPAL fonts, the zero-based index of the color palette to use. (Use `Font.palettes.get()` to get the available palettes) * @property {integer} [drawLayers=true] For COLR/CPAL fonts, this can be turned to false in order to draw the fallback glyphs instead + * @property {integer} [drawSVG=true] For SVG fonts, this can be turned to false in order to draw the fallback glyphs instead */ Font.prototype.defaultRenderOptions = { kerning: true, @@ -348,6 +351,7 @@ Font.prototype.defaultRenderOptions = { hinting: false, usePalette: 0, drawLayers: true, + drawSVG: true, }; /** diff --git a/src/glyph.js b/src/glyph.js index 4fb96030..93a24da0 100644 --- a/src/glyph.js +++ b/src/glyph.js @@ -148,6 +148,21 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) { let yScale = options.yScale; const scale = 1 / (this.path.unitsPerEm || 1000) * fontSize; + if (options.drawSVG) { + const svgImage = this.getSvgImage(font); + if (svgImage) { + 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. @@ -226,6 +241,17 @@ Glyph.prototype.getLayers = function(font) { return font.layers.get(this.index); }; +/** + * @param {opentype.Font} font + * @returns {import('./svgimages.js').SVGImage | undefined} + */ +Glyph.prototype.getSvgImage = function(font) { + if(!font) { + throw Error('The font object is required to read the svg table in order to get the image.'); + } + return font.svgImages.get(this.index); +}; + /** * Split the glyph into contours. * This function is here for backwards compatibility, and to diff --git a/src/opentype.js b/src/opentype.js index 26761276..32539f44 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'; import { PaletteManager } from './palettes.js'; /** * The opentype library. @@ -391,6 +392,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 b29c3d0d..54484210 100644 --- a/src/path.js +++ b/src/path.js @@ -415,6 +415,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 @@ -490,6 +505,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); } @@ -526,6 +549,8 @@ Path.prototype.draw = function(ctx) { ctx.quadraticCurveTo(cmd.x1, cmd.y1, cmd.x, cmd.y); } else if (cmd.type === 'Z' && this.stroke && this.strokeWidth) { ctx.closePath(); + } else if (cmd.type === 'I') { + ctx.drawImage(cmd.image, cmd.x, cmd.y, cmd.width, cmd.height); } } diff --git a/src/svgimages.js b/src/svgimages.js new file mode 100644 index 00000000..660369fa --- /dev/null +++ b/src/svgimages.js @@ -0,0 +1,230 @@ +import { isGzip, unGzip } from './util.js'; + +/** + * @typedef {object} SVGDocCacheEntry + * @prop {Promise} template + * @prop {Map} images + */ + +/** + * @typedef {string | [string, string, string, string, string, string, string]} SVGTemplate + */ + +/** + * @typedef {object} SVGImageCacheEntry + * @prop {Promise} promise + * @prop {SVGImage | undefined} image + */ + +/** + * @typedef {Object} SVGImage + * @prop {number} leftSideBearing + * @prop {number} baseline + * @prop {HTMLImageElement} image + */ + +export class SVGImageManager { + /** + * @param {opentype.Font} font + */ + constructor(font) { + /** @type {opentype.Font} */ + this.font = font; + /** @type {WeakMap} */ + this.cache = new WeakMap(); + } + + /** + * @param {number} glyphIndex + * @returns {SvgImage | undefined} + */ + get(glyphIndex) { + const svgImageCacheEntry = this.getOrCreateSvgImageCacheEntry(glyphIndex); + return svgImageCacheEntry && svgImageCacheEntry.image; + } + + /** + * @param {number} glyphIndex + * @returns {Promise | undefined} + */ + getAsync(glyphIndex) { + const svgImageCacheEntry = this.getOrCreateSvgImageCacheEntry(glyphIndex); + return svgImageCacheEntry && svgImageCacheEntry.promise; + } + + /** + * @param {number} glyphIndex + * @returns {SVGImageCacheEntry | undefined} + */ + getOrCreateSvgImageCacheEntry(glyphIndex) { + const svg = this.font.tables.svg; + if (svg === undefined) return; + + const svgBuf = svg.get(glyphIndex); + if (svgBuf === undefined) return; + + let svgDocCacheEntry = this.cache.get(svgBuf); + if (svgDocCacheEntry === undefined) { + svgDocCacheEntry = createSvgDocCacheEntry(svgBuf); + this.cache.set(svgBuf, svgDocCacheEntry); + } + + let svgImageCacheEntry = svgDocCacheEntry.images.get(glyphIndex); + if (svgImageCacheEntry === undefined) { + svgImageCacheEntry = createSvgImageCacheEntry(this.font, svgDocCacheEntry.template, glyphIndex); + svgImageCacheEntry.promise.then((svgImage) => { + svgImageCacheEntry.image = svgImage; + if (typeof this.font.onGlyphUpdated === 'function') { + try { + this.font.onGlyphUpdated(glyphIndex); + } catch (error) { + console.error('font.onGlyphUpdated', glyphIndex, error); + } + } + }); + svgDocCacheEntry.images.set(glyphIndex, svgImageCacheEntry); + } + return svgImageCacheEntry; + } +} + +/** + * @param {Uint8Array} svgBuf + * @returns {SVGDocCacheEntry} + */ +function createSvgDocCacheEntry(svgBuf) { + return { + template: decodeSvgDocument(svgBuf).then(makeSvgTemplate), + images: new Map(), + }; +} + +/** + * @param {opentype.Font} font + * @param {Promise} svgTemplatePromise + * @param {number} glyphIndex + * @returns {SVGImageCacheEntry} + */ +function createSvgImageCacheEntry(font, svgTemplatePromise, glyphIndex) { + return { + promise: svgTemplatePromise.then((svgTemplate) => { + let svgText; + if (typeof svgTemplate === 'string') { + svgText = svgTemplate; + } else { + svgTemplate[4] = glyphIndex; + svgText = svgTemplate.join(''); + } + const svgImage = makeSvgImage(svgText, font.unitsPerEm); + return svgImage.image.decode().then(() => svgImage); + }), + image: undefined, + }; +} + +/** +* @param {Uint8Array} buf +* @returns {Promise} +*/ +export const decodeSvgDocument = globalThis.DecompressionStream + ? decodeSvgDocumentWithDecompressionStream + : decodeSvgDocumentWithTinyInflate; + +/** + * @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); + } +} + +/** + * https://learn.microsoft.com/en-us/typography/opentype/spec/svg#glyph-identifiers + * @param {string} text + * @returns {SVGTemplate} + */ +export 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 +* @param {number} unitsPerEm +* @returns {SVGImage} +*/ +export function makeSvgImage(text, unitsPerEm) { + 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; + } else if (unitsPerEm) { + xScale = unitsPerEm / viewBoxVal.width; + yScale = unitsPerEm / viewBoxVal.height; + } + } + + 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 }; +} 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..7bf389e1 --- /dev/null +++ b/src/tables/svg.js @@ -0,0 +1,110 @@ +import { Parser } from '../parse.js'; +import table from '../table.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); +} + +export default { + make: makeSvgTable, + parse: parseSvgTable, +}; diff --git a/src/util.js b/src/util.js index 87e1e2bb..2ddcc717 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' || @@ -90,4 +92,48 @@ function binarySearchInsert(array, key, value) { return low; } -export { isBrowser, isNode, checkArgument, arraysEqual, objectsEqual, binarySearch, binarySearchIndex, binarySearchInsert }; +/** + * [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, binarySearch, binarySearchIndex, binarySearchInsert, 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..96e0f67d --- /dev/null +++ b/test/svg.js @@ -0,0 +1,53 @@ +import assert from 'assert'; +import { loadSync } from '../src/opentype.js'; +import svg from '../src/tables/svg.js'; +import { decodeSvgDocument } from '../src/svgimages.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} glyphId + */ + async function checkDocument(svgTable, glyphId) { + const svgDocBytes = svgTable.get(glyphId); + const svgDocText = await decodeSvgDocument(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); + }); +});