Skip to content

Commit 1a6e910

Browse files
committed
fix(og): measure cover text with the font's real advance widths
An average glyph width under-measures caps-heavy names and over-measures narrow ones, so a viewer-supplied file name could still clip off the fixed canvas. Measure against the same font Satori is handed instead, matching the library cover generator; the tests parse the font independently so the assertion is not made with the estimator it is checking.
1 parent 9f4f1fe commit 1a6e910

4 files changed

Lines changed: 119 additions & 65 deletions

File tree

apps/sim/lib/og/cover-image.test.tsx

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,41 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { readFileSync } from 'node:fs'
5+
import { join } from 'node:path'
6+
import { parse as parseFont } from 'opentype.js'
47
import { describe, expect, it } from 'vitest'
58
import {
69
COVER_MAX_TITLE_LINES,
710
COVER_TITLE_BOX_WIDTH,
811
createCoverOgImage,
912
layoutCover,
10-
measureCoverText,
1113
} from '@/lib/og/cover-image'
1214

1315
const SUBTITLE_FONT_SIZE = 30
1416

17+
/**
18+
* The font is measured here independently of the renderer rather than through
19+
* a helper it exports. Sharing one measurement function between the layout and
20+
* its test makes the assertion circular: swap the renderer back to an
21+
* average-glyph-width estimate and a shared helper agrees with it, so a title
22+
* that really does run off the canvas still passes.
23+
*/
24+
const fontFile = readFileSync(join(process.cwd(), 'public', 'brand', 'fonts', 'Soehne-Kraftig.ttf'))
25+
const coverFont = parseFont(
26+
fontFile.buffer.slice(
27+
fontFile.byteOffset,
28+
fontFile.byteOffset + fontFile.byteLength
29+
) as ArrayBuffer
30+
)
31+
const measure = (text: string, fontSize: number) => coverFont.getAdvanceWidth(text, fontSize)
32+
/** Undoes the U+00A0 packing so assertions can be written with ordinary spaces. */
33+
const plain = (text: string) => text.replace(/\u00a0/g, ' ')
34+
1535
/**
1636
* Both inputs are chosen by whoever created the share — a file name and a
17-
* workspace/owner pair — so nothing upstream bounds their length. The canvas
18-
* is fixed, so the layout has to do the bounding.
37+
* workspace/owner pair — so nothing upstream bounds their length or their
38+
* glyphs. The canvas is fixed, so the layout has to do the bounding.
1939
*/
2040
describe('cover OG layout', () => {
2141
const expectWithinCanvas = (title: string, subtitle?: string) => {
@@ -24,11 +44,11 @@ describe('cover OG layout', () => {
2444
expect(layout.lines.length).toBeGreaterThan(0)
2545
expect(layout.lines.length).toBeLessThanOrEqual(COVER_MAX_TITLE_LINES)
2646
for (const line of layout.lines) {
27-
expect(measureCoverText(line, layout.fontSize)).toBeLessThanOrEqual(COVER_TITLE_BOX_WIDTH)
47+
expect(measure(line, layout.fontSize)).toBeLessThanOrEqual(COVER_TITLE_BOX_WIDTH)
2848
}
2949
if (subtitle) {
3050
expect(layout.subtitle).not.toBeNull()
31-
expect(measureCoverText(layout.subtitle as string, SUBTITLE_FONT_SIZE)).toBeLessThanOrEqual(
51+
expect(measure(layout.subtitle as string, SUBTITLE_FONT_SIZE)).toBeLessThanOrEqual(
3252
COVER_TITLE_BOX_WIDTH
3353
)
3454
}
@@ -37,7 +57,7 @@ describe('cover OG layout', () => {
3757

3858
it('sets a short title at the largest step on one line', () => {
3959
const layout = expectWithinCanvas('Protected file')
40-
expect(layout.lines).toEqual(['Protected file'])
60+
expect(layout.lines.map(plain)).toEqual(['Protected file'])
4161
expect(layout.fontSize).toBe(110)
4262
expect(layout.subtitle).toBeNull()
4363
})
@@ -48,12 +68,27 @@ describe('cover OG layout', () => {
4868
})
4969

5070
it('steps the type down before it truncates', () => {
51-
const long = 'Quarterly planning notes for the platform and infrastructure teams'
52-
const layout = expectWithinCanvas(long)
71+
const layout = expectWithinCanvas(
72+
'Quarterly planning notes for the platform and infrastructure teams'
73+
)
5374
expect(layout.fontSize).toBeLessThan(110)
5475
expect(layout.lines.join('')).not.toContain('…')
5576
})
5677

78+
/**
79+
* The cases an average-glyph-width estimate gets wrong. Caps run well wider
80+
* than the mean and glyphs the font has no coverage for run narrower, so an
81+
* estimator misjudges both — in the caps direction, by letting the line
82+
* render straight off the right edge.
83+
*/
84+
it('keeps a caps-heavy title inside the box', () => {
85+
expectWithinCanvas('QUARTERLY WORKFORCE PLANNING SUMMARY')
86+
})
87+
88+
it('keeps a title of uncovered glyphs inside the box', () => {
89+
expectWithinCanvas('四半期計画メモ・共有ファイル', '共有ワークスペース')
90+
})
91+
5792
it('truncates a title too long to fit even at the smallest step', () => {
5893
const layout = expectWithinCanvas(`${'unbroken'.repeat(60)}.pdf`)
5994
expect(layout.lines).toHaveLength(COVER_MAX_TITLE_LINES)
@@ -70,7 +105,20 @@ describe('cover OG layout', () => {
70105

71106
it('leaves a caption that already fits intact', () => {
72107
const layout = expectWithinCanvas('report.pdf', 'Design · Shared by Someone')
73-
expect(layout.subtitle).toBe('Design · Shared by Someone')
108+
expect(plain(layout.subtitle as string)).toBe('Design · Shared by Someone')
109+
})
110+
111+
/**
112+
* Satori measures the first plain space in a text node at roughly double
113+
* width, so every space that reaches it has to be a U+00A0 — and the layout
114+
* has to pack lines with it already in place, or it would be measuring
115+
* something other than what it renders.
116+
*/
117+
it('packs lines and captions with non-breaking spaces', () => {
118+
const layout = expectWithinCanvas('two words.pdf', 'Design · Shared by Someone')
119+
expect(layout.lines[0]).toContain('\u00a0')
120+
expect(layout.lines.join('')).not.toContain(' ')
121+
expect(layout.subtitle).not.toContain(' ')
74122
})
75123
})
76124

apps/sim/lib/og/cover-image.tsx

Lines changed: 57 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
22
import { join } from 'node:path'
33
import type { CSSProperties } from 'react'
44
import { ImageResponse } from 'next/og'
5+
import { parse as parseFont } from 'opentype.js'
56

67
/**
78
* The brandbook cover template, rendered on demand.
@@ -27,9 +28,6 @@ const MUTED_INK_COLOR = 'rgba(81, 81, 81, 0.72)'
2728
const TITLE_FONT_SIZES = [110, 96, 85] as const
2829
const SUBTITLE_FONT_SIZE = 30
2930
const ELLIPSIS = '\u2026'
30-
/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */
31-
const CHAR_WIDTH_EM = 0.42
32-
3331
/** Width the title and its caption are laid out into, leaving the right third of the card open. */
3432
export const COVER_TITLE_BOX_WIDTH = 1020
3533
/**
@@ -58,6 +56,28 @@ const titleFont = await readFile(
5856
join(process.cwd(), 'public', 'brand', 'fonts', 'Soehne-Kraftig.ttf')
5957
)
6058

59+
/**
60+
* Real advance widths for the font we actually render with.
61+
*
62+
* An average-glyph-width estimate is not good enough here. File names are
63+
* whatever a viewer named them: a caps-heavy or wide-glyph name runs far past
64+
* the average and clips off the fixed canvas, while a narrow one wraps early
65+
* for no reason. Measuring against the same font Satori is handed makes the
66+
* two agree by construction — including for glyphs Söhne has no coverage for
67+
* (CJK, emoji), which measure and render at the same notdef advance because
68+
* this is the only font in the `fonts` array.
69+
*
70+
* The library cover generator measures the same way and for the same reason
71+
* (`scripts/generate-library-covers.tsx`); the docs route falls back to an
72+
* estimate only because the edge runtime has no filesystem.
73+
*/
74+
const titleFontMetrics = parseFont(
75+
titleFont.buffer.slice(
76+
titleFont.byteOffset,
77+
titleFont.byteOffset + titleFont.byteLength
78+
) as ArrayBuffer
79+
)
80+
6181
const CONTAINER_STYLE = {
6282
height: '100%',
6383
width: '100%',
@@ -97,41 +117,28 @@ const SUBTITLE_STYLE = {
97117
lineHeight: 1.2,
98118
} satisfies CSSProperties
99119

100-
function estimateWidthEm(text: string): number {
101-
return text.length * CHAR_WIDTH_EM
102-
}
103-
104-
/** Estimated rendered width of `text` in pixels, at `fontSize`, in the cover typeface. */
105-
export function measureCoverText(text: string, fontSize: number): number {
106-
return estimateWidthEm(text) * fontSize
120+
/** Whether `text` fits the title box at `fontSize`, by the font's real advance widths. */
121+
function fits(text: string, fontSize: number): boolean {
122+
return titleFontMetrics.getAdvanceWidth(text, fontSize) <= COVER_TITLE_BOX_WIDTH
107123
}
108124

109-
/** Trims `text` from the right until it plus an ellipsis fits `maxWidthEm`. */
110-
function withEllipsis(text: string, maxWidthEm: number): string {
125+
/** Trims `text` from the right until it plus an ellipsis fits the title box at `fontSize`. */
126+
function withEllipsis(text: string, fontSize: number): string {
111127
let kept = text
112-
while (kept && estimateWidthEm(kept + ELLIPSIS) > maxWidthEm) {
128+
while (kept && !fits(kept + ELLIPSIS, fontSize)) {
113129
kept = kept.slice(0, -1)
114130
}
115131
return kept + ELLIPSIS
116132
}
117133

118-
/**
119-
* Splits a single word wider than `maxWidthEm` into chunks that each fit.
120-
*
121-
* Hyphens are tried first because that is where a reader expects a compound to
122-
* break, and the trailing hyphen stays on the upper line. A chunk with no
123-
* usable hyphen falls back to a character-level split, which only a
124-
* pathological token reaches — and file names, the titles this renders,
125-
* supply plenty of them.
126-
*/
127-
function splitOversizedWord(word: string, maxWidthEm: number): string[] {
134+
/** Greedily packs `pieces` into chunks that each fit the title box at `fontSize`. */
135+
function packChunks(pieces: string[], fontSize: number): string[] {
128136
const chunks: string[] = []
129137
let chunk = ''
130138

131-
const pieces = word.split(/(?<=-)/).flatMap((piece) => (piece.length > 1 ? [piece] : [...piece]))
132139
for (const piece of pieces) {
133140
const candidate = chunk + piece
134-
if (estimateWidthEm(candidate) > maxWidthEm && chunk) {
141+
if (!fits(candidate, fontSize) && chunk) {
135142
chunks.push(chunk)
136143
chunk = piece
137144
} else {
@@ -140,28 +147,23 @@ function splitOversizedWord(word: string, maxWidthEm: number): string[] {
140147
}
141148
if (chunk) chunks.push(chunk)
142149

143-
return chunks.flatMap((entry) =>
144-
estimateWidthEm(entry) > maxWidthEm ? splitByCharacter(entry, maxWidthEm) : [entry]
145-
)
150+
return chunks
146151
}
147152

148-
/** Last-resort break for a run with no hyphen to break on — a long URL, an unbroken identifier. */
149-
function splitByCharacter(word: string, maxWidthEm: number): string[] {
150-
const chunks: string[] = []
151-
let chunk = ''
152-
153-
for (const char of word) {
154-
const candidate = chunk + char
155-
if (estimateWidthEm(candidate) > maxWidthEm && chunk) {
156-
chunks.push(chunk)
157-
chunk = char
158-
} else {
159-
chunk = candidate
160-
}
161-
}
162-
if (chunk) chunks.push(chunk)
153+
/**
154+
* Splits a single word wider than the title box into chunks that each fit.
155+
*
156+
* Hyphens are tried first because that is where a reader expects a compound to
157+
* break, and the trailing hyphen stays on the upper line. A chunk with no
158+
* usable hyphen falls back to a character-level split — which file names, the
159+
* titles this renders, reach constantly.
160+
*/
161+
function splitOversizedWord(word: string, fontSize: number): string[] {
162+
const afterHyphens = packChunks(word.split(/(?<=-)/), fontSize)
163163

164-
return chunks
164+
return afterHyphens.flatMap((chunk) =>
165+
fits(chunk, fontSize) ? [chunk] : packChunks([...chunk], fontSize)
166+
)
165167
}
166168

167169
/**
@@ -170,32 +172,32 @@ function splitByCharacter(word: string, maxWidthEm: number): string[] {
170172
* roughly double width — a non-breaking space measures correctly and reads
171173
* identically at these sizes, so it sidesteps the bug rather than fighting
172174
* Satori's own line-wrapping, which is disabled here since lines arrive
173-
* pre-split.
175+
* pre-split. Title lines are packed with the U+00A0 already in them so that
176+
* what is measured is exactly what is rendered.
174177
*/
175178
function withHardSpaces(text: string): string {
176179
return text.replace(/ /g, '\u00a0')
177180
}
178181

179182
/** Greedily packs words into lines that fit `COVER_TITLE_BOX_WIDTH` at `fontSize`. */
180183
function wrapTitleLines(title: string, fontSize: number): string[] {
181-
const maxWidthEm = COVER_TITLE_BOX_WIDTH / fontSize
182184
const lines: string[] = []
183185
let current = ''
184186

185187
for (const word of title.split(' ')) {
186-
if (estimateWidthEm(word) > maxWidthEm) {
188+
if (!fits(word, fontSize)) {
187189
if (current) {
188190
lines.push(current)
189191
current = ''
190192
}
191-
const chunks = splitOversizedWord(word, maxWidthEm)
193+
const chunks = splitOversizedWord(word, fontSize)
192194
lines.push(...chunks.slice(0, -1))
193195
current = chunks[chunks.length - 1] ?? ''
194196
continue
195197
}
196198

197-
const candidate = current ? `${current} ${word}` : word
198-
if (estimateWidthEm(candidate) > maxWidthEm && current) {
199+
const candidate = current ? `${current}\u00a0${word}` : word
200+
if (!fits(candidate, fontSize) && current) {
199201
lines.push(current)
200202
current = word
201203
} else {
@@ -204,7 +206,7 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
204206
}
205207
if (current) lines.push(current)
206208

207-
return lines.map(withHardSpaces)
209+
return lines
208210
}
209211

210212
/** "sim" wordmark, no icon — the brandbook wordmark geometry the docs navbar and library covers use. */
@@ -276,9 +278,8 @@ export function layoutCover({ title, subtitle }: CoverOgImageProps): CoverLayout
276278
}
277279

278280
if (lines.length > COVER_MAX_TITLE_LINES) {
279-
const maxWidthEm = COVER_TITLE_BOX_WIDTH / smallest
280281
lines = lines.slice(0, COVER_MAX_TITLE_LINES)
281-
lines[lines.length - 1] = withEllipsis(lines[lines.length - 1], maxWidthEm)
282+
lines[lines.length - 1] = withEllipsis(lines[lines.length - 1], smallest)
282283
}
283284

284285
return { fontSize, lines, subtitle: subtitle ? fitCaption(subtitle) : null }
@@ -290,9 +291,9 @@ export function layoutCover({ title, subtitle }: CoverOgImageProps): CoverLayout
290291
* the right edge instead of wrapping.
291292
*/
292293
function fitCaption(subtitle: string): string {
293-
const maxWidthEm = COVER_TITLE_BOX_WIDTH / SUBTITLE_FONT_SIZE
294-
const fitted =
295-
estimateWidthEm(subtitle) <= maxWidthEm ? subtitle : withEllipsis(subtitle, maxWidthEm)
294+
const fitted = fits(subtitle, SUBTITLE_FONT_SIZE)
295+
? subtitle
296+
: withEllipsis(subtitle, SUBTITLE_FONT_SIZE)
296297
return withHardSpaces(fitted)
297298
}
298299

apps/sim/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@
208208
"nuqs": "2.8.9",
209209
"officeparser": "^5.2.0",
210210
"openai": "7.0.0",
211+
"opentype.js": "1.3.4",
211212
"pdf-lib": "1.17.1",
212213
"pdfjs-dist": "5.4.296",
213214
"postgres": "^3.4.5",
@@ -264,6 +265,7 @@
264265
"@types/mssql": "12.3.0",
265266
"@types/node": "24.2.1",
266267
"@types/nodemailer": "8.0.1",
268+
"@types/opentype.js": "1.3.10",
267269
"@types/prismjs": "^1.26.5",
268270
"@types/react": "^19",
269271
"@types/react-dom": "^19",

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)