Higher Order Interpolation of OpenType variable fonts

  1. Preliminary
  2. First attempt - Independent Curves
  3. Second attempt - Contour of G0 Curves
  4. Example - Font for First and Second attempts
  5. Third attempt - Contour of Jump Discontinuous Curves

Preliminary

In this discussion a region is specified by near, far, and peak points, and deltas applied at the peak. (The use of near and far instead of min and max makes it easier to reason about the negative direction.)

Non-linear interpolation takes advantage of the fact that "The overall scalar is the product of all per-axis scalars." When a region is defined on more than one axis the per-axis scalars (abbreviated AS) are multiplied together to create the overall scalar (abbreviated S) which will be multiplied by the deltas. The per-axis scalars AS have a value of 0 outside the region and linearly increase from the edge of the region to a value of 1 at the peak for that axis. If a region is defined along axis1, axis2, ..., axisN and all the axis values vary so as to approach the peak at the same rate so that AS1 == AS2 == ... == ASN, then the deltas will be multiplied by S = AS1*AS2*...*ASN == AS^N.

Making the axis values all approach the peak at the same rate is easiest to accomplish if all the axes of the region have the same near, far, and peak values. One possible way to vary the axes together is to give them all the same axis tag, which works because internally the axes are stored as a list of axes and each axis has a tag. Specifying a variation value by axis tag applies the variation value to all axes with that axis tag in all current major implementations. While this is not currently required by the specification, this is the only addition to the specification used here[1]. However, there are ways to break this. An API could allow setting each axis by index. Internally the named instances set values by axis index, so can themselves set the values to anything.

Using one such region for each required power of AS with deltas for the coefficient allows the use of a polynomial basis for parametric curves. The following discussion considers these polynomial parametric curves in bezier form. Quadratic bezier curves are used in the following examples, but any degree bezier should work. However, high degree curves are difficult to tweak and can run into issues with numerical stability in implementations. Keep in mind that these curves will be describing deltas relative to the default point. Since quadratic beziers are used in the examples, the examples will be using two axes locked together to create the t2 and t terms required. Note that from the near point to the peak point will be used as a t term and from a peak point to a far point will be a 1-t term.

A quadratic bezier curve with control points P0, P1, P2 can be described in polynomial form as C(t) = (P0 - 2P1 + P2)t2 + 2(P1 - P0)t + P0

A contour of N beziers is a vector of bezier curves C[N] where each follows the previous as C[n].peak == C[n+1].near.

First attempt - Independent Curves

Since regions must peak at a single point, constants other than zero are difficult. So re-write P0 as P0t + P0(1-t). C(t) = (P0 - 2P1 + P2)t2 + (2P1 - P0)t + P0(1-t)

To translate this into regions, create three regions where 'begin' is where the curve begins (closest to axis origin) and 'end' is where the curve ends (furthest from axis origin) C.region[0] = { axes = [axis1] , near = begin, far = end, peak = begin, delta = ( P0) } C.region[1] = { axes = [axis1] , near = begin, far = end, peak = end , delta = ( 2P1 - P0) } C.region[2] = { axes = [axis1, axis2], near = begin, far = end, peak = end , delta = (P2 - 2P1 + P0) }

The number of axes required is deg(C). The number of regions required is deg(C)+1 for each curve. At most points on a contour deg(C)+1 regions are active, with 2(deg(C)+1) regions active at any point of curve overlap (including where curves abut, which is bad).

Example

A not great but easy to understand approximation of a half circle in a square using two quads is to put the on-curve points in the middle of the edges and the off-curve points in the corners. This example starts with the left point of the half circle approximation at the origin and has it travel clockwise (font space being y-up) in the negative axis direction. C[0].P = [( 0, 0), ( 0, 512), ( 512, 512)] C[1].P = [( 512, 512), (1024, 512), (1024, 0)]

Plugging in the numbers for the example C[0].region[0].delta x = 0 y = 0 C[0].region[1].delta x = 0 y = 1024 C[0].region[2].delta x = 512 y = -512 C[1].region[0].delta x = 512 y = 512 C[1].region[1].delta x = 1536 y = 512 C[1].region[2].delta x = -512 y = -512

Applying the curves evenly, translating this into ttx notation, and naming the first axis wght and the second WGHT produces the following example. The ttx format uses the gx term 'tuple' for what is here and in the OpenType specification referred to as a region. Currently ttx keys axes by tag and not by index, making it less capable than the underlying format, so give the axes different tags. After compiling with ttx, open with a hex editor and replace WGHT with wght. <fvar> <Axis> <AxisTag>wght</AxisTag> <Flags>0x1</Flags> <MinValue>400</MinValue> <DefaultValue>500</DefaultValue> <MaxValue>900</MaxValue> <AxisNameID>256</AxisNameID> </Axis> <Axis> <AxisTag>WGHT</AxisTag> <Flags>0x1</Flags> <MinValue>400</MinValue> <DefaultValue>500</DefaultValue> <MaxValue>900</MaxValue> <AxisNameID>256</AxisNameID> </Axis> </fvar> <gvar> <glyphVariations glyph="foo"> <!-- curves heading in the negative axis direction from the origin (default point) --> <tuple> <coord axis="wght" value="-0.5" min="-0.5" max="0"/> <delta pt="0" x="0" y="1024"/> </tuple> <tuple> <coord axis="wght" value="-0.5" min="-0.5" max="0"/> <coord axis="WGHT" value="-0.5" min="-0.5" max="0"/> <delta pt="0" x="512" y="-512"/> </tuple> <tuple> <coord axis="wght" value="-0.5" min="-1.0" max="-0.5"/> <delta pt="0" x="512" y="512"/> </tuple> <tuple> <coord axis="wght" value="-1.0" min="-1.0" max="-0.5"/> <delta pt="0" x="1536" y="512"/> </tuple> <tuple> <coord axis="wght" value="-1.0" min="-1.0" max="-0.5"/> <coord axis="WGHT" value="-1.0" min="-1.0" max="-0.5"/> <delta pt="0" x="-512" y="-512"/> </tuple> </glyphVariations> </gvar>

Considerations

Unfortunately, attempting to construct a contour with consecutive curves C[n] and C[n+1] with the common point Pc == C[n].PN == C[n+1].P0 the offset Pc will be double applied where the two contours overlap, resulting in a discontinuity. Offsetting the constant term or all of C[n+1] by an ULP (1 / 214) may somewhat mitigate, but can still give rise to a discontinuity as the application may allow setting the variation more finely than the ULP available.

Second attempt - Contour of G0 Curves

The general idea is to avoid discontinuity by assuming a single contour with G0 continuity instead of arbitrary curves so that C[n].PN == C[n+1].P0 to avoid constants.

A curve C is applied from near position to peak position as C(t) and fades out from peak position to the far position as C(1-t). In order to preserve the offset C(1) over the period C(1-t) is applied one must add in addition C' = C(1) - C(1-t). (C' here may be pronounced "C compliment".) This can be interpreted as taking the initial curve C, reversing it, negating that, then translating that by C(1) to re-align.

For brevity in the following block, all Pn are C.Pn (the points in the order of the original curve C). C (t) = ( P0 - 2P1 + P2)t2 + 2( P1 - P0)t + P0 Cr (t) = ( P2 - 2P1 + P0)t2 + 2( P1 - P2)t + P2 // C(1-t), or C in reverse, or "delta applied as C moves from peak to the far position" -Cr (t) = (-P2 + 2P1 - P0)t2 + 2(-P1 + P2)t - P2 // - C(1-t), or arithmetic inverse of Cr C' (t) = (-P2 + 2P1 - P0)t2 + 2(-P1 + P2)t // C(1) - C(1-t), or add C(1) = (C.P2) to -Cr C' (t) = (-P2 + 2P1 - P0)t2 + 2(-P1 + P2)t C'r(t) = (-P0 + 2P1 - P2)t2 + 2(-P1 + P0)t + ( P2 - P0) // C'(1-t) -C'r(t) = ( P0 - 2P1 + P2)t2 + 2( P1 - P0)t + (-P2 + P0) // - C'(1-t) C''(t) = ( P0 - 2P1 + P2)t2 + 2( P1 - P0)t // C'(1) - C'(1-t) Co (t) = ( P0 - 2P1 + P2)t2 + 2( P1 - P0)t // The curve C(t) translated to the origin, or C(t) - C.P0 Cor(t) = ( P2 - 2P1 + P0)t2 + 2( P1 - P2)t + ( P2 - P0) // Cor(1-t) -Cor(t) = (-P2 + 2P1 - P0)t2 + 2(-P1 + P2)t + (-P2 + P0) // - Cor(1-t) Co'(t) = (-P2 + 2P1 - P0)t2 + 2(-P1 + P2)t // Cor(1) - Cor(1-t)

Importantly, neither of Co or C' has a constant term.

Without loss of generality, assume C[0].P0 = (0,0) so that C[0] == Co[0]. If a discontinuous offset is desired, a Pt + P(1-t) constant delta region (or any shaped region) can be added across the contour (or wherever for that matter). Often hard discontinuity can be avoided by allowing contours extremely fast applying curves.

C[0] is applied from where it starts to C[0].peak as Co[0](t). It then phases out to the C[1].peak as Co[0](1-t) == Cr[0](t). Subsequent curves in the contour may be followed with regions described by

C[N] works the same way except that C[N].far == C[N].peak.

The first line of the following factorings is the C[n-1](1-t) == Cr[n-1](t) of the previous curve being 'unapplied', Cu[n]. The second line of the following factorings is the C[n-1](1) - C[n-1](1-t) + Co[n] term for the current curve to be 'applied', Ca[n]. C[0] == Co [0] C[1] == Cor[0] + C' [0] + Co [1] C[2] == C'r[0] + Cor[1] + Co [0] + C' [1] + Co [2] C[3] == Cor[0] + C'r[1] + Cor[2] + C' [0] + Co [1] + C' [2] + Co [3] C[n+2] == C'r[n] + Cor[n+1] + C [n] + C' [n+1] + Co[n+2] The Ca curves describe the deltas to apply C. Ca[0] = Co[0] Ca[1] = C'[0] + Co[1] Ca[2] = Co[0] + C'[1] + Co[2] Ca[3] = C'[0] + Co[1] + C'[2] + Co[3] Ca[n] = Ca[n-2] + C'[n-1] + Co[n]

The number of axes required is max{deg(C[0]), deg(C[1]), ..., deg(C[N])}. Since Ca has no constant term, there is no need to use a region to support a constant term. The number of regions for C[n] will be max{deg(C[n]), deg(C[n-1], ..., deg(C[0])} since Ca[n] depends on all previous curves. Consider the maximal case where all curves have the same degree D = deg(C[0]). At the point C[N](1) or while C[0] is being applied D regions are active. At most other points 2D regions are active. At middle peak points, depending on how the implementation treats edges, either D or 3D regions will be active (though 2D of those will contribute zero delta at that point).

Example

A not great but easy to understand approximation of a circle in a square using four quads is to put the on-curve points in the middle of the edges and the off-curve points in the corners. This example starts with the left point of the circle approximation at the origin and has it travel counter-clockwise (font space being y-up) in the positive axis direction. C [0].P = [( 0, 0), ( 0, -512), ( 512, -512)] C [1].P = [( 512, -512), (1024, -512), (1024, 0)] C [2].P = [(1024, 0), (1024, 512), ( 512, 512)] C [3].P = [( 512, 512), ( 0, 512), ( 0, 0)] Co[0].P = [(0, 0), ( 0, -512), ( 512, -512)] Co[1].P = [(0, 0), ( 512, 0), ( 512, 512)] Co[2].P = [(0, 0), ( 0, 512), (-512, 512)] Co[3].P = [(0, 0), (-512, 0), (-512, -512)]

For ease of calculation note that since Co.P0 == 0 Co (t) = (Co.P0 - 2Co.P1 + Co.P2)t2 + 2(Co.P1 - Co.P0)t + Co.P0 = (Co.P2 - 2Co.P1)t2 + 2(Co.P1)t Co'(t) = (-Co.P2 + 2Co.P1 - Co.P0)t2 + 2(-Co.P1 + Co.P2)t = (-Co.P2 + 2Co.P1)t2 + 2(Co.P2 - Co.P1)t Note that this statement of Co' != C' since it assumes Co.P0 == 0 and the original C' does not assume C.P0 == 0. However since this was already assumed when factoring C into Cu + Ca we can use this Co'(t) in place of C' for calculating Ca.

Plugging in the numbers for the example Co[0](t) x = 512t2 + 0t y = 512t2 + -1024t C'[0](t) x = -512t2 + 1024t y = -512t2 + 0t Co[1](t) x = -512t2 + 1024t y = 512t2 + 0t C'[1](t) x = 512t2 + 0t y = -512t2 + 1024t Co[2](t) x = -512t2 + 0t y = -512t2 + 1024t C'[2](t) x = 512t2 + -1024t y = 512t2 + 0t Co[3](t) x = 512t2 + -1024t y = -512t2 + 0t Ca[0](t) x = 512t2 + 0t y = 512t2 + -1024t Ca[1](t) x = -1024t2 + 2048t y = 0t2 + 0t Ca[2](t) x = 512t2 + 0t y = -512t2 + 1024t Ca[3](t) x = 0t2 + 0t y = 0t2 + 0t

Applying the curves evenly, translating this into ttx notation, and naming the first axis wght and the second WGHT produces the following example. The ttx format uses the gx term 'tuple' for what is here and in the OpenType specification referred to as a region. Currently ttx keys axes by tag and not by index, making it less capable than the underlying format, so give the axes different tags. After compiling with ttx, open with a hex editor and replace WGHT with wght. <fvar> <Axis> <AxisTag>wght</AxisTag> <Flags>0x1</Flags> <MinValue>400</MinValue> <DefaultValue>500</DefaultValue> <MaxValue>900</MaxValue> <AxisNameID>256</AxisNameID> </Axis> <Axis> <AxisTag>WGHT</AxisTag> <Flags>0x1</Flags> <MinValue>400</MinValue> <DefaultValue>500</DefaultValue> <MaxValue>900</MaxValue> <AxisNameID>256</AxisNameID> </Axis> </fvar> <gvar> <glyphVariations glyph="foo"> <tuple> <coord axis="wght" value="0.25" min="0" max="0.5"/> <coord axis="WGHT" value="0.25" min="0" max="0.5"/> <delta pt="0" x="512" y="512"/> </tuple> <tuple> <coord axis="wght" value="0.25" min="0" max="0.5"/> <delta pt="0" x="0" y="-1024"/> </tuple> <tuple> <coord axis="wght" value="0.5" min="0.25" max="0.75"/> <coord axis="WGHT" value="0.5" min="0.25" max="0.75"/> <delta pt="0" x="-1024" y="0"/> </tuple> <tuple> <coord axis="wght" value="0.5" min="0.25" max="0.75"/> <delta pt="0" x="2048" y="0"/> </tuple> <tuple> <coord axis="wght" value="0.75" min="0.5" max="1.0"/> <coord axis="WGHT" value="0.75" min="0.5" max="1.0"/> <delta pt="0" x="512" y="-512"/> </tuple> <tuple> <coord axis="wght" value="0.75" min="0.5" max="1.0"/> <delta pt="0" x="0" y="1024"/> </tuple> <tuple> <coord axis="wght" value="1.0" min="0.75" max="1.0"/> <delta pt="0" x="0" y="0"/> </tuple> <tuple> <coord axis="wght" value="1.0" min="0.75" max="1.0"/> <coord axis="WGHT" value="1.0" min="0.75" max="1.0"/> <delta pt="0" x="0" y="0"/> </tuple> </glyphVariations> </gvar>

Considerations

For brevity this example moves a single point along a contour. If there are multiple points moving along different curves but the curves are applied over the same regions of axis space then those curves can share regions. From a simplified editing perspective this is probably easiest to understand as having a 'master' at each point along the diagonal of the axes where a bezier begins/ends, allowing non-linear interpolation for each of the deltas. If just moving many points as rigid body the IUP like compression of the deltas means only needing to mention a single point. Also, it is possible to construct a component glyph which specifies attachment points of multiple variable glyphs in order to compose curves across multiple glyphs.

This example also only considers the [0,1] side of the axis, but uses the terms near and far instead of min and max to suggest how contours on the [0,-1] side are constructed. Such contours can be constructed by starting at the origin (zero delta) and go the other way.

Constants outside the curve can be faked by limiting the min and max values of the axis in the fvar table and then allowing (or expecting) the user to attempt values outside that range, which will be clamped.

This method admits contours with curves of various degree. While interesting for specifying a contour, generally the number of regions for C[n] will be max{deg(C[n]), deg(C[n-1], ..., deg(C[0])} since Ca[n] depends on all previous curves.

Example - Font For First and Second attempts

A font with the example curves from the first and second attempt applied to some glyphs can be had from VaryAlongQuad.ttf. It is most instructive to view this font in Samsa. This example font has the example curves from both the first and second attempt. The part of this font derived from the first attempt (user axis values less than 500) will have an odd discontinuous jump between curves on the contour which will show up at user axis value 450 (mapped to internal axis value -0.5). This discontinuous jump (and avoiding too many active regions at once) is the motivation for doing all the math in the second attempt. The part of this font derived from the second attempt (user axis values greater than 500) will transition between the curves on the contour smoothly.

Third attempt - Contour of Jump Discontinuous Curves

The general idea is to apply and un-apply curves as in the second attempt, but drop the requirement of continuity. A curve C[n] is applied from near position to peak position as Ca[n](t) and fades out from peak position to the far position as Ca[n](1-t). In order to preserve the offset Ca[n](1) over the period Ca[n](1-t) is applied one must add in addition Ca'[n] = Ca[n](1) - Ca[n](1-t). (C' here may be pronounced "C compliment".) This can be interpreted as taking the curve Ca, reversing it, negating that, then translating that by Ca(1) to re-align. In order to travel C[n+1] while Ca'[n] is maintaining Ca[n](1) it is necessary to apply in addition Co[n+1] = C[n+1](t) - Ca[n](1). This means applying Ca[n+1] = Ca'[n] + Co[n+1] == C[n+1](t) - Ca[n](1-t). Ca[0] = C[0]( t) Ca[1] = - C[0](1-t) + C[1]( t) Ca[2] = C[0]( t) - C[1](1-t) + C[2]( t) Ca[3] = - C[0](1-t) + C[1]( t) - C[2](1-t) + C[3] Ca[n] = Ca[n-2] - C[n-1](1-t) + C[n]

Example

Replicating the example from the second attempt C(t) = ( P0 - 2P1 + P2)t2 + 2( P1 - P0)t + P0 -C(1-t) = (-P2 + 2P1 - P0)t2 + 2(-P1 + P2)t - P2 C [0].P = [( 0, 0), ( 0, -512), ( 512, -512)] C [1].P = [( 512, -512), (1024, -512), (1024, 0)] C [2].P = [(1024, 0), (1024, 512), ( 512, 512)] C [3].P = [( 512, 512), ( 0, 512), ( 0, 0)] Ca[0](t) x = 512t2 + 0t y = 512t2 + -1024t Ca[1](t) x = -1024t2 + 2048t y = 0t2 + 0t Ca[2](t) x = 512t2 + 0t y = -512t2 + 1024t Ca[3](t) x = 0t2 + 0t y = 0t2 + 0t And end up with the same numbers as the second attempt.

With the example from the second attempt, but skipping around and reversing one curve C [0].P = [( 0, 0), ( 0, -512), ( 512, -512)] C [1].P = [(1024, 0), (1024, 512), ( 512, 512)] C [2].P = [( 0, 0), ( 0, 512), ( 512, 512)] C [3].P = [( 512, -512), (1024, -512), (1024, 0)] The following inputs start out with the above values. If script is enabled the rest of the example will update automatically when these values are changed. C [0].P = [(, ), (, ), (, )] C [1].P = [(, ), (, ), (, )] C [2].P = [(, ), (, ), (, )] C [3].P = [(, ), (, ), (, )] Ca[0](t) x = 512t2 + 0t + 0 y = 512t2 + -1024t + 0 Ca[1](t) x = -1024t2 + 1024t + 512 y = -1024t2 + 1024t + 512 Ca[2](t) x = 1536t2 + -1024t + -512 y = 512t2 + 0t + -512 Ca[3](t) x = -2048t2 + 3072t + 512 y = 0t2 + 1024t + -512

But now the constants are not all going to cancel, so the constants will need to be factored C( t) = ( P0 - 2P1 + P2)t2 + ( 2P1 - P0)t + P0(1-t) -C(1-t) = (-P2 + 2P1 - P0)t2 + (-2P1 + P2)t - P2(1-t) Ca[0](t) x = 512t2 + 0t + 0(1-t) y = 512t2 + -1024t + 0(1-t) Ca[1](t) x = -1024t2 + 1536t + 512(1-t) y = -1024t2 + 1536t + 512(1-t) Ca[2](t) x = 1536t2 + -1536t + -512(1-t) y = 512t2 + -512t + -512(1-t) Ca[3](t) x = -2048t2 + 3584t + 512(1-t) y = 0t2 + 512t + -512(1-t)

But converting those 1-t terms to regions is a bit difficult. They must peak at the near side of the region to produce the discontinuity that they are. However, they will need to then be re-applied as a t term. But that t term cannot just end when fully applied at the far side of the region or it will create a discontinuity as in the first attempt. So apply the t part of this by rolling it into the next region. However, the extra 1-t term on the far side of the next region was not taken into account. So the region after that will need to also add in a t term to counter the 1-t term (and so on). Effectively, each 1-t term as it comes up must be added as a t term to all subsequent regions. So C[0](t) fades as C[0](1-t). But leave out the re-apply constant term region and instead fold it into the next next curve. This means not un-applying C[0].P0(1-t) as C[0].P0(t). (Only un-apply the t2 and t terms.) Do the same with each subsequent curve, folding over any 1-t bits that need to be re-applied as t bits into the next Ca. C [0] == C[0]( t) C [1] == C[0](1-t) - C[0].P0( t) - C[0](1-t) + C[0].P0( t) + C[1]( t) C [2] == - C[0]( t) + C[0].P0(1-t) + C[1](1-t) - C[1].P0( t) C[0]( t) - C[0].P0(1-t) - C[1](1-t) + C[1].P0( t) + C[2]( t) C [3] == C[0](1-t) - C[0].P0( t) - C[1]( t) + C[1].P0(1-t) + C[2](1-t) - C[2].P0( t) - C[0](1-t) + C[0].P0( t) + C[1]( t) - C[1].P0(1-t) - C[2](1-t) + C[2].P0( t) + C[3]( t) C [n] == C[n-1](1-t) - C[n-1].P0(t) - C[n-1](1-t) + C[n-1].P0(t) + C[n](t) Ca[0] = C[0]( t) Ca[1] = - C[0](1-t) + C[0].P0( t) + C[1]( t) Ca[2] = C[0]( t) - C[0].P0(1-t) - C[1](1-t) + C[1].P0( t) + C[2]( t) Ca[3] = - C[0](1-t) + C[0].P0( t) + C[1]( t) - C[1].P0(1-t) - C[2](1-t) + C[2].P0( t) + C[3]( t) Ca[n] = -Ca[n-1](1-t) + C[n-1].P0(t) + C[n](t) Ca[n] = Ca[n-2]( t) - C[n-2].P0(1-t) - C[n-1](1-t) + C[n-1].P0(t) + C[n](t)

<fvar> <Axis> <AxisTag>wght</AxisTag> <Flags>0x1</Flags> <MinValue>400</MinValue> <DefaultValue>500</DefaultValue> <MaxValue>900</MaxValue> <AxisNameID>256</AxisNameID> </Axis> <Axis> <AxisTag>WGHT</AxisTag> <Flags>0x1</Flags> <MinValue>400</MinValue> <DefaultValue>500</DefaultValue> <MaxValue>900</MaxValue> <AxisNameID>256</AxisNameID> </Axis> </fvar> <gvar> <glyphVariations glyph="foo"> <tuple> <coord axis="wght" value="0.25" min="0" max="0.5"/> <coord axis="WGHT" value="0.25" min="0" max="0.5"/> <delta pt="0" x="512" y="512"/> </tuple> <tuple> <coord axis="wght" value="0.25" min="0" max="0.5"/> <delta pt="0" x="0" y="-1024"/> </tuple> <tuple> <coord axis="wght" value="0" min="0" max="0.25"/> <delta pt="0" x="0" y="0"/> </tuple> <tuple> <coord axis="wght" value="0.5" min="0.25" max="0.75"/> <coord axis="WGHT" value="0.5" min="0.25" max="0.75"/> <delta pt="0" x="-1024" y="-1024"/> </tuple> <tuple> <coord axis="wght" value="0.5" min="0.25" max="0.75"/> <delta pt="0" x="1536" y="1536"/> </tuple> <tuple> <coord axis="wght" value="0.25" min="0.25" max="0.5"/> <delta pt="0" x="512" y="512"/> </tuple> <tuple> <coord axis="wght" value="0.75" min="0.5" max="1.0"/> <coord axis="WGHT" value="0.75" min="0.5" max="1.0"/> <delta pt="0" x="1536" y="512"/> </tuple> <tuple> <coord axis="wght" value="0.75" min="0.5" max="1.0"/> <delta pt="0" x="-1024" y="0"/> </tuple> <tuple> <coord axis="wght" value="0.5" min="0.5" max="0.75"/> <delta pt="0" x="-512" y="-512"/> </tuple> <tuple> <coord axis="wght" value="1.0" min="0.75" max="1.0"/> <coord axis="WGHT" value="1.0" min="0.75" max="1.0"/> <delta pt="0" x="-2048" y="0"/> </tuple> <tuple> <coord axis="wght" value="1.0" min="0.75" max="1.0"/> <delta pt="0" x="3072" y="0"/> </tuple> <tuple> <coord axis="wght" value="0.75" min="0.75" max="1.0"/> <delta pt="0" x="0" y="-1024"/> </tuple> </glyphVariations> </gvar> A font file with the default example is available as VaryAlongQuads.ttf.

Considerations

This does not make the assumption of a continuous contour and allows for contolled discontinuity. This is a generalization of the second attempt, which essentially does the same thing except demand that C[n].PN == C[n+1].P0 so that the constant terms cancel out. If these terms do not cancel one will require an extra region per curve on the contour in order to support the constant term (as was done in the first attempt).

More attempts

Another possible way to support a constant without discontinuity between curves of a contour is to exploit more axes with the same tag, but allow the min and max to differ. C[0] would be done as in the first attempt example but with the min and max of its axes set to the begin and end of the region the curve occupies. Then add another set of axes to describe C[1] by C[1] - C[0](1) over the next region with different min and max from the axes for C[0]. This has the property of showing something which could be interestingly done with axes with the same tag with different min, max, and default. However, it has the downside of using a lot of axes and having a lot of active regions.

Yet another possible way to support the constant would be to to do C[0] with the end of the region at the end of the contour. This would the be balanced by having another region go from C[0].peak to C[0].far applying C'[0] to maintain the constant. Then the regions for the other curves can be stacked on top in a similar way. This uses fewer axes, but many regions, due to the fact that the curves aren't sharing regions.

Both of these seem interesting areas to think about, but seem strictly less practical.

Footnotes

  1. Technically the specification would probably also want all axes with the same tag to have the same min, max, and default as well. Someone might find some sneaky use for these not being the same, so one proposal is to require all but one axis with a given axis tag to be marked hidden so APIs know which one to use to report the min, max, and default values. However here they are all given the same values.