Skip to content

Commit 893e729

Browse files
authored
fix(og): put the shared-file card on the brandbook cover template (#6907)
* fix(og): put the shared-file card on the brandbook cover template * fix(og): bound the cover title and caption to the fixed canvas * fix(og): measure cover text with the font's real advance widths An average glyph width under-measures caps-heavy names and over-measures narrow ones, so a viewer-supplied file name could still clip off the fixed canvas. Measure against the same font Satori is handed instead, matching the library cover generator; the tests parse the font independently so the assertion is not made with the estimator it is checking. * fix(og): apply the leading compensation to the title, not the whole footer On the footer the 14px nudge dragged the caption into the bottom padding as well, and the caption has no phantom leading to correct for. Matches how the sibling cover renderers scope the same offset.
1 parent 865f817 commit 893e729

6 files changed

Lines changed: 496 additions & 14 deletions

File tree

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,32 @@
1+
import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image'
12
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
2-
import { createLandingOgImage } from '@/app/(landing)/og-utils'
33
import { buildProvenance } from '@/app/f/[token]/utils'
44

55
export const dynamic = 'force-dynamic'
66
export const contentType = 'image/png'
7-
export const size = {
8-
width: 1200,
9-
height: 630,
10-
}
7+
export const size = COVER_OG_SIZE
118

129
/**
13-
* Social-preview card for a shared file. Public shares show the file name +
14-
* provenance; protected (password / email / SSO) and unknown shares stay generic
15-
* so the filename never leaks pre-auth.
10+
* Social-preview card for a shared file, on the same brandbook cover template
11+
* as library posts and docs pages. Public shares show the file name +
12+
* provenance; protected (password / email / SSO) and unknown shares stay
13+
* generic so the filename never leaks pre-auth.
1614
*/
1715
export default async function Image({ params }: { params: Promise<{ token: string }> }) {
1816
const { token } = await params
1917
const resolved = await resolveActiveShareByToken(token)
2018

2119
if (!resolved || resolved.share.authType !== 'public') {
22-
return createLandingOgImage({
23-
eyebrow: 'Shared file',
20+
return createCoverOgImage({
2421
title: 'Protected file',
2522
subtitle: 'Authentication is required to view this file',
2623
})
2724
}
2825

2926
const { file, workspaceName, ownerName } = resolved
30-
const subtitle = buildProvenance(workspaceName, ownerName) || 'Shared via Sim'
3127

32-
return createLandingOgImage({
33-
eyebrow: 'Shared file',
28+
return createCoverOgImage({
3429
title: file.originalName,
35-
subtitle,
30+
subtitle: buildProvenance(workspaceName, ownerName) || 'Shared via Sim',
3631
})
3732
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { readFileSync } from 'node:fs'
5+
import { join } from 'node:path'
6+
import { parse as parseFont } from 'opentype.js'
7+
import { describe, expect, it } from 'vitest'
8+
import {
9+
COVER_MAX_TITLE_LINES,
10+
COVER_TITLE_BOX_WIDTH,
11+
createCoverOgImage,
12+
layoutCover,
13+
} from '@/lib/og/cover-image'
14+
15+
const SUBTITLE_FONT_SIZE = 30
16+
17+
/**
18+
* The font is measured here independently of the renderer rather than through
19+
* a helper it exports. Sharing one measurement function between the layout and
20+
* its test makes the assertion circular: swap the renderer back to an
21+
* average-glyph-width estimate and a shared helper agrees with it, so a title
22+
* that really does run off the canvas still passes.
23+
*/
24+
const fontFile = readFileSync(join(process.cwd(), 'public', 'brand', 'fonts', 'Soehne-Kraftig.ttf'))
25+
const coverFont = parseFont(
26+
fontFile.buffer.slice(
27+
fontFile.byteOffset,
28+
fontFile.byteOffset + fontFile.byteLength
29+
) as ArrayBuffer
30+
)
31+
const measure = (text: string, fontSize: number) => coverFont.getAdvanceWidth(text, fontSize)
32+
/** Undoes the U+00A0 packing so assertions can be written with ordinary spaces. */
33+
const plain = (text: string) => text.replace(/\u00a0/g, ' ')
34+
35+
/**
36+
* Both inputs are chosen by whoever created the share — a file name and a
37+
* workspace/owner pair — so nothing upstream bounds their length or their
38+
* glyphs. The canvas is fixed, so the layout has to do the bounding.
39+
*/
40+
describe('cover OG layout', () => {
41+
const expectWithinCanvas = (title: string, subtitle?: string) => {
42+
const layout = layoutCover({ title, subtitle })
43+
44+
expect(layout.lines.length).toBeGreaterThan(0)
45+
expect(layout.lines.length).toBeLessThanOrEqual(COVER_MAX_TITLE_LINES)
46+
for (const line of layout.lines) {
47+
expect(measure(line, layout.fontSize)).toBeLessThanOrEqual(COVER_TITLE_BOX_WIDTH)
48+
}
49+
if (subtitle) {
50+
expect(layout.subtitle).not.toBeNull()
51+
expect(measure(layout.subtitle as string, SUBTITLE_FONT_SIZE)).toBeLessThanOrEqual(
52+
COVER_TITLE_BOX_WIDTH
53+
)
54+
}
55+
return layout
56+
}
57+
58+
it('sets a short title at the largest step on one line', () => {
59+
const layout = expectWithinCanvas('Protected file')
60+
expect(layout.lines.map(plain)).toEqual(['Protected file'])
61+
expect(layout.fontSize).toBe(110)
62+
expect(layout.subtitle).toBeNull()
63+
})
64+
65+
it('breaks a hyphenated file name after a hyphen', () => {
66+
const layout = expectWithinCanvas('quarterly-planning-notes.pdf')
67+
expect(layout.lines[0].endsWith('-')).toBe(true)
68+
})
69+
70+
it('steps the type down before it truncates', () => {
71+
const layout = expectWithinCanvas(
72+
'Quarterly planning notes for the platform and infrastructure teams'
73+
)
74+
expect(layout.fontSize).toBeLessThan(110)
75+
expect(layout.lines.join('')).not.toContain('…')
76+
})
77+
78+
/**
79+
* The cases an average-glyph-width estimate gets wrong. Caps run well wider
80+
* than the mean and glyphs the font has no coverage for run narrower, so an
81+
* estimator misjudges both — in the caps direction, by letting the line
82+
* render straight off the right edge.
83+
*/
84+
it('keeps a caps-heavy title inside the box', () => {
85+
expectWithinCanvas('QUARTERLY WORKFORCE PLANNING SUMMARY')
86+
})
87+
88+
it('keeps a title of uncovered glyphs inside the box', () => {
89+
expectWithinCanvas('四半期計画メモ・共有ファイル', '共有ワークスペース')
90+
})
91+
92+
it('truncates a title too long to fit even at the smallest step', () => {
93+
const layout = expectWithinCanvas(`${'unbroken'.repeat(60)}.pdf`)
94+
expect(layout.lines).toHaveLength(COVER_MAX_TITLE_LINES)
95+
expect(layout.lines[COVER_MAX_TITLE_LINES - 1].endsWith('…')).toBe(true)
96+
})
97+
98+
it('truncates a caption too long for one line', () => {
99+
const layout = expectWithinCanvas(
100+
'report.pdf',
101+
`${'Very Long Workspace Name '.repeat(10)}· Shared by Someone`
102+
)
103+
expect((layout.subtitle as string).endsWith('…')).toBe(true)
104+
})
105+
106+
it('leaves a caption that already fits intact', () => {
107+
const layout = expectWithinCanvas('report.pdf', 'Design · Shared by Someone')
108+
expect(plain(layout.subtitle as string)).toBe('Design · Shared by Someone')
109+
})
110+
111+
/**
112+
* Satori measures the first plain space in a text node at roughly double
113+
* width, so every space that reaches it has to be a U+00A0 — and the layout
114+
* has to pack lines with it already in place, or it would be measuring
115+
* something other than what it renders.
116+
*/
117+
it('packs lines and captions with non-breaking spaces', () => {
118+
const layout = expectWithinCanvas('two words.pdf', 'Design · Shared by Someone')
119+
expect(layout.lines[0]).toContain('\u00a0')
120+
expect(layout.lines.join('')).not.toContain(' ')
121+
expect(layout.subtitle).not.toContain(' ')
122+
})
123+
})
124+
125+
/**
126+
* Renders a real PNG. The font read at module scope is the point: it comes off
127+
* disk rather than the network, so a missing `public/brand/fonts` entry would
128+
* otherwise surface only as a broken card in production — Satori throws
129+
* "No fonts are loaded" when it receives an empty `fonts` array.
130+
*/
131+
describe('cover OG image', () => {
132+
const expectPng = async (response: Response) => {
133+
expect(response.status).toBe(200)
134+
const bytes = new Uint8Array(await response.arrayBuffer())
135+
expect(bytes.byteLength).toBeGreaterThan(1000)
136+
// PNG magic number — proves Satori laid the text out and resvg rasterized it.
137+
expect(Array.from(bytes.slice(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
138+
}
139+
140+
it('renders a PNG using the bundled Söhne font', async () => {
141+
await expectPng(
142+
await createCoverOgImage({
143+
title: 'quarterly-planning-notes.pdf',
144+
subtitle: 'Design · Shared by Someone',
145+
})
146+
)
147+
}, 30_000)
148+
149+
it('renders without a caption', async () => {
150+
await expectPng(await createCoverOgImage({ title: 'Protected file' }))
151+
}, 30_000)
152+
})

0 commit comments

Comments
 (0)