Skip to content

Commit dddbd3a

Browse files
committed
fix(og): prevent OG title clipping
1 parent f350442 commit dddbd3a

File tree

3 files changed

+76
-14
lines changed

3 files changed

+76
-14
lines changed

server/og/skillOgSvg.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ describe('skill OG SVG', () => {
1919
expect(svg).toContain('clawdhub.com/jhillock/discord-doctor')
2020
})
2121

22+
it('wraps long titles to avoid clipping', () => {
23+
const svg = buildSkillOgSvg({
24+
markDataUrl: 'data:image/png;base64,AAA=',
25+
title: 'Excalidraw Flowchart',
26+
description: 'Create Excalidraw flowcharts from descriptions.',
27+
ownerLabel: '@swiftlysisngh',
28+
versionLabel: 'v1.0.2',
29+
footer: 'clawdhub.com/swiftlysisngh/excalidraw-flowchart',
30+
})
31+
32+
const titleBlock = svg.match(/<text[^>]*font-weight="800"[\s\S]*?<\/text>/)?.[0] ?? ''
33+
const titleTspans = titleBlock.match(/<tspan /g) ?? []
34+
expect(titleTspans.length).toBe(2)
35+
expect(svg).toContain('Excalidraw')
36+
expect(svg).toContain('Flowchart')
37+
})
38+
2239
it('clips and wraps long descriptions', () => {
2340
const longWord = 'a'.repeat(200)
2441
const svg = buildSkillOgSvg({

server/og/skillOgSvg.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,39 @@ function escapeXml(value: string) {
1818
.replace(/'/g, '&#39;')
1919
}
2020

21-
function wrapText(value: string, maxChars: number, maxLines: number) {
21+
function glyphWidthFactor(char: string) {
22+
if (char === ' ') return 0.28
23+
if (char === '…') return 0.62
24+
if (/[ilI.,:;|!'"`]/.test(char)) return 0.28
25+
if (/[mwMW@%&]/.test(char)) return 0.9
26+
if (/[A-Z]/.test(char)) return 0.68
27+
if (/[0-9]/.test(char)) return 0.6
28+
return 0.56
29+
}
30+
31+
function estimateTextWidth(value: string, fontSize: number) {
32+
let width = 0
33+
for (const char of value) width += glyphWidthFactor(char) * fontSize
34+
return width
35+
}
36+
37+
function truncateToWidth(value: string, maxWidth: number, fontSize: number) {
38+
const trimmed = value.trim()
39+
if (!trimmed) return ''
40+
if (estimateTextWidth(trimmed, fontSize) <= maxWidth) return trimmed
41+
42+
const ellipsis = '…'
43+
const ellipsisWidth = estimateTextWidth(ellipsis, fontSize)
44+
let out = ''
45+
for (const char of trimmed) {
46+
const next = out + char
47+
if (estimateTextWidth(next, fontSize) + ellipsisWidth > maxWidth) break
48+
out = next
49+
}
50+
return `${out.replace(/\s+$/g, '').replace(/[.,;:!?]+$/g, '')}${ellipsis}`
51+
}
52+
53+
function wrapText(value: string, maxWidth: number, fontSize: number, maxLines: number) {
2254
const words = value.trim().split(/\s+/).filter(Boolean)
2355
const lines: string[] = []
2456
let current = ''
@@ -29,19 +61,26 @@ function wrapText(value: string, maxChars: number, maxLines: number) {
2961
}
3062

3163
function splitLongWord(word: string) {
32-
if (word.length <= maxChars) return [word]
64+
if (estimateTextWidth(word, fontSize) <= maxWidth) return [word]
3365
const parts: string[] = []
3466
let remaining = word
35-
while (remaining.length > maxChars) {
36-
parts.push(`${remaining.slice(0, maxChars - 1)}…`)
37-
remaining = remaining.slice(maxChars - 1)
67+
while (remaining && estimateTextWidth(remaining, fontSize) > maxWidth) {
68+
let chunk = ''
69+
for (const char of remaining) {
70+
const next = chunk + char
71+
if (estimateTextWidth(`${next}…`, fontSize) > maxWidth) break
72+
chunk = next
73+
}
74+
if (!chunk) break
75+
parts.push(`${chunk}…`)
76+
remaining = remaining.slice(chunk.length)
3877
}
3978
if (remaining) parts.push(remaining)
4079
return parts
4180
}
4281

4382
for (const word of words) {
44-
if (word.length > maxChars) {
83+
if (estimateTextWidth(word, fontSize) > maxWidth) {
4584
if (current) {
4685
pushLine(current)
4786
current = ''
@@ -58,7 +97,7 @@ function wrapText(value: string, maxChars: number, maxLines: number) {
5897
}
5998

6099
const next = current ? `${current} ${word}` : word
61-
if (next.length <= maxChars) {
100+
if (estimateTextWidth(next, fontSize) <= maxWidth) {
62101
current = next
63102
continue
64103
}
@@ -71,9 +110,7 @@ function wrapText(value: string, maxChars: number, maxLines: number) {
71110

72111
const usedWords = lines.join(' ').split(/\s+/).filter(Boolean).length
73112
if (usedWords < words.length) {
74-
const last = lines.at(-1) ?? ''
75-
const trimmed = last.length > maxChars ? last.slice(0, maxChars) : last
76-
lines[lines.length - 1] = `${trimmed.replace(/\s+$/g, '').replace(/[.,;:!?]+$/g, '')}…`
113+
lines[lines.length - 1] = truncateToWidth(lines.at(-1) ?? '', maxWidth, fontSize)
77114
}
78115
return lines
79116
}
@@ -88,10 +125,18 @@ export function buildSkillOgSvg(params: SkillOgSvgParams) {
88125
const cardH = 456
89126
const cardR = 34
90127

91-
const titleLines = wrapText(rawTitle, 22, 2)
92-
const descLines = wrapText(rawDescription, 42, 3)
128+
const contentX = 114
129+
const contentRightPadding = 28
130+
const contentMaxWidth = cardX + cardW - contentX - contentRightPadding
131+
132+
const titleMaxLines = 2
133+
const descMaxLines = 3
134+
135+
const titleProbeLines = wrapText(rawTitle, contentMaxWidth, 80, titleMaxLines)
136+
const titleFontSize = titleProbeLines.length > 1 ? 72 : 80
137+
const titleLines = wrapText(rawTitle, contentMaxWidth, titleFontSize, titleMaxLines)
93138

94-
const titleFontSize = titleLines.length > 1 || rawTitle.length > 24 ? 72 : 80
139+
const descLines = wrapText(rawDescription, contentMaxWidth, 26, descMaxLines)
95140
const titleY = titleLines.length > 1 ? 258 : 280
96141
const titleLineHeight = 84
97142

src/lib/og.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type SkillMeta = {
1616

1717
const DEFAULT_SITE = 'https://clawdhub.com'
1818
const DEFAULT_DESCRIPTION = 'ClawdHub — a fast skill registry for agents, with vector search.'
19-
const OG_SKILL_IMAGE_LAYOUT_VERSION = '4'
19+
const OG_SKILL_IMAGE_LAYOUT_VERSION = '5'
2020

2121
export function getSiteUrl() {
2222
return import.meta.env.VITE_SITE_URL ?? DEFAULT_SITE

0 commit comments

Comments
 (0)