From dd739000a0313b571573f2dba52d470b9f40ab17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Mon, 15 Jun 2026 22:34:48 +0200 Subject: [PATCH] Evaluate FeatureVariations at the default location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A variable font with no explicit variation set builds no _variationProcessor (non-CFF2 path), so FeatureVariations were skipped entirely — unlike HarfBuzz, which always evaluates them at the current location - Evaluate FeatureVariations at the normalized origin (all-zero coords) for such fonts, so default-location substitutions (e.g. bracket-layer alternates) apply - Add a minimal generated TTF variable-font fixture + test (rvrn A -> A.alt gated on a default-covering condition set) --- src/opentype/OTProcessor.js | 16 +++++++-- test/data/fonttest/TestFeatureVariations.py | 33 +++++++++++++++++++ test/data/fonttest/TestFeatureVariations.ttf | Bin 0 -> 988 bytes test/shaping.js | 11 +++++++ 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 test/data/fonttest/TestFeatureVariations.py create mode 100644 test/data/fonttest/TestFeatureVariations.ttf diff --git a/src/opentype/OTProcessor.js b/src/opentype/OTProcessor.js index 80d1ce69..a6e25e8c 100644 --- a/src/opentype/OTProcessor.js +++ b/src/opentype/OTProcessor.js @@ -17,9 +17,19 @@ export default class OTProcessor { this.features = {}; this.lookups = {}; - // Setup variation substitutions - this.variationsIndex = font._variationProcessor - ? this.findVariationsIndex(font._variationProcessor.normalizedCoords) + // Setup variation substitutions. FeatureVariations apply at the font's + // current location, defaulting to the normalized origin (all-zero coords) + // when no variation is set — matching HarfBuzz, which always evaluates them. + // fontkit only builds a _variationProcessor once coords are applied (or for + // CFF2), so a plain variable font would otherwise skip FeatureVariations + // (e.g. default-location bracket-layer substitutions) entirely. + let variationCoords = font._variationProcessor + ? font._variationProcessor.normalizedCoords + : font.fvar + ? new Array(font.fvar.axis.length).fill(0) + : null; + this.variationsIndex = variationCoords + ? this.findVariationsIndex(variationCoords) : -1; // initialize to default script + language diff --git a/test/data/fonttest/TestFeatureVariations.py b/test/data/fonttest/TestFeatureVariations.py new file mode 100644 index 00000000..87434f4a --- /dev/null +++ b/test/data/fonttest/TestFeatureVariations.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# Generates TestFeatureVariations.ttf: a minimal TTF variable font whose `rvrn` +# FeatureVariations substitutes A -> A.alt under a condition set that covers the +# default location (normalized wght in [0, 1], which includes the default 0). +# Used to test that fontkit evaluates FeatureVariations at the default location. +# Build: python3 TestFeatureVariations.py TestFeatureVariations.ttf +import sys +from fontTools.fontBuilder import FontBuilder +from fontTools.varLib.featureVars import addFeatureVariations +from fontTools.pens.ttGlyphPen import TTGlyphPen + + +def box(x0, y0, x1, y1): + pen = TTGlyphPen(None) + pen.moveTo((x0, y0)); pen.lineTo((x0, y1)) + pen.lineTo((x1, y1)); pen.lineTo((x1, y0)); pen.closePath() + return pen.glyph() + + +glyphs = ['.notdef', 'A', 'A.alt'] +fb = FontBuilder(1000, isTTF=True) +fb.setupGlyphOrder(glyphs) +fb.setupCharacterMap({0x41: 'A'}) +fb.setupGlyf({'.notdef': box(0, 0, 0, 0), 'A': box(100, 0, 500, 700), + 'A.alt': box(100, 0, 400, 700)}) +fb.setupHorizontalMetrics({g: (600, 100) for g in glyphs}) +fb.setupHorizontalHeader(ascent=800, descent=-200) +fb.setupNameTable({'familyName': 'TestFeatureVariations', 'styleName': 'Regular'}) +fb.setupOS2(); fb.setupPost() +fb.setupFvar(axes=[('wght', 0, 400, 1000, 'Weight')], instances=[]) +fb.setupGvar({}) +addFeatureVariations(fb.font, [([{'wght': (0, 1)}], {'A': 'A.alt'})], featureTag='rvrn') +fb.save(sys.argv[1]) diff --git a/test/data/fonttest/TestFeatureVariations.ttf b/test/data/fonttest/TestFeatureVariations.ttf new file mode 100644 index 0000000000000000000000000000000000000000..338670da6a157a23d6f4bb58ac9bdf676fa7a1af GIT binary patch literal 988 zcmZ`&L2DCH5dPk7QX|ybmMEe{qUKbzr7BXPXj4MGv?^_y1~2+ZwwtBdByKio6%YP| zARayW2fP&Y&_j>jJqgmIconKAW&P%DOe4}4-kbU6`*vn_-UbT5DZGS@hxgXjm)^gN zE&z6x(e8t_+3UIa+yS7<#7`=|ZebL!iT8;=A{X`ZvT`xBhH%<@1$01^arZ z#e5^g%!y1x`_9VN+XcW{B;IW`+u_rL*$T1fXCUv!eQ|Dm`|;_8A5iu;M)0+i{xodW zA*-@USQ6#pGj=9o4XcbQs;n-VhL)=@GNoBFL!0P#my04Ip3(l`JyM+O_UC&-{N|rscsQ)66O>nVZq|kiOjZB+j#2u&&~SRlcxX#5t&-8#DwP)wog6B_5L) zCm%DCLA-c6 A.alt under a + // condition set covering the default location. HarfBuzz applies it; so + // must fontkit, even with no explicit variation instance selected. + let font = fontkit.openSync(new URL('data/fonttest/TestFeatureVariations.ttf', import.meta.url)); + let { glyphs } = font.layout('A'); + assert.deepEqual(glyphs.map(g => g.name), ['A.alt']); + }); + }); });