diff --git a/bitext/src/lib/mcp/server.ts b/bitext/src/lib/mcp/server.ts index f353bdf..0a498eb 100644 --- a/bitext/src/lib/mcp/server.ts +++ b/bitext/src/lib/mcp/server.ts @@ -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'; @@ -234,15 +232,8 @@ function error(id: JsonRpcId, code: number, message: string) { async function renderPreviewPng(url: string): Promise { 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) { diff --git a/bitext/src/lib/seo/og-fonts.ts b/bitext/src/lib/seo/og-fonts.ts index 4a9f02a..ee91bf1 100644 Binary files a/bitext/src/lib/seo/og-fonts.ts and b/bitext/src/lib/seo/og-fonts.ts differ diff --git a/bitext/src/lib/seo/og-render.ts b/bitext/src/lib/seo/og-render.ts new file mode 100644 index 0000000..c587fce --- /dev/null +++ b/bitext/src/lib/seo/og-render.ts @@ -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 { + 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 }> { + const fontFiles: string[] = []; + const unrenderable = new Set(); + + 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 }; +} diff --git a/bitext/src/lib/seo/og-svg.test.ts b/bitext/src/lib/seo/og-svg.test.ts new file mode 100644 index 0000000..f821182 --- /dev/null +++ b/bitext/src/lib/seo/og-svg.test.ts @@ -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…'); + }); +}); diff --git a/bitext/src/lib/seo/og-svg.ts b/bitext/src/lib/seo/og-svg.ts index 847f46d..956b998 100644 --- a/bitext/src/lib/seo/og-svg.ts +++ b/bitext/src/lib/seo/og-svg.ts @@ -72,7 +72,8 @@ function renderSentenceText( y: number, tokens: Token[], truncated: boolean, - colorByTokenId: Map + colorByTokenId: Map, + fontFamily: string ): string { const parts: string[] = []; tokens.forEach((t, i) => { @@ -85,36 +86,71 @@ function renderSentenceText( `${escapeXml(' …')}` ); } - return `${parts.join('')}`; + // 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 `${parts.join('')}`; +} + +function plural(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? '' : 's'}`; } function renderPlaceholder(x: number, y: number, text: string): string { return `${escapeXml(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 = 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; diff --git a/bitext/src/routes/api/og/+server.ts b/bitext/src/routes/api/og/+server.ts index e557066..a2cdbf5 100644 --- a/bitext/src/routes/api/og/+server.ts +++ b/bitext/src/routes/api/og/+server.ts @@ -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',