Skip to content

Commit 9f4f1fe

Browse files
committed
fix(og): bound the cover title and caption to the fixed canvas
1 parent e33f225 commit 9f4f1fe

2 files changed

Lines changed: 154 additions & 29 deletions

File tree

apps/sim/lib/og/cover-image.test.tsx

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,77 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { createCoverOgImage } from '@/lib/og/cover-image'
5+
import {
6+
COVER_MAX_TITLE_LINES,
7+
COVER_TITLE_BOX_WIDTH,
8+
createCoverOgImage,
9+
layoutCover,
10+
measureCoverText,
11+
} from '@/lib/og/cover-image'
12+
13+
const SUBTITLE_FONT_SIZE = 30
14+
15+
/**
16+
* Both inputs are chosen by whoever created the share — a file name and a
17+
* workspace/owner pair — so nothing upstream bounds their length. The canvas
18+
* is fixed, so the layout has to do the bounding.
19+
*/
20+
describe('cover OG layout', () => {
21+
const expectWithinCanvas = (title: string, subtitle?: string) => {
22+
const layout = layoutCover({ title, subtitle })
23+
24+
expect(layout.lines.length).toBeGreaterThan(0)
25+
expect(layout.lines.length).toBeLessThanOrEqual(COVER_MAX_TITLE_LINES)
26+
for (const line of layout.lines) {
27+
expect(measureCoverText(line, layout.fontSize)).toBeLessThanOrEqual(COVER_TITLE_BOX_WIDTH)
28+
}
29+
if (subtitle) {
30+
expect(layout.subtitle).not.toBeNull()
31+
expect(measureCoverText(layout.subtitle as string, SUBTITLE_FONT_SIZE)).toBeLessThanOrEqual(
32+
COVER_TITLE_BOX_WIDTH
33+
)
34+
}
35+
return layout
36+
}
37+
38+
it('sets a short title at the largest step on one line', () => {
39+
const layout = expectWithinCanvas('Protected file')
40+
expect(layout.lines).toEqual(['Protected file'])
41+
expect(layout.fontSize).toBe(110)
42+
expect(layout.subtitle).toBeNull()
43+
})
44+
45+
it('breaks a hyphenated file name after a hyphen', () => {
46+
const layout = expectWithinCanvas('quarterly-planning-notes.pdf')
47+
expect(layout.lines[0].endsWith('-')).toBe(true)
48+
})
49+
50+
it('steps the type down before it truncates', () => {
51+
const long = 'Quarterly planning notes for the platform and infrastructure teams'
52+
const layout = expectWithinCanvas(long)
53+
expect(layout.fontSize).toBeLessThan(110)
54+
expect(layout.lines.join('')).not.toContain('…')
55+
})
56+
57+
it('truncates a title too long to fit even at the smallest step', () => {
58+
const layout = expectWithinCanvas(`${'unbroken'.repeat(60)}.pdf`)
59+
expect(layout.lines).toHaveLength(COVER_MAX_TITLE_LINES)
60+
expect(layout.lines[COVER_MAX_TITLE_LINES - 1].endsWith('…')).toBe(true)
61+
})
62+
63+
it('truncates a caption too long for one line', () => {
64+
const layout = expectWithinCanvas(
65+
'report.pdf',
66+
`${'Very Long Workspace Name '.repeat(10)}· Shared by Someone`
67+
)
68+
expect((layout.subtitle as string).endsWith('…')).toBe(true)
69+
})
70+
71+
it('leaves a caption that already fits intact', () => {
72+
const layout = expectWithinCanvas('report.pdf', 'Design · Shared by Someone')
73+
expect(layout.subtitle).toBe('Design · Shared by Someone')
74+
})
75+
})
676

777
/**
878
* Renders a real PNG. The font read at module scope is the point: it comes off
@@ -22,19 +92,13 @@ describe('cover OG image', () => {
2292
it('renders a PNG using the bundled Söhne font', async () => {
2393
await expectPng(
2494
await createCoverOgImage({
25-
title: 'sim-cli-quick-reference',
26-
subtitle: 'Native Canaries · Shared by Test Sim',
95+
title: 'quarterly-planning-notes.pdf',
96+
subtitle: 'Design · Shared by Someone',
2797
})
2898
)
2999
}, 30_000)
30100

31-
it('renders without a subtitle', async () => {
101+
it('renders without a caption', async () => {
32102
await expectPng(await createCoverOgImage({ title: 'Protected file' }))
33103
}, 30_000)
34-
35-
it('lays out a title with no break opportunity', async () => {
36-
await expectPng(
37-
await createCoverOgImage({ title: 'a'.repeat(300), subtitle: 'Shared via Sim' })
38-
)
39-
}, 30_000)
40104
})

apps/sim/lib/og/cover-image.tsx

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,23 @@ const BACKGROUND_COLOR = '#c1c1c1'
2323
/** The title's ink, dropped back so a secondary line reads as caption rather than headline. */
2424
const MUTED_INK_COLOR = 'rgba(81, 81, 81, 0.72)'
2525

26-
const TITLE_FONT_SIZE = {
27-
large: 110,
28-
medium: 96,
29-
small: 85,
30-
} as const
26+
/** Tried largest-first; the first size whose title wraps within `COVER_MAX_TITLE_LINES` wins. */
27+
const TITLE_FONT_SIZES = [110, 96, 85] as const
3128
const SUBTITLE_FONT_SIZE = 30
32-
const TITLE_BOX_WIDTH = 1020
29+
const ELLIPSIS = '\u2026'
3330
/** Average glyph width as a fraction of font size, for this weight/family — used to pack words into lines. */
3431
const CHAR_WIDTH_EM = 0.42
3532

33+
/** Width the title and its caption are laid out into, leaving the right third of the card open. */
34+
export const COVER_TITLE_BOX_WIDTH = 1020
35+
/**
36+
* Titles here are file names a viewer chose, so nothing upstream bounds their
37+
* length. Past three lines the block runs off the bottom of the fixed canvas
38+
* and reads as a paragraph rather than a headline, so the layout steps the
39+
* type down and then truncates rather than growing.
40+
*/
41+
export const COVER_MAX_TITLE_LINES = 3
42+
3643
/**
3744
* Söhne Kräftig (weight 500), the typeface of the reference cover template, as
3845
* a plain TTF — Satori (the renderer behind `ImageResponse`) parses neither
@@ -70,7 +77,7 @@ const HEADER_STYLE = {
7077
const FOOTER_STYLE = {
7178
display: 'flex',
7279
flexDirection: 'column',
73-
width: `${TITLE_BOX_WIDTH}px`,
80+
width: `${COVER_TITLE_BOX_WIDTH}px`,
7481
/** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */
7582
transform: 'translateY(14px)',
7683
} satisfies CSSProperties
@@ -90,16 +97,24 @@ const SUBTITLE_STYLE = {
9097
lineHeight: 1.2,
9198
} satisfies CSSProperties
9299

93-
function getTitleFontSize(title: string): number {
94-
if (title.length > 45) return TITLE_FONT_SIZE.small
95-
if (title.length > 30) return TITLE_FONT_SIZE.medium
96-
return TITLE_FONT_SIZE.large
97-
}
98-
99100
function estimateWidthEm(text: string): number {
100101
return text.length * CHAR_WIDTH_EM
101102
}
102103

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
107+
}
108+
109+
/** Trims `text` from the right until it plus an ellipsis fits `maxWidthEm`. */
110+
function withEllipsis(text: string, maxWidthEm: number): string {
111+
let kept = text
112+
while (kept && estimateWidthEm(kept + ELLIPSIS) > maxWidthEm) {
113+
kept = kept.slice(0, -1)
114+
}
115+
return kept + ELLIPSIS
116+
}
117+
103118
/**
104119
* Splits a single word wider than `maxWidthEm` into chunks that each fit.
105120
*
@@ -161,9 +176,9 @@ function withHardSpaces(text: string): string {
161176
return text.replace(/ /g, '\u00a0')
162177
}
163178

164-
/** Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`. */
179+
/** Greedily packs words into lines that fit `COVER_TITLE_BOX_WIDTH` at `fontSize`. */
165180
function wrapTitleLines(title: string, fontSize: number): string[] {
166-
const maxWidthEm = TITLE_BOX_WIDTH / fontSize
181+
const maxWidthEm = COVER_TITLE_BOX_WIDTH / fontSize
167182
const lines: string[] = []
168183
let current = ''
169184

@@ -235,9 +250,55 @@ interface CoverOgImageProps {
235250
subtitle?: string
236251
}
237252

253+
interface CoverLayout {
254+
fontSize: number
255+
lines: string[]
256+
subtitle: string | null
257+
}
258+
259+
/**
260+
* Largest type size at which the title fits `COVER_MAX_TITLE_LINES`, with the
261+
* overflow truncated at the smallest step, and a caption clipped to one line.
262+
*
263+
* Separate from the render so the bound is assertable: every string this
264+
* returns has to sit inside the fixed canvas, and both inputs — a file name
265+
* and a workspace/owner pair — are supplied by whoever created the share.
266+
*/
267+
export function layoutCover({ title, subtitle }: CoverOgImageProps): CoverLayout {
268+
const smallest = TITLE_FONT_SIZES[TITLE_FONT_SIZES.length - 1]
269+
let fontSize = smallest
270+
let lines: string[] = []
271+
272+
for (const step of TITLE_FONT_SIZES) {
273+
fontSize = step
274+
lines = wrapTitleLines(title, step)
275+
if (lines.length <= COVER_MAX_TITLE_LINES) break
276+
}
277+
278+
if (lines.length > COVER_MAX_TITLE_LINES) {
279+
const maxWidthEm = COVER_TITLE_BOX_WIDTH / smallest
280+
lines = lines.slice(0, COVER_MAX_TITLE_LINES)
281+
lines[lines.length - 1] = withEllipsis(lines[lines.length - 1], maxWidthEm)
282+
}
283+
284+
return { fontSize, lines, subtitle: subtitle ? fitCaption(subtitle) : null }
285+
}
286+
287+
/**
288+
* Clips a caption to a single line. Because `withHardSpaces` leaves Satori no
289+
* break opportunities, an over-long caption would otherwise run straight off
290+
* the right edge instead of wrapping.
291+
*/
292+
function 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)
296+
return withHardSpaces(fitted)
297+
}
298+
238299
/** Renders the brandbook cover template for a single title. */
239-
export function createCoverOgImage({ title, subtitle }: CoverOgImageProps) {
240-
const fontSize = getTitleFontSize(title)
300+
export function createCoverOgImage(props: CoverOgImageProps) {
301+
const { fontSize, lines, subtitle } = layoutCover(props)
241302

242303
return new ImageResponse(
243304
<div style={CONTAINER_STYLE}>
@@ -248,11 +309,11 @@ export function createCoverOgImage({ title, subtitle }: CoverOgImageProps) {
248309

249310
<div style={FOOTER_STYLE}>
250311
<div style={{ ...TITLE_STYLE, fontSize }}>
251-
{wrapTitleLines(title, fontSize).map((line, index) => (
312+
{lines.map((line, index) => (
252313
<span key={index}>{line}</span>
253314
))}
254315
</div>
255-
{subtitle ? <span style={SUBTITLE_STYLE}>{withHardSpaces(subtitle)}</span> : null}
316+
{subtitle ? <span style={SUBTITLE_STYLE}>{subtitle}</span> : null}
256317
</div>
257318
</div>,
258319
{

0 commit comments

Comments
 (0)