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.
This commit is contained in:
Waleed
2026-07-11 11:17:54 -07:00
committed by GitHub
parent 591516a9b9
commit def2d5299a
3 changed files with 78 additions and 32 deletions
+78 -32
View File
@@ -5,16 +5,19 @@ import type { NextRequest } from 'next/server'
export const runtime = 'edge'
const TITLE_FONT_SIZE = {
large: 105,
medium: 91,
small: 81,
large: 110,
medium: 96,
small: 85,
} as const
/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */
const AVG_CHAR_WIDTH_EM = 0.46
const LATIN_CHAR_WIDTH_EM = 0.42
/** CJK glyphs (docs ships `ja`/`zh` locales) render near-square, roughly 2.4x a Latin glyph at this weight. */
const CJK_CHAR_WIDTH_EM = 1
const CJK_RANGE = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff00-\uffef]/
const TITLE_BOX_WIDTH = 1020
const FONT_CACHE_REVALIDATE_SECONDS = 60 * 60 * 24 * 30
/** Measured off the reference cover template (`apps/sim/public/library/best-zapier-alternatives/cover.jpg`). */
const INK_COLOR = '#525252'
/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */
const INK_COLOR = '#515151'
const OG_CONTAINER_STYLE = {
height: '100%',
width: '100%',
@@ -22,8 +25,8 @@ const OG_CONTAINER_STYLE = {
flexDirection: 'column',
justifyContent: 'space-between',
padding: '26px',
background: '#c3c3c3',
fontFamily: 'Season',
background: '#c1c1c1',
fontFamily: 'Soehne',
} satisfies CSSProperties
const OG_HEADER_STYLE = {
display: 'flex',
@@ -34,10 +37,12 @@ const OG_HEADER_STYLE = {
const OG_TITLE_STYLE = {
display: 'flex',
flexDirection: 'column',
fontWeight: 600,
fontWeight: 500,
color: INK_COLOR,
lineHeight: 1.15,
lineHeight: 1.1,
width: `${TITLE_BOX_WIDTH}px`,
/** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */
transform: 'translateY(14px)',
} satisfies CSSProperties
function getTitleFontSize(title: string): number {
@@ -53,6 +58,41 @@ function getTitleStyle(title: string): CSSProperties {
}
}
/** Sums per-character em-widths rather than counting characters, so wide CJK glyphs (docs ships `ja`/`zh`) don't under-wrap. */
function estimateWidthEm(text: string): number {
let width = 0
for (const char of text) {
width += CJK_RANGE.test(char) ? CJK_CHAR_WIDTH_EM : LATIN_CHAR_WIDTH_EM
}
return width
}
/**
* Splits a single word wider than `maxWidthEm` into character-level chunks
* that each fit. CJK titles (docs ships `ja`/`zh` locales) are often
* space-free, so a whole run can arrive as one "word" from `wrapTitleLines`'
* space-based split. Breaking mid-word is correct for CJK, where each glyph
* is independently readable; Latin words never reach this path since they
* stay under `maxWidthEm` in practice.
*/
function splitOversizedWord(word: string, maxWidthEm: number): string[] {
const chunks: string[] = []
let chunk = ''
for (const char of word) {
const candidate = chunk + char
if (estimateWidthEm(candidate) > maxWidthEm && chunk) {
chunks.push(chunk)
chunk = char
} else {
chunk = candidate
}
}
if (chunk) chunks.push(chunk)
return chunks
}
/**
* Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`,
* then joins each line with U+00A0 instead of a plain space. Satori
@@ -63,14 +103,25 @@ function getTitleStyle(title: string): CSSProperties {
* (which is also disabled here — lines are pre-split, not auto-wrapped).
*/
function wrapTitleLines(title: string, fontSize: number): string[] {
const maxCharsPerLine = Math.floor(TITLE_BOX_WIDTH / (fontSize * AVG_CHAR_WIDTH_EM))
const maxWidthEm = TITLE_BOX_WIDTH / fontSize
const words = title.split(' ')
const lines: string[] = []
let current = ''
for (const word of words) {
if (estimateWidthEm(word) > maxWidthEm) {
if (current) {
lines.push(current)
current = ''
}
const chunks = splitOversizedWord(word, maxWidthEm)
lines.push(...chunks.slice(0, -1))
current = chunks[chunks.length - 1] ?? ''
continue
}
const candidate = current ? `${current} ${word}` : word
if (candidate.length > maxCharsPerLine && current) {
if (estimateWidthEm(candidate) > maxWidthEm && current) {
lines.push(current)
current = word
} else {
@@ -83,21 +134,16 @@ function wrapTitleLines(title: string, fontSize: number): string[] {
}
/**
* Loads a static (600/semibold) TTF instance of the site's own Season Sans
* font — the platform's real brand/body font, also used by the library/blog
* cover template this OG image matches. Instantiated from the variable font
* at `apps/docs/app/fonts/SeasonSansUprightsVF.woff2` (`fonttools
* varLib.instancer wght=600`, then flavor-stripped to plain TTF) rather than
* loading the variable WOFF2 directly: Satori (`next/og`'s renderer) can't
* parse variable fonts without excessive memory use, and can't parse WOFF2
* at all ("Unsupported OpenType signature wOF2") — it needs an uncompressed
* TTF/OTF. Fetched over HTTP since the edge runtime has no filesystem access
* — served from `/static/fonts/` (not `/fonts/`) so it isn't intercepted by
* the site's i18n proxy (`proxy.ts`), whose matcher excludes `static` but
* not `fonts`.
* Loads Söhne Kräftig (weight 500), the typeface used on the reference cover
* template this OG image matches. Converted to a plain TTF from the
* last-shipped `soehne-kraftig.woff2` since Satori (`next/og`'s renderer)
* can't parse WOFF2 or variable fonts. Fetched over HTTP since the edge
* runtime has no filesystem access — served from `/static/fonts/` (not
* `/fonts/`) so it isn't intercepted by the site's i18n proxy (`proxy.ts`),
* whose matcher excludes `static` but not `fonts`.
*/
async function loadSeasonFont(baseUrl: string): Promise<ArrayBuffer> {
const response = await fetch(new URL('/static/fonts/SeasonSans-600-static.ttf', baseUrl), {
async function loadTitleFont(baseUrl: string): Promise<ArrayBuffer> {
const response = await fetch(new URL('/static/fonts/Soehne-Kraftig.ttf', baseUrl), {
next: { revalidate: FONT_CACHE_REVALIDATE_SECONDS },
})
@@ -128,7 +174,7 @@ function SimWordmark() {
)
}
/** Diagonal "open" arrow, top-right — matches the library/blog cover template. */
/** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners. */
function CornerArrow() {
return (
<svg width='58' height='58' viewBox='0 0 24 24' fill='none'>
@@ -136,8 +182,8 @@ function CornerArrow() {
d='M2 22 22 2M22 2H12M22 2V12'
stroke={INK_COLOR}
strokeWidth={3.6}
strokeLinecap='round'
strokeLinejoin='round'
strokeLinecap='square'
strokeLinejoin='miter'
/>
</svg>
)
@@ -153,7 +199,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'Documentation'
const fontData = await loadSeasonFont(request.url)
const fontData = await loadTitleFont(request.url)
const fontSize = getTitleFontSize(title)
const titleLines = wrapTitleLines(title, fontSize)
@@ -175,10 +221,10 @@ export async function GET(request: NextRequest) {
height: 675,
fonts: [
{
name: 'Season',
name: 'Soehne',
data: fontData,
style: 'normal',
weight: 600,
weight: 500,
},
],
}
Binary file not shown.