fix: lint

This commit is contained in:
DarkSky
2026-03-18 21:45:31 +08:00
parent 502a49eb7b
commit 477662864f
16 changed files with 404 additions and 57 deletions

View File

@@ -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<String>? {
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 {

View File

@@ -11,7 +11,6 @@ export interface PreviewPlugin {
code: string;
options?: {
fontUrls?: string[];
theme?: 'light' | 'dark';
};
}): Promise<{ svg: string }>;
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -11,7 +11,6 @@ export interface PreviewPlugin {
code: string;
options?: {
fontUrls?: string[];
theme?: 'light' | 'dark';
};
}): Promise<{ svg: string }>;
}

View File

@@ -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',
]);
});
});

View File

@@ -8,6 +8,7 @@ import type {
} 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);
@@ -39,12 +40,23 @@ 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> {
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 };
});
}

View File

@@ -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');
});
});

View File

@@ -7,6 +7,7 @@ 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) {
@@ -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();

View File

@@ -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',
]);
});
});

View File

@@ -41,7 +41,13 @@ type TypstWasmModuleUrls = {
rendererWasmUrl?: string;
};
let typstInitPromise: Promise<void> | null = null;
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) {
@@ -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<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 = {}
) {
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 };
}

View File

@@ -2,7 +2,6 @@ import type { OpSchema } from '@toeverything/infra/op';
export type TypstRenderOptions = {
fontUrls?: string[];
theme?: 'light' | 'dark';
};
export type TypstRenderRequest = {

View File

@@ -23,7 +23,11 @@ class TypstRendererBackend extends OpConsumer<TypstOps> {
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;
}

View File

@@ -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<string>
export declare function renderMermaidSvg(request: MermaidRenderRequest): MermaidRenderResult
@@ -72,7 +63,6 @@ export declare function renderTypstSvg(request: TypstRenderRequest): TypstRender
export interface TypstRenderOptions {
fontUrls?: Array<string>
theme?: string
fontDirs?: Array<string>
}

View File

@@ -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<bool>,
pub svg_only: Option<bool>,
pub text_metrics: Option<MermaidTextMetrics>,
pub theme: Option<String>,
pub font_family: Option<String>,
pub font_size: Option<f64>,
@@ -65,7 +55,6 @@ pub fn render_mermaid_svg(request: MermaidRenderRequest) -> Result<MermaidRender
#[napi(object)]
pub struct TypstRenderOptions {
pub font_urls: Option<Vec<String>>,
pub theme: Option<String>,
pub font_dirs: Option<Vec<String>>,
}
@@ -80,12 +69,40 @@ pub struct TypstRenderResult {
pub svg: String,
}
fn resolve_local_font_dir(value: &str) -> Option<PathBuf> {
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<TypstRenderOptions>) -> Vec<PathBuf> {
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::<Vec<_>>())
.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 {