feat: 发布 v0.4.0 桌面端
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Desktop 桌面端
|
||||
|
||||
`apps/desktop` 是 `v0.4.0` 新增的 Electron 桌面应用。当前已经完成
|
||||
Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron
|
||||
Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。
|
||||
|
||||
## 开发
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
桌面端不启动 Fastify,也不占用本地 HTTP 端口。React 界面通过受限 IPC
|
||||
调用 `packages/application` 中的 Markdown 渲染和主题服务,PDF 由
|
||||
Electron 自带 Chromium 生成。Web 与 Docker 继续通过 Fastify HTTP
|
||||
适配器使用同一套应用服务。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
```powershell
|
||||
npm run build -w @md-to-pdf/desktop
|
||||
npm run desktop:package
|
||||
npm run make -w @md-to-pdf/desktop
|
||||
```
|
||||
|
||||
版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到
|
||||
`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以
|
||||
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名,
|
||||
Windows 首次运行可能显示“未知发布者”提示。
|
||||
|
||||
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。
|
||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||
`mdpdf://bundle/` 自定义协议加载。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||
- 启用 Context Isolation 和 Chromium Sandbox;
|
||||
- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口;
|
||||
- 主进程校验 IPC 发送者和分页载荷;
|
||||
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
|
||||
- 应用页面使用自定义协议,不使用 `file://`。
|
||||
@@ -0,0 +1,45 @@
|
||||
const path = require("node:path");
|
||||
const windowsIcon = path.resolve(
|
||||
__dirname,
|
||||
"../../logos/desktop/windows/app.ico"
|
||||
);
|
||||
const macosIcon = path.resolve(
|
||||
__dirname,
|
||||
"../../logos/desktop/macos/app.icns"
|
||||
);
|
||||
const { version } = require("../../package.json");
|
||||
|
||||
module.exports = {
|
||||
buildIdentifier: `v${version}`,
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
prune: false,
|
||||
icon: process.platform === "darwin" ? macosIcon : windowsIcon,
|
||||
ignore: [
|
||||
/^\/node_modules(?:\/|$)/,
|
||||
/^\/out(?:\/|$)/,
|
||||
/^\/src(?:\/|$)/,
|
||||
/^\/tests(?:\/|$)/,
|
||||
/^\/tsconfig\.json$/
|
||||
],
|
||||
extraResource: [
|
||||
path.resolve(__dirname, "../web/dist"),
|
||||
path.resolve(__dirname, "../../themes"),
|
||||
windowsIcon,
|
||||
macosIcon
|
||||
]
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
name: "@electron-forge/maker-squirrel",
|
||||
config: {
|
||||
name: "markdown_pdf_exporter",
|
||||
setupIcon: windowsIcon
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "@electron-forge/maker-zip",
|
||||
platforms: ["win32", "darwin"]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"productName": "Markdown PDF 导出器",
|
||||
"description": "Markdown PDF 导出器桌面端",
|
||||
"author": "md-to-pdf contributors",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||
"build:main": "node scripts/build-main.mjs",
|
||||
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||
"package": "npm run build && electron-forge package",
|
||||
"make": "npm run build && electron-forge make",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "7.11.2",
|
||||
"@electron-forge/maker-squirrel": "7.11.2",
|
||||
"@electron-forge/maker-zip": "7.11.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"electron": "43.2.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
external: ["electron"],
|
||||
outfile: "dist/main.js",
|
||||
banner: {
|
||||
js: [
|
||||
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
|
||||
"const require = __mdToPdfCreateRequire(import.meta.url);"
|
||||
].join("\n")
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { PagedDocumentPayload } from "@md-to-pdf/core";
|
||||
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||
import {
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF
|
||||
} from "./channels.js";
|
||||
|
||||
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
|
||||
renderMarkdown: (input: MarkdownRenderRequest) =>
|
||||
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
|
||||
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
|
||||
getThemeCss: (themeId: string) =>
|
||||
ipcRenderer.invoke(DESKTOP_GET_THEME_CSS, themeId),
|
||||
generatePdf: (payload: PagedDocumentPayload) =>
|
||||
ipcRenderer.invoke(DESKTOP_GENERATE_PDF, payload),
|
||||
savePdf: (fileName: string, pdf: Uint8Array) =>
|
||||
ipcRenderer.invoke(DESKTOP_SAVE_PDF, fileName, pdf)
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { MarkdownRenderRequest } from "@md-to-pdf/application";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function parseMarkdownRenderRequest(
|
||||
value: unknown
|
||||
): MarkdownRenderRequest {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
markdown: value.markdown,
|
||||
language: value.language
|
||||
};
|
||||
}
|
||||
|
||||
export function parseThemeId(value: unknown) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(value)
|
||||
) {
|
||||
throw new Error("主题 ID 无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseThemeResourceUrl(requestUrl: string) {
|
||||
const url = new URL(requestUrl);
|
||||
if (url.protocol !== "mdpdf:" || url.host !== "theme") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let segments: string[];
|
||||
try {
|
||||
segments = url.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
} catch {
|
||||
throw new Error("主题资源地址无效");
|
||||
}
|
||||
const [unsafeThemeId, ...assetSegments] = segments;
|
||||
if (!unsafeThemeId || assetSegments.length === 0) {
|
||||
throw new Error("主题资源地址无效");
|
||||
}
|
||||
|
||||
return {
|
||||
themeId: parseThemeId(unsafeThemeId),
|
||||
assetPath: assetSegments.join("/")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const DESKTOP_RENDER_MARKDOWN =
|
||||
"md-to-pdf:desktop:render-markdown";
|
||||
export const DESKTOP_LIST_THEMES =
|
||||
"md-to-pdf:desktop:list-themes";
|
||||
export const DESKTOP_GET_THEME_CSS =
|
||||
"md-to-pdf:desktop:get-theme-css";
|
||||
export const DESKTOP_GENERATE_PDF =
|
||||
"md-to-pdf:desktop:generate-pdf";
|
||||
export const DESKTOP_SAVE_PDF =
|
||||
"md-to-pdf:desktop:save-pdf";
|
||||
export const PDF_RUNTIME_READY =
|
||||
"md-to-pdf:desktop-pdf:ready";
|
||||
export const PDF_RUNTIME_RENDER =
|
||||
"md-to-pdf:desktop-pdf:render";
|
||||
export const PDF_RUNTIME_COMPLETE =
|
||||
"md-to-pdf:desktop-pdf:complete";
|
||||
export const PDF_RUNTIME_FAIL =
|
||||
"md-to-pdf:desktop-pdf:fail";
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
type IpcMainEvent
|
||||
} from "electron";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
PDF_RUNTIME_FAIL,
|
||||
PDF_RUNTIME_READY,
|
||||
PDF_RUNTIME_RENDER
|
||||
} from "./channels.js";
|
||||
import {
|
||||
createElectronPdfPrintOptions,
|
||||
isAllowedPdfRuntimeUrl
|
||||
} from "./pdf-contract.js";
|
||||
|
||||
export const DESKTOP_PDF_PARTITION =
|
||||
"md-to-pdf-desktop-pdf";
|
||||
|
||||
export interface ElectronPdfGenerationResult {
|
||||
pdf: Buffer;
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
timings: {
|
||||
documentRenderMs: number;
|
||||
pdfPrintMs: number;
|
||||
totalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronPdfGeneratorOptions {
|
||||
renderUrl: string;
|
||||
preloadPath: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingRender {
|
||||
requestId: number;
|
||||
resolve: (result: PagedDocumentRenderResult) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
|
||||
async function waitWithTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
) {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(message)),
|
||||
timeoutMs
|
||||
);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronPdfGenerator {
|
||||
private readonly renderUrl: string;
|
||||
private readonly preloadPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||
private renderWindow: BrowserWindow | undefined;
|
||||
private pendingRender: PendingRender | undefined;
|
||||
private nextRequestId = 1;
|
||||
private queue = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: ElectronPdfGeneratorOptions) {
|
||||
this.renderUrl = options.renderUrl;
|
||||
this.preloadPath = options.preloadPath;
|
||||
this.timeoutMs = options.timeoutMs ?? 60_000;
|
||||
|
||||
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
|
||||
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||
}
|
||||
|
||||
generate(payload: PagedDocumentPayload) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||
}
|
||||
|
||||
const operation = this.queue.then(() => this.generateNow(payload));
|
||||
this.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
ipcMain.removeListener(
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
this.handleRenderComplete
|
||||
);
|
||||
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||
this.pendingRender = undefined;
|
||||
|
||||
const windowPromise = this.windowPromise;
|
||||
this.windowPromise = undefined;
|
||||
this.renderWindow = undefined;
|
||||
if (!windowPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const window = await windowPromise;
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
} catch {
|
||||
// 创建失败时没有需要关闭的窗口。
|
||||
}
|
||||
}
|
||||
|
||||
private async generateNow(payload: PagedDocumentPayload) {
|
||||
const startedAt = performance.now();
|
||||
const window = await this.getWindow();
|
||||
const documentRenderStartedAt = performance.now();
|
||||
const renderResult = await this.requestRender(window, payload);
|
||||
const documentRenderMs =
|
||||
performance.now() - documentRenderStartedAt;
|
||||
await window.webContents.executeJavaScript(
|
||||
"new Promise((resolve) => setTimeout(resolve, 0))"
|
||||
);
|
||||
|
||||
const pdfPrintStartedAt = performance.now();
|
||||
window.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
let pdf: Buffer;
|
||||
try {
|
||||
pdf = await waitWithTimeout(
|
||||
window.webContents.printToPDF(
|
||||
createElectronPdfPrintOptions(payload, renderResult)
|
||||
),
|
||||
this.timeoutMs,
|
||||
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
|
||||
);
|
||||
} catch (error) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
|
||||
|
||||
return {
|
||||
pdf,
|
||||
pageCount: renderResult.pageCount,
|
||||
echartsErrors: renderResult.echartsErrors,
|
||||
mermaidErrors: renderResult.mermaidErrors,
|
||||
timings: {
|
||||
documentRenderMs,
|
||||
pdfPrintMs,
|
||||
totalMs: performance.now() - startedAt
|
||||
}
|
||||
} satisfies ElectronPdfGenerationResult;
|
||||
}
|
||||
|
||||
private getWindow() {
|
||||
if (!this.windowPromise) {
|
||||
this.windowPromise = this.createWindow().catch((error) => {
|
||||
this.windowPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this.windowPromise;
|
||||
}
|
||||
|
||||
private async createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
focusable: false,
|
||||
opacity: 0,
|
||||
skipTaskbar: true,
|
||||
x: -32_000,
|
||||
y: -32_000,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: DESKTOP_PDF_PARTITION,
|
||||
preload: this.preloadPath
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
const target = new URL(url);
|
||||
const renderTarget = new URL(this.renderUrl);
|
||||
if (
|
||||
target.origin !== renderTarget.origin ||
|
||||
target.pathname !== renderTarget.pathname
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
window.webContents.on("destroyed", () => {
|
||||
if (this.windowPromise) {
|
||||
this.windowPromise = undefined;
|
||||
}
|
||||
this.renderWindow = undefined;
|
||||
this.pendingRender?.reject(
|
||||
new Error("桌面 PDF 渲染窗口已关闭")
|
||||
);
|
||||
this.pendingRender = undefined;
|
||||
});
|
||||
window.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ["*://*/*"] },
|
||||
(details, callback) => {
|
||||
callback({
|
||||
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("桌面 PDF 分页运行时启动超时"));
|
||||
}, this.timeoutMs);
|
||||
const handleReady = (event: IpcMainEvent) => {
|
||||
if (event.sender !== window.webContents) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
|
||||
};
|
||||
ipcMain.on(PDF_RUNTIME_READY, handleReady);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([window.loadURL(this.renderUrl), ready]);
|
||||
window.webContents.setZoomFactor(1);
|
||||
this.renderWindow = window;
|
||||
return window;
|
||||
} catch (error) {
|
||||
window.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private requestRender(
|
||||
window: BrowserWindow,
|
||||
payload: PagedDocumentPayload
|
||||
) {
|
||||
if (this.pendingRender) {
|
||||
throw new Error("桌面 PDF 渲染器状态异常");
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId++;
|
||||
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingRender?.requestId === requestId) {
|
||||
this.pendingRender = undefined;
|
||||
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}, this.timeoutMs);
|
||||
|
||||
this.pendingRender = {
|
||||
requestId,
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly handleRenderComplete = (
|
||||
event: IpcMainEvent,
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
) => {
|
||||
const pending = this.pendingRender;
|
||||
if (
|
||||
!pending ||
|
||||
pending.requestId !== requestId ||
|
||||
event.sender !== this.renderWindow?.webContents
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingRender = undefined;
|
||||
pending.resolve(result);
|
||||
};
|
||||
|
||||
private readonly handleRenderFailure = (
|
||||
event: IpcMainEvent,
|
||||
requestId: number,
|
||||
message: string
|
||||
) => {
|
||||
const pending = this.pendingRender;
|
||||
if (
|
||||
!pending ||
|
||||
pending.requestId !== requestId ||
|
||||
event.sender !== this.renderWindow?.webContents
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingRender = undefined;
|
||||
pending.reject(
|
||||
new Error(
|
||||
typeof message === "string" ? message : "桌面 PDF 分页失败"
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
net,
|
||||
protocol,
|
||||
session,
|
||||
type IpcMainInvokeEvent
|
||||
} from "electron";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
createApplicationService,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import {
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF
|
||||
} from "./channels.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
parseThemeId,
|
||||
parseThemeResourceUrl
|
||||
} from "./application-contract.js";
|
||||
import {
|
||||
DESKTOP_PDF_PARTITION,
|
||||
ElectronPdfGenerator
|
||||
} from "./electron-pdf-generator.js";
|
||||
import { parsePagedDocumentPayload } from "./pdf-contract.js";
|
||||
|
||||
const APP_SCHEME = "mdpdf";
|
||||
const APP_HOST = "bundle";
|
||||
const THEME_HOST = "theme";
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
codeCache: true,
|
||||
corsEnabled: true
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
function getCommandLineValue(name: string) {
|
||||
const prefix = `--${name}=`;
|
||||
return process.argv
|
||||
.find((argument) => argument.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
}
|
||||
|
||||
function getWebRoot() {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "dist")
|
||||
: path.resolve(currentDirectory, "../../web/dist");
|
||||
}
|
||||
|
||||
function getApplicationIcon() {
|
||||
if (app.isPackaged) {
|
||||
return path.join(
|
||||
process.resourcesPath,
|
||||
process.platform === "darwin" ? "app.icns" : "app.ico"
|
||||
);
|
||||
}
|
||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||
return path.join(
|
||||
projectRoot,
|
||||
"logos",
|
||||
"desktop",
|
||||
process.platform === "darwin" ? "macos" : "windows",
|
||||
process.platform === "darwin" ? "app.icns" : "app.ico"
|
||||
);
|
||||
}
|
||||
|
||||
function encodeThemeAssetPath(assetPath: string) {
|
||||
return assetPath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function createDesktopApplicationService() {
|
||||
const projectRoot = path.resolve(currentDirectory, "../../..");
|
||||
return createApplicationService({
|
||||
bundledRoot: app.isPackaged
|
||||
? path.join(process.resourcesPath, "themes")
|
||||
: path.join(projectRoot, "themes"),
|
||||
localRoot: app.isPackaged
|
||||
? path.join(app.getPath("userData"), "themes")
|
||||
: path.join(projectRoot, ".local", "themes"),
|
||||
createAssetUrl: (themeId, assetPath) =>
|
||||
`${APP_SCHEME}://${THEME_HOST}/${encodeURIComponent(themeId)}/${encodeThemeAssetPath(assetPath)}`,
|
||||
onWarning: (message) => console.warn(message)
|
||||
});
|
||||
}
|
||||
|
||||
function isContainedPath(root: string, target: string) {
|
||||
const relative = path.relative(root, target);
|
||||
return (
|
||||
relative !== ".." &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
async function registerApplicationProtocol(
|
||||
applicationService: ApplicationService
|
||||
) {
|
||||
const webRoot = getWebRoot();
|
||||
const handleRequest = async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.host === THEME_HOST) {
|
||||
try {
|
||||
const resource = parseThemeResourceUrl(request.url);
|
||||
if (!resource) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
const asset = await applicationService.getThemeAsset(
|
||||
resource.themeId,
|
||||
resource.assetPath
|
||||
);
|
||||
if (!asset) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return new Response(asset.content, {
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=300",
|
||||
"content-type": asset.contentType,
|
||||
"x-content-type-options": "nosniff"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
}
|
||||
if (url.host !== APP_HOST) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
let relativePath: string;
|
||||
try {
|
||||
relativePath =
|
||||
decodeURIComponent(url.pathname).replace(/^\/+/, "") ||
|
||||
"index.html";
|
||||
} catch {
|
||||
return new Response("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const target = path.resolve(webRoot, relativePath);
|
||||
if (!isContainedPath(webRoot, target)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(target);
|
||||
} catch {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
return net.fetch(pathToFileURL(target).href);
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
protocol.handle(APP_SCHEME, handleRequest),
|
||||
session
|
||||
.fromPartition(DESKTOP_PDF_PARTITION)
|
||||
.protocol.handle(APP_SCHEME, handleRequest)
|
||||
]);
|
||||
}
|
||||
|
||||
function lockDownWindow(window: BrowserWindow, allowedOrigin: string) {
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("will-navigate", (event, targetUrl) => {
|
||||
if (new URL(targetUrl).origin !== allowedOrigin) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
const applicationSession = window.webContents.session;
|
||||
applicationSession.setPermissionCheckHandler(() => false);
|
||||
applicationSession.setPermissionRequestHandler(
|
||||
(_webContents, _permission, callback) => callback(false)
|
||||
);
|
||||
}
|
||||
|
||||
async function createApplication(
|
||||
applicationService: ApplicationService
|
||||
) {
|
||||
const developmentUrl = getCommandLineValue("web-url");
|
||||
const applicationUrl =
|
||||
developmentUrl ?? `${APP_SCHEME}://${APP_HOST}/index.html`;
|
||||
const renderUrl = new URL(
|
||||
"/preview-frame.html?target=pdf",
|
||||
applicationUrl
|
||||
).href;
|
||||
|
||||
const window = new BrowserWindow({
|
||||
title: `Markdown PDF 导出器 v${app.getVersion()}`,
|
||||
icon: getApplicationIcon(),
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1024,
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: path.join(currentDirectory, "app-preload.cjs")
|
||||
}
|
||||
});
|
||||
lockDownWindow(window, new URL(applicationUrl).origin);
|
||||
|
||||
const pdfGenerator = new ElectronPdfGenerator({
|
||||
renderUrl,
|
||||
preloadPath: path.join(currentDirectory, "pdf-preload.cjs")
|
||||
});
|
||||
const validateSender = (event: IpcMainInvokeEvent) =>
|
||||
event.sender === window.webContents &&
|
||||
event.senderFrame !== null &&
|
||||
new URL(event.senderFrame.url).origin ===
|
||||
new URL(applicationUrl).origin;
|
||||
|
||||
ipcMain.handle(
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
async (event, unsafeRequest: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面渲染请求");
|
||||
}
|
||||
return applicationService.render(
|
||||
parseMarkdownRenderRequest(unsafeRequest)
|
||||
);
|
||||
}
|
||||
);
|
||||
ipcMain.handle(DESKTOP_LIST_THEMES, async (event) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面主题请求");
|
||||
}
|
||||
return applicationService.listThemes();
|
||||
});
|
||||
ipcMain.handle(
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
async (event, unsafeThemeId: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面主题请求");
|
||||
}
|
||||
const css = await applicationService.getThemeCss(
|
||||
parseThemeId(unsafeThemeId)
|
||||
);
|
||||
if (css === undefined) {
|
||||
throw new Error("未找到指定主题");
|
||||
}
|
||||
return css;
|
||||
}
|
||||
);
|
||||
ipcMain.handle(
|
||||
DESKTOP_GENERATE_PDF,
|
||||
async (event, unsafePayload: unknown) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面 PDF 请求");
|
||||
}
|
||||
const payload = parsePagedDocumentPayload(unsafePayload);
|
||||
const result = await pdfGenerator.generate(payload);
|
||||
return {
|
||||
pdf: result.pdf,
|
||||
pageCount: result.pageCount,
|
||||
echartsErrors: result.echartsErrors,
|
||||
mermaidErrors: result.mermaidErrors
|
||||
};
|
||||
}
|
||||
);
|
||||
ipcMain.handle(
|
||||
DESKTOP_SAVE_PDF,
|
||||
async (
|
||||
event,
|
||||
unsafeFileName: unknown,
|
||||
unsafePdf: unknown
|
||||
) => {
|
||||
if (!validateSender(event)) {
|
||||
throw new Error("拒绝未授权的桌面 PDF 保存请求");
|
||||
}
|
||||
if (
|
||||
typeof unsafeFileName !== "string" ||
|
||||
unsafeFileName.length === 0 ||
|
||||
unsafeFileName.length > 500 ||
|
||||
!(unsafePdf instanceof Uint8Array) ||
|
||||
unsafePdf.byteLength === 0 ||
|
||||
unsafePdf.byteLength > 200 * 1024 * 1024
|
||||
) {
|
||||
throw new Error("PDF 保存参数无效");
|
||||
}
|
||||
|
||||
const suggestedName = path.basename(unsafeFileName).endsWith(".pdf")
|
||||
? path.basename(unsafeFileName)
|
||||
: `${path.basename(unsafeFileName)}.pdf`;
|
||||
const selection = await dialog.showSaveDialog(window, {
|
||||
title: "导出 PDF",
|
||||
defaultPath: path.join(app.getPath("documents"), suggestedName),
|
||||
filters: [{ name: "PDF 文件", extensions: ["pdf"] }]
|
||||
});
|
||||
if (selection.canceled || !selection.filePath) {
|
||||
return false;
|
||||
}
|
||||
await writeFile(selection.filePath, unsafePdf);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
window.once("ready-to-show", () => window.show());
|
||||
window.on("closed", () => {
|
||||
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
|
||||
ipcMain.removeHandler(DESKTOP_LIST_THEMES);
|
||||
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
|
||||
ipcMain.removeHandler(DESKTOP_GENERATE_PDF);
|
||||
ipcMain.removeHandler(DESKTOP_SAVE_PDF);
|
||||
void pdfGenerator.close();
|
||||
});
|
||||
await window.loadURL(applicationUrl);
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||
}
|
||||
const applicationService = createDesktopApplicationService();
|
||||
await registerApplicationProtocol(applicationService);
|
||||
await createApplication(applicationService);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("桌面应用启动失败", error);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
exportConfigSchema,
|
||||
themeFeatureSchema,
|
||||
type PagedDocumentPayload,
|
||||
type PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
|
||||
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
maximum: number
|
||||
) {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.length > maximum) {
|
||||
throw new Error(`${key} 无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown, key: string, maximum: number) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > maximum ||
|
||||
value.some((item) => typeof item !== "string")
|
||||
) {
|
||||
throw new Error(`${key} 无效`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
export function parsePagedDocumentPayload(
|
||||
value: unknown
|
||||
): PagedDocumentPayload {
|
||||
if (!isRecord(value) || !isRecord(value.metadata)) {
|
||||
throw new Error("PDF 分页载荷无效");
|
||||
}
|
||||
|
||||
const metadata = value.metadata;
|
||||
const features = readStringArray(value.features, "features", 20);
|
||||
const parsedFeatures = features.map((feature) =>
|
||||
themeFeatureSchema.parse(feature)
|
||||
);
|
||||
|
||||
return {
|
||||
articleHtml: readString(
|
||||
value,
|
||||
"articleHtml",
|
||||
MAX_ARTICLE_HTML_LENGTH
|
||||
),
|
||||
fileName: readString(value, "fileName", 500),
|
||||
metadata: {
|
||||
title: readString(metadata, "title", 500),
|
||||
author: readString(metadata, "author", 500),
|
||||
subject: readString(metadata, "subject", 1_000),
|
||||
keywords: readStringArray(
|
||||
metadata.keywords,
|
||||
"metadata.keywords",
|
||||
100
|
||||
),
|
||||
language: readString(metadata, "language", 50)
|
||||
},
|
||||
features: parsedFeatures,
|
||||
themeCss: readString(
|
||||
value,
|
||||
"themeCss",
|
||||
MAX_THEME_CSS_LENGTH
|
||||
),
|
||||
exportConfig: exportConfigSchema.parse(value.exportConfig)
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronPdfPrintOptions {
|
||||
displayHeaderFooter: false;
|
||||
margins: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
pageRanges: string;
|
||||
preferCSSPageSize: true;
|
||||
printBackground: boolean;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function createElectronPdfPrintOptions(
|
||||
payload: PagedDocumentPayload,
|
||||
renderResult: PagedDocumentRenderResult
|
||||
): ElectronPdfPrintOptions {
|
||||
return {
|
||||
displayHeaderFooter: false,
|
||||
margins: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
|
||||
preferCSSPageSize: true,
|
||||
printBackground: payload.exportConfig.print.background,
|
||||
scale: payload.exportConfig.print.scale
|
||||
};
|
||||
}
|
||||
|
||||
export function isAllowedPdfRuntimeUrl(
|
||||
requestUrl: string,
|
||||
renderUrl: string
|
||||
) {
|
||||
const requested = new URL(requestUrl);
|
||||
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
|
||||
return true;
|
||||
}
|
||||
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
|
||||
return true;
|
||||
}
|
||||
return requested.origin === new URL(renderUrl).origin;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
contextBridge,
|
||||
ipcRenderer,
|
||||
type IpcRendererEvent
|
||||
} from "electron";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
PDF_RUNTIME_FAIL,
|
||||
PDF_RUNTIME_READY,
|
||||
PDF_RUNTIME_RENDER
|
||||
} from "./channels.js";
|
||||
|
||||
type RenderListener = (
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => Promise<void> | void;
|
||||
|
||||
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
|
||||
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
|
||||
onRender: (listener: RenderListener) => {
|
||||
const handler = (
|
||||
_event: IpcRendererEvent,
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => {
|
||||
void listener(requestId, payload);
|
||||
};
|
||||
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
|
||||
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
|
||||
},
|
||||
complete: (
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
|
||||
fail: (requestId: number, message: string) =>
|
||||
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createElectronPdfPrintOptions,
|
||||
isAllowedPdfRuntimeUrl,
|
||||
parsePagedDocumentPayload
|
||||
} from "../src/pdf-contract.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
parseThemeId,
|
||||
parseThemeResourceUrl
|
||||
} from "../src/application-contract.js";
|
||||
|
||||
const payload = createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||
bodyHtml: "<h1>测试</h1>",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: ["mermaid"],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "测试.md",
|
||||
themeCss: "#write { color: black; }",
|
||||
exportConfig: defaultExportConfig
|
||||
});
|
||||
|
||||
describe("桌面 PDF 载荷", () => {
|
||||
it("校验并保留合法的共享分页载荷", () => {
|
||||
expect(parsePagedDocumentPayload(payload)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("拒绝无效功能标识", () => {
|
||||
expect(() =>
|
||||
parsePagedDocumentPayload({
|
||||
...payload,
|
||||
features: ["remote-script"]
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("映射为与 Playwright 对齐的 Electron 打印参数", () => {
|
||||
expect(
|
||||
createElectronPdfPrintOptions(payload, {
|
||||
pageCount: 3,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
timings: {
|
||||
setupMs: 0,
|
||||
echartsMs: 0,
|
||||
echartsFitMs: 0,
|
||||
mermaidMs: 0,
|
||||
mermaidFitMs: 0,
|
||||
mermaidConversionMs: 0,
|
||||
resourceWaitMs: 0,
|
||||
paginationMs: 0,
|
||||
finalizeMs: 0,
|
||||
totalMs: 0
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayHeaderFooter: false,
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
pageRanges: "1-3",
|
||||
preferCSSPageSize: true,
|
||||
printBackground: true,
|
||||
scale: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面 PDF 网络边界", () => {
|
||||
const renderUrl =
|
||||
"http://localhost:5173/preview-frame.html?target=pdf";
|
||||
|
||||
it("允许同源和内嵌资源", () => {
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"http://localhost:5173/assets/runtime.js",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl))
|
||||
.toBe(true);
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"mdpdf://theme/typora-like/font.woff2",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝外部网络资源", () => {
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面应用服务 IPC 载荷", () => {
|
||||
it("只提取 Markdown 渲染所需字段", () => {
|
||||
expect(
|
||||
parseMarkdownRenderRequest({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN",
|
||||
ignored: true
|
||||
})
|
||||
).toEqual({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN"
|
||||
});
|
||||
});
|
||||
|
||||
it("校验主题 ID 与主题资源地址", () => {
|
||||
expect(parseThemeId("typora-like")).toBe("typora-like");
|
||||
expect(
|
||||
parseThemeResourceUrl(
|
||||
"mdpdf://theme/typora-like/fonts/test.woff2"
|
||||
)
|
||||
).toEqual({
|
||||
themeId: "typora-like",
|
||||
assetPath: "fonts/test.woff2"
|
||||
});
|
||||
expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效");
|
||||
expect(
|
||||
parseThemeResourceUrl("mdpdf://bundle/index.html")
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.0",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0",
|
||||
"fastify": "^5.6.2",
|
||||
|
||||
+44
-121
@@ -10,10 +10,11 @@ import {
|
||||
supportedPaperFormats
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
MarkdownDocumentParseError,
|
||||
RENDERER_VERSION,
|
||||
renderMarkdown
|
||||
} from "@md-to-pdf/renderer";
|
||||
ApplicationRequestError,
|
||||
createApplicationService,
|
||||
type ApplicationService
|
||||
} from "@md-to-pdf/application";
|
||||
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
|
||||
import {
|
||||
createPdfGenerator,
|
||||
PdfEngineClosedError,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
PdfRenderTimeoutError,
|
||||
type PdfGenerator
|
||||
} from "./pdf-engine.js";
|
||||
import { createThemeRegistry } from "./theme-registry.js";
|
||||
|
||||
interface RenderRequestBody {
|
||||
markdown?: unknown;
|
||||
@@ -34,66 +34,6 @@ interface PdfRequestBody extends RenderRequestBody {
|
||||
}
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const maximumMarkdownLength = 1_500_000;
|
||||
|
||||
function validateMarkdownRequest(
|
||||
body: RenderRequestBody | undefined
|
||||
):
|
||||
| { markdown: string; language?: string }
|
||||
| { statusCode: 400 | 413; error: string; message: string } {
|
||||
const { markdown, language } = body ?? {};
|
||||
|
||||
if (typeof markdown !== "string") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: "INVALID_MARKDOWN",
|
||||
message: "markdown 必须是字符串"
|
||||
};
|
||||
}
|
||||
if (markdown.length > maximumMarkdownLength) {
|
||||
return {
|
||||
statusCode: 413,
|
||||
error: "MARKDOWN_TOO_LARGE",
|
||||
message: "Markdown 内容不能超过 1.5 MB"
|
||||
};
|
||||
}
|
||||
if (language !== undefined && typeof language !== "string") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
error: "INVALID_LANGUAGE",
|
||||
message: "language 必须是字符串"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
markdown,
|
||||
...(typeof language === "string" ? { language } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdownRequest(
|
||||
markdown: string,
|
||||
language: string | undefined
|
||||
) {
|
||||
try {
|
||||
return {
|
||||
success: true as const,
|
||||
document: renderMarkdown(markdown, {
|
||||
...(language ? { language } : {})
|
||||
})
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownDocumentParseError) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: error.code,
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRfc5987(value: string) {
|
||||
return encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
@@ -127,6 +67,7 @@ export interface BuildAppOptions {
|
||||
logger?: boolean;
|
||||
pdfGenerator?: PdfGenerator;
|
||||
prewarmPdfBrowser?: boolean;
|
||||
applicationService?: ApplicationService;
|
||||
}
|
||||
|
||||
function milliseconds(value: number) {
|
||||
@@ -169,12 +110,14 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
logger: options.logger ?? true,
|
||||
bodyLimit: 2 * 1024 * 1024
|
||||
});
|
||||
const themes = createThemeRegistry({
|
||||
bundledRoot: resolve(projectRoot, "themes"),
|
||||
localRoot:
|
||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||
onWarning: (message) => app.log.warn(message)
|
||||
});
|
||||
const applicationService =
|
||||
options.applicationService ??
|
||||
createApplicationService({
|
||||
bundledRoot: resolve(projectRoot, "themes"),
|
||||
localRoot:
|
||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||
onWarning: (message) => app.log.warn(message)
|
||||
});
|
||||
const pdfGenerator =
|
||||
options.pdfGenerator ?? createPdfGenerator();
|
||||
|
||||
@@ -213,23 +156,16 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
planned: []
|
||||
}));
|
||||
|
||||
app.get("/api/themes", async () => ({
|
||||
themes: (await themes.list()).map(({ manifest, source }) => ({
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
bundled: manifest.bundled,
|
||||
source
|
||||
}))
|
||||
}));
|
||||
app.get("/api/themes", async () =>
|
||||
applicationService.listThemes()
|
||||
);
|
||||
|
||||
app.get<{ Params: { themeId: string } }>(
|
||||
"/api/themes/:themeId/css",
|
||||
async (request, reply) => {
|
||||
const { themeId } = request.params;
|
||||
|
||||
const css = await themes.getCss(themeId);
|
||||
const css = await applicationService.getThemeCss(themeId);
|
||||
if (css === undefined) {
|
||||
return reply.code(404).send({
|
||||
error: "THEME_NOT_FOUND",
|
||||
@@ -248,7 +184,7 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
"/api/themes/:themeId/assets/*",
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const asset = await themes.getAsset(
|
||||
const asset = await applicationService.getThemeAsset(
|
||||
request.params.themeId,
|
||||
request.params["*"]
|
||||
);
|
||||
@@ -277,25 +213,17 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
app.post<{ Body: RenderRequestBody }>(
|
||||
"/api/render",
|
||||
async (request, reply) => {
|
||||
const validated = validateMarkdownRequest(request.body);
|
||||
if ("statusCode" in validated) {
|
||||
return reply.code(validated.statusCode).send({
|
||||
error: validated.error,
|
||||
message: validated.message
|
||||
});
|
||||
try {
|
||||
return applicationService.render(request.body ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rendered = renderMarkdownRequest(
|
||||
validated.markdown,
|
||||
validated.language
|
||||
);
|
||||
if (!rendered.success) {
|
||||
return reply.code(400).send({
|
||||
error: rendered.error,
|
||||
message: rendered.message
|
||||
});
|
||||
}
|
||||
return rendered.document;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -303,14 +231,6 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
"/api/pdf",
|
||||
async (request, reply) => {
|
||||
const requestStartedAt = performance.now();
|
||||
const validated = validateMarkdownRequest(request.body);
|
||||
if ("statusCode" in validated) {
|
||||
return reply.code(validated.statusCode).send({
|
||||
error: validated.error,
|
||||
message: validated.message
|
||||
});
|
||||
}
|
||||
|
||||
const parsedConfig = exportConfigSchema.safeParse(
|
||||
request.body?.exportConfig
|
||||
);
|
||||
@@ -333,7 +253,9 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
|
||||
const themeStartedAt = performance.now();
|
||||
const themeCss = await themes.getCss(parsedConfig.data.themeId);
|
||||
const themeCss = await applicationService.getThemeCss(
|
||||
parsedConfig.data.themeId
|
||||
);
|
||||
const themeMs = performance.now() - themeStartedAt;
|
||||
if (themeCss === undefined) {
|
||||
return reply.code(404).send({
|
||||
@@ -343,18 +265,19 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
}
|
||||
|
||||
const markdownStartedAt = performance.now();
|
||||
const renderedResult = renderMarkdownRequest(
|
||||
validated.markdown,
|
||||
validated.language
|
||||
);
|
||||
const markdownMs = performance.now() - markdownStartedAt;
|
||||
if (!renderedResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: renderedResult.error,
|
||||
message: renderedResult.message
|
||||
});
|
||||
let rendered;
|
||||
try {
|
||||
rendered = applicationService.render(request.body ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.code,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const rendered = renderedResult.document;
|
||||
const markdownMs = performance.now() - markdownStartedAt;
|
||||
|
||||
try {
|
||||
const generated = await pdfGenerator.generate(
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
import {
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
stat
|
||||
} from "node:fs/promises";
|
||||
import {
|
||||
extname,
|
||||
isAbsolute,
|
||||
posix,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from "node:path";
|
||||
import {
|
||||
themeManifestSchema,
|
||||
type ThemeManifest
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface ThemeRecord {
|
||||
manifest: ThemeManifest;
|
||||
directory: string;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
export interface ThemeRegistryOptions {
|
||||
bundledRoot: string;
|
||||
localRoot: string;
|
||||
onWarning?: (message: string) => void;
|
||||
cacheTtlMs?: number;
|
||||
}
|
||||
|
||||
const assetContentTypes: Record<string, string> = {
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".ttf": "font/ttf",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2"
|
||||
};
|
||||
|
||||
const cssImportPattern =
|
||||
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
|
||||
const maximumCssImportDepth = 8;
|
||||
|
||||
function isMissingFileError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function isInsideDirectory(directory: string, path: string) {
|
||||
const pathFromDirectory = relative(directory, path);
|
||||
return (
|
||||
pathFromDirectory === "" ||
|
||||
(!pathFromDirectory.startsWith(`..${sep}`) &&
|
||||
pathFromDirectory !== ".." &&
|
||||
!isAbsolute(pathFromDirectory))
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveThemeFile(directory: string, relativePath: string) {
|
||||
if (
|
||||
isAbsolute(relativePath) ||
|
||||
relativePath.includes("\0") ||
|
||||
relativePath.split(/[\\/]/).includes("..")
|
||||
) {
|
||||
throw new Error("主题资源路径不安全");
|
||||
}
|
||||
|
||||
const realDirectory = await realpath(directory);
|
||||
const candidate = resolve(realDirectory, relativePath);
|
||||
if (!isInsideDirectory(realDirectory, candidate)) {
|
||||
throw new Error("主题资源路径越界");
|
||||
}
|
||||
|
||||
const realCandidate = await realpath(candidate);
|
||||
if (!isInsideDirectory(realDirectory, realCandidate)) {
|
||||
throw new Error("主题资源符号链接越界");
|
||||
}
|
||||
|
||||
const fileStat = await stat(realCandidate);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error("主题资源不是文件");
|
||||
}
|
||||
|
||||
return realCandidate;
|
||||
}
|
||||
|
||||
async function readThemeRoot(
|
||||
root: string,
|
||||
source: ThemeRecord["source"],
|
||||
onWarning?: (message: string) => void
|
||||
): Promise<ThemeRecord[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error) && source === "local") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const records: ThemeRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const directory = resolve(root, entry.name);
|
||||
try {
|
||||
const manifestPath = await resolveThemeFile(directory, "theme.json");
|
||||
const manifest = themeManifestSchema.parse(
|
||||
JSON.parse(await readFile(manifestPath, "utf8"))
|
||||
);
|
||||
|
||||
if (manifest.id !== entry.name) {
|
||||
throw new Error("主题目录名必须与主题 ID 一致");
|
||||
}
|
||||
if (manifest.bundled !== (source === "bundled")) {
|
||||
throw new Error("主题 bundled 标记与来源不一致");
|
||||
}
|
||||
|
||||
if (manifest.base) {
|
||||
await resolveThemeFile(directory, manifest.base);
|
||||
}
|
||||
await resolveThemeFile(directory, manifest.entry);
|
||||
if (manifest.print) {
|
||||
await resolveThemeFile(directory, manifest.print);
|
||||
}
|
||||
|
||||
records.push({
|
||||
manifest,
|
||||
directory,
|
||||
source
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (source === "local") {
|
||||
onWarning?.(`已忽略无效本地主题 ${entry.name}:${message}`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`主题 ${entry.name} 无效:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function encodeAssetPath(path: string) {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function prepareCssSegment(
|
||||
css: string,
|
||||
themeId: string,
|
||||
cssDirectory: string
|
||||
) {
|
||||
return css.replace(
|
||||
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
|
||||
(match, _quote: string, rawValue: string) => {
|
||||
const value = rawValue.trim();
|
||||
if (value.startsWith("data:") || value.startsWith("#")) {
|
||||
return match;
|
||||
}
|
||||
if (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(value)
|
||||
) {
|
||||
throw new Error(`主题包含不允许的外部资源:${value}`);
|
||||
}
|
||||
|
||||
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
if (normalized.split("/").includes("..")) {
|
||||
throw new Error(`主题资源路径不安全:${value}`);
|
||||
}
|
||||
|
||||
const assetPath = posix.normalize(posix.join(cssDirectory, normalized));
|
||||
if (
|
||||
assetPath === ".." ||
|
||||
assetPath.startsWith("../") ||
|
||||
assetPath.startsWith("/")
|
||||
) {
|
||||
throw new Error(`主题资源路径越界:${value}`);
|
||||
}
|
||||
|
||||
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}")`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readImportedPath(match: RegExpMatchArray) {
|
||||
return match.slice(1).find((value): value is string => value !== undefined);
|
||||
}
|
||||
|
||||
function normalizeCssPath(relativePath: string) {
|
||||
const normalized = relativePath.replaceAll("\\", "/");
|
||||
if (
|
||||
normalized.startsWith("/") ||
|
||||
normalized.split("/").includes("..") ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(normalized)
|
||||
) {
|
||||
throw new Error(`主题 CSS 路径不安全:${relativePath}`);
|
||||
}
|
||||
|
||||
return posix.normalize(normalized.replace(/^\.\//, ""));
|
||||
}
|
||||
|
||||
async function loadThemeCssFile(
|
||||
theme: ThemeRecord,
|
||||
relativePath: string,
|
||||
importStack: string[] = []
|
||||
): Promise<string> {
|
||||
const normalizedPath = normalizeCssPath(relativePath);
|
||||
if (importStack.includes(normalizedPath)) {
|
||||
throw new Error(
|
||||
`主题 CSS 存在循环引用:${[...importStack, normalizedPath].join(" -> ")}`
|
||||
);
|
||||
}
|
||||
if (importStack.length >= maximumCssImportDepth) {
|
||||
throw new Error(`主题 CSS 导入层级超过 ${maximumCssImportDepth} 层`);
|
||||
}
|
||||
|
||||
const cssPath = await resolveThemeFile(theme.directory, normalizedPath);
|
||||
const css = (await readFile(cssPath, "utf8")).replace(
|
||||
/^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim,
|
||||
""
|
||||
);
|
||||
const cssDirectory = posix.dirname(normalizedPath);
|
||||
const matches = [...css.matchAll(cssImportPattern)];
|
||||
let result = "";
|
||||
let previousEnd = 0;
|
||||
|
||||
for (const match of matches) {
|
||||
const matchStart = match.index ?? 0;
|
||||
result += prepareCssSegment(
|
||||
css.slice(previousEnd, matchStart),
|
||||
theme.manifest.id,
|
||||
cssDirectory
|
||||
);
|
||||
|
||||
const importedPath = readImportedPath(match);
|
||||
if (!importedPath) {
|
||||
throw new Error(`无法解析主题 CSS 导入:${match[0]}`);
|
||||
}
|
||||
const normalizedImport = normalizeCssPath(importedPath);
|
||||
const importPath = posix.normalize(posix.join(cssDirectory, normalizedImport));
|
||||
result += await loadThemeCssFile(theme, importPath, [
|
||||
...importStack,
|
||||
normalizedPath
|
||||
]);
|
||||
previousEnd = matchStart + match[0].length;
|
||||
}
|
||||
|
||||
const remainder = css.slice(previousEnd);
|
||||
result += prepareCssSegment(
|
||||
remainder,
|
||||
theme.manifest.id,
|
||||
cssDirectory
|
||||
);
|
||||
if (/@import\b/i.test(result)) {
|
||||
throw new Error("主题包含不支持的 CSS @import 语法");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createThemeRegistry(options: ThemeRegistryOptions) {
|
||||
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
|
||||
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
|
||||
throw new Error("主题缓存有效期必须是非负有限数值");
|
||||
}
|
||||
let cachedThemes:
|
||||
| {
|
||||
expiresAt: number;
|
||||
promise: Promise<ThemeRecord[]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
async function scanThemes() {
|
||||
const [bundledThemes, localThemes] = await Promise.all([
|
||||
readThemeRoot(options.bundledRoot, "bundled"),
|
||||
readThemeRoot(options.localRoot, "local", options.onWarning)
|
||||
]);
|
||||
|
||||
const themes = [...bundledThemes, ...localThemes];
|
||||
const themeIds = new Set<string>();
|
||||
for (const theme of themes) {
|
||||
if (themeIds.has(theme.manifest.id)) {
|
||||
throw new Error(`主题 ID 重复:${theme.manifest.id}`);
|
||||
}
|
||||
themeIds.add(theme.manifest.id);
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
function list() {
|
||||
const now = Date.now();
|
||||
if (cachedThemes && now < cachedThemes.expiresAt) {
|
||||
return cachedThemes.promise;
|
||||
}
|
||||
|
||||
const promise = scanThemes();
|
||||
cachedThemes = {
|
||||
expiresAt: now + cacheTtlMs,
|
||||
promise
|
||||
};
|
||||
void promise.catch(() => {
|
||||
if (cachedThemes?.promise === promise) {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidate() {
|
||||
cachedThemes = undefined;
|
||||
}
|
||||
|
||||
async function get(themeId: string) {
|
||||
return (await list()).find((theme) => theme.manifest.id === themeId);
|
||||
}
|
||||
|
||||
async function getCss(themeId: string) {
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cssFiles = [
|
||||
...(theme.manifest.base ? [theme.manifest.base] : []),
|
||||
theme.manifest.entry,
|
||||
...(theme.manifest.print
|
||||
? [theme.manifest.print]
|
||||
: [])
|
||||
];
|
||||
return (
|
||||
await Promise.all(
|
||||
cssFiles.map((path) => loadThemeCssFile(theme, path))
|
||||
)
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
async function getAsset(themeId: string, assetPath: string) {
|
||||
const theme = await get(themeId);
|
||||
if (!theme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const extension = extname(assetPath).toLowerCase();
|
||||
const contentType = assetContentTypes[extension];
|
||||
if (!contentType) {
|
||||
throw new Error("不支持的主题资源类型");
|
||||
}
|
||||
|
||||
const path = await resolveThemeFile(theme.directory, assetPath);
|
||||
return {
|
||||
contentType,
|
||||
content: await readFile(path)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
getAsset,
|
||||
getCss,
|
||||
invalidate,
|
||||
list
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createPdfContentDisposition,
|
||||
createPdfFileName
|
||||
} from "../src/app.js";
|
||||
import { createThemeRegistry } from "../src/theme-registry.js";
|
||||
import { createThemeRegistry } from "@md-to-pdf/application";
|
||||
import {
|
||||
PdfEngineOverloadedError,
|
||||
type PdfGenerator
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/apple-touch-icon.png"
|
||||
sizes="180x180"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:;"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: mdpdf:; connect-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
|
||||
+15
-3
@@ -37,9 +37,11 @@ import {
|
||||
downloadPdf,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
requestDesktopPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "./pdf-export";
|
||||
import { APP_VERSION_LABEL } from "./app-version";
|
||||
import { getSyncedScrollTop } from "./scroll-sync";
|
||||
import {
|
||||
loadPreviewZoom,
|
||||
@@ -832,7 +834,10 @@ export function App() {
|
||||
try {
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
pdfRequest,
|
||||
pdfCache
|
||||
pdfCache,
|
||||
previewPayload && window.mdToPdfDesktop
|
||||
? () => requestDesktopPdfExport(previewPayload)
|
||||
: undefined
|
||||
);
|
||||
if (pdfRequestKeyRef.current !== requestedKey) {
|
||||
setStatus("文档已更新,已丢弃过期 PDF");
|
||||
@@ -841,7 +846,11 @@ export function App() {
|
||||
setPdfCache(resolved);
|
||||
const exported = resolved.result;
|
||||
if (download) {
|
||||
downloadPdf(exported.blob, exported.fileName);
|
||||
const saved = await downloadPdf(exported.blob, exported.fileName);
|
||||
if (!saved) {
|
||||
setStatus("已取消 PDF 保存");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pageDescription =
|
||||
exported.pageCount === undefined
|
||||
@@ -893,7 +902,10 @@ export function App() {
|
||||
<div className="topbar-primary">
|
||||
<div className="topbar-brand">
|
||||
<p className="eyebrow">内网文档工具</p>
|
||||
<h1>Markdown PDF 导出器</h1>
|
||||
<div className="topbar-brand-title">
|
||||
<h1>Markdown PDF 导出器</h1>
|
||||
<span className="app-version">{APP_VERSION_LABEL}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-summary">
|
||||
<span className="panel-kicker">源文件</span>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const APP_VERSION = __APP_VERSION__;
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
|
||||
export interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
|
||||
export interface MarkdownRenderInput {
|
||||
markdown: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
async function readErrorMessage(
|
||||
response: Response,
|
||||
fallback: string
|
||||
) {
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: unknown };
|
||||
return typeof payload.message === "string"
|
||||
? payload.message
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderMarkdownDocument(
|
||||
input: MarkdownRenderInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.renderMarkdown(input);
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
...(signal ? { signal } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, "渲染失败"));
|
||||
}
|
||||
return response.json() as Promise<RenderedMarkdownDocument>;
|
||||
}
|
||||
|
||||
export async function listThemes(signal?: AbortSignal) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.listThemes();
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/themes", {
|
||||
...(signal ? { signal } : {})
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
}
|
||||
|
||||
export async function getThemeCss(
|
||||
themeId: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
throwIfAborted(signal);
|
||||
if (window.mdToPdfDesktop) {
|
||||
const result = await window.mdToPdfDesktop.getThemeCss(themeId);
|
||||
throwIfAborted(signal);
|
||||
return result;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/themes/${encodeURIComponent(themeId)}/css`,
|
||||
{ ...(signal ? { signal } : {}) }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
@@ -87,6 +87,30 @@ window.__mdToPdfRender = async (
|
||||
};
|
||||
document.documentElement.dataset.runtimeReady = "true";
|
||||
|
||||
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||
if (desktopPdfBridge) {
|
||||
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||
const requestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (callback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0);
|
||||
try {
|
||||
const result = await window.__mdToPdfRender?.(payload, "pdf");
|
||||
if (!result) {
|
||||
throw new Error("PDF 分页运行时不可用");
|
||||
}
|
||||
desktopPdfBridge.complete(requestId, result);
|
||||
} catch (reason: unknown) {
|
||||
desktopPdfBridge.fail(
|
||||
requestId,
|
||||
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||
);
|
||||
} finally {
|
||||
window.requestAnimationFrame = requestAnimationFrame;
|
||||
}
|
||||
});
|
||||
desktopPdfBridge.ready();
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import type {
|
||||
ExportConfig,
|
||||
PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface PdfExportRequest {
|
||||
markdown: string;
|
||||
@@ -129,11 +132,38 @@ export async function requestPdfExport(
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadPdf(blob: Blob, fileName: string) {
|
||||
export async function requestDesktopPdfExport(
|
||||
payload: PagedDocumentPayload
|
||||
): Promise<PdfExportResult> {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
throw new Error("桌面 PDF 引擎不可用");
|
||||
}
|
||||
|
||||
const result = await bridge.generatePdf(payload);
|
||||
const pdfBytes = Uint8Array.from(result.pdf);
|
||||
return {
|
||||
blob: new Blob([pdfBytes.buffer], { type: "application/pdf" }),
|
||||
fileName: payload.fileName.replace(/\.(md|markdown)$/i, "") + ".pdf",
|
||||
pageCount: result.pageCount,
|
||||
echartsErrorCount: result.echartsErrors.length,
|
||||
mermaidErrorCount: result.mermaidErrors.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadPdf(blob: Blob, fileName: string) {
|
||||
if (window.mdToPdfDesktop) {
|
||||
return window.mdToPdfDesktop.savePdf(
|
||||
fileName,
|
||||
new Uint8Array(await blob.arrayBuffer())
|
||||
);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ button:disabled {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.topbar-brand-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #c8d5cf;
|
||||
border-radius: 999px;
|
||||
background: #edf3f0;
|
||||
color: #527061;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.035em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1029,6 +1047,12 @@ textarea:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.topbar-brand-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
import { renderMarkdownDocument } from "./application-backend";
|
||||
|
||||
export interface MarkdownRenderCallbacks {
|
||||
onRenderStart?: () => void;
|
||||
@@ -23,27 +24,10 @@ export function useMarkdownRender(
|
||||
onRenderStart?.();
|
||||
setError("");
|
||||
|
||||
void fetch("/api/render", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ markdown, language }),
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json()) as
|
||||
| RenderedMarkdownDocument
|
||||
| { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"message" in payload
|
||||
? payload.message ?? "渲染失败"
|
||||
: "渲染失败"
|
||||
);
|
||||
}
|
||||
return payload as RenderedMarkdownDocument;
|
||||
})
|
||||
void renderMarkdownDocument(
|
||||
{ markdown, language },
|
||||
controller.signal
|
||||
)
|
||||
.then((payload) => {
|
||||
setResult(payload);
|
||||
onStatusChange?.("预览已更新");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getThemeCss,
|
||||
listThemes,
|
||||
type ThemeSummary
|
||||
} from "./application-backend";
|
||||
|
||||
export interface ThemeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}
|
||||
export type { ThemeSummary } from "./application-backend";
|
||||
|
||||
export function useThemeResources(
|
||||
themeId: string,
|
||||
@@ -21,15 +19,7 @@ export function useThemeResources(
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void fetch("/api/themes", {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题清单");
|
||||
}
|
||||
return response.json() as Promise<{ themes: ThemeSummary[] }>;
|
||||
})
|
||||
void listThemes(controller.signal)
|
||||
.then(({ themes: availableThemes }) => {
|
||||
setThemes(availableThemes);
|
||||
setCatalogError("");
|
||||
@@ -51,15 +41,7 @@ export function useThemeResources(
|
||||
setThemeCss("");
|
||||
setCssError("");
|
||||
|
||||
void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("无法加载主题");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
void getThemeCss(themeId, controller.signal)
|
||||
.then(setThemeCss)
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
|
||||
Vendored
+57
@@ -1 +1,58 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult,
|
||||
RenderedMarkdownDocument
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
declare global {
|
||||
const __APP_VERSION__: string;
|
||||
|
||||
interface DesktopPdfResult {
|
||||
pdf: Uint8Array;
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
}
|
||||
|
||||
interface DesktopApplicationBridge {
|
||||
renderMarkdown(input: {
|
||||
markdown: string;
|
||||
language: string;
|
||||
}): Promise<RenderedMarkdownDocument>;
|
||||
listThemes(): Promise<{
|
||||
themes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
bundled: boolean;
|
||||
source: "bundled" | "local";
|
||||
}>;
|
||||
}>;
|
||||
getThemeCss(themeId: string): Promise<string>;
|
||||
generatePdf(payload: PagedDocumentPayload): Promise<DesktopPdfResult>;
|
||||
savePdf(fileName: string, pdf: Uint8Array): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface DesktopPdfRuntimeBridge {
|
||||
ready(): void;
|
||||
onRender(
|
||||
listener: (
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => Promise<void> | void
|
||||
): () => void;
|
||||
complete(
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
): void;
|
||||
fail(requestId: number, message: string): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
mdToPdfDesktop?: DesktopApplicationBridge;
|
||||
__mdToPdfDesktopPdf?: DesktopPdfRuntimeBridge;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APP_VERSION,
|
||||
APP_VERSION_LABEL
|
||||
} from "../src/app-version";
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.4.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from "vitest";
|
||||
import {
|
||||
getThemeCss,
|
||||
listThemes,
|
||||
renderMarkdownDocument
|
||||
} from "../src/application-backend";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("应用后端适配器", () => {
|
||||
it("在桌面环境通过窄 IPC 调用共享服务", async () => {
|
||||
const rendered = {
|
||||
rendererVersion: 1 as const,
|
||||
articleHtml: '<article id="write">测试</article>',
|
||||
bodyHtml: "测试",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
};
|
||||
const renderMarkdown = vi.fn(async () => rendered);
|
||||
const listDesktopThemes = vi.fn(async () => ({
|
||||
themes: [
|
||||
{
|
||||
id: "test",
|
||||
name: "测试",
|
||||
version: "1",
|
||||
description: "",
|
||||
bundled: true,
|
||||
source: "bundled" as const
|
||||
}
|
||||
]
|
||||
}));
|
||||
const getDesktopThemeCss = vi.fn(async () => "#write {}");
|
||||
vi.stubGlobal("window", {
|
||||
mdToPdfDesktop: {
|
||||
renderMarkdown,
|
||||
listThemes: listDesktopThemes,
|
||||
getThemeCss: getDesktopThemeCss
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
renderMarkdownDocument({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN"
|
||||
})
|
||||
).resolves.toBe(rendered);
|
||||
await expect(listThemes()).resolves.toEqual({
|
||||
themes: [expect.objectContaining({ id: "test" })]
|
||||
});
|
||||
await expect(getThemeCss("test")).resolves.toBe("#write {}");
|
||||
expect(renderMarkdown).toHaveBeenCalledWith({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN"
|
||||
});
|
||||
expect(getDesktopThemeCss).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("在浏览器环境保持现有 HTTP 协议", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write">测试</article>',
|
||||
bodyHtml: "测试",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({ themes: [] })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response("#write {}", { status: 200 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
|
||||
await renderMarkdownDocument({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN"
|
||||
});
|
||||
await listThemes();
|
||||
await getThemeCss("typora-like");
|
||||
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/api/render",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/api/themes",
|
||||
{}
|
||||
);
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"/api/themes/typora-like/css",
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from "vitest";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
downloadPdf,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
parseContentDispositionFileName,
|
||||
requestDesktopPdfExport,
|
||||
requestPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "../src/pdf-export";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PDF 导出客户端", () => {
|
||||
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
|
||||
const request: PdfExportRequest = {
|
||||
@@ -178,4 +193,49 @@ describe("PDF 导出客户端", () => {
|
||||
)
|
||||
).rejects.toThrow("PDF 生成队列已满");
|
||||
});
|
||||
|
||||
it("桌面端使用共享分页载荷生成并保存 PDF", async () => {
|
||||
const payload = createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write">测试</article>',
|
||||
bodyHtml: "测试",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "测试.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
});
|
||||
const generatePdf = vi.fn(async () => ({
|
||||
pdf: new TextEncoder().encode("%PDF-desktop"),
|
||||
pageCount: 2,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: ["图表 1"]
|
||||
}));
|
||||
const savePdf = vi.fn(async () => true);
|
||||
vi.stubGlobal("window", {
|
||||
mdToPdfDesktop: { generatePdf, savePdf }
|
||||
});
|
||||
|
||||
const generated = await requestDesktopPdfExport(payload);
|
||||
const saved = await downloadPdf(generated.blob, generated.fileName);
|
||||
|
||||
expect(generatePdf).toHaveBeenCalledWith(payload);
|
||||
expect(generated.fileName).toBe("测试.pdf");
|
||||
expect(generated.pageCount).toBe(2);
|
||||
expect(generated.mermaidErrorCount).toBe(1);
|
||||
expect(savePdf).toHaveBeenCalledWith(
|
||||
"测试.pdf",
|
||||
expect.any(Uint8Array)
|
||||
);
|
||||
expect(saved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootPackage = JSON.parse(
|
||||
readFileSync(
|
||||
fileURLToPath(new URL("../../package.json", import.meta.url)),
|
||||
"utf8"
|
||||
)
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
publicDir: fileURLToPath(
|
||||
new URL("../../logos/web", import.meta.url)
|
||||
),
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(rootPackage.version)
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
||||
Reference in New Issue
Block a user