11import { escapeXml } from '$lib/export/xml.js' ;
22import { ALIGNER_SITE_HOST } from '$lib/brand.js' ;
3+ import { tokenize , type Token } from '$lib/domain/tokens.js' ;
4+ import { connectedLinkComponents } from '$lib/domain/link-graph.js' ;
5+ import { PALETTES } from '$lib/domain/palettes.js' ;
36import type { AppStateV1 } from '$lib/serialization/schema.js' ;
7+ import type { Link } from '$lib/domain/alignment.js' ;
48
59/** Social preview dimensions recommended by both Facebook and Twitter/X (1.91:1). */
610export const OG_IMAGE_WIDTH = 1200 ;
711export const OG_IMAGE_HEIGHT = 630 ;
812
913const FONT_FAMILY = 'Inter, system-ui, sans-serif' ;
1014
11- function truncate ( text : string , max : number ) : string {
12- const trimmed = text . trim ( ) ;
13- if ( trimmed . length <= max ) return trimmed ;
14- return trimmed . slice ( 0 , Math . max ( 0 , max - 1 ) ) . trimEnd ( ) + '…' ;
15+ /** Default token color (unlinked words) on the dark OG background. */
16+ const DEFAULT_TOKEN_COLOR = '#e2e8f0' ;
17+
18+ /**
19+ * Character budget per sentence line.
20+ *
21+ * Inter SemiBold at 40px averages ~21–23px per glyph across Latin and Cyrillic.
22+ * With 60px side padding on a 1200px canvas we have 1080px to work with
23+ * → ~48–50 characters fits comfortably. We keep a little headroom for the
24+ * trailing "…" when we truncate.
25+ */
26+ const CHAR_BUDGET = 50 ;
27+
28+ /** Fit tokens into the character budget, dropping trailing tokens if needed. */
29+ function fitTokens ( tokens : Token [ ] , budget : number ) : { tokens : Token [ ] ; truncated : boolean } {
30+ if ( tokens . length === 0 ) return { tokens : [ ] , truncated : false } ;
31+ const out : Token [ ] = [ ] ;
32+ let used = 0 ;
33+ for ( const t of tokens ) {
34+ const sep = out . length === 0 || t . joinLeft ? 0 : 1 ;
35+ const len = [ ...t . text ] . length ;
36+ const next = used + sep + len ;
37+ if ( next > budget && out . length > 0 ) return { tokens : out , truncated : true } ;
38+ out . push ( t ) ;
39+ used = next ;
40+ }
41+ return { tokens : out , truncated : false } ;
42+ }
43+
44+ /**
45+ * Assign a vivid-palette color to each connected component of links.
46+ *
47+ * We intentionally ignore the user's saved palette and their own colors on links:
48+ * the OG preview must stay readable on a dark background (pastels wash out, academic
49+ * greys vanish) and a user who painted everything black should still see a colorful
50+ * card. Components are iterated in link-array order so the mapping is deterministic
51+ * for a given `?data=` payload.
52+ */
53+ function buildTokenColorMap ( links : Link [ ] ) : Map < string , string > {
54+ const byId = new Map ( links . map ( ( l ) => [ l . id , l ] as const ) ) ;
55+ const map = new Map < string , string > ( ) ;
56+ const vivid = PALETTES . vivid ;
57+ const components = connectedLinkComponents ( links ) ;
58+ components . forEach ( ( component , idx ) => {
59+ const color = vivid [ idx % vivid . length ] ;
60+ for ( const linkId of component ) {
61+ const link = byId . get ( linkId ) ;
62+ if ( ! link ) continue ;
63+ map . set ( link . sourceId , color ) ;
64+ map . set ( link . targetId , color ) ;
65+ }
66+ } ) ;
67+ return map ;
68+ }
69+
70+ function renderSentenceText (
71+ x : number ,
72+ y : number ,
73+ tokens : Token [ ] ,
74+ truncated : boolean ,
75+ colorByTokenId : Map < string , string >
76+ ) : string {
77+ const parts : string [ ] = [ ] ;
78+ tokens . forEach ( ( t , i ) => {
79+ const prefix = i === 0 || t . joinLeft ? '' : ' ' ;
80+ const color = colorByTokenId . get ( t . id ) ?? DEFAULT_TOKEN_COLOR ;
81+ parts . push ( `<tspan xml:space="preserve" fill="${ color } ">${ escapeXml ( prefix + t . text ) } </tspan>` ) ;
82+ } ) ;
83+ if ( truncated ) {
84+ parts . push (
85+ `<tspan xml:space="preserve" fill="${ DEFAULT_TOKEN_COLOR } ">${ escapeXml ( ' …' ) } </tspan>`
86+ ) ;
87+ }
88+ return `<text x="${ x } " y="${ y } " fill="${ DEFAULT_TOKEN_COLOR } " font-family="${ FONT_FAMILY } " font-size="40" font-weight="600">${ parts . join ( '' ) } </text>` ;
89+ }
90+
91+ function renderPlaceholder ( x : number , y : number , text : string ) : string {
92+ return `<text x="${ x } " y="${ y } " fill="#64748b" font-family="${ FONT_FAMILY } " font-size="40" font-weight="500" font-style="italic">${ escapeXml ( text ) } </text>` ;
1593}
1694
17- /** Minimal OG preview: the two sentences as a visual sample of the alignment, plus branding . */
95+ /** OG preview: colored tokens from the shared state, no alignment lines . */
1896export function buildOgSvg ( state : AppStateV1 ) : string {
19- const src = truncate ( state . project . sourceText || 'Type a sentence…' , 80 ) ;
20- const tgt = truncate ( state . project . targetText || 'Add its translation…' , 80 ) ;
97+ const splitChars = state . settings . tokenSplitChars ?? '' ;
98+ const sourceTokens = tokenize ( state . project . sourceText , 'source' , splitChars ) ;
99+ const targetTokens = tokenize ( state . project . targetText , 'target' , splitChars ) ;
100+ const src = fitTokens ( sourceTokens , CHAR_BUDGET ) ;
101+ const tgt = fitTokens ( targetTokens , CHAR_BUDGET ) ;
102+ const colorByTokenId = buildTokenColorMap ( state . project . links ) ;
103+
104+ const sourceLine =
105+ src . tokens . length > 0
106+ ? renderSentenceText ( 60 , 340 , src . tokens , src . truncated , colorByTokenId )
107+ : renderPlaceholder ( 60 , 340 , 'Type a sentence…' ) ;
108+ const targetLine =
109+ tgt . tokens . length > 0
110+ ? renderSentenceText ( 60 , 430 , tgt . tokens , tgt . truncated , colorByTokenId )
111+ : renderPlaceholder ( 60 , 430 , 'Add its translation…' ) ;
112+
21113 const w = OG_IMAGE_WIDTH ;
22114 const h = OG_IMAGE_HEIGHT ;
23115
@@ -33,9 +125,9 @@ export function buildOgSvg(state: AppStateV1): string {
33125 <rect x="60" y="80" width="${ w - 120 } " height="4" fill="#6366f1" opacity="0.9"/>
34126 <text x="60" y="180" fill="#f8fafc" font-family="${ FONT_FAMILY } " font-size="64" font-weight="700">Word-by-word translation</text>
35127 <text x="60" y="228" fill="#c7d2fe" font-family="${ FONT_FAMILY } " font-size="26" font-weight="500">Bilingual sentence alignment visualizer</text>
36- <text x="60" y="340" fill="#e2e8f0" font-family=" ${ FONT_FAMILY } " font-size="40" font-weight="600"> ${ escapeXml ( src ) } </text>
128+ ${ sourceLine }
37129 <line x1="60" y1="372" x2="${ w - 60 } " y2="372" stroke="#334155" stroke-width="2" stroke-dasharray="6 8"/>
38- <text x="60" y="430" fill="#e2e8f0" font-family=" ${ FONT_FAMILY } " font-size="40" font-weight="600"> ${ escapeXml ( tgt ) } </text>
130+ ${ targetLine }
39131 <text x="60" y="560" fill="#94a3b8" font-family="${ FONT_FAMILY } " font-size="24" font-weight="500">${ escapeXml ( ALIGNER_SITE_HOST ) } </text>
40132 <text x="${ w - 60 } " y="560" text-anchor="end" fill="#64748b" font-family="${ FONT_FAMILY } " font-size="22" font-weight="400">Free · PNG / SVG / PDF / HTML</text>
41133</svg>` ;
0 commit comments