diff --git a/.github/actions/validate-slides/action.yml b/.github/actions/validate-slides/action.yml index d642f6f83..d03865d84 100644 --- a/.github/actions/validate-slides/action.yml +++ b/.github/actions/validate-slides/action.yml @@ -269,6 +269,14 @@ runs: shell: bento-step {0} run: node scripts/test-slides-fonts.ts + - name: Slides embedded font weight rig + # #850: a font file's own weight — the fvar wght range of a variable + # TTF/WOFF (the WOFF's fvar really inflated), the OS/2 weight and + # italic bit of a static face; a WOFF2 only says it is variable; + # truncated or foreign bytes read as nothing, never a throw. + shell: bento-step {0} + run: node scripts/test-slides-font-weight.ts + - name: Slides code colours rig # theme.codePalette's editor: one row per token scope from one list # (code.ts CODE_SCOPES), built-in colour shown when the deck has diff --git a/.github/workflows/ci-slides.yml b/.github/workflows/ci-slides.yml index 18912893a..221efeb43 100644 --- a/.github/workflows/ci-slides.yml +++ b/.github/workflows/ci-slides.yml @@ -125,6 +125,12 @@ jobs: # Real UI and serialization, offline; delayed mock writes expose save races. run: | node scripts/test-slides-font-lifecycle-browser.mjs + - name: An embedded font keeps its weights in Chromium + # #850: "Embed font file…" writes the range a variable TTF/WOFF states, + # the range a variable WOFF2 renders (ours and Instrument Sans), a + # static face's weight and italic; Bold then draws the font's own 700, + # 1.55x wider than 400, where the old `normal` drew faux bold at 1x. + run: node scripts/test-slides-font-weight-browser.mjs - name: Write chrome follows the copy's role in Chromium # canWriteDeck is an allowlist that fails closed: an audience, reader or # unknown-role copy arriving in a running editor (dropped, loadDoc) gets diff --git a/scripts/fixtures/fonts/BentoWeightStatic.ttf b/scripts/fixtures/fonts/BentoWeightStatic.ttf new file mode 100644 index 000000000..233b20ac2 Binary files /dev/null and b/scripts/fixtures/fonts/BentoWeightStatic.ttf differ diff --git a/scripts/fixtures/fonts/BentoWeightStatic.woff2 b/scripts/fixtures/fonts/BentoWeightStatic.woff2 new file mode 100644 index 000000000..fd7f066be Binary files /dev/null and b/scripts/fixtures/fonts/BentoWeightStatic.woff2 differ diff --git a/scripts/fixtures/fonts/BentoWeightVar.ttf b/scripts/fixtures/fonts/BentoWeightVar.ttf new file mode 100644 index 000000000..83ddd1387 Binary files /dev/null and b/scripts/fixtures/fonts/BentoWeightVar.ttf differ diff --git a/scripts/fixtures/fonts/BentoWeightVar.woff b/scripts/fixtures/fonts/BentoWeightVar.woff new file mode 100644 index 000000000..30f8487e3 Binary files /dev/null and b/scripts/fixtures/fonts/BentoWeightVar.woff differ diff --git a/scripts/fixtures/fonts/BentoWeightVar.woff2 b/scripts/fixtures/fonts/BentoWeightVar.woff2 new file mode 100644 index 000000000..e2786a24a Binary files /dev/null and b/scripts/fixtures/fonts/BentoWeightVar.woff2 differ diff --git a/scripts/fixtures/fonts/make-weight-fonts.py b/scripts/fixtures/fonts/make-weight-fonts.py new file mode 100644 index 000000000..380c7309d --- /dev/null +++ b/scripts/fixtures/fonts/make-weight-fonts.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 The Bento authors +# Builds the weight-test fonts used by scripts/test-slides-font-weight*. +# Our own design (one bar glyph), MIT like the rest of the repo. +# +# python3 -m venv /tmp/ft && /tmp/ft/bin/pip install fonttools brotli +# /tmp/ft/bin/python scripts/fixtures/fonts/make-weight-fonts.py +# +# Output, next to this file: +# BentoWeightVar.ttf / .woff / .woff2 variable, one wght axis 100..900 +# BentoWeightStatic.ttf / .woff2 static, OS/2 weight 700, italic bit +# +# The bar's stem and advance grow with wght, so a rendered weight is +# measurable: real 700 is visibly wider than the 400 instance, and a browser's +# faux bold of the 400 instance is not. + +import os +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib.tables._g_v_a_r import TupleVariation +from fontTools.varLib.instancer import instantiateVariableFont + +HERE = os.path.dirname(os.path.abspath(__file__)) +FAMILY = 'Bento Weight Test' +CHARS = 'HIOabcdefghijklmnopqrstuvwxyz' + + +def bar(stem): + pen = TTGlyphPen(None) + pen.moveTo((50, 0)); pen.lineTo((50, 700)); pen.lineTo((50 + stem, 700)); pen.lineTo((50 + stem, 0)) + pen.closePath() + return pen.glyph() + + +def empty(): + return TTGlyphPen(None).glyph() + + +def build(): + order = ['.notdef', 'space', 'bar'] + fb = FontBuilder(1000, isTTF=True) + fb.setupGlyphOrder(order) + cmap = {ord(' '): 'space'} + cmap.update({ord(c): 'bar' for c in CHARS}) + fb.setupCharacterMap(cmap) + # default instance = wght 400: stem 160, advance 260 + fb.setupGlyf({'.notdef': empty(), 'space': empty(), 'bar': bar(160)}) + fb.setupHorizontalMetrics({'.notdef': (500, 0), 'space': (250, 0), 'bar': (260, 50)}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupNameTable({'familyName': FAMILY, 'styleName': 'Regular'}) + fb.setupOS2(usWeightClass=400, sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200) + fb.setupPost() + # named instances make fvar big enough that WOFF stores it compressed, so the + # WOFF row exercises the inflate path + names = ['Thin', 'ExtraLight', 'Light', 'Regular', 'Medium', 'SemiBold', 'Bold', 'ExtraBold', 'Black'] + fb.setupFvar([('wght', 100, 400, 900, 'Weight')], + [{'stylename': n, 'location': {'wght': 100 * (i + 1)}} for i, n in enumerate(names)]) + # bar points: 4 outline points then 4 phantom points (left, right, top, bottom). + # Moving the right edge and the right phantom widens stem and advance together. + def deltas(dx): + return [(0, 0), (0, 0), (dx, 0), (dx, 0), (0, 0), (dx, 0), (0, 0), (0, 0)] + fb.setupGvar({ + 'bar': [ + TupleVariation({'wght': (-1.0, -1.0, 0)}, deltas(-120)), # 100: stem 40 + TupleVariation({'wght': (0, 1.0, 1.0)}, deltas(240)), # 900: stem 400 + ], + '.notdef': [], 'space': [], + }) + return fb.font + + +def main(): + font = build() + font.save(os.path.join(HERE, 'BentoWeightVar.ttf')) + font.flavor = 'woff' + font.save(os.path.join(HERE, 'BentoWeightVar.woff')) + font.flavor = 'woff2' + font.save(os.path.join(HERE, 'BentoWeightVar.woff2')) + + static = instantiateVariableFont(build(), {'wght': 700}) + static['OS/2'].usWeightClass = 700 + static['OS/2'].fsSelection = (static['OS/2'].fsSelection & ~0x40) | 0x01 # italic, not regular + static['head'].macStyle |= 0x03 + static.save(os.path.join(HERE, 'BentoWeightStatic.ttf')) + static.flavor = 'woff2' + static.save(os.path.join(HERE, 'BentoWeightStatic.woff2')) + + +main() diff --git a/scripts/test-slides-font-weight-browser.mjs b/scripts/test-slides-font-weight-browser.mjs new file mode 100644 index 000000000..725956ce0 --- /dev/null +++ b/scripts/test-slides-font-weight-browser.mjs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// An embedded font is declared with the weights it really has (#850). +// +// npm run build:single --prefix slides && node scripts/test-slides-font-weight-browser.mjs +// +// Before: "+ Embed font file…" wrote every face as `font-weight: normal`. A +// variable font covering 100..900 then matched only 400, and Bold drew the +// 400 instance thickened by the browser — faux bold. +// +// WHAT THIS PROVES, through the real picker in the packaged editor: +// 1. each file lands in doc.fonts with the weight it states: the fvar range +// for a variable TTF/WOFF, the MEASURED range for a variable WOFF2 (our +// test font and the shell's own Instrument Sans, a real one), the OS/2 +// weight and italic bit for a static TTF, nothing for a static WOFF2 +// (whose tables cannot be read); +// 2. it renders: a text element at weight 700 in the embedded variable +// family is much wider than one at 400 — the test font's bar grows with +// wght, and only the font's own 700 grows it; +// 3. the measure can tell: the same bytes declared `normal` (the old +// behaviour) render 700 at the 400 width, so (2) is not luck. +// +// Fonts: scripts/fixtures/fonts (ours, MIT; make-weight-fonts.py rebuilds them). +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright') + +let checks = 0 +let failures = 0 +function ok(cond, msg) { + checks++ + if (!cond) { failures++; console.log(` FAIL ${msg}`) } else console.log(` ok ${msg}`) +} + +const fixture = (name) => fileURLToPath(new URL(`./fixtures/fonts/${name}`, import.meta.url)) +// the shell's own variable WOFF2, written out so it can go through the picker +const scratch = mkdtempSync(join(tmpdir(), 'bento-font-weight-')) +const fontdata = readFileSync(new URL('../slides/src/fontdata.ts', import.meta.url), 'utf8') +const instrument = join(scratch, 'InstrumentSans.woff2') +writeFileSync(instrument, Buffer.from(fontdata.match(/INSTRUMENT_VAR = 'data:font\/woff2;base64,([^']+)'/)[1], 'base64')) + +const rows = [ + ['variable ttf', fixture('BentoWeightVar.ttf'), 'BentoWeightVar', { weight: '100 900' }], + ['variable woff', fixture('BentoWeightVar.woff'), 'BentoWeightVar', { weight: '100 900' }], + ['variable woff2 (measured)', fixture('BentoWeightVar.woff2'), 'BentoWeightVar', { weight: '100 900' }], + ['Instrument Sans woff2 (measured)', instrument, 'InstrumentSans', { weight: '400 700' }], + ['static ttf', fixture('BentoWeightStatic.ttf'), 'BentoWeightStatic', { weight: '700', style: 'italic' }], + ['static woff2', fixture('BentoWeightStatic.woff2'), 'BentoWeightStatic', {}], +] + +const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH, headless: true }) +try { + const p = await browser.newPage({ viewport: { width: 1440, height: 900 } }) + const errors = [] + p.on('pageerror', (e) => errors.push(e.message)) + await p.route(/^https?:/, (r) => r.abort()) + await p.goto(new URL('../slides/dist-single/Bento_Slides.bento.html', import.meta.url).href) + await p.waitForFunction(() => window.bento?.doc) + const text = (id, y, weight) => ({ id, type: 'text', x: 100, y, w: 1100, h: 120, rotation: 0, opacity: 1, html: 'HHHHHHHH', fontSize: 80, fontWeight: weight, color: '#1e2a3a' }) + + for (const [name, path, family, want] of rows) { + console.log(`\n${name}\n`) + await p.evaluate(([t1]) => { + const d = JSON.parse(JSON.stringify(window.bento.doc)) + d.slides = [{ id: 's1', background: '#ffffff', elements: [t1] }] + d.fonts = [] + window.bento.loadDoc(JSON.stringify(d)) + }, [text('t1', 100, 400)]) + await p.waitForTimeout(300) + await p.locator('.ed-stage-scale [data-el-id="t1"]').click() + const chooser = p.waitForEvent('filechooser') + await p.getByText('+ Embed font file…', { exact: true }).click() + await (await chooser).setFiles(path) + await p.waitForFunction((family) => window.bento.doc.fonts?.some((f) => f.family === family), family, { timeout: 10000 }) + const entry = await p.evaluate((family) => window.bento.doc.fonts.find((f) => f.family === family), family) + const got = { weight: entry.weight, style: entry.style } + ok(got.weight === want.weight && got.style === want.style, + `doc.fonts states ${JSON.stringify(want)} (got ${JSON.stringify(got)})`) + } + + // 2 + 3: render the variable font at 400 and 700, declared as embedded and as the old `normal`. + const asset = 'data:font/ttf;base64,' + readFileSync(fixture('BentoWeightVar.ttf')).toString('base64') + const width = async (faces) => { + await p.evaluate(([faces, asset, a, b]) => { + const d = JSON.parse(JSON.stringify(window.bento.doc)) + d.slides = [{ id: 's1', background: '#ffffff', elements: [a, b] }] + d.fonts = faces + d.assets = { ...d.assets, 'weight-test': asset } + window.bento.loadDoc(JSON.stringify(d)) + }, [faces, asset, { ...text('w400', 100, 400), fontFamily: faces[0].family }, { ...text('w700', 300, 700), fontFamily: faces[0].family }]) + await p.evaluate(() => document.fonts.ready) + await p.waitForTimeout(300) + return p.evaluate(() => ['w400', 'w700'].map((id) => { + const inner = document.querySelector(`.ed-stage-scale [data-el-id="${id}"] .bento-text-inner`) + const r = document.createRange(); r.selectNodeContents(inner); return r.getBoundingClientRect().width + })) + } + console.log('\nrendering\n') + const [real400, real700] = await width([{ family: 'Bento Weight Embedded', asset: 'weight-test', weight: '100 900' }]) + const [old400, old700] = await width([{ family: 'Bento Weight Old', asset: 'weight-test' }]) + const ratio = (a, b) => Math.round((b / a) * 100) / 100 + // the bar's advance is 260 units at 400 and 404 at 700 (make-weight-fonts.py): 1.55x + ok(ratio(real400, real700) > 1.4, `declared 100 900: 700 renders the font's own bold, ${ratio(real400, real700)}x the 400 width`) + ok(ratio(old400, old700) < 1.1, `control, declared normal: 700 is faux bold of 400, ${ratio(old400, old700)}x`) + ok(Math.abs(real400 - old400) < 1, `both render the same 400 (${Math.round(real400)} vs ${Math.round(old400)}px)`) + + ok(errors.length === 0, `no page errors (${errors.join('; ')})`) +} finally { + await browser.close() +} +console.log(`\n${checks - failures}/${checks} checks pass`) +if (failures) process.exit(1) diff --git a/scripts/test-slides-font-weight.ts b/scripts/test-slides-font-weight.ts new file mode 100644 index 000000000..3ca10dd2e --- /dev/null +++ b/scripts/test-slides-font-weight.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// Font-file descriptor rig for bento/slides (#850). +// +// node scripts/test-slides-font-weight.ts +// +// WHAT THIS PROVES. readFontFacts reads what a font file says about its own +// weight and style, in every container the embed picker accepts: +// +// - a variable TTF and WOFF give their fvar wght range, "100 900" +// - the WOFF row really goes through the inflate path: its fvar is stored +// compressed (checked here, not assumed) +// - a variable WOFF2 reports only `variable` — its tables are one brotli +// stream; the range is measured in the browser rig +// - a static TTF gives its OS/2 weight and italic bit, "700" / "italic" +// - a static WOFF2 gives {} (its OS/2 is inside the brotli stream) +// - the shell's own Instrument Sans (a real variable WOFF2) is variable +// - truncated or foreign bytes give {}, never a throw +// +// The fonts are ours (scripts/fixtures/fonts, MIT); make-weight-fonts.py +// rebuilds them. + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { readFontFacts } from '../slides/src/fontmeta.ts' +import { INSTRUMENT_VAR } from '../slides/src/fontdata.ts' + +let failures = 0 +let checks = 0 +function ok(cond: boolean, msg: string) { + checks++ + if (!cond) { failures++; console.error('FAIL', msg) } +} +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +const dir = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'fonts') +const font = (name: string) => new Uint8Array(readFileSync(join(dir, name))) + +const cases: Array<[string, Uint8Array, object]> = [ + ['variable ttf', font('BentoWeightVar.ttf'), { weight: '100 900' }], + ['variable woff', font('BentoWeightVar.woff'), { weight: '100 900' }], + ['variable woff2', font('BentoWeightVar.woff2'), { variable: true }], + ['static bold italic ttf', font('BentoWeightStatic.ttf'), { weight: '700', style: 'italic' }], + // a static WOFF2's OS/2 is inside the brotli stream: unreadable, so unstated + ['static woff2', font('BentoWeightStatic.woff2'), {}], + ['Instrument Sans woff2', Buffer.from(INSTRUMENT_VAR.split(',')[1], 'base64'), { variable: true }], + ['truncated ttf', font('BentoWeightVar.ttf').subarray(0, 40), {}], + ['truncated woff', font('BentoWeightVar.woff').subarray(0, 60), {}], + ['truncated woff2', font('BentoWeightVar.woff2').subarray(0, 50), {}], + ['not a font', new TextEncoder().encode(''), {}], + ['empty', new Uint8Array(0), {}], +] +for (const [name, bytes, want] of cases) { + let got: unknown + try { got = await readFontFacts(bytes) } catch (e) { got = `threw ${e}` } + ok(same(got, want), `${name}: want ${JSON.stringify(want)}, got ${JSON.stringify(got)}`) +} + +// The WOFF row must exercise inflate: find fvar in its directory, stored smaller than its original. +{ + const woff = font('BentoWeightVar.woff') + const v = new DataView(woff.buffer, woff.byteOffset, woff.byteLength) + let compressed = false + for (let i = 0; i < v.getUint16(12); i++) { + const at = 44 + i * 20 + const tag = String.fromCharCode(...woff.subarray(at, at + 4)) + if (tag === 'fvar') compressed = v.getUint32(at + 8) < v.getUint32(at + 12) + } + ok(compressed, 'woff fixture stores fvar compressed (else the inflate path is untested)') +} + +console.log(`${checks - failures}/${checks} font weight checks pass`) +if (failures) process.exit(1) diff --git a/slides/src/editor/panels.ts b/slides/src/editor/panels.ts index 59267cd76..078ee6f53 100644 --- a/slides/src/editor/panels.ts +++ b/slides/src/editor/panels.ts @@ -14,6 +14,7 @@ import { PALETTE_SLOTS, paletteOf, refAt, setColor, slotIsSet } from '../palette import { isMacOS } from '../screens' import { CHART_PRESETS } from '../charts' import { FONT_CHOICES, firstFamily, injectFonts } from '../fonts' +import { fontFaceDescriptors } from '../fontmeta' import { CODE_SCOPES, DEFAULT_CODE_COLORS } from '../code' import { TIPS } from '../tips' import { DATE_PRESETS, TIME_PRESETS, OTHER_FIELDS, formatDate } from '../datefmt' @@ -1286,13 +1287,17 @@ export class PropsPanel { const file = input.files?.[0] if (!file) return const reader = new FileReader() - reader.onload = () => { + reader.onload = async () => { const family = file.name.replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ').trim() || 'Embedded font' const asset = `font_${family.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` + const src = String(reader.result) + // the weights and style the file states (#850): otherwise every face is + // declared 400 and Bold on a variable or bold font is faux bold + const faces = await fontFaceDescriptors(new Uint8Array(await file.arrayBuffer()), src) this.store.commit(() => { const doc = this.store.doc - doc.assets = { ...(doc.assets ?? {}), [asset]: String(reader.result) } - doc.fonts = [...(doc.fonts ?? []).filter((f) => f.family !== family), { family, asset }] + doc.assets = { ...(doc.assets ?? {}), [asset]: src } + doc.fonts = [...(doc.fonts ?? []).filter((f) => f.family !== family), { family, asset, ...faces }] const live = this.store.element(el.id) if (live && live.type === 'text') live.fontFamily = family }) diff --git a/slides/src/fontmeta.ts b/slides/src/fontmeta.ts new file mode 100644 index 000000000..bf4e8d09c --- /dev/null +++ b/slides/src/fontmeta.ts @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// Reads the @font-face descriptors a font file states about itself, so an +// embedded face is declared with the weights it really has (#850). +// +// Without this every embedded file was declared `font-weight: normal`. A +// variable font covering 100..900 then matched only 400: Bold drew the 400 +// instance thickened by the browser (faux bold), never the font's own bold. +// A static Bold file was declared 400 too, and got faux bold ON TOP of bold. +// +// What each container gives up: +// - TTF/OTF: tables are plain. fvar's wght axis is the range; with no wght +// axis, OS/2 usWeightClass is the single weight. +// - WOFF: the same tables, each zlib-compressed — inflated with the +// browser's DecompressionStream. +// - WOFF2: all tables are ONE brotli stream, and no browser offers brotli in +// DecompressionStream (measured: Chrome 149 and 156 refuse it). Only the +// table directory is readable, and it says whether fvar is there. For a +// variable WOFF2 the range is measured by rendering (probeWeightRange); +// a static WOFF2's weight cannot be read and stays unstated. + +export interface FaceDescriptors { weight?: string; style?: string } + +/** What the file says. `variable` = a WOFF2 that has fvar but whose axes cannot be read. */ +export interface FontFileFacts extends FaceDescriptors { variable?: boolean } + +const tag = (v: DataView, at: number) => + String.fromCharCode(v.getUint8(at), v.getUint8(at + 1), v.getUint8(at + 2), v.getUint8(at + 3)) + +/** Read descriptors from a font file's bytes. Never throws: a file it cannot read gives {}. */ +export async function readFontFacts(bytes: Uint8Array): Promise { + try { + const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const sig = tag(v, 0) + if (sig === 'wOF2') return woff2Directory(v).has('fvar') ? { variable: true } : {} + const tables = sig === 'wOFF' ? await woffTables(bytes, v) : sfntTables(bytes, v, 0) + if (!tables) return {} + return descriptors(tables.get('fvar'), tables.get('OS/2')) + } catch { + return {} + } +} + +type Tables = Map + +function sfntTables(bytes: Uint8Array, v: DataView, base: number): Tables | undefined { + const sig = v.getUint32(base) + if (sig !== 0x00010000 && sig !== 0x74727565 /* true */ && sig !== 0x4f54544f /* OTTO */) return undefined + const out: Tables = new Map() + const n = v.getUint16(base + 4) + for (let i = 0; i < n; i++) { + const at = base + 12 + i * 16 + const offset = v.getUint32(at + 8), length = v.getUint32(at + 12) + out.set(tag(v, at), new DataView(bytes.buffer, bytes.byteOffset + offset, length)) + } + return out +} + +async function woffTables(bytes: Uint8Array, v: DataView): Promise { + const out: Tables = new Map() + const n = v.getUint16(12) + for (let i = 0; i < n; i++) { + const at = 44 + i * 20 + const name = tag(v, at) + if (name !== 'fvar' && name !== 'OS/2') continue + const offset = v.getUint32(at + 4), compLength = v.getUint32(at + 8), origLength = v.getUint32(at + 12) + let data = bytes.subarray(offset, offset + compLength) + if (compLength < origLength) data = await inflate(data) + out.set(name, new DataView(data.buffer, data.byteOffset, data.byteLength)) + } + return out +} + +async function inflate(data: Uint8Array): Promise { + const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate')) + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +// WOFF2 "known table" indices (WOFF2 spec, table 5.1); 63 = tag follows. +const WOFF2_FVAR = 47 + +function woff2Directory(v: DataView): Set { + const out = new Set() + const n = v.getUint16(12) + let at = 48 + const base128 = () => { + let value = 0 + for (let i = 0; i < 5; i++) { + const b = v.getUint8(at++) + value = value * 128 + (b & 0x7f) + if (!(b & 0x80)) return value + } + throw new Error('bad UIntBase128') + } + for (let i = 0; i < n; i++) { + const flags = v.getUint8(at++) + const known = flags & 0x3f + let name = '' + if (known === 0x3f) { name = tag(v, at); at += 4 } else if (known === WOFF2_FVAR) name = 'fvar' + base128() // origLength + const version = flags >> 6 + // glyf/loca carry a transformLength unless version 3; every other table only when version != 0 + const glyfOrLoca = known === 10 || known === 11 || name === 'glyf' || name === 'loca' + if (glyfOrLoca ? version !== 3 : version !== 0) base128() + if (name) out.add(name) + } + return out +} + +const weightValue = (w: number) => Math.min(1000, Math.max(1, Math.round(w))) + +function descriptors(fvar: DataView | undefined, os2: DataView | undefined): FaceDescriptors { + const out: FaceDescriptors = {} + const range = fvar && wghtAxis(fvar) + if (range) { + const [lo, hi] = range.map(weightValue) + out.weight = lo === hi ? String(lo) : `${lo} ${hi}` + } else if (os2 && os2.byteLength >= 6) { + const w = os2.getUint16(4) + if (w >= 1 && w <= 1000) out.weight = String(w) + } + if (os2 && os2.byteLength >= 64 && os2.getUint16(62) & 0x01) out.style = 'italic' + return out +} + +function wghtAxis(fvar: DataView): [number, number] | undefined { + const axesAt = fvar.getUint16(4), count = fvar.getUint16(8), size = fvar.getUint16(10) + for (let i = 0; i < count; i++) { + const at = axesAt + i * size + if (tag(fvar, at) !== 'wght') continue + const min = fvar.getInt32(at + 4) / 65536, max = fvar.getInt32(at + 12) / 65536 + return min <= max ? [min, max] : undefined + } + return undefined +} + +/** + * Measure a variable face's weight range by rendering, for the WOFF2 whose + * fvar cannot be read. The face is loaded with a 1..1000 descriptor, so every + * requested weight sets wght directly and the FONT clamps it to its own axis: + * outside the axis the rendering is pixel-identical to the clamped end. So the + * range's low end is the highest probe that still renders exactly as weight 1, + * the high end the lowest that renders exactly as 1000. Probes sit on the + * hundreds, where axis ends almost always are; an end between two probes + * reads as the outer one — a range wider than the font, never narrower, which + * costs nothing (the font clamps) where narrower would bring faux bold back. + * Returns undefined when weight changes nothing, or rendering is unavailable. + */ +export async function probeWeightRange(src: string): Promise { + try { + const family = `bento-weight-probe-${Math.random().toString(36).slice(2)}` + const face = new FontFace(family, `url(${JSON.stringify(src)})`, { weight: '1 1000' }) + await face.load() + document.fonts.add(face) + try { + const canvas = document.createElement('canvas') + canvas.width = 800; canvas.height = 160 + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return undefined + const render = (w: number) => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.font = `${w} 96px "${family}"` + ctx.fillText('HOIamnoe', 8, 120) + return ctx.getImageData(0, 0, canvas.width, canvas.height).data + } + const same = (a: Uint8ClampedArray, b: Uint8ClampedArray) => a.every((x, i) => x === b[i]) + const weights = [1, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] + const shots = weights.map(render) + const lowest = shots[0], highest = shots[shots.length - 1] + if (same(lowest, highest)) return undefined + let lo = 0 + while (lo + 1 < shots.length && same(shots[lo + 1], lowest)) lo++ + let hi = shots.length - 1 + while (hi - 1 >= 0 && same(shots[hi - 1], highest)) hi-- + return weights[lo] === weights[hi] ? String(weights[lo]) : `${weights[lo]} ${weights[hi]}` + } finally { + document.fonts.delete(face) + } + } catch { + return undefined + } +} + +/** Everything embedFont needs: the file's own descriptors, measured where they cannot be read. */ +export async function fontFaceDescriptors(bytes: Uint8Array, src: string): Promise { + const { variable, ...facts } = await readFontFacts(bytes) + if (variable && !facts.weight) { + const weight = await probeWeightRange(src) + if (weight) facts.weight = weight + } + return facts +}