Skip to content

Commit e4bee8b

Browse files
committed
fix: custom fonts
1 parent a55c906 commit e4bee8b

7 files changed

Lines changed: 311 additions & 51 deletions

File tree

bitext/package-lock.json

Lines changed: 58 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bitext/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@sveltejs/vite-plugin-svelte": "^7.0.0",
2929
"@tailwindcss/vite": "^4.2.2",
3030
"@types/node": "^22",
31+
"@types/opentype.js": "^1.3.9",
3132
"@types/qrcode": "^1.5.6",
3233
"eslint": "^10.2.0",
3334
"eslint-plugin-svelte": "^3.17.0",
@@ -50,6 +51,7 @@
5051
"fflate": "^0.8.2",
5152
"idb-keyval": "^6.2.1",
5253
"jspdf": "^4.2.1",
54+
"opentype.js": "^1.3.4",
5355
"qrcode": "^1.5.4"
5456
}
5557
}

bitext/src/lib/components/share/ExportMenu.svelte

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
} from '$lib/fonts/visualization-font.js';
1616
import { buildInlinedFontCss } from '$lib/fonts/inline-fonts.js';
1717
import { ensureVisualizationCustomFontsInDocument } from '$lib/fonts/ensure-document-fonts.js';
18+
import { convertCustomFontTextToPaths } from '$lib/fonts/text-to-paths.js';
1819
import { encodeState } from '$lib/serialization/encode.js';
1920
import { SCHEMA_VERSION, type AppStateV1 } from '$lib/serialization/schema.js';
2021
import { getShareUrl } from '$lib/share/url.js';
@@ -84,31 +85,42 @@
8485
8586
async function downloadSvg() {
8687
await flushPreviewLayout();
87-
const svg = buildSvg({ includeAttributionFooter: true });
88+
/** Convert custom-font text to paths for portability (recipients don't need the font file). */
89+
const rawSvg = buildSvg({ includeAttributionFooter: true });
90+
const svg = await convertCustomFontTextToPaths(rawSvg, settingsStore.settings);
8891
downloadBlob('alignment.svg', new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
8992
}
9093
91-
async function downloadPng() {
92-
await flushPreviewLayout();
94+
/**
95+
* Build the SVG we feed to the rasterizer. Custom-font text is converted to vector paths
96+
* so it renders identically regardless of whether the browser finished decoding the
97+
* embedded `@font-face` before `<img>` drew the first frame.
98+
*/
99+
async function buildRasterSvg(): Promise<string> {
93100
await ensureVisualizationCustomFontsInDocument(settingsStore.settings);
94101
const embedFontCss = await buildInlinedFontCss(settingsStore.settings);
95102
const svg = buildSvg({ includeAttributionFooter: true, embedFontCss, includeImports: false });
103+
return await convertCustomFontTextToPaths(svg, settingsStore.settings);
104+
}
105+
106+
async function downloadPng() {
107+
await flushPreviewLayout();
108+
const svg = await buildRasterSvg();
96109
const blob = await svgStringToPngBlob(svg, 2);
97110
downloadBlob('alignment.png', blob);
98111
}
99112
100113
async function downloadPdf() {
101114
await flushPreviewLayout();
102-
await ensureVisualizationCustomFontsInDocument(settingsStore.settings);
103-
const embedFontCss = await buildInlinedFontCss(settingsStore.settings);
104-
const svg = buildSvg({ includeAttributionFooter: true, embedFontCss, includeImports: false });
115+
const svg = await buildRasterSvg();
105116
const blob = await svgStringToPdfBlob(svg);
106117
downloadBlob('alignment.pdf', blob);
107118
}
108119
109120
async function downloadHtml() {
110121
await flushPreviewLayout();
111-
const svg = buildSvg({ includeAttributionFooter: false });
122+
const rawSvg = buildSvg({ includeAttributionFooter: false });
123+
const svg = await convertCustomFontTextToPaths(rawSvg, settingsStore.settings);
112124
const html = wrapSvgInHtml(svg, 'Alignment export', googleFontImportList());
113125
downloadBlob('alignment.html', new Blob([html], { type: 'text/html;charset=utf-8' }));
114126
}
Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
11
/**
2-
* Decode SVG to a canvas (shared by PNG and PDF export). Avoids svg2pdf.js CJS/Vite issues.
2+
* Decode SVG to a canvas (shared by PNG and PDF export).
3+
*
4+
* Delivery: `data:image/svg+xml;charset=utf-8,…` with encoded contents — reliably
5+
* re-parses styles on load and keeps the embedded `@font-face` (Google fonts as
6+
* woff2 data URLs) intact. Custom TTFs are handled separately by converting
7+
* `<text>` to `<path>` upstream, so this path no longer has to race async font loads.
38
*/
49
export async function svgStringToCanvas(
510
svg: string,
611
scale = 2
712
): Promise<{ canvas: HTMLCanvasElement; cssWidth: number; cssHeight: number }> {
8-
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
9-
const url = URL.createObjectURL(blob);
10-
try {
11-
const img = new Image();
12-
await new Promise<void>((resolve, reject) => {
13-
img.onload = () => resolve();
14-
img.onerror = () => reject(new Error('SVG image decode failed'));
15-
img.src = url;
16-
});
17-
const cssWidth = img.naturalWidth || img.width;
18-
const cssHeight = img.naturalHeight || img.height;
19-
const canvas = document.createElement('canvas');
20-
canvas.width = Math.ceil(cssWidth * scale);
21-
canvas.height = Math.ceil(cssHeight * scale);
22-
const ctx = canvas.getContext('2d');
23-
if (!ctx) throw new Error('no 2d context');
24-
ctx.scale(scale, scale);
25-
ctx.drawImage(img, 0, 0);
26-
return { canvas, cssWidth, cssHeight };
27-
} finally {
28-
URL.revokeObjectURL(url);
29-
}
13+
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
14+
const img = new Image();
15+
await new Promise<void>((resolve, reject) => {
16+
img.onload = () => resolve();
17+
img.onerror = () => reject(new Error('SVG image decode failed'));
18+
img.src = dataUrl;
19+
});
20+
21+
/** Two rAFs — let any late-binding style / font apply step settle before rasterizing. */
22+
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
23+
24+
const cssWidth = img.naturalWidth || img.width;
25+
const cssHeight = img.naturalHeight || img.height;
26+
const canvas = document.createElement('canvas');
27+
canvas.width = Math.ceil(cssWidth * scale);
28+
canvas.height = Math.ceil(cssHeight * scale);
29+
const ctx = canvas.getContext('2d');
30+
if (!ctx) throw new Error('no 2d context');
31+
ctx.scale(scale, scale);
32+
ctx.drawImage(img, 0, 0);
33+
return { canvas, cssWidth, cssHeight };
3034
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Sniff a font file’s format from its first bytes so we can:
3+
* (1) produce a data-URL with a proper `font/<subtype>` MIME (some browsers
4+
* refuse to load `application/octet-stream` as a font face)
5+
* (2) add the `format("...")` hint required by some engines for data-URL sources.
6+
*/
7+
export type FontFormat = 'woff2' | 'woff' | 'otf' | 'ttf';
8+
9+
export function detectFontFormat(head: Uint8Array, fallbackName?: string): FontFormat {
10+
if (head.length >= 4) {
11+
const b0 = head[0]!;
12+
const b1 = head[1]!;
13+
const b2 = head[2]!;
14+
const b3 = head[3]!;
15+
16+
if (b0 === 0x77 && b1 === 0x4f && b2 === 0x46 && b3 === 0x32) return 'woff2'; // wOF2
17+
if (b0 === 0x77 && b1 === 0x4f && b2 === 0x46 && b3 === 0x46) return 'woff'; // wOFF
18+
if (b0 === 0x4f && b1 === 0x54 && b2 === 0x54 && b3 === 0x4f) return 'otf'; // OTTO
19+
if (b0 === 0x00 && b1 === 0x01 && b2 === 0x00 && b3 === 0x00) return 'ttf';
20+
// 'true' / 'typ1' historical variants → still TTF bytes
21+
if (b0 === 0x74 && b1 === 0x72 && b2 === 0x75 && b3 === 0x65) return 'ttf';
22+
if (b0 === 0x74 && b1 === 0x79 && b2 === 0x70 && b3 === 0x31) return 'ttf';
23+
}
24+
const lower = fallbackName?.toLowerCase() ?? '';
25+
if (lower.endsWith('.woff2')) return 'woff2';
26+
if (lower.endsWith('.woff')) return 'woff';
27+
if (lower.endsWith('.otf')) return 'otf';
28+
return 'ttf';
29+
}
30+
31+
export function fontMimeFor(fmt: FontFormat): string {
32+
if (fmt === 'woff2') return 'font/woff2';
33+
if (fmt === 'woff') return 'font/woff';
34+
if (fmt === 'otf') return 'font/otf';
35+
return 'font/ttf';
36+
}
37+
38+
export function fontFormatHint(fmt: FontFormat): string {
39+
if (fmt === 'woff2') return 'woff2';
40+
if (fmt === 'woff') return 'woff';
41+
if (fmt === 'otf') return 'opentype';
42+
return 'truetype';
43+
}

bitext/src/lib/fonts/inline-fonts.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { loadCustomFontBlob } from './custom-fonts.js';
22
import type { VisualSettingsV1 } from '$lib/serialization/schema.js';
33
import { googleFontStylesheetUrl } from './google-fonts.js';
4+
import { detectFontFormat, fontFormatHint, fontMimeFor } from './font-format.js';
45

56
/**
67
* Fetches a Google Fonts stylesheet (forcing woff2 via UA-less fetch),
@@ -54,9 +55,17 @@ async function inlineGoogleStylesheet(href: string): Promise<string | null> {
5455
async function customFontFaceCss(family: string): Promise<string | null> {
5556
const blob = await loadCustomFontBlob(family);
5657
if (!blob) return null;
57-
const dataUrl = await blobToDataUrl(blob);
58+
/**
59+
* Re-type the blob from its magic bytes — IDB often restores it as `application/octet-stream`,
60+
* which some engines refuse to load as a font source even inside an SVG blob.
61+
*/
62+
const buf = await blob.arrayBuffer();
63+
const head = new Uint8Array(buf, 0, Math.min(buf.byteLength, 8));
64+
const fmt = detectFontFormat(head);
65+
const typedBlob = new Blob([buf], { type: fontMimeFor(fmt) });
66+
const dataUrl = await blobToDataUrl(typedBlob);
5867
const safeFamily = family.replace(/"/g, '\\"');
59-
return `@font-face{font-family:"${safeFamily}";font-style:normal;font-weight:400 700;src:url(${dataUrl});font-display:swap;}`;
68+
return `@font-face{font-family:"${safeFamily}";font-style:normal;font-weight:400 700;src:url(${dataUrl}) format("${fontFormatHint(fmt)}");font-display:swap;}`;
6069
}
6170

6271
function uniqueGoogleHrefs(settings: VisualSettingsV1): string[] {

0 commit comments

Comments
 (0)