Skip to content

Commit 9dd9674

Browse files
committed
feat: colored tokens
1 parent e4cbd89 commit 9dd9674

1 file changed

Lines changed: 101 additions & 9 deletions

File tree

bitext/src/lib/seo/og-svg.ts

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,115 @@
11
import { escapeXml } from '$lib/export/xml.js';
22
import { 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';
36
import 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). */
610
export const OG_IMAGE_WIDTH = 1200;
711
export const OG_IMAGE_HEIGHT = 630;
812

913
const 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. */
1896
export 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

Comments
 (0)