From 477662864f5f111d6d03d4ca615883db0c4e5514 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Wed, 18 Mar 2026 21:45:31 +0800 Subject: [PATCH] fix: lint --- .../app/affine/pro/plugin/PreviewPlugin.kt | 38 ++++++++- .../src/plugins/preview/definitions.ts | 1 - .../App/Plugins/Preview/PreviewPlugin.swift | 49 ++++++++++- .../Sources/Schema/CustomScalars/JSON.swift | 12 +-- .../Schema/CustomScalars/JSONObject.swift | 20 ++++- .../ios/src/plugins/preview/definitions.ts | 1 - .../classic-mermaid.spec.ts | 68 +++++++++++++++ .../classic-mermaid.ts | 20 ++++- .../modules/shared/worker-op-renderer.spec.ts | 42 ++++++++++ .../src/modules/shared/worker-op-renderer.ts | 4 + .../modules/typst/renderer/runtime.spec.ts | 84 +++++++++++++++++++ .../src/modules/typst/renderer/runtime.ts | 58 ++++++++++--- .../core/src/modules/typst/renderer/types.ts | 1 - .../modules/typst/renderer/typst.worker.ts | 6 +- packages/frontend/native/index.d.ts | 10 --- packages/frontend/native/src/preview.rs | 47 +++++++---- 16 files changed, 404 insertions(+), 57 deletions(-) create mode 100644 packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.spec.ts create mode 100644 packages/frontend/core/src/modules/shared/worker-op-renderer.spec.ts create mode 100644 packages/frontend/core/src/modules/typst/renderer/runtime.spec.ts diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PreviewPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PreviewPlugin.kt index 38f08ff4f3..444c95c77f 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PreviewPlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PreviewPlugin.kt @@ -1,5 +1,6 @@ package app.affine.pro.plugin +import android.net.Uri import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.getcapacitor.PluginCall @@ -9,6 +10,7 @@ import kotlinx.coroutines.Dispatchers import timber.log.Timber import uniffi.affine_mobile_native.renderMermaidPreviewSvg import uniffi.affine_mobile_native.renderTypstPreviewSvg +import java.io.File private fun JSObject.getOptionalString(key: String): String? { return if (has(key) && !isNull(key)) getString(key) else null @@ -18,6 +20,39 @@ private fun JSObject.getOptionalDouble(key: String): Double? { return if (has(key) && !isNull(key)) getDouble(key) else null } +private fun resolveLocalFontDir(fontUrl: String): String? { + val uri = Uri.parse(fontUrl) + val path = when { + uri.scheme == null -> { + val file = File(fontUrl) + if (!file.isAbsolute) { + return null + } + file.path + } + uri.scheme == "file" -> uri.path + else -> null + } ?: return null + + val file = File(path) + val directory = if (file.isDirectory) file else file.parentFile ?: return null + return directory.absolutePath +} + +private fun JSObject.resolveTypstFontDirs(): List? { + val fontUrls = optJSONArray("fontUrls") ?: return null + val fontDirs = buildList(fontUrls.length()) { + repeat(fontUrls.length()) { index -> + val fontUrl = fontUrls.optString(index, null) + ?: throw IllegalArgumentException("Typst preview fontUrls must be strings.") + val fontDir = resolveLocalFontDir(fontUrl) + ?: throw IllegalArgumentException("Typst preview on mobile only supports local font file URLs or absolute font directories.") + add(fontDir) + } + } + return fontDirs.distinct() +} + @CapacitorPlugin(name = "Preview") class PreviewPlugin : Plugin() { @@ -48,9 +83,10 @@ class PreviewPlugin : Plugin() { launch(Dispatchers.IO) { try { val code = call.getStringEnsure("code") + val options = call.getObject("options") val svg = renderTypstPreviewSvg( code = code, - fontDirs = null, + fontDirs = options?.resolveTypstFontDirs(), cacheDir = context.cacheDir.absolutePath, ) call.resolve(JSObject().apply { diff --git a/packages/frontend/apps/android/src/plugins/preview/definitions.ts b/packages/frontend/apps/android/src/plugins/preview/definitions.ts index a74ec9c724..5e6cfaf26f 100644 --- a/packages/frontend/apps/android/src/plugins/preview/definitions.ts +++ b/packages/frontend/apps/android/src/plugins/preview/definitions.ts @@ -11,7 +11,6 @@ export interface PreviewPlugin { code: string; options?: { fontUrls?: string[]; - theme?: 'light' | 'dark'; }; }): Promise<{ svg: string }>; } diff --git a/packages/frontend/apps/ios/App/App/Plugins/Preview/PreviewPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/Preview/PreviewPlugin.swift index b894da008d..08ca1e8748 100644 --- a/packages/frontend/apps/ios/App/App/Plugins/Preview/PreviewPlugin.swift +++ b/packages/frontend/apps/ios/App/App/Plugins/Preview/PreviewPlugin.swift @@ -1,6 +1,51 @@ import Foundation import Capacitor +private func resolveLocalFontDir(from fontURL: String) -> String? { + let path: String + if fontURL.hasPrefix("file://") { + guard let url = URL(string: fontURL), url.isFileURL else { + return nil + } + path = url.path + } else { + let candidate = (fontURL as NSString).standardizingPath + guard candidate.hasPrefix("/") else { + return nil + } + path = candidate + } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + isDirectory.boolValue + { + return path + } + + let directory = (path as NSString).deletingLastPathComponent + return directory.isEmpty ? nil : directory +} + +private func resolveTypstFontDirs(from options: [AnyHashable: Any]?) throws -> [String]? { + guard let fontUrls = options?["fontUrls"] as? [String] else { + return nil + } + + return Array(Set(try fontUrls.map { fontURL in + guard let fontDir = resolveLocalFontDir(from: fontURL) else { + throw NSError( + domain: "PreviewPlugin", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Typst preview on mobile only supports local font file URLs or absolute font directories." + ] + ) + } + return fontDir + })) +} + @objc(PreviewPlugin) public class PreviewPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "PreviewPlugin" @@ -32,8 +77,10 @@ public class PreviewPlugin: CAPPlugin, CAPBridgedPlugin { DispatchQueue.global(qos: .userInitiated).async { do { let code = try call.getStringEnsure("code") + let options = call.getObject("options") let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path - let svg = try renderTypstPreviewSvg(code: code, fontDirs: nil, cacheDir: cacheDir) + let fontDirs = try resolveTypstFontDirs(from: options) + let svg = try renderTypstPreviewSvg(code: code, fontDirs: fontDirs, cacheDir: cacheDir) call.resolve(["svg": svg]) } catch { call.reject("Failed to render Typst preview, \(error)", nil, error) diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSON.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSON.swift index 3329c3afd9..e17e2f6a13 100644 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSON.swift +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSON.swift @@ -9,21 +9,17 @@ import ApolloAPI /// The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). public struct JSON: CustomScalarType, Hashable, ExpressibleByDictionaryLiteral { - public let object: ApolloAPI.JSONObject + public let value: JSONValue public init(_jsonValue value: JSONValue) throws { - object = try ApolloAPI.JSONObject(_jsonValue: value) - } - - public init(_ object: ApolloAPI.JSONObject) { - self.object = object + self.value = value } public init(dictionaryLiteral elements: (String, JSONValue)...) { - object = .init(uniqueKeysWithValues: elements) + value = ApolloAPI.JSONObject(uniqueKeysWithValues: elements) as JSONValue } public var _jsonValue: JSONValue { - object + value } } diff --git a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift index 85e3b26898..c1f2c501f4 100644 --- a/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift +++ b/packages/frontend/apps/ios/App/Packages/AffineGraphQL/Sources/Schema/CustomScalars/JSONObject.swift @@ -8,4 +8,22 @@ import ApolloAPI /// The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -public typealias JSONObject = JSON +public struct JSONObject: CustomScalarType, Hashable, ExpressibleByDictionaryLiteral { + public let object: ApolloAPI.JSONObject + + public init(_jsonValue value: JSONValue) throws { + object = try ApolloAPI.JSONObject(_jsonValue: value) + } + + public init(_ object: ApolloAPI.JSONObject) { + self.object = object + } + + public init(dictionaryLiteral elements: (String, JSONValue)...) { + object = ApolloAPI.JSONObject(uniqueKeysWithValues: elements) + } + + public var _jsonValue: JSONValue { + object + } +} diff --git a/packages/frontend/apps/ios/src/plugins/preview/definitions.ts b/packages/frontend/apps/ios/src/plugins/preview/definitions.ts index a74ec9c724..5e6cfaf26f 100644 --- a/packages/frontend/apps/ios/src/plugins/preview/definitions.ts +++ b/packages/frontend/apps/ios/src/plugins/preview/definitions.ts @@ -11,7 +11,6 @@ export interface PreviewPlugin { code: string; options?: { fontUrls?: string[]; - theme?: 'light' | 'dark'; }; }): Promise<{ svg: string }>; } diff --git a/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.spec.ts b/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.spec.ts new file mode 100644 index 0000000000..5d7711ac84 --- /dev/null +++ b/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.spec.ts @@ -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(resolve => { + releaseFirstRender = resolve; + }); + events.push('render:first:end'); + return { svg: 'first' }; + }) + .mockImplementationOnce(async () => { + events.push('render:second:start'); + return { svg: 'second' }; + }); + + 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: 'first' }); + await expect(second).resolves.toEqual({ svg: 'second' }); + expect(events).toEqual([ + 'init:default', + 'render:first:start', + 'render:first:end', + 'init:base', + 'render:second:start', + ]); + }); +}); diff --git a/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.ts b/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.ts index 99b5703c86..5f21964741 100644 --- a/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.ts +++ b/packages/frontend/core/src/modules/code-block-preview-renderer/classic-mermaid.ts @@ -8,6 +8,7 @@ import type { } from '../mermaid/renderer'; let mermaidPromise: Promise | null = null; +let mermaidRenderQueue: Promise = Promise.resolve(); function toTheme(theme: MermaidRenderTheme | undefined) { return theme === 'modern' ? ('base' as const) : ('default' as const); @@ -39,12 +40,23 @@ function createDiagramId() { return `mermaid-diagram-${Date.now()}-${Math.random().toString(36).slice(2)}`; } +function enqueueClassicMermaidRender(task: () => Promise): Promise { + const run = mermaidRenderQueue.then(task, task); + mermaidRenderQueue = run.then( + () => undefined, + () => undefined + ); + return run; +} + export async function renderClassicMermaidSvg( request: MermaidRenderRequest ): Promise { - const mermaid = await loadMermaid(); - mermaid.initialize(createClassicMermaidConfig(request.options)); + return enqueueClassicMermaidRender(async () => { + const mermaid = await loadMermaid(); + mermaid.initialize(createClassicMermaidConfig(request.options)); - const { svg } = await mermaid.render(createDiagramId(), request.code); - return { svg }; + const { svg } = await mermaid.render(createDiagramId(), request.code); + return { svg }; + }); } diff --git a/packages/frontend/core/src/modules/shared/worker-op-renderer.spec.ts b/packages/frontend/core/src/modules/shared/worker-op-renderer.spec.ts new file mode 100644 index 0000000000..27898acace --- /dev/null +++ b/packages/frontend/core/src/modules/shared/worker-op-renderer.spec.ts @@ -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'); + }); +}); diff --git a/packages/frontend/core/src/modules/shared/worker-op-renderer.ts b/packages/frontend/core/src/modules/shared/worker-op-renderer.ts index ebfc30794e..b937092986 100644 --- a/packages/frontend/core/src/modules/shared/worker-op-renderer.ts +++ b/packages/frontend/core/src/modules/shared/worker-op-renderer.ts @@ -7,6 +7,7 @@ export abstract class WorkerOpRenderer< Ops extends OpSchema, > extends OpClient { private readonly worker: Worker; + private destroyed = false; private initPromise: Promise | null = null; protected constructor(workerName: string) { @@ -16,6 +17,7 @@ export abstract class WorkerOpRenderer< } protected ensureInitialized(task: InitTask) { + if (this.destroyed) return Promise.reject(new Error('renderer destroyed')); if (!this.initPromise) { this.initPromise = task() .then(() => undefined) @@ -32,6 +34,8 @@ export abstract class WorkerOpRenderer< } override destroy() { + if (this.destroyed) return; + this.destroyed = true; super.destroy(); this.worker.terminate(); this.resetInitialization(); diff --git a/packages/frontend/core/src/modules/typst/renderer/runtime.spec.ts b/packages/frontend/core/src/modules/typst/renderer/runtime.spec.ts new file mode 100644 index 0000000000..0443c2c390 --- /dev/null +++ b/packages/frontend/core/src/modules/typst/renderer/runtime.spec.ts @@ -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(''); + }); + + 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(resolve => { + releaseFirstRender = resolve; + }); + events.push('svg:first:end'); + return 'first'; + }); + svg.mockImplementationOnce(async () => { + events.push('svg:second:start'); + return 'second'; + }); + + 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: 'first' }); + await expect(second).resolves.toEqual({ svg: 'second' }); + expect(events).toEqual([ + 'svg:first:start', + 'svg:first:end', + 'svg:second:start', + ]); + }); +}); diff --git a/packages/frontend/core/src/modules/typst/renderer/runtime.ts b/packages/frontend/core/src/modules/typst/renderer/runtime.ts index 3053e529c8..1b9ebba9fa 100644 --- a/packages/frontend/core/src/modules/typst/renderer/runtime.ts +++ b/packages/frontend/core/src/modules/typst/renderer/runtime.ts @@ -41,7 +41,13 @@ type TypstWasmModuleUrls = { rendererWasmUrl?: string; }; -let typstInitPromise: Promise | null = null; +type TypstInitState = { + key: string; + promise: Promise; +}; + +let typstInitState: TypstInitState | null = null; +let typstRenderQueue: Promise = Promise.resolve(); function extractInputUrl(input: RequestInfo | URL): string | null { if (input instanceof URL) { @@ -129,15 +135,36 @@ function getBeforeBuildHooks(fontUrls: string[]): BeforeBuildFn[] { ]; } +function createTypstInitKey( + fontUrls: string[], + wasmModuleUrls: TypstWasmModuleUrls +) { + return JSON.stringify({ + fontUrls, + compilerWasmUrl: wasmModuleUrls.compilerWasmUrl ?? compilerWasmUrl, + rendererWasmUrl: wasmModuleUrls.rendererWasmUrl ?? rendererWasmUrl, + }); +} + +function enqueueTypstRender(task: () => Promise): Promise { + const run = typstRenderQueue.then(task, task); + typstRenderQueue = run.then( + () => undefined, + () => undefined + ); + return run; +} + export async function ensureTypstReady( fontUrls: string[], wasmModuleUrls: TypstWasmModuleUrls = {} ) { - if (typstInitPromise) { - return typstInitPromise; + const key = createTypstInitKey(fontUrls, wasmModuleUrls); + if (typstInitState?.key === key) { + return typstInitState.promise; } - typstInitPromise = Promise.resolve() + const promise = Promise.resolve() .then(() => { const compilerBeforeBuild = getBeforeBuildHooks(fontUrls); @@ -150,11 +177,14 @@ export async function ensureTypstReady( }); }) .catch(error => { - typstInitPromise = null; + if (typstInitState?.key === key) { + typstInitState = null; + } throw error; }); - return typstInitPromise; + typstInitState = { key, promise }; + return promise; } export async function renderTypstSvgWithOptions( @@ -166,12 +196,14 @@ export async function renderTypstSvgWithOptions( DEFAULT_TYPST_RENDER_OPTIONS, options ); - await ensureTypstReady( - resolvedOptions.fontUrls ?? [...DEFAULT_TYPST_FONT_URLS], - wasmModuleUrls - ); - const svg = await $typst.svg({ - mainContent: code, + return enqueueTypstRender(async () => { + await ensureTypstReady( + resolvedOptions.fontUrls ?? [...DEFAULT_TYPST_FONT_URLS], + wasmModuleUrls + ); + const svg = await $typst.svg({ + mainContent: code, + }); + return { svg }; }); - return { svg }; } diff --git a/packages/frontend/core/src/modules/typst/renderer/types.ts b/packages/frontend/core/src/modules/typst/renderer/types.ts index 18697582a8..b9804ade4d 100644 --- a/packages/frontend/core/src/modules/typst/renderer/types.ts +++ b/packages/frontend/core/src/modules/typst/renderer/types.ts @@ -2,7 +2,6 @@ import type { OpSchema } from '@toeverything/infra/op'; export type TypstRenderOptions = { fontUrls?: string[]; - theme?: 'light' | 'dark'; }; export type TypstRenderRequest = { diff --git a/packages/frontend/core/src/modules/typst/renderer/typst.worker.ts b/packages/frontend/core/src/modules/typst/renderer/typst.worker.ts index e4a88a6262..ae758b07fd 100644 --- a/packages/frontend/core/src/modules/typst/renderer/typst.worker.ts +++ b/packages/frontend/core/src/modules/typst/renderer/typst.worker.ts @@ -23,7 +23,11 @@ class TypstRendererBackend extends OpConsumer { DEFAULT_TYPST_RENDER_OPTIONS, options ); - await ensureTypstReady(this.options.fontUrls ?? []); + await ensureTypstReady( + this.options.fontUrls ?? [ + ...(DEFAULT_TYPST_RENDER_OPTIONS.fontUrls ?? []), + ] + ); return { ok: true } as const; } diff --git a/packages/frontend/native/index.d.ts b/packages/frontend/native/index.d.ts index ac97659e4e..1c21358b7f 100644 --- a/packages/frontend/native/index.d.ts +++ b/packages/frontend/native/index.d.ts @@ -41,9 +41,6 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u /** Decode audio file into a Float32Array */ export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array export interface MermaidRenderOptions { - fastText?: boolean - svgOnly?: boolean - textMetrics?: MermaidTextMetrics theme?: string fontFamily?: string fontSize?: number @@ -58,12 +55,6 @@ export interface MermaidRenderResult { svg: string } -export interface MermaidTextMetrics { - ascii: number - cjk: number - space: number -} - export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise export declare function renderMermaidSvg(request: MermaidRenderRequest): MermaidRenderResult @@ -72,7 +63,6 @@ export declare function renderTypstSvg(request: TypstRenderRequest): TypstRender export interface TypstRenderOptions { fontUrls?: Array - theme?: string fontDirs?: Array } diff --git a/packages/frontend/native/src/preview.rs b/packages/frontend/native/src/preview.rs index d1f6008c05..53042351a8 100644 --- a/packages/frontend/native/src/preview.rs +++ b/packages/frontend/native/src/preview.rs @@ -6,18 +6,8 @@ use napi_derive::napi; use typst::layout::{Abs, PagedDocument}; use typst_as_lib::{TypstEngine, typst_kit_options::TypstKitFontOptions}; -#[napi(object)] -pub struct MermaidTextMetrics { - pub ascii: f64, - pub cjk: f64, - pub space: f64, -} - #[napi(object)] pub struct MermaidRenderOptions { - pub fast_text: Option, - pub svg_only: Option, - pub text_metrics: Option, pub theme: Option, pub font_family: Option, pub font_size: Option, @@ -65,7 +55,6 @@ pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result>, - pub theme: Option, pub font_dirs: Option>, } @@ -80,12 +69,40 @@ pub struct TypstRenderResult { pub svg: String, } +fn resolve_local_font_dir(value: &str) -> Option { + let path = if let Some(stripped) = value.strip_prefix("file://") { + PathBuf::from(stripped) + } else { + let path = PathBuf::from(value); + if !path.is_absolute() { + return None; + } + path + }; + + if path.is_dir() { + return Some(path); + } + + path.parent().map(|parent| parent.to_path_buf()) +} + fn resolve_typst_font_dirs(options: &Option) -> Vec { - options + let Some(options) = options.as_ref() else { + return Vec::new(); + }; + + let mut font_dirs = options + .font_dirs .as_ref() - .and_then(|options| options.font_dirs.as_ref()) - .map(|dirs| dirs.iter().map(PathBuf::from).collect()) - .unwrap_or_default() + .map(|dirs| dirs.iter().map(PathBuf::from).collect::>()) + .unwrap_or_default(); + + if let Some(font_urls) = options.font_urls.as_ref() { + font_dirs.extend(font_urls.iter().filter_map(|url| resolve_local_font_dir(url))); + } + + font_dirs } fn normalize_typst_svg(svg: String) -> String {