fix(fonts): supplement alias faces from the canonical family - #3085
fix(fonts): supplement alias faces from the canonical family#3085akzarma wants to merge 3 commits into
Conversation
`buildFontFaceCss` emits a bundled canonical's faces under the authored family name, then fills the weights and styles the bundle lacks by querying Google Fonts. That supplementary query used the authored name. For a cross-typeface alias — `helvetica`, `noto sans`, `georgia` and the other FONT_ALIAS_MAP entries that do not point at themselves — the authored name is a different typeface from the canonical the alias resolves to, and Google now serves many of those names. No canonical bundle ships an italic face, so every italic Google returns for the authored name is injected: `font-family: Helvetica` renders upright as Inter and italic as real Helvetica, two typefaces under one family. Query the canonical display name instead. The faces are still emitted under the authored family, so authored CSS keeps matching, and self-referencing aliases are unaffected. Closes heygen-com#3083
miguel-heygen
left a comment
There was a problem hiding this comment.
Reproduced the bug against live Google Fonts, confirmed this branch fixes it, and found one thing I'd like addressed before it lands.
Repro (main, unfixed)
font-family: Helvetica, upright + italic text, real network:
google css2 family= queried: [ "Helvetica" ]
Helvetica normal 400 EMBEDDED INTER BUNDLE
Helvetica normal 700 EMBEDDED INTER BUNDLE
Helvetica normal 900 EMBEDDED INTER BUNDLE
Helvetica italic 400 FETCHED FROM GOOGLE
Helvetica italic 700 FETCHED FROM GOOGLE
Rendered headless, the upright line is Inter and the italic line is Google's Helvetica substitute. Two typefaces under one font-family, exactly as described. css2?family=Helvetica returns 200 today, so the "alias names 4xx" comment is indeed stale.
With this branch
google css2 family= queried: [ "Inter" ]
... italic 400 / 700 now come from Inter
Italic renders as Inter Italic. Bug is gone, and weights 100-800 that previously had no face at all now resolve to real Inter weights. The fix is at the right layer, in the one function every aliased family routes through, and reusing CANONICAL_FONT_DISPLAY_NAMES is the right call: all 18 canonical slugs have an entry and every one is a real Google family.
Blocking: the payload multiplies
Google serves Inter as a variable font, so css2 hands back the same woff2 URL for every static weight. The supplement loop embeds that identical base64 blob once per weight.
One family, Helvetica, compiled HTML:
| faces | unique blobs | HTML | |
|---|---|---|---|
| main | 5 | 5 | 110 KB |
| this branch | 11 | 5 | 302 KB |
Six @font-face rules (100/200/300/500/600/800) carry byte-identical copies of one 25 KB blob; the two italics are another duplicate pair.
It compounds across aliases, because names that used to 4xx now fetch. Four families that all resolve to Inter (Helvetica, Arial, SF Pro, Verdana):
| faces | unique blobs | HTML | |
|---|---|---|---|
| main | 16 | 7 | 405 KB |
| this branch | 44 | 5 | 963 KB |
963 KB carrying five distinct fonts. The duplication is pre-existing in the supplement loop, but this PR is what makes it fire on every cross-typeface alias, so I'd rather not land the amplification untouched.
Smallest fix that keeps semantics: when consecutive Google faces share a src, emit one rule with a weight range (font-weight: 100 800) instead of one rule per weight. That is the correct declaration for a variable font anyway and takes the 4-family case back under 300 KB. If you'd rather keep this PR to the one-line correctness fix, that's fine by me, but please open the follow-up and link it here.
Non-blocking
?? originalCaseFamilyis unreachable today, and if it ever becomes reachable it silently restores this exact bug. Worth a line indeterministicFonts.test.tsasserting everyCANONICAL_FONTSkey has aCANONICAL_FONT_DISPLAY_NAMESentry, then the fallback can go.resolveAliasDisplayName()already exists in the same module and does this lookup in one call. Would drop the extra import. Pure taste, ignore if you prefer the explicit map.
Test file looks right to me. It classifies into the unit/bun lane with no manifest edit needed, and the assertions do bite: on unfixed source queriedFamilies is ["Noto Sans"], so the first test fails and the Montserrat guard passes. Matches your 1 pass / 1 fail.
Google serves several canonical families as a variable font, so every static weight in the css2 response points at the same woff2. Emitting one rule per weight embedded that identical blob once per weight, which this branch made fire on every cross-typeface alias. Collapse a consecutive run of supplementary faces sharing a src, style and unicode-range into a single weight-range rule, which is also the correct declaration for a variable font. A run stops at any weight the embedded bundle already covers, so a range can never shadow a bundled face, and the pair is sorted low-to-high rather than trusting Google's ordering. Also resolve the canonical family through `resolveAliasDisplayName` and drop the `?? originalCaseFamily` fallback: when resolution fails the supplement is now skipped instead of silently querying the authored family again. A test asserts every alias resolves, so the branch is provably unreachable.
|
@miguel-heygen Thanks for the repro and the measurements — the amplification is fair, and I took the weight-range route rather than deferring it. Consecutive supplementary faces sharing a Both non-blocking notes taken: it uses |
miguel-heygen
left a comment
There was a problem hiding this comment.
R2 at 1a87dcb02. The core fix is valid: resolving the supplementary query through the canonical display name at packages/producer/src/services/deterministicFonts.ts:531-534 eliminates the real mixed-typeface output, and the positive assertions at packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts:111-132 pin both the query family and injected bytes. The earlier resolver/fallback concerns are also cleanly closed.
blocker — the payload collapse does not handle Google’s real no-text= response shape. packages/producer/src/services/deterministicFonts.ts:545-557 only collapses consecutive faces with the same source/style/unicode-range. When extractGoogleFontsText exceeds its 1,700-byte budget and returns undefined (packages/producer/src/services/deterministicFonts.ts:1130-1143), Google returns the normal subset CSS ordered by weight, then unicode subset: 100/latin, 100/latin-ext, 200/latin, 200/latin-ext, etc. A variable font reuses the same source per subset across weights, but those matching faces are never adjacent, so every base64 blob is still emitted once per weight.
I reproduced this against the current code with a hermetic real-shape response: two unicode subsets × weights 100/200/300/500, one shared source per subset, and HTML large enough to omit text=. Expected four emitted blobs (100–300 + 500 for each subset); head emitted eight. The existing test at packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts:135-169 has one subset only, so it cannot falsify this path.
Please group supplementary faces by (dataUri, style, unicodeRange), numerically sort each group, then partition ranges around covered embedded weights; add the interleaved-subset/no-text= case. That also makes the “Google ordering is not guaranteed” claim true structurally instead of relying on adjacency.
Exact-head unit/build/lint lanes are green and the touched suite passes 4/4 locally, but the exact-head regression workflow is also red on style-15-prod visual comparison; rerun or resolve that before the next verdict.
Verdict: REQUEST CHANGES
Reasoning: The alias correctness fix is real and well placed, but the requested payload guard still misses a normal production response path and leaves the amplification intact for larger compositions.
— Magi
Without `text=` Google orders the response weight-major, subset-minor, so faces sharing a variable font's source are never adjacent and the previous consecutive-run scan collapsed nothing: every blob was still embedded once per weight. Group supplementary faces by (source, style, unicode-range), sort each group numerically, and split it wherever the embedded bundle already covers a weight inside the span. Each collapsed run is emitted at the position of its first face in the response, because overlapping unicode-range rules resolve last-defined-first and collapsing must not reorder the subsets. Coverage keys are compared numerically so a differently spelled weight cannot slip past and shadow a bundled face. Tests cover the no-`text=` interleaved shape and the overlapping-subset ordering; both fail against the previous implementation.
|
@miguel-heygen R3 pushed — grouped by (source, style, unicode-range) with runs split at covered weights, each run emitted at its first face's position so overlapping One flag on the ranges: for a genuine variable font, On the red |
What breaks
A family that resolves through
FONT_ALIAS_MAPto a bundled canonical can end up with two different typefaces under onefont-family.font-family: Helveticawith any italic text renders upright as Inter (the canonical bundle) and italic as real Helvetica (fetched from Google under the authored name). Same forNoto Sans,Georgia,Verdana,Garamondand the other cross-typeface aliases — including three thattypography.mdadvertises as safe.Details, measurements and the alias-by-alias table are in #3083.
Root cause
packages/producer/src/services/deterministicFonts.ts, inbuildFontFaceCss:The embedded canonical's faces are emitted under the authored family name, and then the weights/styles the bundle lacks are supplemented by querying Google — with the authored name. For a self-referencing alias (
montserrat→ Montserrat) that is correct. For a cross-typeface alias it fetches the very typeface the alias exists to replace.Two properties make it bite rather than stay theoretical:
CANONICAL_FONTSentry declaresstyle: "italic", so every italic Google serves for the authored name is classified "missing from the bundle" and injected.css2endpoint now serves many of these names (Helvetica, Helvetica Neue, Georgia, Verdana, Tahoma, Trebuchet MS, Garamond, Noto Sans, …), so the comment atfetchGoogleFontassuming alias names 4xx is no longer true for them.The fix
Query
CANONICAL_FONT_DISPLAY_NAMES[canonicalKey]instead of the authored name. It is already re-exported from@hyperframes/core/fonts/aliases, which this file imports.Faces are still emitted under
originalCaseFamily, so authored CSS keeps matching and nothing about the aliasing policy changes — only the source the supplementary faces come from.Test
deterministicFonts-aliasSupplement.test.ts— hermetic, injectsfetchImpl, no network:Noto Sans→ asserts the supplementary query asks forInter, that the authored spelling still names the family, and that every injectedsrcis Inter (embedded bundle or Inter fetch) with no real Noto Sans bytes present.Montserrat→ asserts self-referencing aliases still supplement from their own family (guards against over-correcting).Verified the test bites: against unfixed
deterministicFonts.tsit is 1 pass / 1 fail; with the fix, 2 pass / 0 fail.Test plan
bun test packages/producer/src/services/deterministicFonts-aliasSupplement.test.ts→ 2 pass, 0 failoxfmt --checkandoxlinton both changed files → clean, 0 warnings / 0 errorspackages/producerunit lane not run — it needs workspace packages built beyond what a fresh clone provides; relying on CI for that