mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 04:26:23 +08:00
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:
@@ -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 };
|
||||
}
|
||||
+68
@@ -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]);
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
+24
@@ -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 };
|
||||
+26
@@ -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);
|
||||
Reference in New Issue
Block a user