Skip to content

Commit def2d52

Browse files
authored
fix(docs-og-image): match reference cover template typography exactly (#5598)
* fix(docs-og-image): match reference cover template typography exactly - swap Season Sans for Söhne Kräftig (500) — the reference cover's actual brand font, confirmed by letterform comparison; recovered from git history since it was removed as an unused static asset - fix ink/background colors to exact reference hex values - square caps + miter join on the corner arrow to match the reference's sharp corners instead of rounded ones - recalibrate title font size, line height, and wrap width for the new font's metrics * fix(docs-og-image): estimate CJK glyph width separately to avoid under-wrap wrapTitleLines budgeted a flat 0.42em/char, tuned for Latin text. Docs ships ja/zh locales — CJK glyphs render near-square (~1em), so a CJK title could overflow the fixed-width title box uncaught. Sum per-char em-width with a CJK-range check instead of counting characters. * fix(docs-og-image): fall back to character-level wrap for oversized CJK words wrapTitleLines only splits at spaces, so a space-free CJK run (common for Chinese titles) still arrived as a single word wider than the title box and rendered as one overflowing line. Falls back to character-level chunking for any word that alone exceeds maxWidthEm.
1 parent 591516a commit def2d52

3 files changed

Lines changed: 78 additions & 32 deletions

File tree

apps/docs/app/api/og/route.tsx

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,28 @@ import type { NextRequest } from 'next/server'
55
export const runtime = 'edge'
66

77
const TITLE_FONT_SIZE = {
8-
large: 105,
9-
medium: 91,
10-
small: 81,
8+
large: 110,
9+
medium: 96,
10+
small: 85,
1111
} as const
1212
/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */
13-
const AVG_CHAR_WIDTH_EM = 0.46
13+
const LATIN_CHAR_WIDTH_EM = 0.42
14+
/** CJK glyphs (docs ships `ja`/`zh` locales) render near-square, roughly 2.4x a Latin glyph at this weight. */
15+
const CJK_CHAR_WIDTH_EM = 1
16+
const CJK_RANGE = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/
1417
const TITLE_BOX_WIDTH = 1020
1518
const FONT_CACHE_REVALIDATE_SECONDS = 60 * 60 * 24 * 30
16-
/** Measured off the reference cover template (`apps/sim/public/library/best-zapier-alternatives/cover.jpg`). */
17-
const INK_COLOR = '#525252'
19+
/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */
20+
const INK_COLOR = '#515151'
1821
const OG_CONTAINER_STYLE = {
1922
height: '100%',
2023
width: '100%',
2124
display: 'flex',
2225
flexDirection: 'column',
2326
justifyContent: 'space-between',
2427
padding: '26px',
25-
background: '#c3c3c3',
26-
fontFamily: 'Season',
28+
background: '#c1c1c1',
29+
fontFamily: 'Soehne',
2730
} satisfies CSSProperties
2831
const OG_HEADER_STYLE = {
2932
display: 'flex',
@@ -34,10 +37,12 @@ const OG_HEADER_STYLE = {
3437
const OG_TITLE_STYLE = {
3538
display: 'flex',
3639
flexDirection: 'column',
37-
fontWeight: 600,
40+
fontWeight: 500,
3841
color: INK_COLOR,
39-
lineHeight: 1.15,
42+
lineHeight: 1.1,
4043
width: `${TITLE_BOX_WIDTH}px`,
44+
/** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */
45+
transform: 'translateY(14px)',
4146
} satisfies CSSProperties
4247

4348
function getTitleFontSize(title: string): number {
@@ -53,6 +58,41 @@ function getTitleStyle(title: string): CSSProperties {
5358
}
5459
}
5560

61+
/** Sums per-character em-widths rather than counting characters, so wide CJK glyphs (docs ships `ja`/`zh`) don't under-wrap. */
62+
function estimateWidthEm(text: string): number {
63+
let width = 0
64+
for (const char of text) {
65+
width += CJK_RANGE.test(char) ? CJK_CHAR_WIDTH_EM : LATIN_CHAR_WIDTH_EM
66+
}
67+
return width
68+
}
69+
70+
/**
71+
* Splits a single word wider than `maxWidthEm` into character-level chunks
72+
* that each fit. CJK titles (docs ships `ja`/`zh` locales) are often
73+
* space-free, so a whole run can arrive as one "word" from `wrapTitleLines`'
74+
* space-based split. Breaking mid-word is correct for CJK, where each glyph
75+
* is independently readable; Latin words never reach this path since they
76+
* stay under `maxWidthEm` in practice.
77+
*/
78+
function splitOversizedWord(word: string, maxWidthEm: number): string[] {
79+
const chunks: string[] = []
80+
let chunk = ''
81+
82+
for (const char of word) {
83+
const candidate = chunk + char
84+
if (estimateWidthEm(candidate) > maxWidthEm && chunk) {
85+
chunks.push(chunk)
86+
chunk = char
87+
} else {
88+
chunk = candidate
89+
}
90+
}
91+
if (chunk) chunks.push(chunk)
92+
93+
return chunks
94+
}
95+
5696
/**
5797
* Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`,
5898
* then joins each line with U+00A0 instead of a plain space. Satori
@@ -63,14 +103,25 @@ function getTitleStyle(title: string): CSSProperties {
63103
* (which is also disabled here — lines are pre-split, not auto-wrapped).
64104
*/
65105
function wrapTitleLines(title: string, fontSize: number): string[] {
66-
const maxCharsPerLine = Math.floor(TITLE_BOX_WIDTH / (fontSize * AVG_CHAR_WIDTH_EM))
106+
const maxWidthEm = TITLE_BOX_WIDTH / fontSize
67107
const words = title.split(' ')
68108
const lines: string[] = []
69109
let current = ''
70110

71111
for (const word of words) {
112+
if (estimateWidthEm(word) > maxWidthEm) {
113+
if (current) {
114+
lines.push(current)
115+
current = ''
116+
}
117+
const chunks = splitOversizedWord(word, maxWidthEm)
118+
lines.push(...chunks.slice(0, -1))
119+
current = chunks[chunks.length - 1] ?? ''
120+
continue
121+
}
122+
72123
const candidate = current ? `${current} ${word}` : word
73-
if (candidate.length > maxCharsPerLine && current) {
124+
if (estimateWidthEm(candidate) > maxWidthEm && current) {
74125
lines.push(current)
75126
current = word
76127
} else {
@@ -83,21 +134,16 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
83134
}
84135

85136
/**
86-
* Loads a static (600/semibold) TTF instance of the site's own Season Sans
87-
* font — the platform's real brand/body font, also used by the library/blog
88-
* cover template this OG image matches. Instantiated from the variable font
89-
* at `apps/docs/app/fonts/SeasonSansUprightsVF.woff2` (`fonttools
90-
* varLib.instancer wght=600`, then flavor-stripped to plain TTF) rather than
91-
* loading the variable WOFF2 directly: Satori (`next/og`'s renderer) can't
92-
* parse variable fonts without excessive memory use, and can't parse WOFF2
93-
* at all ("Unsupported OpenType signature wOF2") — it needs an uncompressed
94-
* TTF/OTF. Fetched over HTTP since the edge runtime has no filesystem access
95-
* — served from `/static/fonts/` (not `/fonts/`) so it isn't intercepted by
96-
* the site's i18n proxy (`proxy.ts`), whose matcher excludes `static` but
97-
* not `fonts`.
137+
* Loads Söhne Kräftig (weight 500), the typeface used on the reference cover
138+
* template this OG image matches. Converted to a plain TTF from the
139+
* last-shipped `soehne-kraftig.woff2` since Satori (`next/og`'s renderer)
140+
* can't parse WOFF2 or variable fonts. Fetched over HTTP since the edge
141+
* runtime has no filesystem access — served from `/static/fonts/` (not
142+
* `/fonts/`) so it isn't intercepted by the site's i18n proxy (`proxy.ts`),
143+
* whose matcher excludes `static` but not `fonts`.
98144
*/
99-
async function loadSeasonFont(baseUrl: string): Promise<ArrayBuffer> {
100-
const response = await fetch(new URL('/static/fonts/SeasonSans-600-static.ttf', baseUrl), {
145+
async function loadTitleFont(baseUrl: string): Promise<ArrayBuffer> {
146+
const response = await fetch(new URL('/static/fonts/Soehne-Kraftig.ttf', baseUrl), {
101147
next: { revalidate: FONT_CACHE_REVALIDATE_SECONDS },
102148
})
103149

@@ -128,16 +174,16 @@ function SimWordmark() {
128174
)
129175
}
130176

131-
/** Diagonal "open" arrow, top-right — matches the library/blog cover template. */
177+
/** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners. */
132178
function CornerArrow() {
133179
return (
134180
<svg width='58' height='58' viewBox='0 0 24 24' fill='none'>
135181
<path
136182
d='M2 22 22 2M22 2H12M22 2V12'
137183
stroke={INK_COLOR}
138184
strokeWidth={3.6}
139-
strokeLinecap='round'
140-
strokeLinejoin='round'
185+
strokeLinecap='square'
186+
strokeLinejoin='miter'
141187
/>
142188
</svg>
143189
)
@@ -153,7 +199,7 @@ export async function GET(request: NextRequest) {
153199
const { searchParams } = new URL(request.url)
154200
const title = searchParams.get('title') || 'Documentation'
155201

156-
const fontData = await loadSeasonFont(request.url)
202+
const fontData = await loadTitleFont(request.url)
157203
const fontSize = getTitleFontSize(title)
158204
const titleLines = wrapTitleLines(title, fontSize)
159205

@@ -175,10 +221,10 @@ export async function GET(request: NextRequest) {
175221
height: 675,
176222
fonts: [
177223
{
178-
name: 'Season',
224+
name: 'Soehne',
179225
data: fontData,
180226
style: 'normal',
181-
weight: 600,
227+
weight: 500,
182228
},
183229
],
184230
}
-155 KB
Binary file not shown.
94.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)