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('');
+ 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('