mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
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:
@@ -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.
Binary file not shown.
Reference in New Issue
Block a user