feat(editor): migrate typst mermaid to native (#14499)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Native/WASM Mermaid and Typst SVG preview rendering on desktop and
mobile, plus cross-platform Preview plugin integrations.

* **Improvements**
* Centralized, sanitized rendering bridge with automatic Typst
font-directory handling and configurable native renderer selection.
* More consistent and robust error serialization and worker-backed
preview flows for improved stability and performance.

* **Tests**
* Extensive unit and integration tests for preview rendering, font
discovery, sanitization, and error serialization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-20 04:04:40 +08:00
committed by GitHub
parent 16a8f17717
commit 7ac8b14b65
85 changed files with 4926 additions and 835 deletions
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const { mermaidRender, typstRender } = vi.hoisted(() => ({
mermaidRender: vi.fn(),
typstRender: vi.fn(),
}));
const { domPurifySanitize } = vi.hoisted(() => ({
domPurifySanitize: vi.fn((value: unknown) => {
if (typeof value !== 'string') {
return '';
}
return value.replace(/<script[\s\S]*?<\/script>/gi, '');
}),
}));
vi.mock(
'@affine/core/modules/code-block-preview-renderer/platform-backend',
() => ({
renderMermaidSvgBackend: mermaidRender,
renderTypstSvgBackend: typstRender,
})
);
vi.mock('dompurify', () => ({
default: {
sanitize: domPurifySanitize,
},
}));
import { renderMermaidSvg, renderTypstSvg } from './bridge';
describe('preview render bridge', () => {
beforeEach(() => {
vi.clearAllMocks();
domPurifySanitize.mockImplementation((value: unknown) => {
if (typeof value !== 'string') {
return '';
}
return value.replace(/<script[\s\S]*?<\/script>/gi, '');
});
});
test('uses worker renderers and only sanitizes mermaid output', async () => {
mermaidRender.mockResolvedValue({
svg: '<svg><script>alert(1)</script><text>mermaid</text></svg>',
});
typstRender.mockResolvedValue({
svg: '<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>',
});
const mermaid = await renderMermaidSvg({ code: 'flowchart TD;A-->B' });
const typst = await renderTypstSvg({ code: '= Title' });
expect(mermaidRender).toHaveBeenCalledTimes(1);
expect(typstRender).toHaveBeenCalledTimes(1);
expect(mermaid.svg).toContain('<svg');
expect(mermaid.svg).toContain('mermaid');
expect(mermaid.svg).not.toContain('<script');
expect(typst.svg).toBe(
'<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>'
);
});
test('throws when sanitized svg is empty', async () => {
mermaidRender.mockResolvedValue({
svg: '<div><text>invalid</text></div>',
});
await expect(
renderMermaidSvg({ code: 'flowchart TD;A-->B' })
).rejects.toThrow('Preview renderer returned invalid SVG.');
});
});
@@ -0,0 +1,68 @@
import {
renderMermaidSvgBackend,
renderTypstSvgBackend,
} from '@affine/core/modules/code-block-preview-renderer/platform-backend';
import type {
MermaidRenderRequest,
MermaidRenderResult,
} from '@affine/core/modules/mermaid/renderer';
import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
import DOMPurify from 'dompurify';
function removeForeignObject(root: ParentNode) {
root
.querySelectorAll('foreignObject, foreignobject')
.forEach(element => element.remove());
}
export function sanitizeSvg(svg: string): string {
if (
typeof DOMParser === 'undefined' ||
typeof XMLSerializer === 'undefined'
) {
const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } });
if (typeof sanitized !== 'string' || !/^\s*<svg[\s>]/i.test(sanitized)) {
return '';
}
return sanitized.trim();
}
const parser = new DOMParser();
const parsed = parser.parseFromString(svg, 'image/svg+xml');
const root = parsed.documentElement;
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
const sanitized = DOMPurify.sanitize(root, { USE_PROFILES: { svg: true } });
if (typeof sanitized !== 'string') return '';
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
const sanitizedRoot = sanitizedDoc.documentElement;
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg')
return '';
removeForeignObject(sanitizedRoot);
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
}
export async function renderMermaidSvg(
request: MermaidRenderRequest
): Promise<MermaidRenderResult> {
const rendered = await renderMermaidSvgBackend(request);
const sanitizedSvg = sanitizeSvg(rendered.svg);
if (!sanitizedSvg) {
throw new Error('Preview renderer returned invalid SVG.');
}
return { svg: sanitizedSvg };
}
export async function renderTypstSvg(
request: TypstRenderRequest
): Promise<TypstRenderResult> {
const rendered = await renderTypstSvgBackend(request);
return { svg: rendered.svg };
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const { initialize, render } = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}));
vi.mock('mermaid', () => ({
default: {
initialize,
render,
},
}));
import { renderClassicMermaidSvg } from './classic-mermaid';
describe('renderClassicMermaidSvg', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('serializes initialize and render across concurrent calls', async () => {
const events: string[] = [];
let releaseFirstRender!: () => void;
initialize.mockImplementation(config => {
events.push(`init:${config.theme}`);
});
render
.mockImplementationOnce(async () => {
events.push('render:first:start');
await new Promise<void>(resolve => {
releaseFirstRender = resolve;
});
events.push('render:first:end');
return { svg: '<svg>first</svg>' };
})
.mockImplementationOnce(async () => {
events.push('render:second:start');
return { svg: '<svg>second</svg>' };
});
const first = renderClassicMermaidSvg({
code: 'flowchart TD;A-->B',
options: { theme: 'default' },
});
const second = renderClassicMermaidSvg({
code: 'flowchart TD;B-->C',
options: { theme: 'modern' },
});
await vi.waitFor(() => {
expect(events).toEqual(['init:default', 'render:first:start']);
});
releaseFirstRender();
await expect(first).resolves.toEqual({ svg: '<svg>first</svg>' });
await expect(second).resolves.toEqual({ svg: '<svg>second</svg>' });
expect(events).toEqual([
'init:default',
'render:first:start',
'render:first:end',
'init:base',
'render:second:start',
]);
});
});
@@ -0,0 +1,62 @@
import type { Mermaid } from 'mermaid';
import type {
MermaidRenderOptions,
MermaidRenderRequest,
MermaidRenderResult,
MermaidRenderTheme,
} from '../mermaid/renderer';
let mermaidPromise: Promise<Mermaid> | null = null;
let mermaidRenderQueue: Promise<void> = Promise.resolve();
function toTheme(theme: MermaidRenderTheme | undefined) {
return theme === 'modern' ? ('base' as const) : ('default' as const);
}
function createClassicMermaidConfig(options?: MermaidRenderOptions) {
return {
startOnLoad: false,
theme: toTheme(options?.theme),
securityLevel: 'strict' as const,
fontFamily: options?.fontFamily ?? 'IBM Plex Mono',
flowchart: { useMaxWidth: true, htmlLabels: true },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
pie: { useMaxWidth: true },
journey: { useMaxWidth: true },
gitGraph: { useMaxWidth: true },
};
}
async function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import('mermaid').then(module => module.default);
}
return mermaidPromise;
}
function createDiagramId() {
return `mermaid-diagram-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function enqueueClassicMermaidRender<T>(task: () => Promise<T>): Promise<T> {
const run = mermaidRenderQueue.then(task, task);
mermaidRenderQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
export async function renderClassicMermaidSvg(
request: MermaidRenderRequest
): Promise<MermaidRenderResult> {
return enqueueClassicMermaidRender(async () => {
const mermaid = await loadMermaid();
mermaid.initialize(createClassicMermaidConfig(request.options));
const { svg } = await mermaid.render(createDiagramId(), request.code);
return { svg };
});
}
@@ -0,0 +1,14 @@
import type { Framework } from '@toeverything/infra';
import { FeatureFlagService } from '../feature-flag';
import { PreviewRendererFeatureSyncService } from './services/preview-renderer-feature-sync';
export { renderMermaidSvg, renderTypstSvg, sanitizeSvg } from './bridge';
export {
registerNativePreviewHandlers,
setMermaidWasmNativeRendererEnabled,
} from './runtime-config';
export function configureCodeBlockPreviewRendererModule(framework: Framework) {
framework.service(PreviewRendererFeatureSyncService, [FeatureFlagService]);
}
@@ -0,0 +1,52 @@
import { apis } from '@affine/electron-api';
import { renderClassicMermaidSvg } from './classic-mermaid';
import { isMermaidWasmNativeRendererEnabled } from './runtime-config';
import type { PreviewRenderRequestMap, PreviewRenderResultMap } from './types';
type DesktopPreviewHandlers = {
renderMermaidSvg?: (
request: PreviewRenderRequestMap['mermaid']
) => Promise<PreviewRenderResultMap['mermaid']>;
renderTypstSvg?: (
request: PreviewRenderRequestMap['typst']
) => Promise<PreviewRenderResultMap['typst']>;
};
type DesktopPreviewApis = {
preview?: DesktopPreviewHandlers;
};
function getDesktopPreviewHandlers() {
const previewApis = apis as unknown as DesktopPreviewApis;
return previewApis.preview ?? null;
}
function getRequiredDesktopHandler<Name extends keyof DesktopPreviewHandlers>(
name: Name
): NonNullable<DesktopPreviewHandlers[Name]> {
const handlers = getDesktopPreviewHandlers();
const handler = handlers?.[name];
if (!handler) {
throw new Error(
`Electron preview handler "${String(name)}" is unavailable.`
);
}
return handler as NonNullable<DesktopPreviewHandlers[Name]>;
}
export async function renderMermaidSvgBackend(
request: PreviewRenderRequestMap['mermaid']
): Promise<PreviewRenderResultMap['mermaid']> {
if (!isMermaidWasmNativeRendererEnabled()) {
return renderClassicMermaidSvg(request);
}
return getRequiredDesktopHandler('renderMermaidSvg')(request);
}
export async function renderTypstSvgBackend(
request: PreviewRenderRequestMap['typst']
): Promise<PreviewRenderResultMap['typst']> {
return getRequiredDesktopHandler('renderTypstSvg')(request);
}
@@ -0,0 +1,24 @@
import { getNativePreviewHandlers } from './runtime-config';
import type { PreviewRenderRequestMap, PreviewRenderResultMap } from './types';
function getRequiredNativeHandler<
Name extends keyof NonNullable<ReturnType<typeof getNativePreviewHandlers>>,
>(name: Name) {
const handler = getNativePreviewHandlers()?.[name];
if (!handler) {
throw new Error(`Mobile preview handler "${String(name)}" is unavailable.`);
}
return handler;
}
export async function renderMermaidSvgBackend(
request: PreviewRenderRequestMap['mermaid']
): Promise<PreviewRenderResultMap['mermaid']> {
return getRequiredNativeHandler('renderMermaidSvg')(request);
}
export async function renderTypstSvgBackend(
request: PreviewRenderRequestMap['typst']
): Promise<PreviewRenderResultMap['typst']> {
return getRequiredNativeHandler('renderTypstSvg')(request);
}
@@ -0,0 +1,22 @@
import { getMermaidRenderer } from '@affine/core/modules/mermaid/renderer';
import { getTypstRenderer } from '@affine/core/modules/typst/renderer';
import { renderClassicMermaidSvg } from './classic-mermaid';
import { isMermaidWasmNativeRendererEnabled } from './runtime-config';
import type { PreviewRenderRequestMap, PreviewRenderResultMap } from './types';
export async function renderMermaidSvgBackend(
request: PreviewRenderRequestMap['mermaid']
): Promise<PreviewRenderResultMap['mermaid']> {
if (!isMermaidWasmNativeRendererEnabled()) {
return renderClassicMermaidSvg(request);
}
return getMermaidRenderer().render(request);
}
export async function renderTypstSvgBackend(
request: PreviewRenderRequestMap['typst']
): Promise<PreviewRenderResultMap['typst']> {
return getTypstRenderer().render(request);
}
@@ -0,0 +1,39 @@
import type {
MermaidRenderRequest,
MermaidRenderResult,
} from '@affine/core/modules/mermaid/renderer';
import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
type NativePreviewHandlers = {
renderMermaidSvg?: (
request: MermaidRenderRequest
) => Promise<MermaidRenderResult>;
renderTypstSvg?: (request: TypstRenderRequest) => Promise<TypstRenderResult>;
};
let enableMermaidWasmNativeRenderer =
BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid;
let nativePreviewHandlers: NativePreviewHandlers | null = null;
export function setMermaidWasmNativeRendererEnabled(enabled: boolean) {
enableMermaidWasmNativeRenderer = enabled;
}
export function isMermaidWasmNativeRendererEnabled() {
return enableMermaidWasmNativeRenderer;
}
export function registerNativePreviewHandlers(
handlers: NativePreviewHandlers | null
) {
nativePreviewHandlers = handlers;
}
export function getNativePreviewHandlers() {
return nativePreviewHandlers;
}
export type { NativePreviewHandlers };
@@ -0,0 +1,26 @@
import { OnEvent, Service } from '@toeverything/infra';
import { distinctUntilChanged } from 'rxjs';
import type { FeatureFlagService } from '../../feature-flag';
import { ApplicationStarted } from '../../lifecycle';
import { setMermaidWasmNativeRendererEnabled } from '../runtime-config';
@OnEvent(ApplicationStarted, e => e.syncFlag)
export class PreviewRendererFeatureSyncService extends Service {
constructor(private readonly featureFlagService: FeatureFlagService) {
super();
}
syncFlag() {
const mermaidFlag =
this.featureFlagService.flags.enable_mermaid_wasm_native_renderer;
setMermaidWasmNativeRendererEnabled(!!mermaidFlag.value);
const subscription = mermaidFlag.$.pipe(distinctUntilChanged()).subscribe(
enabled => {
setMermaidWasmNativeRendererEnabled(!!enabled);
}
);
this.disposables.push(() => subscription.unsubscribe());
}
}
@@ -0,0 +1,18 @@
import type {
MermaidRenderRequest,
MermaidRenderResult,
} from '@affine/core/modules/mermaid/renderer';
import type {
TypstRenderRequest,
TypstRenderResult,
} from '@affine/core/modules/typst/renderer';
export type PreviewRenderRequestMap = {
mermaid: MermaidRenderRequest;
typst: TypstRenderRequest;
};
export type PreviewRenderResultMap = {
mermaid: MermaidRenderResult;
typst: TypstRenderResult;
};
@@ -4,6 +4,7 @@ import type { FlagInfo } from './types';
const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary';
const isMobile = BUILD_CONFIG.isMobileEdition;
const isIOS = BUILD_CONFIG.isIOS;
const isAndroid = BUILD_CONFIG.isAndroid;
export const AFFINE_FLAGS = {
enable_ai: {
@@ -203,6 +204,14 @@ export const AFFINE_FLAGS = {
configurable: isMobile && isIOS,
defaultState: isMobile && isIOS,
},
enable_mermaid_wasm_native_renderer: {
category: 'affine',
displayName: 'Enable Native Mermaid Renderer',
description:
'Use the new Mermaid renderer backend. Web uses WASM, desktop uses native, and mobile always uses native. The native renderer is more than 10x faster, but its styling/aesthetic quality and the types of graphics it supports are not as good as the JS version.',
configurable: !isIOS && !isAndroid,
defaultState: isIOS || isAndroid,
},
enable_turbo_renderer: {
category: 'blocksuite',
bsFlag: 'enable_turbo_renderer',
@@ -13,6 +13,7 @@ import { configureAppSidebarModule } from './app-sidebar';
import { configAtMenuConfigModule } from './at-menu-config';
import { configureBlobManagementModule } from './blob-management';
import { configureCloudModule } from './cloud';
import { configureCodeBlockPreviewRendererModule } from './code-block-preview-renderer';
import { configureCollectionModule } from './collection';
import { configureCollectionRulesModule } from './collection-rules';
import { configureCommentModule } from './comment';
@@ -77,6 +78,7 @@ export function configureCommonModules(framework: Framework) {
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
configureFeatureFlagModule(framework);
configureCodeBlockPreviewRendererModule(framework);
configureCollectionModule(framework);
configureNavigationModule(framework);
configureTagModule(framework);
@@ -0,0 +1,39 @@
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
import type {
MermaidOps,
MermaidRenderOptions,
MermaidRenderRequest,
} from './types';
class MermaidRenderer extends WorkerOpRenderer<MermaidOps> {
constructor() {
super('mermaid');
}
init(options?: MermaidRenderOptions) {
return this.ensureInitialized(() => this.call('init', options));
}
async render(request: MermaidRenderRequest) {
await this.init();
return this.call('render', request);
}
}
let sharedMermaidRenderer: MermaidRenderer | null = null;
export function getMermaidRenderer() {
if (!sharedMermaidRenderer) {
sharedMermaidRenderer = new MermaidRenderer();
}
return sharedMermaidRenderer;
}
export type {
MermaidOps,
MermaidRenderOptions,
MermaidRenderRequest,
MermaidRenderResult,
MermaidRenderTheme,
MermaidTextMetrics,
} from './types';
@@ -0,0 +1,63 @@
import type { MessageCommunicapable } from '@toeverything/infra/op';
import { OpConsumer } from '@toeverything/infra/op';
import initMmdr, { render_mermaid_svg } from '@toeverything/mermaid-wasm';
import type {
MermaidOps,
MermaidRenderOptions,
MermaidRenderRequest,
} from './types';
const DEFAULT_RENDER_OPTIONS: MermaidRenderOptions = {
fastText: true,
svgOnly: true,
theme: 'modern',
fontFamily: 'IBM Plex Mono',
};
function mergeOptions(
base: MermaidRenderOptions,
override: MermaidRenderOptions | undefined
): MermaidRenderOptions {
if (!override) {
return base;
}
return {
...base,
...override,
textMetrics: override.textMetrics ?? base.textMetrics,
};
}
class MermaidRendererBackend extends OpConsumer<MermaidOps> {
private initPromise: Promise<void> | null = null;
private options: MermaidRenderOptions = DEFAULT_RENDER_OPTIONS;
constructor(port: MessageCommunicapable) {
super(port);
this.register('init', this.init.bind(this));
this.register('render', this.render.bind(this));
}
private ensureReady() {
if (!this.initPromise) {
this.initPromise = initMmdr().then(() => undefined);
}
return this.initPromise;
}
async init(options?: MermaidRenderOptions) {
this.options = mergeOptions(DEFAULT_RENDER_OPTIONS, options);
await this.ensureReady();
return { ok: true } as const;
}
async render({ code, options }: MermaidRenderRequest) {
await this.ensureReady();
const mergedOptions = mergeOptions(this.options, options);
const svg = render_mermaid_svg(code, JSON.stringify(mergedOptions));
return { svg };
}
}
new MermaidRendererBackend(self as MessageCommunicapable);
@@ -0,0 +1,32 @@
import type { OpSchema } from '@toeverything/infra/op';
export type MermaidTextMetrics = {
ascii: number;
cjk: number;
space: number;
};
export type MermaidRenderTheme = 'modern' | 'default';
export type MermaidRenderOptions = {
fastText?: boolean;
svgOnly?: boolean;
textMetrics?: MermaidTextMetrics;
theme?: MermaidRenderTheme;
fontFamily?: string;
fontSize?: number;
};
export type MermaidRenderRequest = {
code: string;
options?: MermaidRenderOptions;
};
export type MermaidRenderResult = {
svg: string;
};
export interface MermaidOps extends OpSchema {
init: [MermaidRenderOptions | undefined, { ok: true }];
render: [MermaidRenderRequest, MermaidRenderResult];
}
@@ -1,2 +1,10 @@
export { PDFRenderer } from './renderer';
export type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
import type { PDFOps } from './types';
export class PDFRenderer extends WorkerOpRenderer<PDFOps> {
constructor() {
super('pdf');
}
}
export type { PDFMeta, PDFOps, RenderedPage, RenderPageOpts } from './types';
@@ -1,8 +0,0 @@
import type { OpSchema } from '@toeverything/infra/op';
import type { PDFMeta, RenderedPage, RenderPageOpts } from './types';
export interface ClientOps extends OpSchema {
open: [{ data: ArrayBuffer }, PDFMeta];
render: [RenderPageOpts, RenderedPage];
}
@@ -23,10 +23,9 @@ import {
switchMap,
} from 'rxjs';
import type { ClientOps } from './ops';
import type { PDFMeta, RenderPageOpts } from './types';
import type { PDFMeta, PDFOps, RenderPageOpts } from './types';
class PDFRendererBackend extends OpConsumer<ClientOps> {
class PDFRendererBackend extends OpConsumer<PDFOps> {
constructor(port: MessageCommunicapable) {
super(port);
this.register('open', this.open.bind(this));
@@ -1,24 +0,0 @@
import { getWorkerUrl } from '@affine/env/worker';
import { OpClient } from '@toeverything/infra/op';
import type { ClientOps } from './ops';
export class PDFRenderer extends OpClient<ClientOps> {
private readonly worker: Worker;
constructor() {
const worker = new Worker(getWorkerUrl('pdf'));
super(worker);
this.worker = worker;
}
override destroy() {
super.destroy();
this.worker.terminate();
}
[Symbol.dispose]() {
this.destroy();
}
}
@@ -1,3 +1,5 @@
import type { OpSchema } from '@toeverything/infra/op';
export type PageSize = {
width: number;
height: number;
@@ -21,3 +23,8 @@ export type RenderPageOpts = {
export type RenderedPage = {
bitmap: ImageBitmap;
};
export interface PDFOps extends OpSchema {
open: [{ data: ArrayBuffer }, PDFMeta];
render: [RenderPageOpts, RenderedPage];
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { WorkerOpRenderer } from './worker-op-renderer';
vi.mock('@affine/env/worker', () => ({
getWorkerUrl: vi.fn(() => '/worker.js'),
}));
class MockWorker {
addEventListener = vi.fn();
postMessage = vi.fn();
removeEventListener = vi.fn();
terminate = vi.fn();
}
class TestRenderer extends WorkerOpRenderer<{
init: [undefined, { ok: true }];
}> {
constructor() {
super('test');
}
init() {
return this.ensureInitialized(async () => {
return { ok: true } as const;
});
}
}
describe('WorkerOpRenderer', () => {
beforeEach(() => {
vi.stubGlobal('Worker', MockWorker);
});
test('rejects initialization after destroy', async () => {
const renderer = new TestRenderer();
renderer.destroy();
await expect(renderer.init()).rejects.toThrow('renderer destroyed');
});
});
@@ -0,0 +1,47 @@
import { getWorkerUrl } from '@affine/env/worker';
import { OpClient, type OpSchema } from '@toeverything/infra/op';
type InitTask = () => Promise<unknown>;
export abstract class WorkerOpRenderer<
Ops extends OpSchema,
> extends OpClient<Ops> {
private readonly worker: Worker;
private destroyed = false;
private initPromise: Promise<void> | null = null;
protected constructor(workerName: string) {
const worker = new Worker(getWorkerUrl(workerName));
super(worker);
this.worker = worker;
}
protected ensureInitialized(task: InitTask) {
if (this.destroyed) return Promise.reject(new Error('renderer destroyed'));
if (!this.initPromise) {
this.initPromise = task()
.then(() => undefined)
.catch(error => {
this.initPromise = null;
throw error;
});
}
return this.initPromise;
}
protected resetInitialization() {
this.initPromise = null;
}
override destroy() {
if (this.destroyed) return;
this.destroyed = true;
super.destroy();
this.worker.terminate();
this.resetInitialization();
}
[Symbol.dispose]() {
this.destroy();
}
}
@@ -0,0 +1,33 @@
import { WorkerOpRenderer } from '../../shared/worker-op-renderer';
import type { TypstOps, TypstRenderOptions, TypstRenderRequest } from './types';
class TypstRenderer extends WorkerOpRenderer<TypstOps> {
constructor() {
super('typst');
}
init(options?: TypstRenderOptions) {
return this.ensureInitialized(() => this.call('init', options));
}
async render(request: TypstRenderRequest) {
await this.init();
return this.call('render', request);
}
}
let sharedTypstRenderer: TypstRenderer | null = null;
export function getTypstRenderer() {
if (!sharedTypstRenderer) {
sharedTypstRenderer = new TypstRenderer();
}
return sharedTypstRenderer;
}
export type {
TypstOps,
TypstRenderOptions,
TypstRenderRequest,
TypstRenderResult,
} from './types';
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const { loadFonts, setCompilerInitOptions, setRendererInitOptions, svg } =
vi.hoisted(() => ({
loadFonts: vi.fn((fontUrls: string[]) => ({ fontUrls })),
setCompilerInitOptions: vi.fn(),
setRendererInitOptions: vi.fn(),
svg: vi.fn(),
}));
vi.mock('@myriaddreamin/typst.ts', () => ({
$typst: {
setCompilerInitOptions,
setRendererInitOptions,
svg,
},
loadFonts,
}));
import { ensureTypstReady, renderTypstSvgWithOptions } from './runtime';
describe('typst runtime', () => {
beforeEach(() => {
vi.clearAllMocks();
svg.mockResolvedValue('<svg />');
});
test('reconfigures typst when fontUrls change', async () => {
await ensureTypstReady(['font-a']);
await ensureTypstReady(['font-b']);
expect(loadFonts).toHaveBeenNthCalledWith(
1,
['font-a'],
expect.any(Object)
);
expect(loadFonts).toHaveBeenNthCalledWith(
2,
['font-b'],
expect.any(Object)
);
expect(setCompilerInitOptions).toHaveBeenCalledTimes(2);
expect(setRendererInitOptions).toHaveBeenCalledTimes(2);
});
test('serializes typst renders that need different configuration', async () => {
const events: string[] = [];
let releaseFirstRender!: () => void;
svg.mockImplementationOnce(async () => {
events.push('svg:first:start');
await new Promise<void>(resolve => {
releaseFirstRender = resolve;
});
events.push('svg:first:end');
return '<svg>first</svg>';
});
svg.mockImplementationOnce(async () => {
events.push('svg:second:start');
return '<svg>second</svg>';
});
const first = renderTypstSvgWithOptions('= First', {
fontUrls: ['font-a'],
});
const second = renderTypstSvgWithOptions('= Second', {
fontUrls: ['font-b'],
});
await vi.waitFor(() => {
expect(events).toEqual(['svg:first:start']);
});
releaseFirstRender();
await expect(first).resolves.toEqual({ svg: '<svg>first</svg>' });
await expect(second).resolves.toEqual({ svg: '<svg>second</svg>' });
expect(events).toEqual([
'svg:first:start',
'svg:first:end',
'svg:second:start',
]);
});
});
@@ -0,0 +1,209 @@
import { $typst, type BeforeBuildFn, loadFonts } from '@myriaddreamin/typst.ts';
import type { TypstRenderOptions } from './types';
export const DEFAULT_TYPST_FONT_URLS = [
'https://cdn.affine.pro/fonts/Inter-Regular.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBold.woff',
'https://cdn.affine.pro/fonts/Inter-Italic.woff',
'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff',
'https://cdn.affine.pro/fonts/SarasaGothicCL-Regular.ttf',
] as const;
export const DEFAULT_TYPST_RENDER_OPTIONS: TypstRenderOptions = {
fontUrls: [...DEFAULT_TYPST_FONT_URLS],
};
const DEFAULT_FONT_FALLBACKS: Record<string, string> = {
'Inter-Regular.woff': 'Inter-Regular.woff2',
'Inter-SemiBold.woff': 'Inter-SemiBold.woff2',
'Inter-Italic.woff': 'Inter-Italic.woff2',
'Inter-SemiBoldItalic.woff': 'Inter-SemiBoldItalic.woff2',
'SarasaGothicCL-Regular.ttf': 'Inter-Regular.woff2',
'Inter-Regular.woff2': 'Inter-Regular.woff2',
'Inter-SemiBold.woff2': 'Inter-SemiBold.woff2',
'Inter-Italic.woff2': 'Inter-Italic.woff2',
'Inter-SemiBoldItalic.woff2': 'Inter-SemiBoldItalic.woff2',
};
const compilerWasmUrl = new URL(
'@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm',
import.meta.url
).toString();
const rendererWasmUrl = new URL(
'@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm',
import.meta.url
).toString();
type TypstWasmModuleUrls = {
compilerWasmUrl?: string;
rendererWasmUrl?: string;
};
type TypstInitState = {
key: string;
promise: Promise<void>;
};
let typstInitState: TypstInitState | null = null;
let typstRenderQueue: Promise<void> = Promise.resolve();
function extractInputUrl(input: RequestInfo | URL): string | null {
if (input instanceof URL) {
return input.toString();
}
if (typeof input === 'string') {
return input;
}
if (typeof Request !== 'undefined' && input instanceof Request) {
return input.url;
}
return null;
}
function resolveLocalFallbackFontUrl(sourceUrl: string): string | null {
if (typeof location === 'undefined') {
return null;
}
const source = new URL(sourceUrl, location.href);
const fileName = source.pathname.split('/').at(-1);
if (!fileName) {
return null;
}
const fallbackFileName = DEFAULT_FONT_FALLBACKS[fileName];
if (!fallbackFileName) {
return null;
}
const workerUrl = new URL(location.href);
const jsPathMarker = '/js/';
const markerIndex = workerUrl.pathname.lastIndexOf(jsPathMarker);
const basePath =
markerIndex >= 0 ? workerUrl.pathname.slice(0, markerIndex + 1) : '/';
return new URL(
`${basePath}fonts/${fallbackFileName}`,
workerUrl.origin
).toString();
}
export function createTypstFontFetcher(baseFetcher: typeof fetch = fetch) {
return async (input: RequestInfo | URL, init?: RequestInit) => {
const sourceUrl = extractInputUrl(input);
const fallbackUrl = sourceUrl
? resolveLocalFallbackFontUrl(sourceUrl)
: null;
try {
const response = await baseFetcher(input, init);
if (!fallbackUrl || response.ok || fallbackUrl === sourceUrl) {
return response;
}
const fallbackResponse = await baseFetcher(fallbackUrl, init);
return fallbackResponse.ok ? fallbackResponse : response;
} catch (error) {
if (!fallbackUrl || fallbackUrl === sourceUrl) {
throw error;
}
return baseFetcher(fallbackUrl, init);
}
};
}
export function mergeTypstRenderOptions(
base: TypstRenderOptions,
override: TypstRenderOptions | undefined
): TypstRenderOptions {
return {
...base,
...override,
fontUrls: override?.fontUrls ?? base.fontUrls,
};
}
function getBeforeBuildHooks(fontUrls: string[]): BeforeBuildFn[] {
return [
loadFonts([...fontUrls], {
assets: ['text'],
fetcher: createTypstFontFetcher(),
}),
];
}
function createTypstInitKey(
fontUrls: string[],
wasmModuleUrls: TypstWasmModuleUrls
) {
return JSON.stringify({
fontUrls,
compilerWasmUrl: wasmModuleUrls.compilerWasmUrl ?? compilerWasmUrl,
rendererWasmUrl: wasmModuleUrls.rendererWasmUrl ?? rendererWasmUrl,
});
}
function enqueueTypstRender<T>(task: () => Promise<T>): Promise<T> {
const run = typstRenderQueue.then(task, task);
typstRenderQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
export async function ensureTypstReady(
fontUrls: string[],
wasmModuleUrls: TypstWasmModuleUrls = {}
) {
const key = createTypstInitKey(fontUrls, wasmModuleUrls);
if (typstInitState?.key === key) {
return typstInitState.promise;
}
const promise = Promise.resolve()
.then(() => {
const compilerBeforeBuild = getBeforeBuildHooks(fontUrls);
$typst.setCompilerInitOptions({
beforeBuild: compilerBeforeBuild,
getModule: () => wasmModuleUrls.compilerWasmUrl ?? compilerWasmUrl,
});
$typst.setRendererInitOptions({
getModule: () => wasmModuleUrls.rendererWasmUrl ?? rendererWasmUrl,
});
})
.catch(error => {
if (typstInitState?.key === key) {
typstInitState = null;
}
throw error;
});
typstInitState = { key, promise };
return promise;
}
export async function renderTypstSvgWithOptions(
code: string,
options: TypstRenderOptions | undefined,
wasmModuleUrls?: TypstWasmModuleUrls
) {
const resolvedOptions = mergeTypstRenderOptions(
DEFAULT_TYPST_RENDER_OPTIONS,
options
);
return enqueueTypstRender(async () => {
await ensureTypstReady(
resolvedOptions.fontUrls ?? [...DEFAULT_TYPST_FONT_URLS],
wasmModuleUrls
);
const svg = await $typst.svg({
mainContent: code,
});
return { svg };
});
}
@@ -0,0 +1,19 @@
import type { OpSchema } from '@toeverything/infra/op';
export type TypstRenderOptions = {
fontUrls?: string[];
};
export type TypstRenderRequest = {
code: string;
options?: TypstRenderOptions;
};
export type TypstRenderResult = {
svg: string;
};
export interface TypstOps extends OpSchema {
init: [TypstRenderOptions | undefined, { ok: true }];
render: [TypstRenderRequest, TypstRenderResult];
}
@@ -0,0 +1,40 @@
import type { MessageCommunicapable } from '@toeverything/infra/op';
import { OpConsumer } from '@toeverything/infra/op';
import {
DEFAULT_TYPST_RENDER_OPTIONS,
ensureTypstReady,
mergeTypstRenderOptions,
renderTypstSvgWithOptions,
} from './runtime';
import type { TypstOps, TypstRenderOptions, TypstRenderRequest } from './types';
class TypstRendererBackend extends OpConsumer<TypstOps> {
private options: TypstRenderOptions = DEFAULT_TYPST_RENDER_OPTIONS;
constructor(port: MessageCommunicapable) {
super(port);
this.register('init', this.init.bind(this));
this.register('render', this.render.bind(this));
}
async init(options?: TypstRenderOptions) {
this.options = mergeTypstRenderOptions(
DEFAULT_TYPST_RENDER_OPTIONS,
options
);
await ensureTypstReady(
this.options.fontUrls ?? [
...(DEFAULT_TYPST_RENDER_OPTIONS.fontUrls ?? []),
]
);
return { ok: true } as const;
}
async render({ code, options }: TypstRenderRequest) {
const mergedOptions = mergeTypstRenderOptions(this.options, options);
return renderTypstSvgWithOptions(code, mergedOptions);
}
}
new TypstRendererBackend(self as MessageCommunicapable);