@@ -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. */
2424const 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
3128const 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. */
3431const 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 = {
7077const 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-
99100function 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`. */
165180function 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