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
+}