harfbuzz / boring-expansion-spec

Better-Engineered Font Formats; Part 1. Boring Expansion
79 stars 8 forks source link

Overcome limitations in gvar? #150

Open davelab6 opened 1 month ago

davelab6 commented 1 month ago

@simoncozens has been hacking up what is effectively merging a font like Roboto Flex into a font like the MegaMerged Noto Sans, and he said today:

Okay. I have a font which works and is now weighing in at 15Mb. I was wrong about the "sparse masters" in gvar; we do need to extend the outlines out to the corner masters, and because of the way OT variations work - they're made up of "tentpoles", and so to make there be a null variation between wght=1-400 (and other places) - we need to add opposing tent-pegs to keep the "canvas" taut. This means we need to add tuple variation entries per glyph to "support" those corner masters at opsz=6-17, opsz=18-144, wght=1-400 and wght=800-1000, so around six more tuple variation entries per glyph than are in the Classic mega-merged font. This is what's blowing up the gvar table. (Note that the gvar table in the Flex font is 6k per glyph, so if there were a full Flex designspace over all the glyphs in the Classic we would expect the gvar table alone to be around 51 megabytes.) The point being, by embedding a subset designspace inside a large design space I don't think there is a reasonable way to make it any smaller.

I suspect that the current spec's gvar has limitations that real-world projects are (about to be) running into.

@justvanrossum also wrote me recently,

Having many fvar axes (whether used in VARC or gvar or other doesn’t matter) makes gvar variations bloaty.

A glyph with (say) 300 master shapes may still compile if the font has (say) 25 axes, but will overflow (with the same master shapes) if the font has (say) 45 axes.

gvar compilation overflows if the compiled variations for a single glyph exceed 65536 bytes.

So, this happens on “atomic elements”: variable glyphs that are used as components, but themselves are made of outlines.

The same would happen in a VARC-less font with that number of axes and that number of variations in a single glyph.

Do we need to explore spec changes that overcome limitations in gvar?

simoncozens commented 1 month ago

To expand on this a little:

Suppose you have a font which has a wdth range of 100-400-1000. But some glyphs have a designed range of 400-800.

I know that gvar stores regions and not store masters, and there is good reason why it does this, but let's assume for a moment that gvar stores masters. (Because designers think it terms of masters so they assume this is how the gvar table works.)

For the full glyphs, you have two masters, one at 400, one at 1000. And for the "sparse" glyphs, you would have have two masters, one at 400, one at 800, and because there was no data about how to interpolate things in the 800-1000 range, the outline during this part of the designspace would be the same as the 800 weight and everything would be fine. Only one variation (deltas from the 400 in glyf) in the gvar table, so very efficient.

"But how should the font know what to do in the 800-1000 range? There's no data so it might extrapolate or fall back to the 400 outlines." Okay, fine, let's call this "gvar stores corner masters". We add another master at 1000 which is identical to the master at 800, same deltas from the 400 default. Only two gvar table entries, and because all the deltas are identical they could be shareable through a common subtable (we're imagining a different font format) so still very efficient.

Instead what we have right now is two regions, one which peaks at 800 (and then falls off towards 1000), and one which "tops up" the 800-1000 range:

Screenshot 2024-07-15 at 09 06 14 Screenshot 2024-07-15 at 09 06 43

The brown deltas are an absolute mirror image of the red ones (Σ(δ₀…δₙ₋₁) in 800-1000 is constant).

This is kind of bearable with one axis. Now let's add an opsz axis ranging from 6-18-144, where we are similarly not filling out the whole range: some glyphs are 17-18. In a "gvar stores masters" world we have wght=400,opsz=18 in glyf and three deltas:

In "gvar stores corner masters" we have:

11 deltas, but there are only three binary subtables because most of the corner masters are synthetic, and therefore sharable.

However, because gvar uses regions and has this "fades-away-and-requires-top-up" behaviour, we currently have to store all 11 sets of deltas:

Screenshot 2024-07-15 at 09 26 12

Even though we are only representing four actual masters.

There are two possible ways around this:

1) An alternative interpolation model which actually does store deltas for masters at fixed points, not regions. 2) A per-glyph avar2 style designspace fencing where we just map everything from opsz=800-1000 to the same region, everything from opsz=6-17 and so on.

The upshot of this is that merging a 2.5M font four-master font into a 3.5M font produces a 26M font. (I then use horrible binary hacking to drop "small" gvar table entries to get it down to 15M...)

justvanrossum commented 1 month ago

Tents that would just "stay up" towards the end of the axis would be very useful.

davelab6 commented 1 month ago

A per-glyph avar2 style designspace

@behdad what do you think the glyph limit will be imposed by avar2 in this case?

Lorp commented 1 month ago

A related issue with gvar is a limitation with "shared tuples", such that only "peak" tuples can be shared; "start" and "end" tuples must be embedded in the gvar data for each glyph. Now if a font does not have any intermediate masters, this is fine, since "start" and "end" are implied by "peak", and a well-built font has all such tuples shared. But if a font has an intermediate form at the same designspace location in all glyphs — a very common case — then we waste lots of space duplicating our "start" and "end" tuple definitions as embedded tuples.

The issue gets potentially serious when axisCount gets large. The size of each tuple is axisCount * 2 (2 = sizeof F2DOT14).

Examples:

Font #axes #glyphs with intermediates #embedded peak tuples #embedded start/end tuples Embedded tuple data (bytes)
Bitter 1 14 1 28 114
Recursive 5 371 0 4450 89000

It will be noted that if one was to 10x the number of axes in Recursive, all the new axes having no gvar deltas, the tuple data would nevertheless bloat by 10x to 890000 bytes.

Thus in any redesign of gvar I would hope that intermediate tuples can be shared, as well as peak tuples. One way to do this would be to store the intermediate tuples as sets of three (start, peak, end) in the sharedTuples array. A flag in the tuple variation tables would control whether only the peak or all 3 tuples should be read from the given tupleIndex. Note that, because of flags in the tupleIndex field, the maximum number of shared tuple ids is currently 4096, which may be too small. Another method would be to provide gvar an ItemVariationStore (containing 0 ItemVariationData structures) to store all the regions used in gvar, thereby simplifying TupleVariationHeader to point to a regionId in the IVS, dropping the fields tupleIndex, peakTuple, intermediateStartTuple, intermediateEndTuple.

Centralizing the storage of regions will also have a (marginal?) performance benefit, since the scalar associated with each region can be calculated just once, indexed by regionId. [edited]

justvanrossum commented 1 month ago

"Tents that stay up" could be defined within the existing data structure: the startCoord, peakCoord and endCoord fields are defined as F2DOT14, and the spec says:

The three values must all be within the range -1.0 to +1.0. startCoord

We could say that if startCoord is the lowest negative number F2DOT14 can represent, the tent must be "up" from -1.0 to peakCoord. Similarly with endCoord: if it is the highest positive number that F2DOT14 can represent, the tent must be "up" from its peakCoord until 1.0.

justvanrossum commented 1 month ago

TBH. I think that "tents that can stay up" are way more useful here than per-glyph avar2, given that the former easily "shelves" off multi-dimensional spaces beyond set limits, and the latter is already notoriously hard to express such a "fence", as so eloquently demoed by Laurence.

behdad commented 1 month ago

"Per-glyph avar2" doesn't make sense to me. You already can do that: define whatever new axis you want in avar2, and use it only in a subset of glyphs.

All limitations shown come from gvar. I'm working on a replacement that allows for better sharing, etc.