Skip to content

Commit

Permalink
feat: add SVG table support
Browse files Browse the repository at this point in the history
  • Loading branch information
kinolaev committed Mar 24, 2024
1 parent 6408975 commit 3f7da0f
Show file tree
Hide file tree
Showing 12 changed files with 387 additions and 5 deletions.
4 changes: 3 additions & 1 deletion docs/font-inspector.html
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ <h1>Free Software</h1>
}
try {
const data = await file.arrayBuffer();
onFontLoaded(opentype.parse(isWoff2 ? Module.decompress(data) : data));
const font = opentype.parse(isWoff2 ? Module.decompress(data) : data)
await font.prepareSvgImages();
onFontLoaded(font);
showErrorMessage('');
} catch (err) {
showErrorMessage(err.toString());
Expand Down
4 changes: 3 additions & 1 deletion docs/glyph-inspector.html
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,9 @@ <h1>Free Software</h1>
}
try {
const data = await file.arrayBuffer();
onFontLoaded(opentype.parse(isWoff2 ? Module.decompress(data) : data));
const font = opentype.parse(isWoff2 ? Module.decompress(data) : data)
await font.prepareSvgImages();
onFontLoaded(font);
showErrorMessage('');
} catch (err) {
showErrorMessage(err.toString());
Expand Down
4 changes: 3 additions & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ <h1>Free Software</h1>
}
try {
const data = await file.arrayBuffer();
onFontLoaded(opentype.parse(isWoff2 ? Module.decompress(data) : data));
const font = opentype.parse(isWoff2 ? Module.decompress(data) : data);
await font.prepareSvgImages();
onFontLoaded(font);
showErrorMessage('');
} catch (err) {
showErrorMessage(err.toString());
Expand Down
11 changes: 11 additions & 0 deletions src/font.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { isBrowser, checkArgument } from './util.js';
import HintingTrueType from './hintingtt.js';
import Bidi from './bidi.js';
import { applyPaintType } from './tables/cff.js';
import svg from './tables/svg.js';

function createDefaultNamesInfo(options) {
return {
Expand Down Expand Up @@ -448,6 +449,16 @@ Font.prototype.getAdvanceWidth = function(text, fontSize, options) {
return this.forEachGlyph(text, 0, 0, fontSize, options, function() {});
};

/**
* @param {Array<import('./tables/svg.js').SVGDocumentRecord>} [documentRecords]
* @returns {Promise<void>}
*/
Font.prototype.prepareSvgImages = function(documentRecords) {
/** @type {import('./tables/svg.js').SVGTable | undefined} */
const table = this.tables.svg;
return table ? svg.prepareImages(table, documentRecords) : Promise.resolve();
};

/**
* Draw the text on the given drawing context.
* @param {CanvasRenderingContext2D} ctx - A 2D drawing context, like Canvas.
Expand Down
14 changes: 14 additions & 0 deletions src/glyph.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ Glyph.prototype.getPath = function(x, y, fontSize, options, font) {
let yScale = options.yScale;
const scale = 1 / (this.path.unitsPerEm || 1000) * fontSize;

/** @type {import('./tables/svg.js').SVGImage | undefined} */
const svgImage = font && font.tables.svg && font.tables.svg.images.get(this.index);
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.
Expand Down
5 changes: 5 additions & 0 deletions src/opentype.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/tables/sfnt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -361,7 +362,8 @@ function fontToSfntTable(font) {
cpal,
colr,
stat,
avar
avar,
svg,
};

const optionalTableArgs = {
Expand Down
217 changes: 217 additions & 0 deletions src/tables/svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
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 {Object} SVGTable
* @prop {SVGDocumentRecord[]} documentRecords
* @prop {Uint8Array[]} documents
* @prop {Map<number, SVGImage>} images
*/

/**
* @typedef {Object} SVGDocumentRecord
* @prop {number} startGlyphID
* @prop {number} endGlyphID
* @prop {number} svgDocIndex
*/

/**
* @typedef {Object} SVGImage
* @prop {number} leftSideBearing
* @prop {number} baseline
* @prop {HTMLImageElement} image
*/

/**
* @param {DataView} data
* @param {number} offset
* @returns {SVGTable}
*/
function parseSvgTable(data, offset) {
const buf = data.buffer;
const p = new Parser(data, offset);
const documents = [];
const version = p.parseUShort();
if (version !== 0) return { documentRecords: [], documents };

p.relativeOffset = p.parseOffset32();
const svgDocumentListOffset = data.byteOffset + offset + p.relativeOffset;
const numEntries = p.parseUShort();
const documentRecords = new Array(numEntries);
const documentIndices = 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 svgDocIndex = documentIndices.get(svgDocOffset);
if (svgDocIndex === undefined) {
svgDocIndex = documents.length;
documents.push(new Uint8Array(buf, svgDocOffset, svgDocLength));
documentIndices.set(svgDocOffset, svgDocIndex);
}
documentRecords[i] = { startGlyphID, endGlyphID, svgDocIndex };
}
return { documentRecords, documents, images: new Map() };
}

/**
* @param {SVGTable} svgTable
* @returns {opentype.Table}
*/
function makeSvgTable({ documentRecords, documents }) {
const numEntries = documentRecords.length;
const numDocuments = documents.length;
const documentsFieldOffset = 3 + 1 + numEntries * 4;
const fields = new Array(documentsFieldOffset + numDocuments);
const documentLocations = new Array(numDocuments);

// SVG Documents
let svgDocOffset = 2 + numEntries * (2 + 2 + 4 + 4);
let fieldIndex = documentsFieldOffset;
for (let i = 0; i < numDocuments; i++) {
const name = 'svgDoc_' + i;
const value = documents[i];
const svgDocLength = value.length;
fields[fieldIndex++] = { name, type: 'LITERAL', value };
documentLocations[i] = { svgDocOffset, svgDocLength };
svgDocOffset += svgDocLength;
}

// SVG Table Header
fields[0] = { name: 'version', type: 'USHORT', value: 0 };
fields[1] = { name: 'svgDocumentListOffset', type: 'ULONG', value: 2 + 4 + 4 };
fields[2] = { name: 'reserved', type: 'ULONG', value: 0 };

// SVG Document List
fields[3] = { name: 'numEntries', type: 'USHORT', value: numEntries };
fieldIndex = 4;
for (let i = 0; i < numEntries; i++) {
const namePrefix = 'documentRecord_' + i;
const { startGlyphID, endGlyphID, svgDocIndex } = documentRecords[i];
const { svgDocOffset, svgDocLength } = documentLocations[svgDocIndex];
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: svgDocOffset };
fields[fieldIndex++] = { name: namePrefix + '_svgDocLength', type: 'ULONG', value: svgDocLength };
}

return new table.Table('SVG ', fields);
}

/**
* @param {Uint8Array} buf
* @returns {string}
*/
function decodeSvgDocument(buf) {
return new TextDecoder().decode(isGzip(buf) ? unGzip(buf) : buf);
}

/**
* @param {SVGTable} svgTable
* @param {Array<SVGDocumentRecord>} [documentRecords]
* @returns {Promise<void>}
*/
function prepareSvgImages(svgTable, documentRecords) {
const records = documentRecords || svgTable.documentRecords;
return Promise.all(records.map(r => prepareSvgRecordImages(svgTable, r)));
}

/**
* @param {SVGTable} svgTable
* @param {SVGDocumentRecord} record
* @returns {Promise<void>}
*/
function prepareSvgRecordImages({ documents, images }, record) {
const promises = [];
const docSvgText = decodeSvgDocument(documents[record.svgDocIndex]);
if (record.startGlyphID === record.endGlyphID) {
const glyphSvgImage = makeGlyphSvgImage(docSvgText);
images.set(record.startGlyphID, glyphSvgImage);
promises.push(glyphSvgImage.image.decode());
} else {
const glyphSvgTemplate = makeGlyphSvgTemplate(docSvgText);
for (let i = record.startGlyphID, l = record.endGlyphID; i <= l; i++) {
const glyphSvgImage = makeGlyphSvgImage(glyphSvgTemplate(i));
images.set(i, glyphSvgImage);
promises.push(glyphSvgImage.image.decode());
}
}
return Promise.all(promises);
}

/**
* https://learn.microsoft.com/en-us/typography/opentype/spec/svg#glyph-identifiers
* @param {string} text
* @returns {(glyphId: number) => string}
*/
function makeGlyphSvgTemplate(text) {
const contentStart = text.indexOf('>', text.indexOf('<svg') + 4) + 1;
const contentEnd = text.lastIndexOf('</svg>');
const parts = [
text.substring(0, contentStart),
'<defs>',
text.substring(contentStart, contentEnd),
'</defs><use href="#glyph', '', '"/>',
text.substring(contentEnd),
];
return (glyphId) => {
parts[4] = glyphId.toString();
return parts.join('');
};
}

/**
* @param {string} text
* @returns {SVGImage}
*/
function makeGlyphSvgImage(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,
prepareImages: prepareSvgImages,
};
Loading

0 comments on commit 3f7da0f

Please sign in to comment.