|
| 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