@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
22import { join } from 'node:path'
33import type { CSSProperties } from 'react'
44import { 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)'
2728const TITLE_FONT_SIZES = [ 110 , 96 , 85 ] as const
2829const SUBTITLE_FONT_SIZE = 30
2930const 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. */
3432export 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+
6181const 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 */
175178function 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`. */
180183function 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 */
292293function 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
0 commit comments