Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/actions/validate-slides/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci-slides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added scripts/fixtures/fonts/BentoWeightStatic.ttf
Binary file not shown.
Binary file added scripts/fixtures/fonts/BentoWeightStatic.woff2
Binary file not shown.
Binary file added scripts/fixtures/fonts/BentoWeightVar.ttf
Binary file not shown.
Binary file added scripts/fixtures/fonts/BentoWeightVar.woff
Binary file not shown.
Binary file added scripts/fixtures/fonts/BentoWeightVar.woff2
Binary file not shown.
89 changes: 89 additions & 0 deletions scripts/fixtures/fonts/make-weight-fonts.py
Original file line number Diff line number Diff line change
@@ -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()
114 changes: 114 additions & 0 deletions scripts/test-slides-font-weight-browser.mjs
Original file line number Diff line number Diff line change
@@ -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)
75 changes: 75 additions & 0 deletions scripts/test-slides-font-weight.ts
Original file line number Diff line number Diff line change
@@ -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('<svg xmlns="http://www.w3.org/2000/svg"/>'), {}],
['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)
11 changes: 8 additions & 3 deletions slides/src/editor/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
})
Expand Down
Loading
Loading