Skip to content

Commit

Permalink
Implement variable font rendering (gvar, avar, hvar, cvar) (#699)
Browse files Browse the repository at this point in the history
* initial implementation of VariationManager class

* glyph inspector: output variation range sliders and instance select

* glyph transformation working for non-interpolated deltas

* interpolation and avar support

* added site.js for common JavaScript across the doc files, implemented variation options on index.html

* implement glyph variation in bin/test-render

* docs: Conditionally fall back to the font's sample text if provided, if the current text results in only space or .notdef glyphs

* variation for CFF2 fonts

* implement hvar parsing, extend and fix variation store data parsing, implement advancedWidth variation

* add some checks and error throws, implement support for lsb in hvar

* docs: keyboard navigation for item grid

* update doc blocks and API documentation in README

* implement cvar handling

* add gvar and CFF2 variation tests

* added fontkit to README "Thanks" section

* fix rounding and naming differences in test-render script to help more unicode suite tests pass

* extend SVG optimization to catch more unnecessary path elements and pass more 
unicode tests

* optimize test-render script to have less tests fail in the unicode test suite due to only minor differences in paths

* add tests for avar and normalized axis tags
  • Loading branch information
Connum authored Apr 20, 2024
1 parent 1e8decf commit 262adb2
Show file tree
Hide file tree
Showing 31 changed files with 1,837 additions and 169 deletions.
89 changes: 88 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,92 @@ Sets a color glyph layer's paletteIndex property to a new index
###### `Font.layers.updateColrTable(glyphIndex, layers)`
Mainly used internally. Updates the colr table, adding a baseGlyphRecord if needed, ensuring that it's inserted at the correct position, updating numLayers, and adjusting firstLayerIndex values for all baseGlyphRecords according to any deletions or insertions.


##### The `Font.variation` object (`VariationManager`)
The `VariationManager` handles variable font properties using the OpenType font variation tables.

###### `Font.variation.activateDefaultVariation()`
Activates the default variation by setting its variation data as the font's default render options. Uses the default instance if available; otherwise, it defaults to the coordinates of all axes.

###### `Font.variation.getDefaultCoordinates()`
Returns the default coordinates for the font's variation axes.
* Returns: An object mapping axis tags to their default values.

###### `Font.variation.getDefaultInstanceIndex()`
Determines and returns the index of the default variation instance. Returns `-1` if it cannot be determined.
* Returns: Integer representing the default instance index or `-1`.

###### `Font.variation.getInstanceIndex(coordinates)`
Finds the index of the variation instance that matches the provided coordinates, or `-1` if none match.
* `coordinates`: Object with axis tags as keys and variation values as corresponding values.
* Returns: Integer of the matching instance index or `-1`.

###### `Font.variation.getInstance(index)`
Retrieves a specific variation instance by its zero-based index.
* `index`: Zero-based index of the variation instance.
* Returns: Object representing the variation instance, or `null` if the index is invalid.

###### `Font.variation.set(instanceIdOrObject)`
Sets the variation coordinates to be used by default for rendering in the font's default render options.
* `instanceIdOrObject`: Either the zero-based index of a variation instance or an object mapping axis tags to variation values.

###### `Font.variation.get()`
Gets the current variation settings from the font's default render options.
* Returns: Object with the current variation settings.


##### The `Font.variation.process` object (`VariationProcessor`)
The `VariationProcessor` is a component of the `VariationManager`, used mainly internally for computing and applying variations to the glyphs in a variable font. It handles transformations and adjustments based on the font's variable axes and instances.

###### `Font.variation.process.getNormalizedCoords()`
Returns normalized coordinates for the variation axes based on the current settings.
* Returns: Normalized coordinates as an object mapping axis tags to normalized values.

###### `Font.variation.process.interpolatePoints(points, deltas, scalar)`
Interpolates points based on provided deltas and a scalar value.
* `points`: Array of original points.
* `deltas`: Array of point deltas.
* `scalar`: Scalar value for interpolation.
* Returns: Array of interpolated points.

###### `Font.variation.process.deltaInterpolate(original, deltaValues, scalar)`
Calculates the interpolated value for a single point given original values, deltas, and a scalar.
* `original`: Original value of the point.
* `deltaValues`: Array of delta values for the point.
* `scalar`: Scalar value for interpolation.
* Returns: Interpolated value.

###### `Font.variation.process.deltaShift(points, deltas)`
Applies delta values to shift points.
* `points`: Array of original points.
* `deltas`: Array of deltas to apply.
* Returns: Array of shifted points.

###### `Font.variation.process.transformComponents(components, transformation)`
Transforms components of a glyph using a specified transformation matrix.
* `components`: Components of the glyph.
* `transformation`: Transformation matrix to apply.
* Returns: Transformed components.

###### `Font.variation.process.getTransform()`
Computes the transformation matrix based on current variation settings.
* Returns: Transformation matrix for the current variation settings.

###### `Font.variation.process.getVariableAdjustment(adjustment)`
Calculates the variable adjustment for a given adjustment parameter.
* `adjustment`: Adjustment parameter.
* Returns: Adjusted value based on current variation settings.

###### `Font.variation.process.getDelta(deltas)`
Selects the appropriate delta values from a collection of deltas based on the current variation settings.
* `deltas`: Collection of delta values.
* Returns: Appropriate delta values for the current settings.

###### `Font.variation.process.getBlendVector()`
Computes the blend vector for interpolations based on the current settings.
* Returns: Blend vector used for interpolation calculations.


#### The Glyph object
A Glyph is an individual mark that often corresponds to a character. Some glyphs, such as ligatures, are a combination of many characters. Glyphs are the basic building blocks of a font.

Expand Down Expand Up @@ -466,12 +552,13 @@ MIT

Thanks
======
I would like to acknowledge the work of others without which opentype.js wouldn't be possible:
We would like to acknowledge the work of others without which opentype.js wouldn't be possible:

* [pdf.js](https://mozilla.github.io/pdf.js/): for an awesome implementation of font parsing in the browser.
* [FreeType](https://www.freetype.org/): for the nitty-gritty details and filling in the gaps when the spec was incomplete.
* [ttf.js](https://ynakajima.github.io/ttf.js/demo/glyflist/): for hints about the TrueType parsing code.
* [CFF-glyphlet-fonts](https://pomax.github.io/CFF-glyphlet-fonts/): for a great explanation/implementation of CFF font writing.
* [fontkit](https://github.com/foliojs/fontkit/): for a great implementation of CFF2 parsing and variable font features
* [tiny-inflate](https://github.com/foliojs/tiny-inflate): for WOFF decompression.
* [Microsoft Typography](https://docs.microsoft.com/en-us/typography/opentype/spec/otff): the go-to reference for all things OpenType.
* [Adobe Compact Font Format spec](http://download.microsoft.com/download/8/0/1/801a191c-029d-4af3-9642-555f6fe514ee/cff.pdf) and the [Adobe Type 2 Charstring spec](http://download.microsoft.com/download/8/0/1/801a191c-029d-4af3-9642-555f6fe514ee/type2.pdf): explains the data structures and commands for the CFF glyph format.
Expand Down
150 changes: 133 additions & 17 deletions bin/test-render
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function printUsage() {
let filename;
let testcase;
let textToRender;
let variation;
for (let i = 0; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg.startsWith('--font=')) {
Expand All @@ -45,6 +46,12 @@ for (let i = 0; i < process.argv.length; i++) {
testcase = arg.substring('--testcase='.length);
} else if (arg.startsWith('--render=')) {
textToRender = arg.substring('--render='.length);
} else if (arg.startsWith('--variation=')) {
variation = {};
arg.substring('--variation='.length).split(';').forEach(function (setting) {
var parts = setting.split(':');
variation[parts[0]] = parseFloat(parts[1]);
});
}
}

Expand All @@ -53,38 +60,147 @@ if (filename === undefined || testcase === undefined || textToRender === undefin
process.exit(1);
}

// getPath function from fontkit adapted to opentype.js
function getPath(points) {
const path = new opentype.Path();
if (!points) {
return path;
}

const contours = opentype.Glyph.prototype.getContours.call(this, points);

for (let i = 0; i < contours.length; i++) {
let contour = contours[i];
let firstPt = contour[0];
let lastPt = contour[contour.length - 1];
let start = 0;

if (firstPt.onCurve) {
// The first point will be consumed by the moveTo command, so skip in the loop
var curvePt = null;
start = 1;
} else {
if (lastPt.onCurve) {
// Start at the last point if the first point is off curve and the last point is on curve
firstPt = lastPt;
} else {
// Start at the middle if both the first and last points are off curve
firstPt = {x: (firstPt.x + lastPt.x) / 2, y: (firstPt.y + lastPt.y) / 2, onCurve: falses};
}

var curvePt = firstPt;
}

path.moveTo(firstPt.x, firstPt.y);

for (let j = start; j < contour.length; j++) {
let pt = contour[j];
let prevPt = j === 0 ? firstPt : contour[j - 1];

if (prevPt.onCurve && pt.onCurve) {
path.lineTo(pt.x, pt.y);
} else if (prevPt.onCurve && !pt.onCurve) {
var curvePt = pt;
} else if (!prevPt.onCurve && !pt.onCurve) {
let midX = (prevPt.x + pt.x) / 2;
let midY = (prevPt.y + pt.y) / 2;
path.quadraticCurveTo(prevPt.x, prevPt.y, midX, midY);
var curvePt = pt;
} else if (!prevPt.onCurve && pt.onCurve) {
path.quadraticCurveTo(curvePt.x, curvePt.y, pt.x, pt.y);
var curvePt = null;
} else {
throw new Error("Unknown TTF path state");
}
}

// Connect the first and last points
if (curvePt) {
path.quadraticCurveTo(curvePt.x, curvePt.y, firstPt.x, firstPt.y);
}

path.closePath();
}

return path;
}

function renderSVG() {
var font = opentype.loadSync(filename);

let svgSymbols = [];
let svgBody = [];

var glyphSet = new Set();
let glyphData = [];
var symbolSet = new Set();

const fontSize = font.unitsPerEm;
let minWidth = 0;
if(variation && font.variation) {
font.variation.set(variation);
}

const scale = 1 / font.unitsPerEm * 1000;

font.forEachGlyph(textToRender, 0, 0, fontSize, {}, function(glyph, gX, gY, gFontSize) {
const glyphPath = glyph.getPath(gX, gY, gFontSize, {}, this);
const glyphWidth = glyph.getMetrics().xMax;
glyphData.push({glyph: glyph, path: glyphPath, gX, gY, w: glyphWidth});
});
let glyphName = glyph.name;
if(!glyphName || /^cid\d+$/.test(glyphName)) {
glyphName = `gid${glyph.index}`;
}
const
symbolId = testcase + '.' + glyphName;

// TrueType paths from opentype.js are often visually the same as fontkit's, but because
// of minor differences in path construction, tests will fail.
// Therefore we re-calculate the path based on the points.
const pointsTransform = function(points) {
// Scale to 1000
for (point of points) {
point.x *= scale;
point.y *= scale;
}

const path = getPath(points);
return path;
}

const pathTransform = function(path) {
// filter out lines back to the starting point of a contour
var startX = 0, startY = 0;
path.commands = path.commands.filter(function (c, i) {
path.commands[i] = c;
if (c.type === 'M') {
startX = c.x;
startY = c.y;
} else if (c.type === 'L' && (!path.commands[i + 1] || path.commands[i + 1].type === 'Z')) {
return Math.abs(c.x - startX) > 1 || Math.abs(c.y - startY) > 1;
}

return true;
});

return path;
};

if (!symbolSet.has(symbolId)) {
const svgPath = glyph.toSVG({optimize: true, decimalPlaces: 2, flipY: false, pointsTransform, pathTransform}, font)
// normalize with other renderers
.replace(/(\d+) (-?\d+)/g, '$1,$2')
.replace(/(\d)(-)/g, '$1,$2')
.replace(/(\d)([A-Z])/g, '$1 $2')
.replace(/Z([^\s])/g, 'Z $1')
.replace(/\.\d+/g, '');

for (let i = 0; i < glyphData.length; i++) {
const glyph = glyphData[i].glyph;
const path = glyphData[i].path;
const symbolId = testcase + '.' + glyph.name;
if (!glyphSet.has(glyph)) {
glyphSet.add(glyph);
const svgPath = glyph.path.toSVG({optimize: true, decimalPlaces: 0, flipY: false});
svgSymbols.push(` <symbol id="${symbolId}" overflow="visible">${svgPath}</symbol>`);
symbolSet.add(symbolId);
}
svgBody.push(` <use xlink:href="#${symbolId}" x="${glyphData[i].gX}" y="${glyphData[i].gY}"/>`);
let xMax = glyphData[i].gX + glyph.advanceWidth;
svgBody.push(` <use xlink:href="#${symbolId}" x="${gX}" y="${gY}"/>`);
let xMax = gX + glyph.advanceWidth;
if(xMax > minWidth) {
minWidth = xMax;
}
}

const glyphWidth = glyph.getMetrics().xMax;
});

let minX = 0;
let minY = Math.round(font.descender);
Expand All @@ -93,7 +209,7 @@ function renderSVG() {
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="${minX} ${minY} ${minWidth} ${height}">`;
viewBox="${Math.round(minX * scale)} ${Math.round(minY * scale)} ${Math.round(minWidth * scale)} ${Math.round(height * scale)}">`;

return svgHeader + svgSymbols.join('\n') + svgBody.join('\n') + SVG_FOOTER;
}
Expand Down
4 changes: 3 additions & 1 deletion docs/font-inspector.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta name="description" content="A JavaScript library to manipulate the letterforms of text from the browser or node.js.">
<meta charset="utf-8">
<link rel="stylesheet" href="site.css">
<script src="site.js"></script>

<div class="header">
<div class="container">
Expand Down Expand Up @@ -102,7 +103,6 @@ <h1>Free Software</h1>

var font = null;
const fontSize = 32;
const textToRender = 'Grumpy wizards make toxic brew for the evil Queen and Jack.';
const drawOptions = {
kerning: true,
features: [
Expand Down Expand Up @@ -142,6 +142,8 @@ <h1>Free Software</h1>
function renderText(font) {
if (!font) return;

const textToRender = conditionalSampleText(font, 'Grumpy wizards make toxic brew for the evil Queen and Jack.');

var previewCanvas = document.getElementById('preview');
var previewCtx = previewCanvas.getContext("2d");
previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
Expand Down
Loading

0 comments on commit 262adb2

Please sign in to comment.