Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions bitext/src/lib/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
// API stay in sync. This module is transport-agnostic: it takes a parsed JSON-RPC message
// and the request origin, and returns the response object (or null for notifications).

import { Resvg } from '@resvg/resvg-js';
import { parseAlignBody, buildAlignUrl } from '$lib/api/align.js';
import { decodeState } from '$lib/serialization/decode.js';
import { buildOgSvg } from '$lib/seo/og-svg.js';
import { loadOgFontFiles } from '$lib/seo/og-fonts.js';
import { renderOgPng } from '$lib/seo/og-render.js';

export const MCP_PROTOCOL_VERSION = '2025-06-18';

Expand Down Expand Up @@ -234,15 +232,8 @@ function error(id: JsonRpcId, code: number, message: string) {
async function renderPreviewPng(url: string): Promise<string | null> {
const data = new URL(url).searchParams.get('data');
if (!data) return null;
const state = decodeState(data);
const svg = buildOgSvg(state);
const fontFiles = await loadOgFontFiles();
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: PREVIEW_WIDTH },
background: '#0f172a',
font: { fontFiles, loadSystemFonts: false, defaultFontFamily: 'Inter' }
});
return Buffer.from(resvg.render().asPng()).toString('base64');
const png = await renderOgPng(decodeState(data), PREVIEW_WIDTH);
return png.toString('base64');
}

async function callCreateAlignment(origin: string, args: unknown) {
Expand Down
Binary file modified bitext/src/lib/seo/og-fonts.ts
Binary file not shown.
56 changes: 56 additions & 0 deletions bitext/src/lib/seo/og-render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Resvg } from '@resvg/resvg-js';
import type { AppStateV2 } from '$lib/serialization/schema.js';
import { buildOgSvg, ogLines } from './og-svg.js';
import { loadOgFontFiles, loadSubsetFontFile, needsNonLatinFont } from './og-fonts.js';

/**
* Render the OG card to PNG.
*
* Shared by `/api/og` and the MCP tool's inline preview so the font handling below cannot drift
* between them.
*
* The card is drawn by resvg, which ships no fonts of its own and only reads font files from
* disk. Only Inter is bundled, so any other script used to render as visible "NO GLYPH" boxes.
* For each of the two lines we now pull the family it asks for from Google Fonts, subsetted to
* the exact characters on the card (a CJK sentence is around 8 KB). A line we cannot cover
* falls back to a neutral summary instead of boxes.
*/
export async function renderOgPng(state: AppStateV2, width: number): Promise<Buffer> {
const [interFiles, resolved] = await Promise.all([loadOgFontFiles(), resolveLineFonts(state)]);

const svg = buildOgSvg(state, resolved.unrenderable);
const resvg = new Resvg(svg, {
fitTo: { mode: 'width', value: width },
// Opaque canvas — some social scrapers (Facebook's in particular) render PNGs with alpha
// as a blank dark rectangle in their preview widget even when the pixels are fully opaque.
background: '#0f172a',
font: {
fontFiles: [...interFiles, ...resolved.fontFiles],
loadSystemFonts: false,
defaultFontFamily: 'Inter'
}
});
return Buffer.from(resvg.render().asPng());
}

/** Fetch a subset per line that needs one; report the lines left without usable glyphs. */
async function resolveLineFonts(
state: AppStateV2
): Promise<{ fontFiles: string[]; unrenderable: Set<number> }> {
const fontFiles: string[] = [];
const unrenderable = new Set<number>();

const lines = ogLines(state);
await Promise.all(
lines.map(async (line, index) => {
if (!line) return;
// Latin and Cyrillic already render from the bundled Inter; skip the network entirely.
if (!(await needsNonLatinFont(line.text))) return;
const path = await loadSubsetFontFile(line.family, line.text);
if (path) fontFiles.push(path);
else unrenderable.add(index);
})
);

return { fontFiles, unrenderable };
}
76 changes: 76 additions & 0 deletions bitext/src/lib/seo/og-svg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { buildOgSvg, ogLines } from './og-svg.js';
import { defaultAppStateV2, type AppStateV2, type LineV2 } from '$lib/serialization/schema.js';

function line(id: string, rawText: string, family = 'Inter'): LineV2 {
return {
id,
rawText,
font: { family, source: 'google' },
textSizePx: 36,
gapWordPx: 14
};
}

function stateWith(lines: LineV2[]): AppStateV2 {
const base = defaultAppStateV2();
return { ...base, project: { ...base.project, lines, connections: [] } };
}

describe('ogLines', () => {
it('reports the family and the exact text each line will draw', () => {
const out = ogLines(stateWith([line('a', '今日 私は', 'Noto Sans JP'), line('b', 'Today I')]));
expect(out[0]).toMatchObject({ family: 'Noto Sans JP', text: '今日 私は' });
expect(out[1]).toMatchObject({ family: 'Inter', text: 'Today I' });
});

it('reports only the text left after truncation, so the subset stays minimal', () => {
const long = Array.from({ length: 40 }, (_, i) => `word${i}`).join(' ');
const out = ogLines(stateWith([line('a', long), line('b', 'short')]));
expect(out[0]!.truncated).toBe(true);
expect(out[0]!.text.length).toBeLessThan(long.length);
});

it('returns null for a missing or empty line', () => {
const out = ogLines(stateWith([line('a', 'only one')]));
expect(out[0]).not.toBeNull();
expect(out[1]).toBeNull();
});
});

describe('buildOgSvg', () => {
it("puts the line's own family ahead of Inter so the right glyphs are picked", () => {
const svg = buildOgSvg(stateWith([line('a', '今日', 'Noto Sans JP'), line('b', 'Today')]));
expect(svg).toContain('font-family="Noto Sans JP, Inter, system-ui, sans-serif"');
});

it('falls back to a neutral summary for a line with no usable font', () => {
const svg = buildOgSvg(
stateWith([line('a', '今日 私は', 'Made Up'), line('b', 'Today I')]),
new Set([0])
);
expect(svg).toContain('2 lines · 0 links');
expect(svg).not.toContain('今日');
// The renderable line is untouched: the fallback is per line, not per card.
expect(svg).toContain('Today');
});

it('counts in singular when there is one of something', () => {
const base = defaultAppStateV2();
const state: AppStateV2 = {
...base,
project: {
...base.project,
lines: [line('a', '今日')],
connections: [{ id: 'c', upperTokenId: 'a-0', lowerTokenId: 'b-0', color: '#fff' }]
}
};
expect(buildOgSvg(state, new Set([0]))).toContain('1 line · 1 link');
});

it('keeps the placeholder wording for a genuinely empty project', () => {
const svg = buildOgSvg(stateWith([]));
expect(svg).toContain('Type a sentence…');
expect(svg).toContain('Add its translation…');
});
});
76 changes: 56 additions & 20 deletions bitext/src/lib/seo/og-svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ function renderSentenceText(
y: number,
tokens: Token[],
truncated: boolean,
colorByTokenId: Map<string, string>
colorByTokenId: Map<string, string>,
fontFamily: string
): string {
const parts: string[] = [];
tokens.forEach((t, i) => {
Expand All @@ -85,36 +86,71 @@ function renderSentenceText(
`<tspan xml:space="preserve" fill="${DEFAULT_TOKEN_COLOR}">${escapeXml(' …')}</tspan>`
);
}
return `<text x="${x}" y="${y}" fill="${DEFAULT_TOKEN_COLOR}" font-family="${FONT_FAMILY}" font-size="40" font-weight="600">${parts.join('')}</text>`;
// The line's own family first: without it a Japanese or Mongolian card asks for Inter and
// resvg draws "NO GLYPH" boxes even when the right font is loaded.
const stack = `${escapeXml(fontFamily)}, ${FONT_FAMILY}`;
return `<text x="${x}" y="${y}" fill="${DEFAULT_TOKEN_COLOR}" font-family="${stack}" font-size="40" font-weight="600">${parts.join('')}</text>`;
}

function plural(n: number, noun: string): string {
return `${n} ${noun}${n === 1 ? '' : 's'}`;
}

function renderPlaceholder(x: number, y: number, text: string): string {
return `<text x="${x}" y="${y}" fill="#64748b" font-family="${FONT_FAMILY}" font-size="40" font-weight="500" font-style="italic">${escapeXml(text)}</text>`;
}

/** OG preview: colored tokens from the shared state, no alignment lines. */
export function buildOgSvg(state: AppStateV2): string {
/** One of the (at most two) sentence lines the card shows, after truncation to the budget. */
export interface OgLine {
tokens: Token[];
truncated: boolean;
/** Font family the line asks for; the card needs a matching file to avoid missing glyphs. */
family: string;
/** Exactly the characters the card will draw — the subset request is built from this. */
text: string;
}

/** The card shows the first two lines; everything downstream works from this shape. */
export function ogLines(state: AppStateV2): (OgLine | null)[] {
const tz = tokenizeOptionsFromVisualSettings(state.settings);
const lines = state.project.lines;
const colorByTokenId = buildTokenColorMap(state.project.connections);
return [0, 1].map((i) => {
const line = state.project.lines[i];
if (!line) return null;
const { tokens, truncated } = fitTokens(tokenize(line.rawText, line.id, tz), CHAR_BUDGET);
if (tokens.length === 0) return null;
return {
tokens,
truncated,
family: line.font.family,
text: tokens.map((t) => t.text).join(' ')
};
});
}

const line0 = lines[0];
const line1 = lines[1];
/**
* OG preview: colored tokens from the shared state, no alignment lines.
*
* `unrenderable` lists line indices whose script has no usable font. Those lines fall back to a
* neutral summary instead of the sentence, because resvg renders a missing glyph as a visible
* "NO GLYPH" box and a card full of boxes is worse than one without the sentence.
*/
export function buildOgSvg(
state: AppStateV2,
unrenderable: ReadonlySet<number> = new Set()
): string {
const colorByTokenId = buildTokenColorMap(state.project.connections);
const [src, tgt] = ogLines(state);

const t0 = line0 ? tokenize(line0.rawText, line0.id, tz) : [];
const t1 = line1 ? tokenize(line1.rawText, line1.id, tz) : [];
const summary = `${plural(state.project.lines.length, 'line')} · ${plural(state.project.connections.length, 'link')}`;

const src = fitTokens(t0, CHAR_BUDGET);
const tgt = fitTokens(t1, CHAR_BUDGET);
function sentence(line: OgLine | null, index: number, y: number, empty: string): string {
if (!line) return renderPlaceholder(60, y, empty);
if (unrenderable.has(index)) return renderPlaceholder(60, y, summary);
return renderSentenceText(60, y, line.tokens, line.truncated, colorByTokenId, line.family);
}

const sourceLine =
src.tokens.length > 0
? renderSentenceText(60, 340, src.tokens, src.truncated, colorByTokenId)
: renderPlaceholder(60, 340, 'Type a sentence…');
const targetLine =
tgt.tokens.length > 0
? renderSentenceText(60, 430, tgt.tokens, tgt.truncated, colorByTokenId)
: renderPlaceholder(60, 430, 'Add its translation…');
const sourceLine = sentence(src, 0, 340, 'Type a sentence…');
const targetLine = sentence(tgt, 1, 430, 'Add its translation…');

const w = OG_IMAGE_WIDTH;
const h = OG_IMAGE_HEIGHT;
Expand Down
24 changes: 3 additions & 21 deletions bitext/src/routes/api/og/+server.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,12 @@
import { Resvg } from '@resvg/resvg-js';
import { decodeState } from '$lib/serialization/decode.js';
import { buildOgSvg, OG_IMAGE_WIDTH } from '$lib/seo/og-svg.js';
import { loadOgFontFiles } from '$lib/seo/og-fonts.js';
import { OG_IMAGE_WIDTH } from '$lib/seo/og-svg.js';
import { renderOgPng } from '$lib/seo/og-render.js';
import type { RequestHandler } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ url }) => {
const data = url.searchParams.get('data');
const state = decodeState(data);
const svg = buildOgSvg(state);
const fontFiles = await loadOgFontFiles();
const resvg = new Resvg(svg, {
fitTo: {
mode: 'width',
value: OG_IMAGE_WIDTH
},
// Opaque canvas — some social scrapers (Facebook's in particular) render PNGs with alpha
// as a blank dark rectangle in their preview widget even when the pixels are fully opaque.
background: '#0f172a',
font: {
fontFiles,
loadSystemFonts: false,
defaultFontFamily: 'Inter'
}
});
const png = resvg.render();
const buffer = png.asPng();
const buffer = await renderOgPng(state, OG_IMAGE_WIDTH);
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/png',
Expand Down