diff --git a/apps/docs/app/api/og/route.tsx b/apps/docs/app/api/og/route.tsx index ec58754d9b3..ee99eec89e9 100644 --- a/apps/docs/app/api/og/route.tsx +++ b/apps/docs/app/api/og/route.tsx @@ -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 { - const response = await fetch(new URL('/static/fonts/SeasonSans-600-static.ttf', baseUrl), { +async function loadTitleFont(baseUrl: string): Promise { + 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 ( @@ -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' /> ) @@ -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, }, ], } diff --git a/apps/docs/public/static/fonts/SeasonSans-600-static.ttf b/apps/docs/public/static/fonts/SeasonSans-600-static.ttf deleted file mode 100644 index 56dcc2b803b..00000000000 Binary files a/apps/docs/public/static/fonts/SeasonSans-600-static.ttf and /dev/null differ diff --git a/apps/docs/public/static/fonts/Soehne-Kraftig.ttf b/apps/docs/public/static/fonts/Soehne-Kraftig.ttf new file mode 100644 index 00000000000..8a92c26f779 Binary files /dev/null and b/apps/docs/public/static/fonts/Soehne-Kraftig.ttf differ