From be661058a06e80cf1cf9931a355282328e12fa2b Mon Sep 17 00:00:00 2001 From: dani-polani Date: Sun, 9 Aug 2026 02:16:49 +0300 Subject: [PATCH] fix(og): render non-Latin scripts instead of "NO GLYPH" boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resvg ships no fonts and reads only files from disk; the deploy image has none and only Inter was bundled. Every non-Latin OG card therefore rendered as visible "NO GLYPH" boxes. This predates vertical writing: a horizontal Japanese project broke the same way. It reached the social preview of any shared `?data=` link and the inline PNG the MCP tool returns. Two causes, both fixed: - `og-svg.ts` hard-coded `font-family: Inter` for the token text and ignored the family the line actually asks for. Even with the right font loaded, the card requested Inter. - No font for the script was available at all. Google's CSS API returns TrueType (the only thing resvg accepts) for an old-style User-Agent, and `text=` subsets it to the characters we are about to draw. A whole CJK sentence comes back around 8 KB, so this costs nothing next to bundling fonts or installing them in the image. Cached in-process by family and text: measured 243ms cold, 19ms warm. A line we cannot cover — an uploaded custom font, an unknown family, no network — degrades to a neutral "2 lines · 1 link" summary rather than boxes. That is per line, so a Japanese/English pair keeps its English. Coverage is decided by asking the bundled Inter for a glyph via opentype.js (already a dependency) rather than guessing at script ranges, and a font fetch failure is caught so it cannot turn the card into a 500. `/api/og` and the MCP preview now share `renderOgPng`, which is where all of the above lives; they had duplicated the resvg call and would have drifted. Co-Authored-By: Claude Opus 5 (1M context) --- bitext/src/lib/mcp/server.ts | 15 ++---- bitext/src/lib/seo/og-fonts.ts | Bin 1758 -> 5642 bytes bitext/src/lib/seo/og-render.ts | 56 ++++++++++++++++++++ bitext/src/lib/seo/og-svg.test.ts | 76 ++++++++++++++++++++++++++++ bitext/src/lib/seo/og-svg.ts | 76 ++++++++++++++++++++-------- bitext/src/routes/api/og/+server.ts | 24 ++------- 6 files changed, 194 insertions(+), 53 deletions(-) create mode 100644 bitext/src/lib/seo/og-render.ts create mode 100644 bitext/src/lib/seo/og-svg.test.ts 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 4a9f02a5c1cae371846e7e6ecf9e0fd85a21ea43..ee91bf1ee2b588da9099aa12edda7d450dab8f4e 100644 GIT binary patch literal 5642 zcmdT|ZEqX75$;#(SIh-LwVTQ-U)lzRa!%^_Y$wf&jYzrR;G`C{OKCS+Ew?1svd$-< zpZWue{=)kueTH1EBwLrF-&!C@B9Sv34xf4E8D+ClX-l^>n^Y}!v-*~-e!$|Tdx%m#y>QENT2a6lK{pQeR8G_VcQ+QoomzDi^x;6?Mm|u%_#l_UpCC z+VLH$zMN$*RG#cKTP^Oe`&AoWsQ0%rj%zGpNv8c(q(J!mYk!_%Xet)t$qLMwCrq~tuDC3 zZnfm@a?g-&xx=WrJS|(FpSD^#E*y(EmC5RM2>xd(syxRX_#bTQK6=!mNAwnu(V57z zpCs8-(x@tuTqfkML4(052~MCPtK2Qk!T{dH*fTU%nudSY(p*ov?(h9CW*EUOdw$Pf z)8pt#bVI2yR46#la=Q;4JCu+p63XCu$RD{%%#c)RGgXi(@&$=FmL=9XyPM~-_n=S~ z{>EdeO$WX$eLO!seH=#gF|(&B!C4vYD7jL&1BI<#Vy<(Dy2yN=+g!vqFaS%Rl}}f=0P0rgVNr z7w^tix%eny>xlkfgbpv z)jBYW^%oAQ5%Yf_<|4D4;7DjK7Jeyk(XEWE&=VB#A-PN8t{q>75#xYq%b&2-UIK5_ z;i~Yjvb-7bx!vHNn2yW1Y&DGUgb<}#b4wk|^yS&TBQznif& z_V}vvWrL5n@v_B#{JSA`cRVt2=K;a9IQ5r*{T+YkSfMgeRRp$*r}ejf?f>O(&$p;9 zsaLn@fyR7~jTq}I?){lSu5t{nNIU`4h0rLvZO&B%Iv2`*j--eJ?>?n@Dp7D6WC>2R zmB_(YOsRq<`Jzl+MGB-)Xe>(|aRKhAJjdy$stI$m{B{N0l;YOq=$pgC^+lTc+}eXz zUzLe3r;RFcQPk4F0dmU{xM@NuIXhn~mrW?&_2Szc1G2{7)`q7ohM{kP16@f@aZxwD zx>>DLYZOSX$u4(~Aydn+Ls=vvPn9%R6-YSIV$P)b;N3Z$z4`ItfM&yN* ziFv1rH^OEGV?5xDtQd8J)<;U^QWOqK9ttk{L-H)h4Z#6o3zmQncYtqrYXdNs9IR}R zG+2y#4^H;f%tqZ$rdvfwwGHAl4ACzOgjh*fI;ZS2;>R4QGcKN2D9Ib%N{7JpL35l1 zDqyq5PksIteVr*a$-T}rWU7%LSYEjLb+7*DqW} z*c71~D;AKE7*o(@MA^WewJvu%ltTvPj~ntQMo6tJJH19K$Mw@5KA{UJW6@vHz#LD$3I?uIAW`}>Lif% z2DmnaBd&l<8J5PZl{MmFoEsHGsFR5`U%@;@wWn1bFF}gonPwH{7p4WIiM6%kQr8ZA zFsCPjq8Od#!&wio#~Cu zmeNczj0~*g*3)^=HmP|0$0zMDs!A@3VEFLXhq?UC!&?q;ISeCj8MIS*gPoW|Vi_EC z=-VgUH0z$`%#~L66~A#bDvs%KGP&HxLeZYv0#gb=D^c zC*$hEXFbA<-QkL<(ZY?1i;+vVMof(ZOix?<7{IzJ*&c_+Kq`I<;aUS55PpwPA$w&v zg|u^}r@#|Ct_pDt+;JO>FpT6mPiY|I+!s8+$U_a3{#Z+vOlYK=y{!?bbt8t$uT3X? zmzGn6#S_YZLeKW_vVtv3YhQ0uxxmx!cwRiA-vL#C9sV&~QA7~_0qppdxu5Zd-HHn< zDBL?j3)J&L^O%oA@AB62xqqO;hqs_j{0Q~o{i|b;5DFL#+^>bp-*Pf{Z{d}2IYeZ2 z^f>Lkn}2*acO(?}5ITOq0N?Uh-NP?q?RA%E_h`+F{PW1(o`ED1_=Vl-iteZsmJ+&%@t zi^V^I8p_naSa#jiX`b-<}Qc zr>}O0{GkQ>7dfGgjMScYr-T=glhvLbNomGUkIQ>(>RHrfarX#%f81T2wmtGX7n|UR zfMW3D0LS2Ny=Ef+4YIyc{ejfl>po5=bU_3aT?E5RHTlfHHhI5z0Zk7Yf_>0~Y=@z5 j_z(|htPU#loWG7BBn;1^J^u9T;c=(j46d0rmaTsS$64d$ delta 10 RcmeCuxyL)9dE>=sHUJtY1egE- 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',