From e49f4d2c8aaedef152497f30e153f192dc22d0da Mon Sep 17 00:00:00 2001 From: be5invis Date: Fri, 30 Jul 2021 21:20:12 -0700 Subject: [PATCH] Add support for expression-style metric override. The old `[metric-override.multiplies]` and `[metric-override.adds]` are no longer supported (#1181). --- README.md | 42 ++++- changes/{8.1.0.md => 9.0.0.md} | 1 + font-src/index.js | 3 +- font-src/support/metric-override.js | 246 ++++++++++++++++++++++++++++ font-src/support/parameters.js | 48 ------ 5 files changed, 282 insertions(+), 58 deletions(-) rename changes/{8.1.0.md => 9.0.0.md} (78%) create mode 100644 font-src/support/metric-override.js diff --git a/README.md b/README.md index a0d5bf976..00a7d43aa 100644 --- a/README.md +++ b/README.md @@ -3014,23 +3014,47 @@ Subsection `metric-override` provides ability to override certain metric values, | `powerlineShiftX`, `powerlineShiftY` | emu | 0 | X and Y shift of Powerline glyphs. | | `onumZeroHeightRatio` | (*ratio*) | 1.145 | Ratio of height of `0` under `onum` feature, to the height of `x`. | -Sub-subsection `metric-override.multiplies` and `metric-override.adds` could be used to override the value by multiplying a scale to the default value, then add a shift to it further. The following configuration +The values of each item could be either a number, or a string representing an expression so that it could be different for different instance fonts, or depending on default values. The syntax of valid expressions are: + +``` +Expression -> Term (('+' | '-') Term)* +Term -> Factor (('*' | '/') Factor)* +Factor -> ('+' | '-')* Primitive +Primitive -> Literal + | Call + | Binding + | Group + | List +Literal -> ['0'..'9']+ ('.' ['0'..'9']+)? +Identifier -> ['A'..'Z', 'a'..'z', '_']+ +Call -> Identifier '(' Expression (',' Expression)* ')' +List -> Identifier '[' Expression (',' Expression)* ']' +Binding -> Identifier +``` + +Valid identifiers include: + * `weight`: being the weight grade; + * `width`: being the characters' unit width, measured in em-units; + * `slopeAngle`: being the slope angle in degrees; + * Default value of all overridable metrics, prefixed with `default_`, i.e., default `cap` value will be accessable thorugh `default_cap`. + +Valid functions include: + * `blend`(_x_, \[_x1_, _y1_\], \[_x2_, _y2_\], ...): Perform a smooth interpolation through data pairs \[_x1_, _y1_\], \[_x2_, _y2_\], ..., against parameter _x_. + +For example, the following configuration: ```toml [buildPlans.iosevka-custom.metric-override] leading = 1500 - -[buildPlans.iosevka-custom.metric-override.multiplies] -sb = 1.0625 - -[buildPlans.iosevka-custom.metric-override.adds] -sb = 15 +sb = 'default_sb * 1.0625 + 15' +dotSize = 'blend(weight, [100, 50], [400, 125], [900, 180])' ``` will: -* Override line height to `1500` em-unit; -* Override the sidebearing value by its value multiplied by `1.0625` then added with `15`. + * Override line height to `1500` em-unit; + * Override the sidebearing value by its value multiplied by `1.0625` then added with `15`. + * Override the dot size by a interpolation against weight: at thin (`100`) being `50`, at regular (`400`) being `125`, and at heavy (`900`) being `180`. #### Sample Configuration diff --git a/changes/8.1.0.md b/changes/9.0.0.md similarity index 78% rename from changes/8.1.0.md rename to changes/9.0.0.md index f61deb519..6efa48137 100644 --- a/changes/8.1.0.md +++ b/changes/9.0.0.md @@ -1,3 +1,4 @@ + * \[**Breaking**\]: Add support for expression-style metric override. The old `[metric-override.multiplies]` and `[metric-override.adds]` are no longer supported (#1181). * Fix motion-serifed N's broken shape (#1170). * Fix bar-serif overlapping in Latin Small H-bar (`U+0127`) and Cyrillic Small Dje (`U+0452`) in Sans subfamily's with-serif variants (#1171). * Add flat-boundary brace shape (#1172). diff --git a/font-src/index.js b/font-src/index.js index 13f40fd02..8ba5cd77e 100644 --- a/font-src/index.js +++ b/font-src/index.js @@ -10,6 +10,7 @@ const Toml = require("@iarna/toml"); const { buildFont } = require("./gen/build-font.js"); const Parameters = require("./support/parameters"); +const { applyMetricOverride } = require("./support/metric-override"); const VariantData = require("./support/variant-data"); const { applyLigationData } = require("./support/ligation-data"); const { createGrDisplaySheet } = require("./support/gr"); @@ -49,7 +50,7 @@ async function getParameters() { if (argv.excludedCharRanges) para.excludedCharRanges = argv.excludedCharRanges; if (argv.compatibilityLigatures) para.compLig = argv.compatibilityLigatures; - if (argv.metricOverride) Parameters.applyMetricOverride(para, argv.metricOverride); + if (argv.metricOverride) applyMetricOverride(para, argv.metricOverride, argv); para.naming = { ...para.naming, diff --git a/font-src/support/metric-override.js b/font-src/support/metric-override.js new file mode 100644 index 000000000..3e16fc0be --- /dev/null +++ b/font-src/support/metric-override.js @@ -0,0 +1,246 @@ +"use strict"; + +const { monotonicInterpolate } = require("./util/monotonic-interpolate"); + +exports.applyMetricOverride = applyMetricOverride; +function applyMetricOverride(para, mo, argv) { + const bindings = initBindings(para, argv); + for (const [field, expr] of Object.entries(mo)) { + if (!validMetricOverrideFields.has(field)) { + console.error(`Field ${field} cannot be get overridden.`); + continue; + } + if (typeof expr === "number") { + para[field] = expr; + } else if (typeof expr === "string") { + const e = RootExpression(new State(expr), bindings); + if (typeof e !== "number") + throw new TypeError(`Expression ${e} do not evaluate to a number`); + para[field] = e; + } else { + throw new SyntaxError(`Invalid expression ${JSON.stringify(expr)}`); + } + } +} + +const validMetricOverrideFields = new Set([ + "cap", + "xheight", + "sb", + "accentWidth", + "accentClearance", + "accentHeight", + "accentStackOffset", + "dotSize", + "periodSize", + "leading", + "winMetricAscenderPad", + "winMetricDescenderPad", + "symbolMid", + "parenSize", + "powerlineScaleY", + "powerlineScaleX", + "powerlineShiftY", + "powerlineShiftX", + "onumZeroHeightRatio" +]); + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Function bindings + +function initBindings(para, argv) { + const valueBindings = new Map(); + for (const k of validMetricOverrideFields) { + valueBindings.set("default_" + k, para[k]); + } + valueBindings.set("weight", argv.shape.weight); + valueBindings.set("width", argv.shape.width); + valueBindings.set("slopeAngle", argv.shape.slopeAngle); + + const functionBindings = new Map(); + functionBindings.set("blend", blend); + return { val: valueBindings, functions: functionBindings }; +} + +function blend(against, ...pairs) { + const xs = [], + ys = []; + for (const [x, y] of pairs) { + xs.push(x), ys.push(y); + } + return monotonicInterpolate(xs, ys)(against); +} + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// Simple expression parser +class State { + constructor(input) { + this.input = input; + this.cp = 0; + } + fetch(ch) { + return this.input[this.cp]; + } + test(ch) { + return this.input[this.cp] === ch; + } + testCk(f) { + return f(this.input[this.cp]); + } + advance() { + return this.input[this.cp++]; + } + expectAndAdvance(ch) { + if (!this.test(ch)) this.fail(); + this.advance(); + } + expectEnd() { + if (this.cp < this.input.length) this.fail(); + } + fail() { + throw new SyntaxError("Failed to parse expression: " + this.input + "@" + this.cp); + } +} + +function RootExpression(state, bindings) { + const e = Expression(state, bindings); + state.expectEnd(); + return e; +} +function Expression(state, bindings) { + skipSpaces(state); + const e = Sum(state, bindings); + skipSpaces(state); + return e; +} + +function Sum(state, bindings) { + let f = Term(state, bindings); + skipSpaces(state); + while (state.test("+") || state.test("-")) { + let op = state.advance(); + skipSpaces(state); + const g = Term(state, bindings); + skipSpaces(state); + switch (op) { + case "+": + f = f + g; + break; + case "-": + f = f - g; + break; + } + } + return f; +} +function Term(state, bindings) { + let f = Factor(state, bindings); + skipSpaces(state); + while (state.test("*") || state.test("/")) { + let op = state.advance(); + skipSpaces(state); + const g = Factor(state, bindings); + skipSpaces(state); + switch (op) { + case "*": + f = f * g; + break; + case "/": + f = f / g; + break; + } + } + return f; +} + +function Factor(state, bindings) { + if (state.test("+")) { + state.advance(); + skipSpaces(state); + return Factor(state, bindings); + } else if (state.test("-")) { + state.advance(); + skipSpaces(state); + return -Factor(state, bindings); + } else { + return Primitive(state, bindings); + } +} + +function Primitive(state, bindings) { + if (state.testCk(isDigit)) return Lit(state, bindings); + if (state.testCk(isAlpha)) return BindingOrCall(state, bindings); + if (state.test("(")) return Group(state, bindings); + if (state.test("[")) return List("[", "]", state, bindings); + state.fail(); +} + +function Lit(state, bindings) { + let integerPart = 0; + let fractionPart = 0; + let fractionScale = 1; + while (state.testCk(isDigit)) { + const digit = state.advance().codePointAt(0) - "0".codePointAt(0); + integerPart = integerPart * 10 + digit; + } + if (state.test(".")) { + state.advance(); + while (state.testCk(isDigit)) { + fractionScale /= 10; + const digit = state.advance().codePointAt(0) - "0".codePointAt(0); + fractionPart += digit * fractionScale; + } + } + return integerPart + fractionPart; +} + +function BindingOrCall(state, bindings) { + let symbolName = ""; + while (state.testCk(isAlpha)) symbolName += state.advance(); + if (state.test("(")) { + const args = List("(", ")", state, bindings); + if (bindings.functions.has(symbolName)) return bindings.functions.get(symbolName)(...args); + else throw new TypeError(`Unknown function ${symbolName}.`); + } else { + if (bindings.val.has(symbolName)) return bindings.val.get(symbolName); + else throw new TypeError(`Unknown identifier ${symbolName}.`); + } +} + +function Group(state, bindings) { + state.expectAndAdvance("("); + skipSpaces(state); + const e = Expression(state, bindings); + skipSpaces(state); + state.expectAndAdvance(")"); + return e; +} +function List(start, end, state, bindings) { + let results = []; + state.expectAndAdvance(start); + skipSpaces(state); + results.push(Expression(state, bindings)); + skipSpaces(state); + while (state.test(",")) { + state.expectAndAdvance(","); + skipSpaces(state); + results.push(Expression(state, bindings)); + skipSpaces(state); + } + state.expectAndAdvance(end); + return results; +} + +function skipSpaces(state) { + while (state.testCk(isSpace)) state.advance(); +} + +function isSpace(ch) { + return ch === " " || ch === "\t"; +} +function isDigit(ch) { + return ch >= "0" && ch <= "9"; +} +function isAlpha(ch) { + return (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || ch === "_"; +} diff --git a/font-src/support/parameters.js b/font-src/support/parameters.js index ff4aabd8c..98b9f1fe3 100644 --- a/font-src/support/parameters.js +++ b/font-src/support/parameters.js @@ -115,51 +115,3 @@ function hiveBlend(hive, value) { } return generatedHive; } - -exports.applyMetricOverride = applyMetricOverride; -function applyMetricOverride(para, mo) { - const overrideObj = { metricOverride: {} }; - createMetricDataSet(overrideObj.metricOverride, mo); - apply(para, overrideObj, ["metricOverride"]); -} - -function createMetricDataSet(sink, mo) { - for (const key in mo) { - if (metricOverrideHandlers[key]) { - metricOverrideHandlers[key](sink, key, mo[key]); - } else { - console.error(`Metric override key ${key} is not supported. Skipping it.`); - } - } -} - -function numericFieldHandler(sink, key, x) { - if (x != null && isFinite(x)) sink[key] = x; -} -function subObjectHandler(sink, key, obj) { - sink[key] = {}; - createMetricDataSet(sink[key], obj); -} -const metricOverrideHandlers = { - cap: numericFieldHandler, - xheight: numericFieldHandler, - sb: numericFieldHandler, - accentWidth: numericFieldHandler, - accentClearance: numericFieldHandler, - accentHeight: numericFieldHandler, - accentStackOffset: numericFieldHandler, - dotSize: numericFieldHandler, - periodSize: numericFieldHandler, - leading: numericFieldHandler, - winMetricAscenderPad: numericFieldHandler, - winMetricDescenderPad: numericFieldHandler, - symbolMid: numericFieldHandler, - parenSize: numericFieldHandler, - powerlineScaleY: numericFieldHandler, - powerlineScaleX: numericFieldHandler, - powerlineShiftY: numericFieldHandler, - powerlineShiftX: numericFieldHandler, - onumZeroHeightRatio: numericFieldHandler, - multiplies: subObjectHandler, - adds: subObjectHandler -};