Skip to content

Commit ce5fcce

Browse files
authored
Variable Fonts support: GVAR and CVAR table parsing (#698)
Implement parsing of gvar and cvar tables * add new parsing structures to the parser * add tests for gvar and cvar parsing * handle gvar and cvar tables during loading
1 parent 946f255 commit ce5fcce

17 files changed

+558
-10
lines changed

src/opentype.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import cff from './tables/cff.js';
1818
import stat from './tables/stat.js';
1919
import fvar from './tables/fvar.js';
2020
import gvar from './tables/gvar.js';
21+
import cvar from './tables/cvar.js';
2122
import avar from './tables/avar.js';
2223
import glyf from './tables/glyf.js';
2324
import gdef from './tables/gdef.js';
@@ -267,6 +268,7 @@ function parseBuffer(buffer, opt={}) {
267268
let fvarTableEntry;
268269
let statTableEntry;
269270
let gvarTableEntry;
271+
let cvarTableEntry;
270272
let avarTableEntry;
271273
let glyfTableEntry;
272274
let gdefTableEntry;
@@ -305,6 +307,9 @@ function parseBuffer(buffer, opt={}) {
305307
case 'gvar':
306308
gvarTableEntry = tableEntry;
307309
break;
310+
case 'cvar':
311+
cvarTableEntry = tableEntry;
312+
break;
308313
case 'fpgm' :
309314
table = uncompressTable(data, tableEntry);
310315
p = new parse.Parser(table.data, table.offset);
@@ -459,7 +464,21 @@ function parseBuffer(buffer, opt={}) {
459464
console.warn('This font provides a gvar table, but no glyf table. Glyph variation only works with TrueType outlines.');
460465
}
461466
const gvarTable = uncompressTable(data, gvarTableEntry);
462-
font.tables.gvar = gvar.parse(gvarTable.data, gvarTable.offset, font.names);
467+
font.tables.gvar = gvar.parse(gvarTable.data, gvarTable.offset, font.tables.fvar, font.glyphs);
468+
}
469+
470+
if (cvarTableEntry) {
471+
if (!fvarTableEntry) {
472+
console.warn('This font provides a cvar table, but no fvar table, which is required for variable fonts.');
473+
}
474+
if (!font.tables.cvt) {
475+
console.warn('This font provides a cvar table, but no cvt table which could be made variable.');
476+
}
477+
if (!glyfTableEntry) {
478+
console.warn('This font provides a gvar table, but no glyf table. Glyph variation only works with TrueType outlines.');
479+
}
480+
const cvarTable = uncompressTable(data, cvarTableEntry);
481+
font.tables.cvar = cvar.parse(cvarTable.data, cvarTable.offset, font.tables.fvar, font.tables.cvt || []);
463482
}
464483

465484
if (avarTableEntry) {

src/parse.js

Lines changed: 272 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function bytesToString(bytes) {
8383
const typeOffsets = {
8484
byte: 1,
8585
uShort: 2,
86+
f2dot14: 2,
8687
short: 2,
8788
uInt24: 3,
8889
uLong: 4,
@@ -93,7 +94,18 @@ const typeOffsets = {
9394

9495
const masks = {
9596
LONG_WORDS: 0x8000,
96-
WORD_DELTA_COUNT_MASK: 0x7FFF
97+
WORD_DELTA_COUNT_MASK: 0x7FFF,
98+
SHARED_POINT_NUMBERS: 0x8000,
99+
COUNT_MASK: 0x0FFF,
100+
EMBEDDED_PEAK_TUPLE: 0x8000,
101+
INTERMEDIATE_REGION: 0x4000,
102+
PRIVATE_POINT_NUMBERS: 0x2000,
103+
TUPLE_INDEX_MASK: 0x0FFF,
104+
POINTS_ARE_WORDS: 0x80,
105+
POINT_RUN_COUNT_MASK: 0x7F,
106+
DELTAS_ARE_ZERO: 0x80,
107+
DELTAS_ARE_WORDS: 0x40,
108+
DELTA_RUN_COUNT_MASK: 0x3F,
97109
};
98110

99111
// A stateful parser that changes the offset whenever a value is retrieved.
@@ -346,6 +358,18 @@ Parser.prototype.parseRecordList32 = function(count, recordDescription) {
346358
return records;
347359
};
348360

361+
Parser.prototype.parseTupleRecords = function(tupleCount, axisCount) {
362+
let tuples = [];
363+
for (let i = 0; i < tupleCount; i++) {
364+
let tuple = [];
365+
for (let axisIndex = 0; axisIndex < axisCount; axisIndex++) {
366+
tuple.push(this.parseF2Dot14());
367+
}
368+
tuples.push(tuple);
369+
}
370+
return tuples;
371+
};
372+
349373
// Parse a data structure into an object
350374
// Example of description: { sequenceIndex: Parser.uShort, lookupListIndex: Parser.uShort }
351375
Parser.prototype.parseStruct = function(description) {
@@ -712,6 +736,253 @@ Parser.prototype.parseDeltaSets = function(itemCount, wordDeltaCount) {
712736
return deltas;
713737
};
714738

739+
Parser.prototype.parseTupleVariationStoreList = function(axisCount, flavor, glyphs) {
740+
const glyphCount = this.parseUShort();
741+
const flags = this.parseUShort();
742+
const offsetSizeIs32Bit = flags & 0x01;
743+
744+
const glyphVariationDataArrayOffset = this.parseOffset32();
745+
const parseOffset = (offsetSizeIs32Bit ? this.parseULong : this.parseUShort).bind(this);
746+
747+
const glyphVariations = {};
748+
749+
let currentOffset = parseOffset();
750+
if (!offsetSizeIs32Bit) currentOffset *= 2;
751+
let nextOffset;
752+
753+
for (let i = 0; i < glyphCount; i++) {
754+
nextOffset = parseOffset();
755+
if (!offsetSizeIs32Bit) nextOffset *= 2;
756+
757+
const length = nextOffset - currentOffset;
758+
759+
glyphVariations[i] = length
760+
? this.parseTupleVariationStore(
761+
glyphVariationDataArrayOffset + currentOffset,
762+
axisCount,
763+
flavor,
764+
glyphs,
765+
i
766+
)
767+
: undefined;
768+
769+
currentOffset = nextOffset;
770+
}
771+
772+
return glyphVariations;
773+
};
774+
775+
Parser.prototype.parseTupleVariationStore = function(tableOffset, axisCount, flavor, glyphs, glyphIndex) {
776+
const relativeOffset = this.relativeOffset;
777+
778+
this.relativeOffset = tableOffset;
779+
if(flavor === 'cvar') {
780+
this.relativeOffset+= 4; // we already parsed the version fields in cvar.js directly
781+
}
782+
783+
// header
784+
const tupleVariationCount = this.parseUShort();
785+
const hasSharedPoints = !!(tupleVariationCount & masks.SHARED_POINT_NUMBERS);
786+
// const reserved = tupleVariationCount & 0x7000;
787+
const count = tupleVariationCount & masks.COUNT_MASK;
788+
let dataOffset = this.parseOffset16();
789+
const headers = [];
790+
let sharedPoints = [];
791+
792+
for(let h = 0; h < count; h++) {
793+
const headerData = this.parseTupleVariationHeader(axisCount, flavor);
794+
headers.push(headerData);
795+
}
796+
797+
if (this.relativeOffset !== tableOffset + dataOffset) {
798+
console.warn(`Unexpected offset after parsing tuple variation headers! Expected ${tableOffset + dataOffset}, actually ${this.relativeOffset}`);
799+
this.relativeOffset = tableOffset + dataOffset;
800+
}
801+
802+
if (flavor === 'gvar' && hasSharedPoints) {
803+
sharedPoints = this.parsePackedPointNumbers();
804+
}
805+
806+
let serializedDataOffset = this.relativeOffset;
807+
808+
for(let h = 0; h < count; h++) {
809+
const header = headers[h];
810+
header.privatePoints = [];
811+
this.relativeOffset = serializedDataOffset;
812+
813+
if(flavor === 'cvar' && !header.peakTuple) {
814+
console.warn('An embedded peak tuple is required in TupleVariationHeaders for the cvar table.');
815+
}
816+
817+
if(header.flags.privatePointNumbers) {
818+
header.privatePoints = this.parsePackedPointNumbers();
819+
}
820+
821+
const deltasOffset = this.offset;
822+
const deltasRelativeOffset = this.relativeOffset;
823+
824+
const defineDeltas = (propertyName) => {
825+
let _deltas = undefined;
826+
let _deltasY = undefined;
827+
828+
const parseDeltas = () => {
829+
let pointsCount = header.privatePoints.length || sharedPoints.length;
830+
if(!pointsCount) {
831+
if(flavor === 'gvar') {
832+
const glyph = glyphs.get(glyphIndex);
833+
// make sure the path is available
834+
glyph.path;
835+
pointsCount = glyph.points.length;
836+
// add 4 phantom points, see https://learn.microsoft.com/en-us/typography/opentype/spec/tt_instructing_glyphs#phantoms
837+
// @TODO: actually generate these points from glyph.getBoundingBox() and glyph.getMetrics(),
838+
// as they may be influenced by variation as well
839+
pointsCount+= 4;
840+
} else if (flavor === 'cvar') {
841+
pointsCount = glyphs.length; // glyphs here is actually font.tables.cvt
842+
}
843+
}
844+
845+
this.offset = deltasOffset;
846+
this.relativeOffset = deltasRelativeOffset;
847+
_deltas = this.parsePackedDeltas(pointsCount);
848+
849+
if(flavor === 'gvar') {
850+
_deltasY = this.parsePackedDeltas(pointsCount);
851+
}
852+
};
853+
854+
return {
855+
configurable: true,
856+
857+
get: function() {
858+
if(_deltas === undefined) parseDeltas();
859+
return propertyName === 'deltasY' ? _deltasY : _deltas;
860+
},
861+
862+
set: function(deltas) {
863+
if(_deltas === undefined) parseDeltas();
864+
if(propertyName === 'deltasY') {
865+
_deltasY = deltas;
866+
} else {
867+
_deltas = deltas;
868+
}
869+
}
870+
};
871+
};
872+
873+
Object.defineProperty(header, 'deltas', defineDeltas.call(this, 'deltas'));
874+
if(flavor === 'gvar') {
875+
Object.defineProperty(header, 'deltasY', defineDeltas.call(this, 'deltasY'));
876+
}
877+
878+
serializedDataOffset += header.variationDataSize;
879+
delete header.variationDataSize; // we don't need to expose this
880+
}
881+
882+
this.relativeOffset = relativeOffset;
883+
const result = {
884+
headers,
885+
};
886+
887+
if(flavor === 'gvar') {
888+
result.sharedPoints = sharedPoints;
889+
}
890+
891+
return result;
892+
};
893+
894+
Parser.prototype.parseTupleVariationHeader = function(axisCount, flavor) {
895+
const variationDataSize = this.parseUShort();
896+
const tupleIndex = this.parseUShort();
897+
898+
const embeddedPeakTuple = !!(tupleIndex & masks.EMBEDDED_PEAK_TUPLE);
899+
const intermediateRegion = !!(tupleIndex & masks.INTERMEDIATE_REGION);
900+
const privatePointNumbers = !!(tupleIndex & masks.PRIVATE_POINT_NUMBERS);
901+
// const reserved = tupleIndex & 0x1000;
902+
const sharedTupleRecordsIndex = embeddedPeakTuple ? undefined : tupleIndex & masks.TUPLE_INDEX_MASK;
903+
904+
const peakTuple = embeddedPeakTuple ? this.parseTupleRecords(1, axisCount)[0] : undefined;
905+
const intermediateStartTuple = intermediateRegion ? this.parseTupleRecords(1, axisCount)[0] : undefined;
906+
const intermediateEndTuple = intermediateRegion ? this.parseTupleRecords(1, axisCount)[0] : undefined;
907+
908+
const result = {
909+
variationDataSize,
910+
peakTuple,
911+
intermediateStartTuple,
912+
intermediateEndTuple,
913+
flags: {
914+
embeddedPeakTuple,
915+
intermediateRegion,
916+
privatePointNumbers,
917+
}
918+
};
919+
920+
if(flavor === 'gvar') {
921+
result.sharedTupleRecordsIndex = sharedTupleRecordsIndex;
922+
}
923+
924+
return result;
925+
};
926+
927+
Parser.prototype.parsePackedPointNumbers = function() {
928+
const countByte1 = this.parseByte();
929+
const points = [];
930+
let totalPointCount = countByte1;
931+
932+
if (countByte1 >= 128) {
933+
// High bit is set, need to read a second byte and combine.
934+
const countByte2 = this.parseByte();
935+
936+
// Combine as big-endian uint16, with high bit of the first byte cleared.
937+
// This is done by masking the first byte with 0x7F (to clear the high bit)
938+
// and then shifting it left by 8 bits before adding the second byte.
939+
totalPointCount = ((countByte1 & masks.POINT_RUN_COUNT_MASK) << 8) | countByte2;
940+
}
941+
942+
let lastPoint = 0;
943+
while (points.length < totalPointCount) {
944+
const controlByte = this.parseByte();
945+
const numbersAre16Bit = !!(controlByte & masks.POINTS_ARE_WORDS); // Check if high bit is set
946+
let runCount = (controlByte & masks.POINT_RUN_COUNT_MASK) + 1; // Number of points in this run
947+
for (let i = 0; i < runCount && points.length < totalPointCount; i++) {
948+
let pointDelta;
949+
if (numbersAre16Bit) {
950+
pointDelta = this.parseUShort(); // Parse delta as uint16
951+
} else {
952+
pointDelta = this.parseByte(); // Parse delta as uint8
953+
}
954+
// For the first point of the first run, use the value directly. Otherwise, accumulate.
955+
lastPoint = lastPoint + pointDelta;
956+
points.push(lastPoint);
957+
}
958+
}
959+
960+
return points;
961+
};
962+
963+
Parser.prototype.parsePackedDeltas = function(expectedCount) {
964+
const deltas = [];
965+
966+
while (deltas.length < expectedCount) {
967+
const controlByte = this.parseByte();
968+
const zeroData = !!(controlByte & masks.DELTAS_ARE_ZERO);
969+
const deltaWords = !!(controlByte & masks.DELTAS_ARE_WORDS);
970+
const runCount = (controlByte & masks.DELTA_RUN_COUNT_MASK) + 1;
971+
972+
for (let i = 0; i < runCount && deltas.length < expectedCount; i++) {
973+
if(zeroData) {
974+
deltas.push(0);
975+
} else if (deltaWords) {
976+
deltas.push(this.parseShort());
977+
} else {
978+
deltas.push(this.parseChar());
979+
}
980+
}
981+
}
982+
983+
return deltas;
984+
};
985+
715986
export default {
716987
getByte,
717988
getCard8: getByte,

src/tables/cvar.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// The `cvar` table stores variation data for CVT values
2+
// https://learn.microsoft.com/en-us/typography/opentype/spec/cvar
3+
4+
import parse from '../parse.js';
5+
6+
function parseCvarTable(data, start, fvar, cvt) {
7+
const p = new parse.Parser(data, start);
8+
const cvtVariations = p.parseTupleVariationStore(
9+
p.relativeOffset,
10+
fvar.axes.length,
11+
'cvar',
12+
cvt
13+
);
14+
const tableVersionMajor = p.parseUShort();
15+
const tableVersionMinor = p.parseUShort();
16+
if (tableVersionMajor !== 1) {
17+
console.warn(`Unsupported cvar table version ${tableVersionMajor}.${tableVersionMinor}`);
18+
}
19+
20+
return {
21+
version: [tableVersionMajor, tableVersionMinor],
22+
...cvtVariations,
23+
};
24+
}
25+
26+
function makeCvarTable(/*cvar*/) {
27+
console.warn('Writing of cvar tables is not yet supported.');
28+
}
29+
30+
export default { make: makeCvarTable, parse: parseCvarTable };

0 commit comments

Comments
 (0)