6 Commits
Author SHA1 Message Date
SkyJourney 32a37293d0 fix: 完善本地与网络图片渲染 2026-07-27 22:12:16 +08:00
SkyJourney 06b30d084e fix: 固定桌面端英文可执行文件名 2026-07-27 21:05:47 +08:00
SkyJourney 4d60741f5a feat: 发布 v0.4.0 桌面端 2026-07-27 20:56:39 +08:00
SkyJourney 92a5bfd016 feat: 扩展 ECharts 第二阶段图表支持
新增 tree、treemap、sunburst、graph、sankey、chord、funnel、gauge 和 pictorialBar 系列,并注册对应的 ECharts 按需模块。

完善层级与关系数据校验、节点和边数量限制、默认布局及错误占位规则,同时补充默认 Markdown 与 README 示例。

静态渲染全局关闭 ECharts 动画,修复 Treemap 标签冻结在透明动画帧的问题,并完成快速预览、精确 PDF、容器镜像和 147 项全项目测试验证。
2026-07-27 17:47:28 +08:00
SkyJourney f5a46f79f1 feat: 增加 ECharts 渲染与预览交互增强
- 新增可复用的 markdown-echarts 工作区,支持安全的 YAML 围栏、默认值、语义校验、错误占位和 SVG 渲染
- 将 ECharts 接入统一 Markdown、快速预览、精确预览与 PDF 链路,并复用 Mermaid 的不可跨页和超高图单页适配策略
- 默认示例加入 ECharts,增加源文本折叠、顶部文档状态、页码输入跳转与滚动同步
- 快速预览改用外层容器滚动,使滚动条与精确预览统一贴在预览区域右侧,并兼容显示缩放
- 完善 Docker 工作区复制、可配置 Compose 镜像、测试覆盖和进度文档
2026-07-27 16:05:14 +08:00
SkyJourney 1db21c80fc docs: 记录显示缩放导致的分页差异 2026-07-27 12:50:28 +08:00
136 changed files with 16758 additions and 468 deletions
+1
View File
@@ -13,4 +13,5 @@ tmp/
playwright-report/
test-results/
output/
/apps/desktop/out/
*.tsbuildinfo
+18 -8
View File
@@ -16,11 +16,12 @@
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
- 快速预览与精确预览共用的 50%~400% 显示缩放;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具
- 内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题
- Web 素材目录上传、桌面同目录相对图片和受限公网图片下载;
- 图片标题、整块分页及超高图片单页等比例适配;
- 无本地 Server 的 Electron 桌面端与 Windows 安装包;
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
本地相对资源文件处理将在后续阶段实现。
## 本地开发
要求 Node.js 22 或更高版本。
@@ -36,7 +37,14 @@ npm run dev
- 后端:http://localhost:3001
- 健康检查:http://localhost:3001/api/health
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。
页面支持直接编辑 Markdown,或选择本地 `.md` 文件。若 Markdown 使用
相对图片路径,再选择对应的素材目录即可;文件与图片只进入当前渲染请求,
不写入数据库。桌面端使用原生“打开 Markdown”对话框,并自动从 Markdown
所在目录安全解析相对图片。
独立一行的 `![图片标题](地址)` 会显示图片标题。图片与标题作为一个整体
参与分页;超出单页内容区时,图片会在预留标题高度后等比例缩放,不跨页
切割。远程图片会先由应用服务受限下载并内嵌,失败时显示可诊断占位。
编辑区和分页预览使用双向比例滚动同步。在任意一侧滚动时,另一侧会跟随到相同的全文阅读进度,方便定位并修改较长文档。
@@ -103,9 +111,10 @@ flowchart LR
```
````
## 本地导入 Typora 默认主题
## 内置与本地 Typora 主题
项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题及 Typora 基础 CSS 复制到 Git 忽略的本地主题目录:
公司内部版本已经内置 GitHub、Pixyll 和 Whitey 三套适合打印的白色
Typora 默认主题。如需从本机 Typora 安装目录刷新本地开发副本,可以运行:
```powershell
npm run theme:import-typora
@@ -146,5 +155,6 @@ git diff --check
## 隐私原则
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程
地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。
+52
View File
@@ -0,0 +1,52 @@
# Desktop 桌面端
`apps/desktop``v0.4.0` 新增的 Electron 桌面应用。当前已经完成
Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择、
同目录图片解析、隐藏分页窗口、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.1/`。该目录被 Git 忽略。公司内部分发以
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名,
Windows 首次运行可能显示“未知发布者”提示。
产品名称、窗口标题、安装包名称和开始菜单名称保持为
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
`md-to-pdf.exe`
当前目录包只包含一套 Electron Chromium,不携带 Playwright Chromium。
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
`mdpdf://bundle/` 自定义协议加载。
## 安全边界
- 应用窗口和 PDF 窗口均关闭 Node Integration
- 启用 Context Isolation 和 Chromium Sandbox
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
的窄接口;
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
链接越界;
- 主进程校验 IPC 发送者和分页载荷;
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
- 应用页面使用自定义协议,不使用 `file://`
+47
View File
@@ -0,0 +1,47 @@
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,
executableName: "md-to-pdf",
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",
exe: "md-to-pdf.exe",
setupIcon: windowsIcon
}
},
{
name: "@electron-forge/maker-zip",
platforms: ["win32", "darwin"]
}
]
};
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@md-to-pdf/desktop",
"version": "0.4.1",
"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.1"
},
"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"
}
}
+16
View File
@@ -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")
}
});
+24
View File
@@ -0,0 +1,24 @@
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_OPEN_MARKDOWN,
DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF
} from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", {
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
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)
});
+54
View File
@@ -0,0 +1,54 @@
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,
resources: value.resources
};
}
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("/")
};
}
+20
View File
@@ -0,0 +1,20 @@
export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf:desktop:render-markdown";
export const DESKTOP_OPEN_MARKDOWN =
"md-to-pdf:desktop:open-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";
+342
View File
@@ -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 分页失败"
)
);
};
}
+376
View File
@@ -0,0 +1,376 @@
import {
app,
BrowserWindow,
dialog,
ipcMain,
net,
protocol,
session,
type IpcMainInvokeEvent
} from "electron";
import { access, readFile, 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_OPEN_MARKDOWN,
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;
let documentRoot: string | undefined;
ipcMain.handle(DESKTOP_OPEN_MARKDOWN, async (event) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面文件请求");
}
const selection = await dialog.showOpenDialog(window, {
title: "打开 Markdown",
properties: ["openFile"],
filters: [
{ name: "Markdown 文件", extensions: ["md", "markdown"] },
{ name: "文本文件", extensions: ["txt"] }
]
});
const filePath = selection.filePaths[0];
if (selection.canceled || !filePath) {
return undefined;
}
const content = await readFile(filePath, "utf8");
documentRoot = path.dirname(filePath);
return {
markdown: content,
fileName: path.basename(filePath)
};
});
ipcMain.handle(
DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => {
if (!validateSender(event)) {
throw new Error("拒绝未授权的桌面渲染请求");
}
return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest),
documentRoot ? { localRoot: documentRoot } : {}
);
}
);
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_OPEN_MARKDOWN);
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();
}
});
+124
View File
@@ -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;
}
+41
View File
@@ -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)
});
+138
View File
@@ -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();
});
});
+20
View File
@@ -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"
]
}
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/application": "0.4.1",
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2",
+52 -122
View File
@@ -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,11 +22,11 @@ import {
PdfRenderTimeoutError,
type PdfGenerator
} from "./pdf-engine.js";
import { createThemeRegistry } from "./theme-registry.js";
interface RenderRequestBody {
markdown?: unknown;
language?: unknown;
resources?: unknown;
}
interface PdfRequestBody extends RenderRequestBody {
@@ -34,66 +35,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 +68,7 @@ export interface BuildAppOptions {
logger?: boolean;
pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean;
applicationService?: ApplicationService;
}
function milliseconds(value: number) {
@@ -149,6 +91,8 @@ function createPdfServerTiming(
`navigation;dur=${milliseconds(timings.navigationMs)}`,
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
`echarts;dur=${milliseconds(timings.echartsMs)}`,
`echarts-fit;dur=${milliseconds(timings.echartsFitMs)}`,
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
`mermaid-conversion;dur=${milliseconds(
@@ -165,14 +109,16 @@ function createPdfServerTiming(
export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
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)
bodyLimit: 24 * 1024 * 1024
});
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();
@@ -211,23 +157,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",
@@ -246,7 +185,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["*"]
);
@@ -275,25 +214,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 await 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;
}
);
@@ -301,14 +232,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
);
@@ -331,7 +254,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({
@@ -341,18 +266,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 = await 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(
@@ -397,6 +323,10 @@ export function buildApp(options: BuildAppOptions = {}) {
.header("cache-control", "no-store")
.header("server-timing", serverTiming)
.header("x-pdf-page-count", String(generated.pageCount))
.header(
"x-echarts-error-count",
String(generated.echartsErrors.length)
)
.header(
"x-mermaid-error-count",
String(generated.mermaidErrors.length)
+8
View File
@@ -10,6 +10,7 @@ export type PdfRenderPayload = PagedDocumentPayload;
export interface PdfGenerationResult {
pdf: Buffer;
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
timings: PdfGenerationTimings;
}
@@ -21,6 +22,8 @@ export interface PdfGenerationTimings {
navigationMs: number;
documentRenderMs: number;
runtimeSetupMs: number;
echartsMs: number;
echartsFitMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
@@ -59,6 +62,8 @@ interface PdfDocumentResult
| "navigationMs"
| "documentRenderMs"
| "runtimeSetupMs"
| "echartsMs"
| "echartsFitMs"
| "mermaidMs"
| "mermaidFitMs"
| "mermaidConversionMs"
@@ -469,11 +474,14 @@ export class PlaywrightPdfGenerator implements PdfGenerator {
return {
pdf,
pageCount: renderResult.pageCount,
echartsErrors: renderResult.echartsErrors,
mermaidErrors: renderResult.mermaidErrors,
timings: {
navigationMs,
documentRenderMs,
runtimeSetupMs: renderResult.timings.setupMs,
echartsMs: renderResult.timings.echartsMs,
echartsFitMs: renderResult.timings.echartsFitMs,
mermaidMs: renderResult.timings.mermaidMs,
mermaidFitMs: renderResult.timings.mermaidFitMs,
mermaidConversionMs:
+8 -1
View File
@@ -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
@@ -369,6 +369,7 @@ describe("PDF API", () => {
const generate = vi.fn(async () => ({
pdf: Buffer.from("%PDF-test"),
pageCount: 3,
echartsErrors: ["图表 2"],
mermaidErrors: ["图表 1"],
timings: {
queueMs: 0,
@@ -377,6 +378,8 @@ describe("PDF API", () => {
navigationMs: 3,
documentRenderMs: 4,
runtimeSetupMs: 0.1,
echartsMs: 0.15,
echartsFitMs: 0.18,
mermaidMs: 0.2,
mermaidFitMs: 0.3,
mermaidConversionMs: 0.35,
@@ -415,6 +418,7 @@ describe("PDF API", () => {
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
);
expect(response.headers["x-pdf-page-count"]).toBe("3");
expect(response.headers["x-echarts-error-count"]).toBe("1");
expect(response.headers["x-mermaid-error-count"]).toBe("1");
expect(response.headers["server-timing"]).toContain(
"document-render;dur=4.0"
@@ -422,6 +426,9 @@ describe("PDF API", () => {
expect(response.headers["server-timing"]).toContain(
"pdf-print;dur=5.0"
);
expect(response.headers["server-timing"]).toContain(
"echarts;dur=0.1"
);
expect(response.headers["server-timing"]).toContain(
"mermaid;dur=0.2"
);
+5
View File
@@ -47,9 +47,12 @@ function fakeBrowser(
waitForFunction: vi.fn(async () => undefined),
evaluate: vi.fn(async () => ({
pageCount: 2,
echartsErrors: [],
mermaidErrors: [],
timings: {
setupMs: 1,
echartsMs: 1.5,
echartsFitMs: 1.75,
mermaidMs: 2,
mermaidFitMs: 3,
mermaidConversionMs: 3.5,
@@ -191,6 +194,8 @@ describe("Playwright PDF 生成器", () => {
navigationMs: expect.any(Number),
documentRenderMs: expect.any(Number),
runtimeSetupMs: 1,
echartsMs: 1.5,
echartsFitMs: 1.75,
mermaidMs: 2,
mermaidFitMs: 3,
mermaidConversionMs: 3.5,
+12
View File
@@ -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"
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
+1 -1
View File
@@ -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>
+587 -74
View File
@@ -16,6 +16,7 @@ import {
type ExportConfig
} from "@md-to-pdf/core";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import { PreviewPageControl } from "./PreviewPageControl";
import { PreviewZoomControl } from "./PreviewZoomControl";
import {
loadExportConfig,
@@ -29,22 +30,27 @@ import {
} from "./paged-preview";
import {
getNearestPageNumber,
PREVIEW_SCROLLBAR_WIDTH_PX
getPreviewPageScrollTop
} from "./preview-page";
import {
createPdfExportCacheKey,
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,
savePreviewZoom
} from "./preview-zoom";
import { useMarkdownRender } from "./use-markdown-render";
import {
type MarkdownImageResource
} from "./application-backend";
import {
type ThemeSummary,
useThemeResources
@@ -56,6 +62,30 @@ const PrecisePdfPreview = lazy(async () => {
});
type PreviewMode = "quick" | "precise";
const imageFilePattern = /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
function bytesToBase64(bytes: Uint8Array) {
const parts: string[] = [];
const chunkSize = 32_768;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
parts.push(
String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
);
}
return window.btoa(parts.join(""));
}
async function readImageResources(files: FileList) {
const images = Array.from(files).filter((file) =>
imageFilePattern.test(file.name)
);
return Promise.all(
images.map(async (file): Promise<MarkdownImageResource> => ({
path: file.webkitRelativePath || file.name,
data: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
}))
);
}
const sampleMarkdown = `---
title: Markdown PDF 示例
@@ -73,7 +103,7 @@ keywords:
- [x] Markdown 实时预览
- [x] 表格与任务列表
- [x] 数学公式Mermaid
- [x] 数学公式Mermaid 与 ECharts
- [ ] Chromium PDF 导出
| 功能 | 当前状态 |
@@ -83,6 +113,19 @@ keywords:
行内公式:$E = mc^2$
## 网络图片
### 中等尺寸
![NASA 地球中等尺寸示例](https://images-assets.nasa.gov/image/PIA18033/PIA18033~medium.jpg)
### 超高尺寸
下图用于验证图片高度超过单页内容区时,会等比例缩放并作为完整块放置,
不会跨页切割。
![超高网络图片分页示例](https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&q=80)
\`\`\`mermaid
flowchart LR
A["Markdown"] --> B["统一 HTML"]
@@ -91,17 +134,268 @@ flowchart LR
C --> E["PDF 导出"]
\`\`\`
## ECharts 图表
### 基础柱状图
\`\`\`echarts
caption: 各模块文档数量
height: 58mm
option:
xAxis:
type: category
data: [研发, 产品, 设计, 运营]
yAxis:
type: value
series:
- type: bar
data: [28, 18, 15, 22]
\`\`\`
### Tree 树图
\`\`\`echarts
caption: 产品能力树
height: 58mm
option:
series:
- type: tree
orient: LR
label:
position: left
data:
- name: 文档平台
children:
- name: Markdown
children:
- name: Mermaid
- name: ECharts
- name: PDF
children:
- name: 快速预览
- name: 精确预览
\`\`\`
### Treemap 矩形树图
\`\`\`echarts
caption: 模块工作量占比
height: 58mm
option:
series:
- type: treemap
roam: false
nodeClick: false
breadcrumb:
show: false
label:
show: true
formatter: "{b}"
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
borderRadius: 3
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
levels:
- itemStyle:
borderWidth: 0
gapWidth: 2
- upperLabel:
show: true
height: 28
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
- label:
show: true
formatter: "{b}"
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
borderRadius: 3
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
data:
- name: 渲染
children:
- { name: Markdown, value: 32 }
- { name: ECharts, value: 24 }
- { name: Mermaid, value: 18 }
- name: 导出
children:
- { name: PDF, value: 28 }
- { name: 主题, value: 12 }
\`\`\`
### Sunburst 旭日图
\`\`\`echarts
caption: 文档内容层级
height: 58mm
option:
series:
- type: sunburst
radius: [15%, 90%]
data:
- name: 技术文档
children:
- { name: 架构, value: 18 }
- { name: 接口, value: 24 }
- name: 项目文档
children:
- { name: 周报, value: 16 }
- { name: 计划, value: 12 }
\`\`\`
### Graph 关系图
\`\`\`echarts
caption: 渲染链路关系
height: 58mm
option:
series:
- type: graph
layout: circular
roam: false
label:
show: true
data:
- { name: Markdown, symbolSize: 42 }
- { name: HTML, symbolSize: 38 }
- { name: 预览, symbolSize: 34 }
- { name: PDF, symbolSize: 34 }
links:
- { source: Markdown, target: HTML }
- { source: HTML, target: 预览 }
- { source: HTML, target: PDF }
\`\`\`
### Sankey 桑基图
\`\`\`echarts
caption: 文档流转量
height: 58mm
option:
series:
- type: sankey
data:
- { name: Markdown }
- { name: HTML }
- { name: 快速预览 }
- { name: 精确预览 }
- { name: PDF }
links:
- { source: Markdown, target: HTML, value: 100 }
- { source: HTML, target: 快速预览, value: 55 }
- { source: HTML, target: 精确预览, value: 25 }
- { source: HTML, target: PDF, value: 20 }
\`\`\`
### Chord 和弦图
\`\`\`echarts
caption: 团队协作关系
height: 58mm
option:
series:
- type: chord
label:
show: true
data:
- { name: 研发 }
- { name: 产品 }
- { name: 设计 }
- { name: 运营 }
links:
- { source: 研发, target: 产品, value: 12 }
- { source: 产品, target: 设计, value: 8 }
- { source: 设计, target: 运营, value: 6 }
- { source: 运营, target: 研发, value: 4 }
\`\`\`
### Funnel 漏斗图
\`\`\`echarts
caption: 文档发布转化
height: 58mm
option:
series:
- type: funnel
sort: descending
label:
formatter: "{b}: {c}"
data:
- { name: 草稿, value: 100 }
- { name: 评审, value: 76 }
- { name: 定稿, value: 58 }
- { name: 发布, value: 45 }
\`\`\`
### Gauge 仪表盘
\`\`\`echarts
caption: 发布完成率
height: 58mm
option:
series:
- type: gauge
min: 0
max: 100
progress:
show: true
detail:
formatter: "{value}%"
data:
- { name: 完成率, value: 78 }
\`\`\`
### PictorialBar 象形柱图
\`\`\`echarts
caption: 各渠道文档数量
height: 58mm
option:
xAxis:
type: category
data: [知识库, 周报, 手册, 方案]
yAxis:
type: value
series:
- type: pictorialBar
symbol: "path://M0,10 L5,0 L10,10 Z"
symbolRepeat: true
symbolSize: [14, 10]
data: [12, 20, 16, 24]
\`\`\`
> 预览和 PDF 将共用同一份 HTML 与 CSS。
`;
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
const [imageResources, setImageResources] = useState<
MarkdownImageResource[]
>([]);
const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sourcePanelCollapsed, setSourcePanelCollapsed] = useState(false);
const [status, setStatus] = useState("正在准备预览…");
const [appError, setAppError] = useState("");
const [echartsError, setEChartsError] = useState("");
const [mermaidError, setMermaidError] = useState("");
const [paginationError, setPaginationError] = useState("");
const [pdfError, setPdfError] = useState("");
@@ -111,6 +405,8 @@ export function App() {
const [previewZoom, setPreviewZoom] = useState(loadPreviewZoom);
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [quickPreviewContentHeight, setQuickPreviewContentHeight] =
useState<number>();
const [previewPagination, setPreviewPagination] = useState({
current: 0,
total: 0
@@ -148,11 +444,13 @@ export function App() {
[]
);
const handleRenderStart = useCallback(() => {
setEChartsError("");
setMermaidError("");
}, []);
const { error: renderError, result } = useMarkdownRender(
markdown,
"zh-CN",
imageResources,
{
onRenderStart: handleRenderStart,
onStatusChange: setStatus
@@ -167,6 +465,7 @@ export function App() {
renderError ||
themeError ||
appError ||
echartsError ||
mermaidError ||
paginationError ||
pdfError;
@@ -177,17 +476,23 @@ export function App() {
exportConfig.paper.orientation
);
const previewScale = previewZoom / 100;
const naturalPaperWidth =
millimetersToCssPixels(paperDimensions.width) +
PREVIEW_SCROLLBAR_WIDTH_PX;
const naturalPaperWidth = millimetersToCssPixels(paperDimensions.width);
const scaledPaperWidth = naturalPaperWidth * previewScale;
const paperStyle = {
width: `${scaledPaperWidth}px`,
minWidth: `${scaledPaperWidth}px`
minWidth: `${scaledPaperWidth}px`,
...(quickPreviewContentHeight
? {
height: `${quickPreviewContentHeight * previewScale}px`,
minHeight: `${quickPreviewContentHeight * previewScale}px`
}
: {})
} satisfies CSSProperties;
const previewFrameStyle = {
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
height: quickPreviewContentHeight
? `${quickPreviewContentHeight}px`
: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: "top left"
} satisfies CSSProperties;
@@ -208,9 +513,10 @@ export function App() {
markdown,
fileName,
language: "zh-CN",
resources: imageResources,
exportConfig
}),
[exportConfig, fileName, markdown]
[exportConfig, fileName, imageResources, markdown]
);
const pdfCacheKey = useMemo(
() => createPdfExportCacheKey(pdfRequest),
@@ -271,7 +577,9 @@ export function App() {
if (event.data.type === "rendering") {
setPaginationError("");
setEChartsError("");
setMermaidError("");
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
setStatus("正在分页…");
return;
@@ -283,12 +591,18 @@ export function App() {
return;
}
setEChartsError(
event.data.echartsErrors.length > 0
? `ECharts${event.data.echartsErrors.join("")}`
: ""
);
setMermaidError(
event.data.mermaidErrors.length > 0
? `Mermaid${event.data.mermaidErrors.join("")}`
: ""
);
setStatus(`预览已分页(${event.data.pageCount} 页)`);
setQuickPreviewContentHeight(event.data.contentHeight);
setPreviewPagination({
current: event.data.pageCount > 0 ? 1 : 0,
total: event.data.pageCount
@@ -305,22 +619,15 @@ export function App() {
}, []);
useEffect(() => {
const frameWindow = previewFrameRef.current?.contentWindow;
if (!frameWindow || !previewFrameReady) {
return;
function handleWindowScroll() {
if (getOuterPreviewScroller() === document.scrollingElement) {
updatePreviewCurrentPage();
}
}
function handlePreviewScroll() {
synchronizeScroll("preview");
updatePreviewCurrentPage();
}
frameWindow.addEventListener("scroll", handlePreviewScroll, {
passive: true
});
return () =>
frameWindow.removeEventListener("scroll", handlePreviewScroll);
}, [previewFrameReady]);
window.addEventListener("scroll", handleWindowScroll, { passive: true });
return () => window.removeEventListener("scroll", handleWindowScroll);
}, [previewMode, quickPreviewContentHeight]);
useEffect(() => {
return () => {
@@ -346,12 +653,19 @@ export function App() {
);
}, [previewFrameReady, previewPayload]);
function getPreviewScroller() {
if (previewModeRef.current === "precise") {
return previewScrollRef.current;
function getOuterPreviewScroller() {
const previewScroller = previewScrollRef.current;
if (!previewScroller) {
return null;
}
return previewFrameRef.current?.contentDocument
?.scrollingElement as HTMLElement | null;
return previewScroller.scrollHeight > previewScroller.clientHeight + 1
? previewScroller
: (document.scrollingElement as HTMLElement | null);
}
function getPreviewScroller() {
return getOuterPreviewScroller();
}
function synchronizeScroll(origin: "editor" | "preview") {
@@ -392,15 +706,28 @@ export function App() {
return;
}
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const frameViewportTop = frame.getBoundingClientRect().top;
const viewportCenter =
scrollElement.scrollTop + frame.clientHeight / 2;
scrollElement.scrollTop + scrollElement.clientHeight / 2;
const pages = Array.from(
frameDocument.querySelectorAll<HTMLElement>(".pagedjs_page")
).map((page) => {
const rect = page.getBoundingClientRect();
return {
top: rect.top + scrollElement.scrollTop,
bottom: rect.bottom + scrollElement.scrollTop
top: getPreviewPageScrollTop(
scrollElement.scrollTop,
frameViewportTop + rect.top * previewScale,
scrollerViewportTop
),
bottom: getPreviewPageScrollTop(
scrollElement.scrollTop,
frameViewportTop + rect.bottom * previewScale,
scrollerViewportTop
)
};
});
const current = getNearestPageNumber(viewportCenter, pages);
@@ -417,20 +744,35 @@ export function App() {
}
function updatePrecisePreviewCurrentPage() {
const scrollElement = previewScrollRef.current;
const scrollElement = getOuterPreviewScroller();
if (!scrollElement) {
return;
}
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const viewportCenter =
scrollElement.scrollTop + scrollElement.clientHeight / 2;
const pages = Array.from(
scrollElement.querySelectorAll<HTMLElement>(
".precise-pdf-page"
)
).map((page) => ({
top: page.offsetTop,
bottom: page.offsetTop + page.offsetHeight
}));
).map((page) => {
const rect = page.getBoundingClientRect();
return {
top: getPreviewPageScrollTop(
scrollElement.scrollTop,
rect.top,
scrollerViewportTop
),
bottom: getPreviewPageScrollTop(
scrollElement.scrollTop,
rect.bottom,
scrollerViewportTop
)
};
});
const current = getNearestPageNumber(viewportCenter, pages);
setPreviewPagination((pagination) =>
@@ -444,6 +786,60 @@ export function App() {
);
}
function handlePreviewPageChange(pageNumber: number) {
const scrollElement = getPreviewScroller();
if (!scrollElement) {
return;
}
let targetTop: number | undefined;
if (previewModeRef.current === "precise") {
const page = scrollElement.querySelectorAll<HTMLElement>(
".precise-pdf-page"
)[pageNumber - 1];
if (page) {
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
targetTop = getPreviewPageScrollTop(
scrollElement.scrollTop,
page.getBoundingClientRect().top,
scrollerViewportTop
);
}
} else {
const page =
previewFrameRef.current?.contentDocument?.querySelectorAll<HTMLElement>(
".pagedjs_page"
)[pageNumber - 1];
if (page) {
const scrollerViewportTop =
scrollElement === document.scrollingElement
? 0
: scrollElement.getBoundingClientRect().top;
const frameViewportTop =
previewFrameRef.current?.getBoundingClientRect().top ?? 0;
targetTop = getPreviewPageScrollTop(
scrollElement.scrollTop,
frameViewportTop +
page.getBoundingClientRect().top * previewScale,
scrollerViewportTop
);
}
}
if (targetTop === undefined) {
return;
}
setPreviewPagination((pagination) => ({
...pagination,
current: pageNumber
}));
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
}
const handlePrecisePageCount = useCallback((pageCount: number) => {
setPreviewPagination({
current: pageCount > 0 ? 1 : 0,
@@ -462,6 +858,8 @@ export function App() {
setAppError("");
setMarkdown(await file.text());
setFileName(file.name);
setImageResources([]);
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
} catch {
setAppError("无法读取所选 Markdown 文件");
} finally {
@@ -469,6 +867,43 @@ export function App() {
}
}
async function handleAssetDirectoryChange(
event: ChangeEvent<HTMLInputElement>
) {
const files = event.target.files;
if (!files?.length) {
return;
}
try {
setAppError("");
const resources = await readImageResources(files);
setImageResources(resources);
setStatus(`已载入 ${resources.length} 个图片素材`);
} catch {
setAppError("无法读取所选图片素材目录");
} finally {
event.target.value = "";
}
}
async function handleDesktopOpenMarkdown() {
try {
setAppError("");
const opened = await window.mdToPdfDesktop?.openMarkdown();
if (!opened) {
return;
}
setMarkdown(opened.markdown);
setFileName(opened.fileName);
setImageResources([]);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开 Markdown 文件"
);
}
}
async function generatePdf(download: boolean) {
if (exportingPdf) {
return;
@@ -483,7 +918,10 @@ export function App() {
try {
const resolved = await getOrRequestPdfExport(
pdfRequest,
pdfCache
pdfCache,
previewPayload && window.mdToPdfDesktop
? () => requestDesktopPdfExport(previewPayload)
: undefined
);
if (pdfRequestKeyRef.current !== requestedKey) {
setStatus("文档已更新,已丢弃过期 PDF");
@@ -492,7 +930,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
@@ -502,8 +944,12 @@ export function App() {
exported.mermaidErrorCount > 0
? `${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
: "";
const echartsDescription =
exported.echartsErrorCount > 0
? `${exported.echartsErrorCount} 个 ECharts 图表渲染失败`
: "";
setStatus(
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${echartsDescription}${mermaidDescription}`
);
} catch (reason) {
setPdfError(
@@ -521,6 +967,7 @@ export function App() {
}
setPreviewMode(mode);
setPreviewFrameReady(false);
setQuickPreviewContentHeight(undefined);
setPreviewPagination({ current: 0, total: 0 });
previewScrollRef.current?.scrollTo({ top: 0 });
if (mode === "precise" && !cachedPdf) {
@@ -529,29 +976,64 @@ export function App() {
}
function handlePreviewScroll() {
if (previewModeRef.current !== "precise") {
return;
}
synchronizeScroll("preview");
updatePrecisePreviewCurrentPage();
updatePreviewCurrentPage();
}
return (
<main className="workspace">
<header className="topbar">
<div>
<p className="eyebrow"></p>
<h1>Markdown PDF </h1>
<div className="topbar-primary">
<div className="topbar-brand">
<p className="eyebrow"></p>
<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>
<strong title={fileName}>{fileName}</strong>
<span
className={`render-status${error ? " is-error" : ""}`}
aria-live="polite"
title={error || status}
>
{error || status}
</span>
</div>
</div>
<div className="topbar-actions">
<label className="file-button">
Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
{window.mdToPdfDesktop ? (
<button
type="button"
onClick={() => void handleDesktopOpenMarkdown()}
>
Markdown
</button>
) : (
<>
<label className="file-button">
Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
<label className="file-button">
<input
type="file"
accept="image/*"
multiple
// React 尚未声明 Chromium 的目录选择属性。
{...({ webkitdirectory: "" } as Record<string, string>)}
onChange={handleAssetDirectoryChange}
/>
</label>
</>
)}
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
@@ -565,25 +1047,52 @@ export function App() {
</div>
</header>
<section className="editor-layout">
<section
className={`editor-layout${
sourcePanelCollapsed ? " is-source-collapsed" : ""
}`}
>
<aside className="editor-panel" aria-label="Markdown 编辑区">
<div className="panel-heading">
<div>
<span className="panel-kicker"></span>
<strong>{fileName}</strong>
<div
id="markdown-source-content"
className="source-panel-content"
hidden={sourcePanelCollapsed}
>
<div className="panel-heading">
<div>
<span className="panel-kicker"></span>
<strong>Markdown </strong>
</div>
<button
type="button"
className="source-panel-toggle"
aria-controls="markdown-source-content"
aria-expanded="true"
onClick={() => setSourcePanelCollapsed(true)}
>
</button>
</div>
<span className={`render-status${error ? " is-error" : ""}`}>
{error || status}
</span>
<textarea
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
</div>
<textarea
ref={editorRef}
aria-label="Markdown 内容"
value={markdown}
spellCheck={false}
onChange={(event) => setMarkdown(event.target.value)}
onScroll={() => synchronizeScroll("editor")}
/>
{sourcePanelCollapsed ? (
<button
type="button"
className="source-panel-restore"
aria-controls="markdown-source-content"
aria-expanded="false"
onClick={() => setSourcePanelCollapsed(false)}
>
</button>
) : null}
</aside>
<section className="preview-panel" aria-label="文档预览">
@@ -593,11 +1102,6 @@ export function App() {
<strong>{result?.metadata.title || "未命名文档"}</strong>
</div>
<span className="preview-heading-actions">
{previewPagination.total > 0 ? (
<span className="preview-page-indicator" aria-live="polite">
{previewPagination.current} / {previewPagination.total}
</span>
) : null}
<span
className="preview-mode-control"
role="group"
@@ -654,6 +1158,15 @@ export function App() {
</span>
</div>
<div className="preview-toolbar">
{previewPagination.total > 0 ? (
<PreviewPageControl
current={previewPagination.current}
total={previewPagination.total}
onNavigate={handlePreviewPageChange}
/>
) : (
<span />
)}
<PreviewZoomControl
value={previewZoom}
onChange={setPreviewZoom}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from "react";
import { normalizePreviewPageNumber } from "./preview-page";
interface PreviewPageControlProps {
current: number;
total: number;
onNavigate: (pageNumber: number) => void;
}
export function PreviewPageControl({
current,
total,
onNavigate
}: PreviewPageControlProps) {
const [draft, setDraft] = useState(String(current));
const editingRef = useRef(false);
useEffect(() => {
if (!editingRef.current) {
setDraft(String(current));
}
}, [current, total]);
function commitPageNumber() {
const pageNumber = normalizePreviewPageNumber(draft, current, total);
setDraft(String(pageNumber));
if (pageNumber !== current) {
onNavigate(pageNumber);
}
}
return (
<label className="preview-page-control">
<span></span>
<input
type="number"
inputMode="numeric"
min={1}
max={total}
step={1}
value={draft}
aria-label="跳转页码"
onBlur={() => {
editingRef.current = false;
commitPageNumber();
}}
onChange={(event) => setDraft(event.target.value)}
onFocus={(event) => {
editingRef.current = true;
event.currentTarget.select();
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
<span aria-live="polite">/ {total} </span>
</label>
);
}
+2
View File
@@ -0,0 +1,2 @@
export const APP_VERSION = __APP_VERSION__;
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+102
View File
@@ -0,0 +1,102 @@
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;
resources?: MarkdownImageResource[];
}
export interface MarkdownImageResource {
path: string;
data: 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();
}
+178
View File
@@ -0,0 +1,178 @@
import {
getPaperDimensionsMm,
lengthToMillimeters,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
export interface DiagramDimensions {
width: number;
height: number;
}
export interface DiagramPageFit extends DiagramDimensions {
scaled: boolean;
}
export interface SvgDiagramFitOptions {
containerSelector: string;
svgSelector?: string;
resolvePageContent?: (
container: HTMLElement,
pageContent: DiagramDimensions
) => DiagramDimensions;
onFit?: (
container: HTMLElement,
svg: SVGSVGElement,
fit: DiagramPageFit
) => void;
}
const PAGE_FIT_EPSILON_PX = 1;
export function parseSvgViewBox(
value: string | null
): DiagramDimensions | undefined {
if (!value) {
return undefined;
}
const values = value
.trim()
.split(/[\s,]+/)
.map((part) => Number(part));
const [, , width = Number.NaN, height = Number.NaN] = values;
if (
values.length !== 4 ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
function parseSvgDimensions(
svg: SVGSVGElement
): DiagramDimensions | undefined {
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
const height = Number.parseFloat(
svg.getAttribute("height") ?? ""
);
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
export function getPageContentDimensions(
config: ExportConfig
): DiagramDimensions {
const paper = getPaperDimensionsMm(
config.paper.format,
config.paper.orientation
);
const width =
paper.width -
lengthToMillimeters(config.paper.margins.left) -
lengthToMillimeters(config.paper.margins.right);
const height =
paper.height -
lengthToMillimeters(config.paper.margins.top) -
lengthToMillimeters(config.paper.margins.bottom);
return {
width: millimetersToCssPixels(width),
height: millimetersToCssPixels(height)
};
}
export function calculateDiagramPageFit(
intrinsic: DiagramDimensions,
pageContent: DiagramDimensions
): DiagramPageFit {
const heightAfterWidthFit =
(pageContent.width * intrinsic.height) / intrinsic.width;
if (heightAfterWidthFit <= pageContent.height) {
return {
width: pageContent.width,
height: heightAfterWidthFit,
scaled: false
};
}
const height = Math.max(
0,
pageContent.height - PAGE_FIT_EPSILON_PX
);
return {
width: (height * intrinsic.width) / intrinsic.height,
height,
scaled: true
};
}
export function fitSvgDiagramsToPage(
root: ParentNode,
config: ExportConfig,
options: SvgDiagramFitOptions
) {
const pageContent = getPageContentDimensions(config);
const containers = Array.from(
root.querySelectorAll<HTMLElement>(options.containerSelector)
);
for (const container of containers) {
const svg = container.querySelector<SVGSVGElement>(
options.svgSelector ?? "svg"
);
const intrinsic = svg
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
parseSvgDimensions(svg)
: undefined;
if (!svg || !intrinsic) {
continue;
}
const availablePageContent =
options.resolvePageContent?.(container, pageContent) ??
pageContent;
const fit = calculateDiagramPageFit(
intrinsic,
availablePageContent
);
if (!fit.scaled) {
continue;
}
container.dataset.pageHeightFitted = "true";
container.style.setProperty("margin-block", "0", "important");
if (!svg.getAttribute("viewBox")) {
svg.setAttribute(
"viewBox",
`0 0 ${intrinsic.width} ${intrinsic.height}`
);
}
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.setProperty("display", "block");
svg.style.setProperty("width", `${fit.width}px`, "important");
svg.style.setProperty("height", `${fit.height}px`, "important");
svg.style.setProperty("max-width", "100%", "important");
svg.style.setProperty(
"max-height",
`${fit.height}px`,
"important"
);
options.onFit?.(container, svg, fit);
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
calculateDiagramPageFit,
getPageContentDimensions
} from "./diagram-page-fit";
function getImageBlock(image: HTMLImageElement) {
const parent = image.parentElement;
if (parent?.classList.contains("md-document-image-block")) {
return parent;
}
if (
parent?.tagName === "P" &&
Array.from(parent.childNodes).every(
(node) =>
node === image ||
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
)
) {
parent.classList.add("md-document-image-block");
return parent;
}
return undefined;
}
export function fitDocumentImagesToPage(
root: ParentNode,
config: ExportConfig
) {
const pageContent = getPageContentDimensions(config);
const images = Array.from(
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
);
for (const image of images) {
const block = getImageBlock(image);
const width = image.naturalWidth;
const height = image.naturalHeight;
if (width <= 0 || height <= 0) {
continue;
}
const imageHeight = image.getBoundingClientRect().height;
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
const fit = calculateDiagramPageFit(
{ width, height },
{
width: pageContent.width,
height: Math.max(0, pageContent.height - nonImageHeight)
}
);
if (!fit.scaled) {
continue;
}
if (block?.classList.contains("md-document-image-block")) {
block.dataset.pageHeightFitted = "true";
block.style.setProperty("margin-block", "0", "important");
}
image.dataset.pageHeightFitted = "true";
image.style.setProperty("display", "block");
image.style.setProperty("width", `${fit.width}px`, "important");
image.style.setProperty("height", `${fit.height}px`, "important");
image.style.setProperty("max-width", "100%", "important");
image.style.setProperty(
"max-height",
`${fit.height}px`,
"important"
);
image.style.setProperty("object-fit", "contain");
image.style.setProperty("margin-inline", "auto");
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { ExportConfig } from "@md-to-pdf/core";
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
export function fitOversizedEChartsToPage(
container: ParentNode,
config: ExportConfig
) {
fitSvgDiagramsToPage(container, config, {
containerSelector: ".md-echarts",
svgSelector: ".md-echarts-host svg",
resolvePageContent(figure, pageContent) {
const host = figure.querySelector<HTMLElement>(
".md-echarts-host"
);
const figureHeight = figure.getBoundingClientRect().height;
const hostHeight = host?.getBoundingClientRect().height ?? 0;
const nonChartHeight = Math.max(
0,
figureHeight - hostHeight
);
return {
width: pageContent.width,
height: Math.max(0, pageContent.height - nonChartHeight)
};
},
onFit(figure, _svg, fit) {
const host = figure.querySelector<HTMLElement>(
".md-echarts-host"
);
if (!host) {
return;
}
host.style.setProperty(
"width",
`${fit.width}px`,
"important"
);
host.style.setProperty(
"height",
`${fit.height}px`,
"important"
);
host.style.setProperty("margin-inline", "auto", "important");
host.style.removeProperty("aspect-ratio");
}
});
}
+18 -115
View File
@@ -1,123 +1,26 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
getPaperDimensionsMm,
lengthToMillimeters,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
calculateDiagramPageFit,
fitSvgDiagramsToPage,
getPageContentDimensions,
parseSvgViewBox,
type DiagramDimensions,
type DiagramPageFit
} from "./diagram-page-fit";
export interface MermaidDimensions {
width: number;
height: number;
}
export interface MermaidPageFit extends MermaidDimensions {
scaled: boolean;
}
const PAGE_FIT_EPSILON_PX = 1;
export function parseSvgViewBox(
value: string | null
): MermaidDimensions | undefined {
if (!value) {
return undefined;
}
const values = value
.trim()
.split(/[\s,]+/)
.map((part) => Number(part));
const [, , width = Number.NaN, height = Number.NaN] = values;
if (
values.length !== 4 ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
export function getPageContentDimensions(
config: ExportConfig
): MermaidDimensions {
const paper = getPaperDimensionsMm(
config.paper.format,
config.paper.orientation
);
const width =
paper.width -
lengthToMillimeters(config.paper.margins.left) -
lengthToMillimeters(config.paper.margins.right);
const height =
paper.height -
lengthToMillimeters(config.paper.margins.top) -
lengthToMillimeters(config.paper.margins.bottom);
return {
width: millimetersToCssPixels(width),
height: millimetersToCssPixels(height)
};
}
export function calculateMermaidPageFit(
intrinsic: MermaidDimensions,
pageContent: MermaidDimensions
): MermaidPageFit {
const heightAfterWidthFit =
(pageContent.width * intrinsic.height) / intrinsic.width;
if (heightAfterWidthFit <= pageContent.height) {
return {
width: pageContent.width,
height: heightAfterWidthFit,
scaled: false
};
}
const height = Math.max(
0,
pageContent.height - PAGE_FIT_EPSILON_PX
);
return {
width: (height * intrinsic.width) / intrinsic.height,
height,
scaled: true
};
}
export type MermaidDimensions = DiagramDimensions;
export type MermaidPageFit = DiagramPageFit;
export {
getPageContentDimensions,
parseSvgViewBox
};
export const calculateMermaidPageFit = calculateDiagramPageFit;
export function fitOversizedMermaidToPage(
container: ParentNode,
config: ExportConfig
) {
const pageContent = getPageContentDimensions(config);
const diagrams = Array.from(
container.querySelectorAll<HTMLElement>(".mermaid")
);
for (const diagram of diagrams) {
const svg = diagram.querySelector<SVGSVGElement>("svg");
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
if (!svg || !intrinsic) {
continue;
}
const fit = calculateMermaidPageFit(intrinsic, pageContent);
if (!fit.scaled) {
continue;
}
diagram.dataset.pageHeightFitted = "true";
diagram.style.setProperty("margin-block", "0", "important");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.setProperty("display", "block");
svg.style.setProperty("width", `${fit.width}px`, "important");
svg.style.setProperty("height", `${fit.height}px`, "important");
svg.style.setProperty("max-width", "100%", "important");
svg.style.setProperty("max-height", `${fit.height}px`, "important");
}
fitSvgDiagramsToPage(container, config, {
containerSelector: ".mermaid"
});
}
+75 -1
View File
@@ -1,10 +1,13 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
import type {
PagedDocumentRenderResult,
PagedDocumentTimings
} from "@md-to-pdf/core";
import { Previewer } from "pagedjs";
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
import { fitDocumentImagesToPage } from "./document-image-fit";
import { createMermaidSiteConfig } from "./mermaid-config";
import {
fitOversizedMermaidToPage,
@@ -82,6 +85,7 @@ function mountMeasurementContainer(
documentBaseCss,
highlightCss,
katexCss,
echartsCss,
themeCss,
documentGeometryCss
].join("\n");
@@ -159,6 +163,11 @@ export class PagedDocumentRuntime {
private mermaidPromise:
| Promise<(typeof import("mermaid"))["default"]>
| undefined;
private echartsPromise:
| Promise<
typeof import("@md-to-pdf/markdown-echarts/browser")
>
| undefined;
constructor(private readonly root: HTMLElement) {}
@@ -188,6 +197,41 @@ export class PagedDocumentRuntime {
return this.mermaidPromise;
}
private loadECharts() {
if (!this.echartsPromise) {
this.echartsPromise = import(
"@md-to-pdf/markdown-echarts/browser"
);
}
return this.echartsPromise;
}
private async renderECharts(
container: ParentNode,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("echarts")) {
return [];
}
const { renderEChartsBlocks } = await this.loadECharts();
const outcomes = await renderEChartsBlocks(container, {
outputMode: "inline-svg",
concurrency: 2,
timeoutMs: 5_000
});
return outcomes.flatMap((outcome, index) =>
outcome.success
? []
: [
`图表 ${index + 1}${
outcome.error?.message ?? "未知错误"
}`
]
);
}
private async renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
@@ -276,9 +320,16 @@ export class PagedDocumentRuntime {
const mermaidErrors = await this.renderMermaid(content, payload);
const mermaidMs = performance.now() - mermaidStartedAt;
let echartsErrors: string[] = [];
let echartsMs = 0;
let echartsFitMs = 0;
let mermaidFitMs = 0;
let mermaidConversionMs = 0;
if (content.querySelector(".mermaid svg")) {
if (
payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image")
) {
const measurement = mountMeasurementContainer(
documentRef,
content,
@@ -287,7 +338,26 @@ export class PagedDocumentRuntime {
);
try {
await documentRef.fonts.ready;
const echartsStartedAt = performance.now();
echartsErrors = await this.renderECharts(
measurement.host,
payload
);
echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host);
fitDocumentImagesToPage(
measurement.host,
payload.exportConfig
);
const echartsFitStartedAt = performance.now();
fitOversizedEChartsToPage(
measurement.host,
payload.exportConfig
);
echartsFitMs =
performance.now() - echartsFitStartedAt;
const mermaidFitStartedAt = performance.now();
fitOversizedMermaidToPage(
@@ -326,6 +396,7 @@ export class PagedDocumentRuntime {
stylesheet(documentRef, documentBaseCss, "document-base"),
stylesheet(documentRef, highlightCss, "highlight"),
stylesheet(documentRef, katexCss, "katex"),
stylesheet(documentRef, echartsCss, "echarts"),
stylesheet(documentRef, payload.themeCss, "theme"),
stylesheet(
documentRef,
@@ -355,9 +426,12 @@ export class PagedDocumentRuntime {
return {
pageCount: flow.total,
echartsErrors,
mermaidErrors,
timings: {
setupMs,
echartsMs,
echartsFitMs,
mermaidMs,
mermaidFitMs,
mermaidConversionMs,
+25
View File
@@ -62,6 +62,7 @@ async function renderPagedPreview(
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
contentHeight: Math.ceil(document.documentElement.scrollHeight),
...result
});
} finally {
@@ -86,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 ||
+53 -16
View File
@@ -4,8 +4,6 @@ import {
type MarkdownDocumentMetadata,
type PagedDocumentPayload
} from "@md-to-pdf/core";
import { PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page";
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
export type PreviewMetadata = MarkdownDocumentMetadata;
@@ -33,6 +31,8 @@ export type PagedPreviewFrameMessage =
type: "rendered";
requestId: number;
pageCount: number;
contentHeight: number;
echartsErrors: string[];
mermaidErrors: string[];
}
| {
@@ -67,6 +67,25 @@ svg {
max-width: 100%;
}
.md-document-image-block {
break-inside: avoid;
page-break-inside: avoid;
text-align: center;
}
.md-document-image-block > .md-document-image {
display: block;
margin-inline: auto;
}
.md-document-image-caption {
margin-top: 0.55em;
color: #64748b;
font-size: 0.875em;
line-height: 1.5;
text-align: center;
}
#write table {
width: 100%;
table-layout: auto;
@@ -92,6 +111,11 @@ svg {
background: #fef2f2;
white-space: pre-wrap;
}
.md-echarts {
break-inside: avoid;
page-break-inside: avoid;
}
`;
export const documentGeometryCss = `
@@ -293,7 +317,8 @@ ${buildFooterCss(config)}
#write pre,
#write blockquote,
#write .katex-display,
#write .mermaid {
#write .mermaid,
#write .md-echarts {
break-inside: avoid;
}
@@ -307,6 +332,11 @@ ${buildFooterCss(config)}
page-break-inside: avoid;
}
#write .md-document-image-block {
break-inside: avoid;
page-break-inside: avoid;
}
${
config.print.pageBreakBeforeH1
? `#write h1:not(:first-child) {
@@ -329,16 +359,7 @@ ${
html[data-render-target="preview"] {
height: 100%;
overflow-x: hidden;
overflow-y: scroll;
scrollbar-gutter: stable;
}
html[data-render-target="preview"]::-webkit-scrollbar {
width: ${PREVIEW_SCROLLBAR_WIDTH_PX}px;
}
html[data-render-target="preview"]::-webkit-scrollbar:horizontal {
height: 0;
overflow-y: hidden;
}
html[data-render-target="preview"] body {
@@ -413,10 +434,26 @@ export function isPagedPreviewFrameMessage(
}
const candidate = value as Partial<PagedPreviewFrameMessage>;
return (
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
["ready", "rendering", "rendered", "error"].includes(
if (
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
!["ready", "rendering", "rendered", "error"].includes(
candidate.type ?? ""
)
) {
return false;
}
if (candidate.type !== "rendered") {
return true;
}
return (
Number.isInteger(candidate.requestId) &&
Number.isInteger(candidate.pageCount) &&
typeof candidate.contentHeight === "number" &&
Number.isFinite(candidate.contentHeight) &&
candidate.contentHeight > 0 &&
Array.isArray(candidate.echartsErrors) &&
Array.isArray(candidate.mermaidErrors)
);
}
+40 -2
View File
@@ -1,9 +1,14 @@
import type { ExportConfig } from "@md-to-pdf/core";
import type {
ExportConfig,
PagedDocumentPayload
} from "@md-to-pdf/core";
import type { MarkdownImageResource } from "./application-backend";
export interface PdfExportRequest {
markdown: string;
fileName: string;
language: string;
resources: MarkdownImageResource[];
exportConfig: ExportConfig;
}
@@ -11,6 +16,7 @@ export interface PdfExportResult {
blob: Blob;
fileName: string;
pageCount: number | undefined;
echartsErrorCount: number;
mermaidErrorCount: number;
}
@@ -24,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
request.markdown,
request.fileName,
request.language,
request.resources,
request.exportConfig
]);
}
@@ -117,6 +124,10 @@ export async function requestPdfExport(
pageCount: parseNumericHeader(
response.headers.get("x-pdf-page-count")
),
echartsErrorCount:
parseNumericHeader(
response.headers.get("x-echarts-error-count")
) ?? 0,
mermaidErrorCount:
parseNumericHeader(
response.headers.get("x-mermaid-error-count")
@@ -124,11 +135,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;
}
+44 -1
View File
@@ -3,7 +3,50 @@ export interface PageVerticalRange {
bottom: number;
}
export const PREVIEW_SCROLLBAR_WIDTH_PX = 14;
export function getPreviewPageScrollTop(
currentScrollTop: number,
pageViewportTop: number,
scrollerViewportTop: number
) {
if (
!Number.isFinite(currentScrollTop) ||
!Number.isFinite(pageViewportTop) ||
!Number.isFinite(scrollerViewportTop)
) {
return 0;
}
return Math.max(
0,
currentScrollTop + pageViewportTop - scrollerViewportTop
);
}
export function normalizePreviewPageNumber(
value: string,
current: number,
total: number
) {
if (!Number.isInteger(total) || total < 1) {
return 0;
}
const normalizedValue = value.trim();
const fallback = Number.isInteger(current)
? Math.min(Math.max(current, 1), total)
: 1;
if (normalizedValue.length === 0) {
return fallback;
}
const parsed = Number(normalizedValue);
if (!Number.isInteger(parsed)) {
return fallback;
}
return Math.min(Math.max(parsed, 1), total);
}
export function getNearestPageNumber(
viewportCenter: number,
+197 -10
View File
@@ -63,6 +63,57 @@ button:disabled {
background: rgb(247 248 246 / 92%);
}
.topbar-primary {
display: flex;
flex: 1;
align-items: center;
gap: 32px;
min-width: 0;
}
.topbar-brand {
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;
min-width: 0;
max-width: min(42vw, 520px);
padding-left: 28px;
border-left: 1px solid #d4dad7;
}
.document-summary > strong {
overflow: hidden;
font-size: 0.82rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-summary .render-status {
max-width: 100%;
margin-top: 3px;
}
.eyebrow {
margin: 0 0 4px;
color: #3b755d;
@@ -82,6 +133,7 @@ h1 {
.topbar-actions {
display: flex;
flex: none;
flex-wrap: wrap;
gap: 10px;
}
@@ -104,6 +156,11 @@ h1 {
grid-template-columns: minmax(360px, 0.8fr) minmax(520px, 1.2fr);
flex: 1;
min-height: 0;
transition: grid-template-columns 180ms ease;
}
.editor-layout.is-source-collapsed {
grid-template-columns: 48px minmax(520px, 1fr);
}
.editor-panel,
@@ -119,6 +176,45 @@ h1 {
background: #f6f7f5;
}
.source-panel-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.source-panel-content[hidden] {
display: none;
}
.source-panel-toggle {
min-height: 32px;
padding: 0 10px;
border-color: #c9d4ce;
background: #f8fbf9;
color: #4f695e;
font-size: 0.7rem;
}
.source-panel-restore {
width: 100%;
height: 100%;
min-height: 0;
padding: 14px 0;
border: 0;
border-radius: 0;
background: #f0f3f1;
color: #4f695e;
font-size: 0.72rem;
letter-spacing: 0.08em;
writing-mode: vertical-rl;
}
.source-panel-restore:hover {
background: #e4ebe7;
color: #356c55;
}
.panel-heading {
display: flex;
align-items: center;
@@ -181,12 +277,43 @@ h1 {
flex: none;
min-height: 44px;
align-items: center;
justify-content: flex-end;
justify-content: space-between;
gap: 12px;
padding: 6px 20px;
border-bottom: 1px solid #d9dedb;
background: rgb(255 255 255 / 58%);
}
.preview-page-control {
display: inline-flex;
flex: none;
align-items: center;
gap: 5px;
color: #5d7168;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.preview-page-control input {
width: 48px;
height: 30px;
padding: 0 6px;
border: 1px solid #c9d4ce;
border-radius: 7px;
outline: 0;
background: #fff;
color: #356c55;
font: inherit;
font-weight: 700;
text-align: center;
}
.preview-page-control input:focus {
border-color: #74a88f;
box-shadow: 0 0 0 3px rgb(116 168 143 / 16%);
}
.preview-zoom-control {
display: flex;
align-items: center;
@@ -248,14 +375,6 @@ h1 {
color: #9ba49f;
}
.preview-page-indicator {
flex: none;
color: #5d7168;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.preview-mode-control {
display: inline-flex;
flex: none;
@@ -362,6 +481,28 @@ textarea:focus {
min-height: 0;
overflow: auto;
padding: 0 30px;
scrollbar-color: #8d9993 #dfe4e1;
scrollbar-width: auto;
}
.preview-scroll::-webkit-scrollbar {
width: 14px;
height: 14px;
}
.preview-scroll::-webkit-scrollbar-track {
background: #dfe4e1;
}
.preview-scroll::-webkit-scrollbar-thumb {
min-height: 48px;
border: 3px solid #dfe4e1;
border-radius: 999px;
background: #8d9993;
}
.preview-scroll::-webkit-scrollbar-thumb:hover {
background: #6f7d76;
}
.paper {
@@ -808,6 +949,21 @@ textarea:focus {
padding: 16px;
}
.topbar-primary {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.document-summary {
width: 100%;
max-width: none;
padding-top: 10px;
padding-left: 0;
border-top: 1px solid #d4dad7;
border-left: 0;
}
.topbar-actions {
flex-direction: column;
}
@@ -816,6 +972,10 @@ textarea:focus {
grid-template-columns: 1fr;
}
.editor-layout.is-source-collapsed {
grid-template-columns: 1fr;
}
.editor-panel,
.preview-panel {
min-height: 650px;
@@ -830,6 +990,19 @@ textarea:focus {
border-bottom: 1px solid #cbd2ce;
}
.editor-layout.is-source-collapsed .editor-panel {
height: 44px;
min-height: 44px;
}
.source-panel-restore {
height: 44px;
min-height: 44px;
padding: 0 16px;
letter-spacing: 0;
writing-mode: horizontal-tb;
}
.preview-scroll {
padding: 0 10px;
}
@@ -847,11 +1020,13 @@ textarea:focus {
}
.preview-toolbar {
justify-content: center;
flex-wrap: wrap;
justify-content: space-between;
padding-inline: 10px;
}
.preview-zoom-control {
flex: 1 0 100%;
width: 100%;
}
@@ -865,7 +1040,19 @@ textarea:focus {
}
}
@media (prefers-reduced-motion: reduce) {
.editor-layout {
transition: none;
}
}
@media (max-width: 520px) {
.topbar-brand-title {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
.settings-drawer {
width: 100vw;
}
+14 -22
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
import { renderMarkdownDocument } from "./application-backend";
import type { MarkdownImageResource } from "./application-backend";
export interface MarkdownRenderCallbacks {
onRenderStart?: () => void;
@@ -9,6 +11,7 @@ export interface MarkdownRenderCallbacks {
export function useMarkdownRender(
markdown: string,
language: string,
resources: MarkdownImageResource[],
callbacks: MarkdownRenderCallbacks = {}
) {
const [result, setResult] =
@@ -23,27 +26,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, resources },
controller.signal
)
.then((payload) => {
setResult(payload);
onStatusChange?.("预览已更新");
@@ -62,7 +48,13 @@ export function useMarkdownRender(
window.clearTimeout(timer);
controller.abort();
};
}, [language, markdown, onRenderStart, onStatusChange]);
}, [
language,
markdown,
onRenderStart,
onStatusChange,
resources
]);
return { error, result };
}
+8 -26
View File
@@ -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) {
+68
View File
@@ -1 +1,69 @@
/// <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 {
openMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
renderMarkdown(input: {
markdown: string;
language: string;
resources?: Array<{
path: string;
data: 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;
}
}
+12
View File
@@ -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.1");
expect(APP_VERSION_LABEL).toBe("v0.4.1");
});
});
+124
View File
@@ -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",
{}
);
});
});
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitDocumentImagesToPage } from "../src/document-image-fit";
describe("Markdown 图片单页适配", () => {
it("标记独立图片段落并将超高图片缩放到单页", () => {
const root = document.createElement("div");
root.innerHTML =
'<p> <img class="md-document-image" src="data:image/png;base64,AA=="> </p>';
const image = root.querySelector("img")!;
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 3000 }
});
fitDocumentImagesToPage(root, defaultExportConfig);
expect(image.parentElement?.classList).toContain(
"md-document-image-block"
);
expect(image.dataset.pageHeightFitted).toBe("true");
expect(image.parentElement?.dataset.pageHeightFitted).toBe("true");
expect(
image.parentElement?.style.getPropertyValue("margin-block")
).toBe("0");
expect(Number.parseFloat(image.style.height)).toBeCloseTo(
1000.5748,
3
);
expect(Number.parseFloat(image.style.width)).toBeCloseTo(
333.5249,
3
);
});
it("普通横图保持主题控制的尺寸", () => {
const root = document.createElement("div");
root.innerHTML =
'<p><img class="md-document-image" src="data:image/png;base64,AA=="></p>';
const image = root.querySelector("img")!;
Object.defineProperties(image, {
naturalWidth: { value: 1600 },
naturalHeight: { value: 900 }
});
fitDocumentImagesToPage(root, defaultExportConfig);
expect(image.dataset.pageHeightFitted).toBeUndefined();
expect(image.style.height).toBe("");
});
});
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
describe("ECharts 单页高度适配", () => {
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 2000px">
<svg width="1000" height="2000"></svg>
</div>
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
const svg = root.querySelector<SVGSVGElement>("svg");
expect(figure?.dataset.pageHeightFitted).toBe("true");
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
1000.5748,
3
);
expect(host?.style.aspectRatio).toBe("");
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
1000.5748,
3
);
});
it("普通高度图表不改变作者设置的宿主高度", () => {
const root = document.createElement("div");
root.innerHTML = `
<figure class="md-echarts">
<div class="md-echarts-host" style="height: 300px">
<svg viewBox="0 0 1000 400"></svg>
</div>
</figure>
`;
fitOversizedEChartsToPage(root, defaultExportConfig);
const figure = root.querySelector<HTMLElement>(".md-echarts");
const host = root.querySelector<HTMLElement>(
".md-echarts-host"
);
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
expect(host?.style.height).toBe("300px");
});
});
+14 -1
View File
@@ -41,7 +41,8 @@ describe("分页预览协议", () => {
expect(css).toContain("padding: 34px 0");
expect(css).toContain('html[data-render-target="preview"]');
expect(css).toContain("overflow-x: hidden");
expect(css).toContain("overflow-y: scroll");
expect(css).toContain("overflow-y: hidden");
expect(css).not.toContain("scrollbar-color");
expect(css).toContain('html[data-render-target="pdf"]');
expect(css).toContain("height: auto !important");
expect(css).toContain("overflow: visible !important");
@@ -178,9 +179,21 @@ describe("分页预览协议", () => {
type: "rendered",
requestId: 3,
pageCount: 2,
contentHeight: 2400,
echartsErrors: [],
mermaidErrors: []
})
).toBe(true);
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
pageCount: 2,
echartsErrors: [],
mermaidErrors: []
})
).toBe(false);
expect(
isPagedPreviewFrameMessage({
scope: "other",
+67 -2
View File
@@ -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 = {
@@ -23,6 +38,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-test"]),
fileName: "测试.pdf",
pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const cache: CachedPdfExport = { key, result };
@@ -65,6 +81,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-cached"]),
fileName: "测试.pdf",
pageCount: 1,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const cache: CachedPdfExport = {
@@ -95,6 +112,7 @@ describe("PDF 导出客户端", () => {
blob: new Blob(["%PDF-new"]),
fileName: "测试.pdf",
pageCount: 2,
echartsErrorCount: 0,
mermaidErrorCount: 0
};
const requester = vi.fn(async () => result);
@@ -128,6 +146,7 @@ describe("PDF 导出客户端", () => {
"content-disposition":
"attachment; filename=\"document.pdf\"",
"x-pdf-page-count": "4",
"x-echarts-error-count": "2",
"x-mermaid-error-count": "1"
}
});
@@ -149,6 +168,7 @@ describe("PDF 导出客户端", () => {
);
expect(result.fileName).toBe("document.pdf");
expect(result.pageCount).toBe(4);
expect(result.echartsErrorCount).toBe(2);
expect(result.mermaidErrorCount).toBe(1);
expect(await result.blob.text()).toBe("%PDF-test");
});
@@ -173,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);
});
});
+37 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { getNearestPageNumber } from "../src/preview-page";
import {
getNearestPageNumber,
getPreviewPageScrollTop,
normalizePreviewPageNumber
} from "../src/preview-page";
const pages = [
{ top: 0, bottom: 100 },
@@ -22,3 +26,35 @@ describe("getNearestPageNumber", () => {
expect(getNearestPageNumber(Number.NaN, pages)).toBe(0);
});
});
describe("normalizePreviewPageNumber", () => {
it("保留有效的整数页码", () => {
expect(normalizePreviewPageNumber("12", 3, 65)).toBe(12);
});
it("将越界页码限制在有效范围", () => {
expect(normalizePreviewPageNumber("0", 3, 65)).toBe(1);
expect(normalizePreviewPageNumber("99", 3, 65)).toBe(65);
});
it("无效输入恢复当前页", () => {
expect(normalizePreviewPageNumber("", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("3.5", 7, 65)).toBe(7);
expect(normalizePreviewPageNumber("abc", 7, 65)).toBe(7);
});
it("没有可用页面时返回 0", () => {
expect(normalizePreviewPageNumber("1", 0, 0)).toBe(0);
});
});
describe("getPreviewPageScrollTop", () => {
it("按目标页相对滚动容器的位置计算滚动量", () => {
expect(getPreviewPageScrollTop(200, 900, 120)).toBe(980);
});
it("避免返回负数或无效滚动量", () => {
expect(getPreviewPageScrollTop(20, 10, 100)).toBe(0);
expect(getPreviewPageScrollTop(Number.NaN, 10, 0)).toBe(0);
});
});
+14
View File
@@ -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: {
+38 -11
View File
@@ -1,5 +1,7 @@
# syntax=docker/dockerfile:1
ARG RUNTIME_BASE_IMAGE=node:22-bookworm-slim
FROM node:22-bookworm-slim AS build
WORKDIR /app
@@ -9,13 +11,21 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci
COPY tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm run build
COPY logos ./logos
RUN npm run build -w @md-to-pdf/markdown-echarts \
&& npm run build -w @md-to-pdf/core \
&& npm run build -w @md-to-pdf/renderer \
&& npm run build -w @md-to-pdf/application \
&& npm run build -w @md-to-pdf/web \
&& npm run build -w @md-to-pdf/server
FROM node:22-bookworm-slim AS production-dependencies
@@ -26,20 +36,28 @@ COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/core/package.json packages/core/package.json
COPY packages/application/package.json packages/application/package.json
COPY packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY packages/renderer/package.json packages/renderer/package.json
RUN npm ci --omit=dev --workspace @md-to-pdf/server --include-workspace-root
FROM node:22-bookworm-slim AS runtime
FROM ${RUNTIME_BASE_IMAGE} AS runtime
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
nginx \
fonts-noto-cjk \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system pwuser \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser
ARG RUNTIME_BASE_IMAGE
ARG REUSE_PLAYWRIGHT_RUNTIME=0
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
echo "Reusing the Playwright runtime from ${RUNTIME_BASE_IMAGE}"; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends \
nginx \
fonts-noto-cjk \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system pwuser \
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
fi
WORKDIR /app
@@ -47,15 +65,24 @@ COPY --chown=pwuser:pwuser package.json package-lock.json ./
COPY --chown=pwuser:pwuser apps/server/package.json apps/server/package.json
COPY --chown=pwuser:pwuser apps/web/package.json apps/web/package.json
COPY --chown=pwuser:pwuser packages/core/package.json packages/core/package.json
COPY --chown=pwuser:pwuser packages/application/package.json packages/application/package.json
COPY --chown=pwuser:pwuser packages/markdown-echarts/package.json packages/markdown-echarts/package.json
COPY --chown=pwuser:pwuser packages/renderer/package.json packages/renderer/package.json
COPY --chown=pwuser:pwuser --from=production-dependencies /app/node_modules ./node_modules
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN npx playwright install --with-deps chromium \
RUN if [ "${REUSE_PLAYWRIGHT_RUNTIME}" = "1" ]; then \
test -x /ms-playwright/chromium-*/chrome-linux/chrome \
|| test -x /ms-playwright/chromium-*/chrome-linux64/chrome; \
else \
npx playwright install --with-deps chromium; \
fi \
&& chown -R pwuser:pwuser /ms-playwright
COPY --chown=pwuser:pwuser --from=build /app/apps/server/dist ./apps/server/dist
COPY --chown=pwuser:pwuser --from=build /app/apps/web/dist ./apps/web/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/core/dist ./packages/core/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/application/dist ./packages/application/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/markdown-echarts/dist ./packages/markdown-echarts/dist
COPY --chown=pwuser:pwuser --from=build /app/packages/renderer/dist ./packages/renderer/dist
COPY --chown=pwuser:pwuser themes ./themes
COPY deploy/nginx.conf /etc/nginx/nginx.conf
+2
View File
@@ -2,9 +2,11 @@
.github
.local
.vscode
apps/desktop
coverage
dist
node_modules
out
output
playwright-report
test-results
+23 -5
View File
@@ -34,13 +34,27 @@ docker compose -f deploy\compose.yaml down
## 构建发布标签
本地验证完成后,可以为镜像增加发布标签
可以直接指定版本化镜像名称构建
```powershell
docker tag md-to-pdf:local yixiong/md-to-pdf:v0.1.0
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
docker compose -f deploy\compose.yaml build
```
该命令只创建本地标签,不会自动推送到镜像仓库。
该命令只生成本地镜像,不会自动推送到镜像仓库。
默认构建会安装项目锁定版本的 Playwright Chromium 及系统依赖。如果本机
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
$env:MD_TO_PDF_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build
```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.1 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载
@@ -55,13 +69,17 @@ docker compose -f deploy\compose.yaml up -d
目录下每套主题都应包含 `theme.json`。主题规范详见
[`docs/THEMES.md`](../docs/THEMES.md)。
本机通过导入工具生成的 Typora 主题不会复制进镜像;只有显式挂载后,
容器才会读取这些本机文件。
从 v0.4.0 起,公司内部私有版本已内置 GitHub、Pixyll 和 Whitey 三套
Typora 默认主题;挂载目录仍可用于本机扩展主题。内置主题与挂载主题 ID
重复时以内置版本为准。
## 运行参数
| 环境变量 | 默认值 | 说明 |
| --- | ---: | --- |
| `MD_TO_PDF_IMAGE` | `md-to-pdf:local` | 构建和运行的镜像标签 |
| `MD_TO_PDF_RUNTIME_BASE_IMAGE` | `node:22-bookworm-slim` | 可选运行时基础镜像 |
| `MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME` | `0` | 是否复用基础镜像中的 Chromium |
| `MD_TO_PDF_PORT` | `8080` | 宿主机监听端口 |
| `MD_TO_PDF_THEME_DIR` | `../.local/themes` | 宿主机主题目录 |
| `PDF_CONCURRENCY` | `1` | 同时执行的 PDF 任务数 |
+4 -1
View File
@@ -5,7 +5,10 @@ services:
build:
context: ..
dockerfile: deploy/Dockerfile
image: md-to-pdf:local
args:
RUNTIME_BASE_IMAGE: ${MD_TO_PDF_RUNTIME_BASE_IMAGE:-node:22-bookworm-slim}
REUSE_PLAYWRIGHT_RUNTIME: ${MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME:-0}
image: "${MD_TO_PDF_IMAGE:-md-to-pdf:local}"
init: true
ports:
- "${MD_TO_PDF_PORT:-8080}:8080"
+326 -24
View File
@@ -31,8 +31,9 @@ fa07472 feat: 实现网页实时预览
ec48bce chore: 初始化项目骨架
```
最新阶段已完成渲染边界逻辑修复,以及前后端分页载荷、文档类型
物理单位、主题资源加载和 Markdown 请求逻辑的复用收敛。
`v0.4.1` 图片资源阶段已完成Web 素材目录、Desktop 同目录资源
受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。
## 2. 已完成
@@ -78,7 +79,10 @@ ec48bce chore: 初始化项目骨架
- 主题 CSS 相对资源重写与受限资源接口;
- 目录匹配、真实路径包含关系和外部 URL 安全检查。
内置主题项目自有实现,没有复制 Typora 官方默认主题。用户可以通过导入工具将本机已安装的 Typora 基础 CSS以及 GitHub、Pixyll 和 Whitey 三套适合打印的白色默认主题复制到 Git 忽略的 `.local/themes`。这些副本仅供本机使用,不进入仓库、容器或发布包。
内置主题包含项目自有的 Typora 风格主题,以及按公司内部私有项目决策
复制的 GitHub、Pixyll 和 Whitey 三套 Typora 默认主题。三套复制主题的
来源和内部使用边界记录在 `themes/INTERNAL_THEME_NOTICE.md`;如未来公开、
商用或向公司外部分发,必须重新完成许可证审查。
### 2.4 Markdown 渲染核心
@@ -230,6 +234,169 @@ ec48bce chore: 初始化项目骨架
- CSS 像素与 PDF 点数换算只使用 core 中的共享常量;删除已经不参与协议
或逻辑的 `contentHeight` 字段。
### 3.8 Windows 显示缩放与快速预览分页差异
- 此前 65 页长文档在快速预览中生成 64 页、在精确预览和最终 PDF 中
生成 65 页;该差异并非由 Chromium 版本不同导致。
- 出现差异时,笔记本内屏使用 Windows 150% 系统显示缩放。切换到使用
100% 显示缩放的外接显示器后,快速预览与精确预览的分页结果基本一致。
- 当前定位是:Windows 显示缩放改变浏览器的 `devicePixelRatio`,并可能
影响字体栅格化和 CSS 布局中的子像素取整;单处差异很小,但会在长文档
分页过程中累计,最终造成页数差异。
- 精确预览直接展示服务端 Chromium 生成的 PDF,最终下载也复用同一结果,
因此精确预览和最终 PDF 继续作为权威分页结果;快速预览定位为编辑期间的
客户端近似分页,不承诺在所有显示缩放环境下与 PDF 页数完全一致。
- 后续优化快速预览时,应重点检查 `devicePixelRatio`、浏览器页面缩放、
字体度量和逐页高度取整;分页测量不得依赖物理像素,视觉缩放不得参与
文档排版和分页计算,并避免逐页取整造成累计误差。
- 后续分页回归应覆盖 Windows 100%、125% 和 150% 系统显示缩放,分别
记录快速预览、精确预览和最终 PDF 的页数及分页边界。
### 3.9 Markdown ECharts 图表
- 新增可独立复用的 `packages/markdown-echarts` 工作区,为 Markdown-it
提供安全的 `echarts` YAML 围栏。
- 围栏支持版本、固定高度或宽高比、主题、图注和 ECharts `option`
高度默认 80mm,主题默认跟随文档。
- 当前支持 line、bar、scatter、pie、radar、heatmap、boxplot 和
candlestick 系列,并校验各系列的数据结构和必要坐标系。
- 每个系列必须使用非空 `series[].data``dataset.source` 或具有有效
上游数据的内置 transform;缺少数据等错误会显示包含配置路径的局部
占位,不中断整篇 Markdown。
- YAML 会转换为安全纯数据对象;禁止函数、自定义标签、锚点和别名、
外部 URL、HTML formatter、原型污染属性及 `custom` 系列。
- 浏览器按需加载 ECharts 模块,以 SVG 渲染,并可冻结为内联 SVG 或
SVG Data URL 图片;快速预览与 PDF 使用同一套运行时。
- 分页遵循 Mermaid 的不可拆分规则:当前页空间足够时原位放置,不足时
移到下一页,超高图表等比例缩放到单页内,不跨页切割。
- PDF 输出等待 ECharts、Mermaid、字体和图片全部完成后再分页,并分别
返回 ECharts 与 Mermaid 错误数量。
- 包内 README 已记录围栏语法、默认值、验证规则、错误行为、安全边界、
Markdown-it 接入和浏览器渲染方式。
### 3.10 工作区布局与页码导航
- 默认 Markdown 示例新增 ECharts 柱状图,打开网页即可验证 YAML 围栏、
SVG 渲染和分页。
- 文件名及渲染、分页、导出错误状态从源文本标题栏迁移到顶部应用标题
旁,长文件名支持省略和完整悬停提示。
- 源文本区域支持收起和展开;桌面端收起为 48px 左侧窄栏,预览区扩展
到剩余宽度并保持纸张居中,窄屏收起为 44px 顶部横条。
- 折叠内容使用真实 `display:none`,不会残留可聚焦文本框或进入无障碍
树;按钮提供 `aria-controls``aria-expanded`
- 当前页从预览标题行移到缩放工具栏左侧,显示为
`第 [n] / 总页数 页`,避免重复信息。
- 页码输入支持 Enter 和失焦提交,自动限制到 1~总页数;空值和非法
小数恢复当前页。
- 快速预览直接定位 iframe 内 `.pagedjs_page`;精确预览定位
`.precise-pdf-page`。滚动预览时输入框同步当前页。
- 精确预览会动态识别桌面内部滚动容器或窄屏主窗口滚动容器,目标页顶端
与实际可视滚动区域对齐。
- 快速预览 iframe 只负责生成分页内容并回传自然高度,滚动统一交给外层
`.preview-scroll`;滚动条因此与精确预览一样贴在右侧预览容器边缘,
不再贴着居中的纸张右边缘。
- 快速预览的页码识别和跳转会合并外层滚动位置、iframe 位置、内部分页
位置与显示缩放比例,50%~400% 视觉缩放不会破坏页码同步。
### 3.11 ECharts 第二阶段图表
- ECharts 系列支持从首版 8 种扩展到 17 种,新增 `tree``treemap`
`sunburst``graph``sankey``chord``funnel``gauge`
`pictorialBar`
- 当前使用 ECharts 6.1.0`chord` 直接采用 ECharts 6 官方和弦图及
`ChordChart` 按需模块,不引入第三方扩展。
- `tree``treemap``sunburst` 校验递归层级结构;矩形树图和旭日图
的叶节点必须提供有限数值。
- `graph``sankey``chord` 校验节点、关系边、端点引用与边权重;
Graph 未指定布局时,有完整坐标则使用 `none`,否则默认使用 `force`
- `funnel``gauge` 校验有限数值数据;`pictorialBar` 复用柱图的数据和
坐标轴规则,并允许不加载外部资源的 `path://` 内嵌矢量路径。
- 安全策略新增层级节点、关系节点和关系边数量限制,继续禁止外部图片、
跳转链接、函数、HTML formatter 和原型污染属性。
- 默认 Markdown 现在包含基础柱状图及九种第二阶段图表,每种均使用独立
YAML 围栏,可直接编辑和观察 SVG、分页及错误占位行为。
- Treemap 默认示例关闭节点下钻和面包屑,显式启用父级与叶节点标签,并
增加白色分隔边框,避免扁平数据区域缺少辨识信息或底部控件占用空间。
- Markdown/PDF 属于确定性的静态输出场景,浏览器引擎会在全局和系列级
强制关闭 ECharts 动画、动画延迟和持续时间,不允许 YAML 动画参数使
预览或 PDF 停留在动画中间帧。
### 3.12 v0.4.0 Electron 桌面端
- 新增 `apps/desktop` Electron 43.2.0 工作区,使用 Electron Forge
7.11.2 打包。
- 根项目版本升级为 `0.4.0`Vite 从根 `package.json` 注入统一版本,
工具标题右侧显示 `v0.4.0` 徽标。
- 应用窗口和 PDF 窗口均关闭 Node Integration,启用 Context Isolation
和 Chromium Sandbox,并使用不同 preload 暴露窄 IPC 接口。
- 桌面端 PDF 直接接收 Web 端已经构造的共享分页载荷,复用现有
`PagedDocumentRuntime`、主题 CSS、Mermaid 和 ECharts 运行时。
- 隐藏 PDF 窗口使用 Electron 自带 Chromium 的
`webContents.printToPDF()`,桌面发行包不携带 Playwright Chromium。
- Paged.js 的帧队列在纯隐藏窗口中不会推进;桌面 PDF 任务期间使用
计时器调度分页队列,分页完成后恢复原生 `requestAnimationFrame`
- Chromium 打印前将透明、不可聚焦、不进入任务栏的 PDF 窗口短暂放到
屏幕外,使复杂分页页面拥有可打印的窗口表面,用户不会看到该窗口。
- PDF Buffer 通过受控 IPC 返回 Web 界面,桌面端使用系统“另存为”对话框
写入文件;Web 版继续使用浏览器下载行为。
- 生产页面通过 `mdpdf://bundle/` 自定义安全协议加载,不使用权限过宽的
`file://`PDF Session 只允许同源、`data:``blob:``about:`
资源。
- Windows x64 未签名目录包、Squirrel `Setup.exe` 和 ZIP 已由 Forge
成功生成;`app.asar` 只包含
编译后的主进程、preload 和清单,Web 静态资源作为只读资源单独打包。
- 新增 `packages/application`,统一封装 Markdown 渲染、主题清单、主题
CSS 和主题资源读取;Fastify 仅作为 HTTP 适配器。
- Web 端通过应用后端抽象选择 HTTP 或 Electron IPC;桌面端通过受限 IPC
直接调用共享服务,不启动 Fastify,不占用本地 HTTP 端口。
- 主题资源通过 `mdpdf://theme/` 安全协议提供,应用窗口与独立 PDF
Session 均完成协议注册和来源限制。
- 修复 Electron 主进程 ESM Bundle 中 YAML 依赖动态加载 `process`
失败的问题,构建时注入 Node `createRequire`
- `logos/` 作为统一品牌源:Web 使用 favicon、Apple Touch Icon 和
Web ManifestElectron 使用 ICO、ICNS、窗口图标和安装器图标。
- Forge 输出目录包含版本号,正式内部交付使用 Squirrel `Setup.exe`
ZIP 作为免安装辅助包;当前未签名。
### 3.13 v0.4.0 内置主题与发布产物
- `.local/themes` 中 GitHub、Pixyll 和 Whitey 三套主题已复制到
`themes/`,从 v0.4.0 起随 Web 容器和桌面安装包内置。
- 内置主题注册优先于相同 ID 的本地主题,避免 Compose 默认挂载旧副本
覆盖发布版本,并记录可诊断警告。
- Windows 正式安装包为
`Markdown PDF 导出器-0.4.0 Setup.exe`,约 137.48 MiBSHA-256 为
`9C12F9A007E102BDB5213ABC5246F4480083FCA5D67DCD45354728BCFDB54220`
- 中文产品名、窗口标题、安装包和开始菜单名称保持不变,Windows 主程序
与进程文件固定为 `md-to-pdf.exe`;目录包和 Squirrel NUPKG 均已验证。
- 本地镜像为 `yixiong/md-to-pdf:v0.4.0`Compose 容器保持健康运行并
监听 `http://localhost:8080`
- Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过
构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。
### 3.14 v0.4.1 图片资源与标题分页
- `packages/application` 统一提取 Markdown 图片引用,将验证后的本地或
远程图片转换为 Data URL,再交给 Web 预览、Playwright PDF 和 Electron
PDF 共用的 HTML。
- Web 增加素材目录选择,支持把 Markdown 的关联图片作为受限 Base64
资源随渲染和 PDF 请求提交;请求体上限同步提高到 24 MiB。
- Desktop 增加原生“打开 Markdown”,主进程记录文档目录并直接读取相对
图片,不启动 Server,也不允许读取文档目录之外的文件。
- 相对路径支持中文、空格和百分号编码;拒绝绝对路径、`..` 穿越和符号
链接越界。
- 远程图片支持 HTTP/HTTPS、逐跳重定向校验、10 秒超时、格式嗅探与
5 分钟缓存;阻止 localhost、私网和链路本地目标,并兼容企业代理常用
的域名 Fake-IP 网段。
- 每篇文档最多 50 个图片引用,单图不超过 8 MiB,总图片不超过 15 MiB
支持 PNG、JPEG、GIF、WebP、AVIF 和受限 SVG。
- 独立图片使用 `figure + img + figcaption``[]` 中的文本显示为图片
标题;行内混排图片继续保持普通段落语义。
- 图片与标题整块分页。超高图先扣除标题区实际高度,再等比例缩放到单页,
与 Mermaid 和 ECharts 遵循相同的不可跨页规则。
- 默认示例增加 NASA 中等尺寸图与 Unsplash 超高竖图,直接覆盖正常图片
和超高图片分页场景。
## 4. 已执行验证
2026-07-27 在当前完整工作区成功执行:
@@ -243,17 +410,57 @@ git diff --check
结果:
- ECharts 围栏测试:36 项通过;
- 共享配置测试:9 项通过;
- 渲染器测试:8 项通过;
- 前端测试:55 项通过;
- 渲染器测试:10 项通过;
- 应用服务测试:5 项通过;
- 前端测试:67 项通过;
- 后端测试:29 项通过;
- 桌面端测试:7 项通过;
- 全项目共 171 项测试通过;
- 全项目类型检查通过;
- 生产构建通过;
- `git diff --check` 通过。
Electron 桌面端第一阶段验证结果:
- Electron 43.2.0 应用窗口成功加载现有 React/Vite 界面,标题旁正确显示
`v0.4.0`
- 最小 Markdown 通过隐藏 Electron Chromium 生成 1 页、52,308 字节
PDF,文字可搜索,并通过桌面原生保存通道落盘。
- 默认示例通过桌面端生成 4 页、439,501 字节 A4 PDFMermaid 和
10 个 ECharts 图表错误数均为 0。
- 将默认示例全部 4 页渲染为 PNG 后逐页检查,Tree、Treemap、Sunburst、
Graph、Sankey、Chord、Funnel、Gauge 和 PictorialBar 均完整显示,
未发现空白页、黑块、跨页切割或明显裁切。
- PDF.js 可以从 4 页中提取 670 个非空白字符,各页均包含可搜索文字。
- Forge 成功生成 Windows x64 未签名目录包,生产窗口通过
`mdpdf://bundle/index.html` 加载,版本徽标和界面静态资源正常。
- 精简后的 `app.asar` 约 554 KiB,Web 静态资源共 134 个文件、约
9.0 MiB;运行目录只包含 Electron 自带的一套 Chromium。
已使用浏览器插件完成视觉验证:
- 重构后的初始 Markdown 和主题资源加载正常,快速预览生成 1 页
- 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位
- 顶栏文件名、分页状态与操作按钮在桌面和窄屏均无重叠;
- 桌面端源文本收起后宽度从 576px 降为 48px,预览区从 864px 扩展
到 1392px;窄屏折叠条为 44px
- 快速预览输入第 2 页后目标页顶端对齐 iframe 视口,滚动回首页和第二页
时输入框分别同步为 1 和 2
- 页码输入 0 和 99 时分别限制到 1 和总页数;
- 精确预览输入第 2 页后,目标页与桌面滚动容器顶部误差为 0;
- 快速预览改用外层滚动容器后,右侧容器边界为 1440px,纸张边界约为
1400px,滚动条稳定贴在容器最右侧而非纸张边缘;
- 快速预览输入第 1 页后目标页与外层可视区域顶部误差为 0,外层滚动到
第 2 页时页码输入框同步更新为 2;
- 快速预览在 50% 和 150% 下的可滚动高度分别随内容显示高度缩放,
iframe 自身保持无滚动;切换精确/快速模式后仍使用同一右侧滚动条;
- 桌面端收起源文本后,预览滚动容器从 48px 延伸至窗口右边缘,纸张仍
在扩展后的预览区域内居中;
- 窄屏精确预览自动使用主窗口滚动,并正确跳转到第 2 页;
- 页码控件在窄屏独占首行,缩放控件在下一行铺满,没有与预览模式或主题
选择器重叠;
- 当前默认 Markdown 和主题资源加载正常,快速预览生成 2 页;
- 编辑 Markdown 后标题、表格、Mermaid 和分页状态均完成防抖重绘;
- 从本地 GitHub 主题切换到内置 Typora 风格主题后 iframe 正常重建;
- 本轮浏览器回归期间控制台无警告或错误;
@@ -262,13 +469,46 @@ git diff --check
- 包含 ELK、Base 主题、Hand-drawn 外观、自定义字体、
`themeVariables``style``classDef` 的 Mermaid 样例在快速预览
与精确预览中均成功渲染;
- 65 页附件的快速预览生成 64 页,67 个 Mermaid 全部使用静态 SVG
- 在 Windows 150% 系统显示缩放的笔记本内屏上,65 页附件的快速预览
生成 64 页,67 个 Mermaid 全部使用静态 SVG
- 同一附件的精确预览和最终 PDF 均为 65 页;
- 切换到 Windows 100% 系统显示缩放的外接显示器后,快速预览与精确预览
的分页结果基本一致,进一步排除了 Chromium 版本差异这一判断方向;
- 精确预览快速跳转至第 46、47、65 页,无黑色 Canvas、空白页或渲染错误;
- PDF.js 同时只保留视口邻近页面的 Canvas,远端页面资源可以释放;
- Mermaid A/B PDF 页数、逐页文字和抽样视觉结果一致;
- 抽查第 2、6、27、53、65 页,Mermaid 尺寸、分页位置和末页内容正常。
ECharts 第二阶段浏览器与 PDF 验证结果:
- 默认示例生成 10 个 ECharts SVG,包含原柱状图和九种新增系列,错误
占位数为 0,浏览器控制台无警告或错误;
- 快速预览生成 6 页,第 2~6 页各放置两张完整图表,所有图表边界均位于
当前页内容区内,没有跨页切割;
- 精确预览同样生成 6 页,逐页跳转正常;Chord、Funnel、Gauge 和
PictorialBar 的 PDF Canvas 视觉检查通过;
- 使用默认导出配置调用 PDF API 生成 4 页 PDF,响应中的 ECharts 和
Mermaid 错误数均为 0
- PDF 中九种新增图表的标题均可搜索;将全部页面渲染为 PNG 后,Tree、
Treemap、Sunburst、Graph、Sankey、Chord、Funnel、Gauge 和
PictorialBar 均显示完整,未发现裁切、重叠、黑块或空白图表。
- 修正 Treemap 示例后再次检查快速与精确预览:底部下钻面包屑已隐藏,
五个叶节点边界清晰;SVG 中父级和叶节点共 7 个标签均处于可见状态。
- 进一步确认此前 Treemap 标签虽存在于 SVG,但被冻结在入场动画初始帧,
`fill-opacity` 约为 `0.000014`,肉眼近似完全透明;全局关闭动画后,
快速预览和精确 PDF 中的 2 个父级及 5 个叶节点标签均清晰可见。
2026-07-27 已重新构建并启动本地发布候选镜像:
- 使用完整多阶段 `deploy/Dockerfile` 构建
`yixiong/md-to-pdf:v0.3.0`,镜像大小约 612 MB
- Compose 强制重建后容器健康检查通过,服务继续监听
`http://localhost:8080`
- 首页返回 200`POST /api/render` 的 ECharts 冒烟请求返回
`code``echarts` 功能标识和 `md-echarts` 安全占位;
- 容器内 `@md-to-pdf/markdown-echarts` 工作区包可正常加载;
- 当前镜像已通过发布前手动验收,并作为 `v0.3.0` 发布镜像保留运行。
此前主题阶段已使用浏览器插件完成视觉验证:
- 导入并打开 `tmp/数据中台项目周报_2026_W30.md`
@@ -295,7 +535,59 @@ git diff --check
- 浏览器快速预览、主题切换、精确 PDF.js 预览和导出交互通过;
- 修复 Nginx 缺少 `.mjs` MIME 映射导致精确预览 Worker 加载失败的问题。
为方便用户继续检查,AIO 容器当前运行在 `http://localhost:8080`
`v0.3.0` 发布验收完成后,已停止并删除本项目 Compose 容器及网络以释放
本地资源;`yixiong/md-to-pdf:v0.3.0` 镜像继续保留。
2026-07-27 已构建并启动 `v0.3.1` 发布候选:
- 使用完整多阶段 Dockerfile 构建 `yixiong/md-to-pdf:v0.3.1`,镜像
大小约 612 MB
- Compose 容器使用该镜像运行并通过健康检查,服务监听
`http://localhost:8080`
- 容器首页返回 200,默认 Markdown 解析出 10 个 ECharts 围栏,
错误占位和渲染警告均为 0
- 容器生成的默认配置 PDF 为 4 页、约 521 KiBECharts 和 Mermaid
渲染错误数均为 0
- ECharts 已编译进按需加载的前端浏览器 Bundle,生产运行时不需要保留
独立的 `echarts` Node.js 包;
- 当前容器保留运行,等待 `v0.3.1` 发布前手动验收。
2026-07-27 已完成 `v0.4.0` 最终发布候选验证:
- 生成本地镜像 `yixiong/md-to-pdf:v0.4.0`,镜像内容 ID 为
`sha256:1bf4a136e9b822fa0925070cd522ea2adbad8b388d8e039d6509024d466666b3`
- Compose 强制替换为 v0.4.0 后健康检查通过,首页返回 200,健康接口
返回 `ok`,主题接口返回 4 个 `bundled` 主题;
- 容器 Chromium 生成中文、表格、KaTeX、代码和 Mermaid 验收 PDF
HTTP 200、1 页、147,374 字节,Mermaid/ECharts 错误数均为 0
- PDF 渲染为 PNG 后检查通过,中文、公式、表格、代码、图表、页边距和
页码均正常,无裁切、重叠、黑块或空白;
- Compose 网页真实加载后标题旁显示 `v0.4.0`,主题选择器包含 4 个内置
主题,多页预览和图表正常,浏览器控制台无错误;
- Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB,
当前 Authenticode 状态为未签名。
2026-07-27 已完成 `v0.4.1` 图片资源发布候选验证:
- 用户提供的 `tmp/数据工程规划.md` 成功解析 2 张本地 PNG,生成约
5.0 MB 内嵌 HTML,图片自然宽度均为 8192px,零资源告警。
- Web 真实选择 Markdown 与 `.assets` 目录后生成 4 页快速预览,两张
本地图片均完整位于各自页面内容区。
- Wikimedia SVG、NASA JPEG 和 Unsplash JPEG 三种公网图片均完成下载、
格式识别、Data URL 内嵌和自然尺寸解码。
- 1200×1800 超高网络图在扣除标题高度后缩放;最终图片约 977px 高,
图加标题约 1001px 高,完整位于同一页且未复制、裁切或跨页。
- 默认示例的快速预览和精确预览均为 7 页,两个图片标题可见;容器
Playwright PDF 的精确预览同样为 7 页。
- Electron 目录版显示 `v0.4.1`,使用 Electron 内置 Chromium 成功渲染
两张默认网络图片;Windows 主程序仍为 `md-to-pdf.exe`
- 最终 Squirrel 安装包为
`Markdown PDF 导出器-0.4.1 Setup.exe`144,166,400 字节,SHA-256
`FC6687494255EE21224B65C1CA2D520316D568CCE8EB99A608A0DEF160611F4E`
Authenticode 状态为未签名。
- 本地镜像 `yixiong/md-to-pdf:v0.4.1` 内容 ID 为
`sha256:1ded8a8a11ecb8b69eaa116bb9129dd4ec039e9918b047169fe5f8add2af3a80`
Compose 容器健康运行在 `http://localhost:8080`
## 5. 当前注意事项
@@ -311,7 +603,11 @@ git diff --check
- `apps/web/src/mermaid-static-image.ts` 负责锁定 Mermaid 盒模型并转换为静态矢量 SVG 图片。
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
- `apps/web/src/PrecisePdfPreview.tsx``pdf-render-queue.ts` 负责 PDF.js 精确预览和 Canvas 生命周期。
- `apps/web/src/PreviewPageControl.tsx``preview-page.ts` 负责当前页输入、
页码边界、滚动页识别和快速跳转。
- `apps/web/src/preview-zoom.ts``PreviewZoomControl.tsx` 负责预览缩放缓存、边界和控制界面。
- `packages/markdown-echarts` 负责 ECharts YAML 协议、验证、安全过滤、
Markdown-it 插件、浏览器 SVG 渲染和分页样式。
- `apps/server/src/pdf-engine.ts` 负责 Chromium 生命周期、并发、网络限制、分页调用和 PDF 输出。
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
@@ -319,25 +615,31 @@ git diff --check
- `packages/core/src/document.ts` 是渲染文档、分页载荷、运行结果和耗时的
共享模型。
- `apps/server/src/app.ts` 是新增的可测试 Fastify 应用。
- `apps/server/src/theme-registry.ts` 负责内置和本地主题发现、CSS 处理及资源安全。
- `.local/themes/typora-*` 是用户本机副本,已被 Git 忽略,不得提交
- `packages/application` 负责共享 Markdown 渲染和主题应用服务;
`apps/server``apps/desktop` 分别提供 HTTP 和 IPC 适配
- `apps/desktop/src/main.ts` 负责 Electron 生命周期、自定义应用协议、
应用窗口、IPC 校验和原生 PDF 保存。
- `apps/desktop/src/electron-pdf-generator.ts` 负责隐藏 PDF 窗口、分页
调度、网络限制、打印和窗口复用。
- `apps/desktop/src/app-preload.ts``pdf-preload.ts` 分别定义应用
窗口和 PDF 运行窗口的最小 IPC 能力。
- `.local/themes/typora-*` 仍可作为用户本机副本;v0.4.0 的三套发布版本
已复制到 `themes/` 并标记为内置主题。
- `.local/theme-backups` 中保留 GitHub、Pixyll 和 Whitey 的可恢复备份;Newsprint 和 Night 的当前副本及备份已按用户要求删除。
- Typora 官方资源是 All Rights Reserved,仅限当前用户本机使用,不进入 Git、容器或发布包。
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
未来改变分发范围前必须重新完成许可证审查
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
目录读取;远程图片仅保留有界内存缓存。
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
导出,不依赖 Fastify 或本地 HTTP Server。
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
当前 Compose 容器保持运行。
## 6. 推荐接手顺序
### 阶段一:资源与安全
- Markdown 文件夹或 ZIP
- 相对图片解析;
- 每请求独立临时目录;
- 路径穿越和符号链接越界防护;
- 文件数量、单文件大小、总大小和超时限制;
- 请求结束可靠清理。
### 阶段二:导出配置扩展
### 阶段一:导出配置扩展
- Front Matter 元数据覆盖;
- 浏览器本地配置预设;
@@ -346,7 +648,7 @@ git diff --check
## 7. 首版验收目标
- 可以选择或粘贴 Markdown
- 网页正确预览常用 Markdown、公式Mermaid
- 网页正确预览常用 Markdown、公式Mermaid 和 ECharts
- 可以下载真实 PDF
- PDF 文本可搜索;
- 预览与 PDF 基本一致;
+7 -3
View File
@@ -13,7 +13,7 @@ themes/<theme-id>/ 随项目分发的内置主题
.local/themes/<theme-id>/ 仅供当前设备使用的本地主题
```
- 内置主题的 `bundled` 必须为 `true`,并具有允许项目分发的明确许可证
- 内置主题的 `bundled` 必须为 `true`,并记录来源、许可证或内部使用边界
- 本地主题的 `bundled` 必须为 `false`,整个 `.local/` 目录已被 Git 忽略。
- 主题目录名必须与清单中的 `id` 完全一致。
@@ -262,11 +262,15 @@ npm run theme:import-typora -- --replace
.local/theme-backups/
```
Typora 当前没有为这些文件提供允许项目再分发的明确许可证,因此导入结果只能保存在 `.local`,不得提交到 Git、打包进容器或随项目发布。
Typora 当前没有为这些文件提供允许公开再分发的明确许可证。普通导入结果
仍默认保存在 `.local`。本项目从 v0.4.0 起按公司内部私有项目决策,将
GitHub、Pixyll 和 Whitey 三套主题复制为内置资源,并在
`themes/INTERNAL_THEME_NOTICE.md` 记录来源和内部使用边界;如未来公开、
商用或向公司外部分发,必须重新完成许可证审查并替换或取得授权。
## 10. 提交内置主题前检查
- 许可证明确允许再分发,并在清单和说明中记录。
- 许可证明确允许目标范围内使用,或已在清单和内部说明中记录限制
- 主题目录不包含用户文档、临时文件或无关资源。
- `npm test` 通过。
- `npm run typecheck` 通过。
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title description">
<title id="title">Markdown PDF Logo</title>
<desc id="description">青绿色折角文档与 M 形透明负空间组成的标志</desc>
<path
fill="#32B8AE"
fill-rule="evenodd"
d="
M 262 64
H 650
V 198
Q 650 254 706 254
H 840
V 850
Q 840 928 762 928
H 262
Q 184 928 184 850
V 142
Q 184 64 262 64
Z
M 282 792
V 498
Q 282 462 318 462
Q 334 462 350 478
L 512 640
L 674 478
Q 690 462 706 462
Q 742 462 742 498
V 792
H 650
V 626
L 548 728
Q 512 764 476 728
L 374 626
V 792
Z
"
/>
<path
fill="#FFD166"
d="
M 678 76
Q 694 76 710 92
L 812 194
Q 828 210 828 226
H 716
Q 678 226 678 188
Z
"
/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"name": "Markdown PDF 导出器",
"short_name": "Markdown PDF",
"start_url": "./",
"display": "standalone",
"background_color": "#f4f2ed",
"theme_color": "#1f2937",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+6841 -2
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,6 +1,6 @@
{
"name": "md-to-pdf",
"version": "0.1.0",
"version": "0.4.1",
"private": true,
"description": "可配置、可主题化的 Markdown PDF 导出工具",
"workspaces": [
@@ -8,15 +8,20 @@
"packages/*"
],
"scripts": {
"build": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
"dev": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n core,renderer,server,web \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
"build": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop",
"dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && concurrently -k -n markdown-echarts,core,renderer,application,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:server\" \"npm:dev:web\"",
"dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts",
"dev:core": "npm run dev -w @md-to-pdf/core",
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
"dev:application": "npm run dev -w @md-to-pdf/application",
"dev:server": "npm run dev -w @md-to-pdf/server",
"dev:web": "npm run dev -w @md-to-pdf/web",
"dev:desktop": "npm run dev -w @md-to-pdf/desktop",
"desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"",
"desktop:package": "npm run build -w @md-to-pdf/web && npm run package -w @md-to-pdf/desktop",
"theme:import-typora": "node scripts/import-typora-theme.mjs",
"test": "npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server",
"typecheck": "npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
"test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop",
"typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop"
},
"engines": {
"node": ">=22"
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@md-to-pdf/application",
"version": "0.4.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,124 @@
import {
MarkdownDocumentParseError,
renderMarkdown
} from "@md-to-pdf/renderer";
import {
createThemeRegistry,
type ThemeRegistryOptions
} from "./theme-registry.js";
import {
createImageResourceResolver,
type ImageResolutionContext,
type ImageResourceResolverOptions
} from "./image-resources.js";
export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000;
export interface MarkdownRenderRequest {
markdown?: unknown;
language?: unknown;
resources?: unknown;
}
export class ApplicationRequestError extends Error {
constructor(
readonly statusCode: 400 | 404 | 413,
readonly code: string,
message: string
) {
super(message);
this.name = "ApplicationRequestError";
}
}
export interface ApplicationServiceOptions
extends ThemeRegistryOptions,
ImageResourceResolverOptions {}
export function createApplicationService(
options: ApplicationServiceOptions
) {
const themes = createThemeRegistry(options);
const resolveImages = createImageResourceResolver(options);
async function render(
request: MarkdownRenderRequest,
context: ImageResolutionContext = {}
) {
const { markdown, language, resources } = request;
if (typeof markdown !== "string") {
throw new ApplicationRequestError(
400,
"INVALID_MARKDOWN",
"markdown 必须是字符串"
);
}
if (markdown.length > MAXIMUM_MARKDOWN_LENGTH) {
throw new ApplicationRequestError(
413,
"MARKDOWN_TOO_LARGE",
"Markdown 内容不能超过 1.5 MB"
);
}
if (language !== undefined && typeof language !== "string") {
throw new ApplicationRequestError(
400,
"INVALID_LANGUAGE",
"language 必须是字符串"
);
}
try {
const images = await resolveImages(markdown, resources, context);
return renderMarkdown(markdown, {
...(language ? { language } : {}),
imageSourceMap: images.sources,
warnings: images.warnings
});
} catch (error) {
if (error instanceof MarkdownDocumentParseError) {
throw new ApplicationRequestError(
400,
error.code,
error.message
);
}
if (error instanceof Error) {
const tooLarge = /|/u.test(error.message);
throw new ApplicationRequestError(
tooLarge ? 413 : 400,
tooLarge
? "IMAGE_RESOURCES_TOO_LARGE"
: "INVALID_IMAGE_RESOURCES",
error.message
);
}
throw error;
}
}
async function listThemes() {
return {
themes: (await themes.list()).map(({ manifest, source }) => ({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
bundled: manifest.bundled,
source
}))
};
}
return {
getThemeAsset: themes.getAsset,
getThemeCss: themes.getCss,
invalidateThemes: themes.invalidate,
listThemes,
render
};
}
export type ApplicationService = ReturnType<
typeof createApplicationService
>;
+440
View File
@@ -0,0 +1,440 @@
import { lookup } from "node:dns/promises";
import { readFile, realpath, stat } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
export const MAXIMUM_IMAGE_COUNT = 50;
export const MAXIMUM_IMAGE_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
export const REMOTE_IMAGE_TIMEOUT_MS = 10_000;
export const MAXIMUM_REMOTE_REDIRECTS = 3;
const failedImageDataUrl = `data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="120" viewBox="0 0 640 120"><rect width="640" height="120" fill="#f8fafc"/><rect x="1" y="1" width="638" height="118" fill="none" stroke="#cbd5e1"/><text x="320" y="67" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#64748b">图片加载失败</text></svg>`
).toString("base64")}`;
export interface MarkdownImageResource {
path: string;
contentType?: string;
data: string;
}
export interface ImageResolutionContext {
localRoot?: string;
}
export interface ResolvedMarkdownImages {
sources: ReadonlyMap<string, string>;
warnings: string[];
}
export interface ImageResourceResolverOptions {
remoteLoader?: (url: string) => Promise<LoadedImage>;
}
export interface LoadedImage {
content: Buffer;
contentType: string;
}
function isPrivateIpv4(
address: string,
allowProxyBenchmarkRange = false
) {
const parts = address.split(".").map(Number);
const [first, second] = parts;
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ||
first === undefined ||
second === undefined
) {
return true;
}
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(!allowProxyBenchmarkRange &&
first === 198 &&
(second === 18 || second === 19)) ||
first >= 224
);
}
function isPrivateIpAddress(
address: string,
allowProxyBenchmarkRange = false
) {
const normalized = address.toLowerCase();
if (isIP(normalized) === 4) {
return isPrivateIpv4(normalized, allowProxyBenchmarkRange);
}
if (isIP(normalized) !== 6) {
return true;
}
if (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
/^fe[89ab]/u.test(normalized)
) {
return true;
}
const mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1];
return mapped
? isPrivateIpv4(mapped, allowProxyBenchmarkRange)
: false;
}
async function assertPublicRemoteUrl(url: URL) {
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("仅支持 HTTP/HTTPS 图片");
}
if (
url.username ||
url.password ||
url.hostname.toLowerCase() === "localhost"
) {
throw new Error("图片地址包含不允许的认证或本地主机");
}
if (isIP(url.hostname)) {
if (isPrivateIpAddress(url.hostname)) {
throw new Error("图片地址指向非公网 IP");
}
return;
}
const addresses = await lookup(url.hostname, {
all: true,
verbatim: true
});
if (
addresses.length === 0 ||
addresses.some(({ address }) => isPrivateIpAddress(address, true))
) {
throw new Error("图片域名解析到非公网 IP");
}
}
function detectImageContentType(content: Buffer) {
if (
content.length >= 8 &&
content.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return "image/png";
}
if (
content.length >= 3 &&
content[0] === 0xff &&
content[1] === 0xd8 &&
content[2] === 0xff
) {
return "image/jpeg";
}
const prefix = content.subarray(0, 12).toString("ascii");
if (prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a")) {
return "image/gif";
}
if (prefix.startsWith("RIFF") && prefix.endsWith("WEBP")) {
return "image/webp";
}
if (
content.length >= 12 &&
content.subarray(4, 12).toString("ascii").startsWith("ftyp") &&
/(?:avif|avis)/u.test(content.subarray(8, 32).toString("ascii"))
) {
return "image/avif";
}
const textPrefix = content.subarray(0, Math.min(content.length, 4096))
.toString("utf8")
.replace(/^\uFEFF/u, "")
.trimStart();
if (
/^(?:<\?xml[\s\S]*?\?>\s*)?<svg[\s>]/iu.test(textPrefix)
) {
const svg = content.toString("utf8");
if (
/<script\b|<foreignObject\b|\son\w+\s*=|(?:href|src)\s*=\s*["'](?:https?:|\/\/)/iu.test(
svg
)
) {
throw new Error("SVG 包含不安全的脚本或外部资源");
}
return "image/svg+xml";
}
throw new Error("资源不是受支持的图片格式");
}
function imageToDataUrl(image: LoadedImage) {
return `data:${image.contentType};base64,${image.content.toString("base64")}`;
}
async function readResponseBody(response: Response) {
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAXIMUM_IMAGE_BYTES
) {
throw new Error("远程图片超过单文件大小限制");
}
if (!response.body) {
throw new Error("远程图片响应为空");
}
const chunks: Buffer[] = [];
let total = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > MAXIMUM_IMAGE_BYTES) {
await reader.cancel();
throw new Error("远程图片超过单文件大小限制");
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
export async function downloadRemoteImage(
source: string,
fetcher: typeof fetch = fetch
): Promise<LoadedImage> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REMOTE_IMAGE_TIMEOUT_MS
);
try {
let current = new URL(source);
for (
let redirectCount = 0;
redirectCount <= MAXIMUM_REMOTE_REDIRECTS;
redirectCount += 1
) {
await assertPublicRemoteUrl(current);
const response = await fetcher(current, {
redirect: "manual",
signal: controller.signal,
headers: {
accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
"user-agent": "md-to-pdf/0.4.1"
}
});
if (
response.status >= 300 &&
response.status < 400 &&
response.headers.has("location")
) {
if (redirectCount === MAXIMUM_REMOTE_REDIRECTS) {
throw new Error("远程图片重定向次数过多");
}
current = new URL(response.headers.get("location")!, current);
continue;
}
if (!response.ok) {
throw new Error(`远程图片返回 HTTP ${response.status}`);
}
const content = await readResponseBody(response);
return {
content,
contentType: detectImageContentType(content)
};
}
throw new Error("远程图片重定向次数过多");
} finally {
clearTimeout(timeout);
}
}
export function normalizeDocumentAssetPath(source: string) {
const pathOnly = source.split(/[?#]/u, 1)[0] ?? "";
let decoded: string;
try {
decoded = decodeURIComponent(pathOnly);
} catch {
throw new Error("图片路径 URL 编码无效");
}
const normalizedSeparators = decoded.replace(/\\/gu, "/");
if (
normalizedSeparators.startsWith("/") ||
/^[a-z]:\//iu.test(normalizedSeparators) ||
normalizedSeparators.includes("\0")
) {
throw new Error("不允许绝对图片路径");
}
const segments = normalizedSeparators
.split("/")
.filter((segment) => segment && segment !== ".");
if (segments.length === 0 || segments.includes("..")) {
throw new Error("图片路径为空或越过文档目录");
}
return segments.join("/");
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function loadLocalImage(root: string, source: string) {
const normalized = normalizeDocumentAssetPath(source);
const realRoot = await realpath(root);
const target = path.resolve(realRoot, ...normalized.split("/"));
const realTarget = await realpath(target);
if (!isContainedPath(realRoot, realTarget)) {
throw new Error("图片路径越过文档目录");
}
const targetStat = await stat(realTarget);
if (!targetStat.isFile()) {
throw new Error("图片资源不是文件");
}
if (targetStat.size > MAXIMUM_IMAGE_BYTES) {
throw new Error("本地图片超过单文件大小限制");
}
const content = await readFile(realTarget);
return {
content,
contentType: detectImageContentType(content)
};
}
function parseUploadedResources(value: unknown) {
if (value === undefined) {
return new Map<string, LoadedImage>();
}
if (!Array.isArray(value)) {
throw new Error("图片素材必须是数组");
}
if (value.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const result = new Map<string, LoadedImage>();
let totalBytes = 0;
for (const item of value) {
if (
typeof item !== "object" ||
item === null ||
!("path" in item) ||
!("data" in item) ||
typeof item.path !== "string" ||
typeof item.data !== "string" ||
item.data.length > Math.ceil(MAXIMUM_IMAGE_BYTES * 4 / 3) + 16
) {
throw new Error("图片素材参数无效");
}
const normalized = normalizeDocumentAssetPath(item.path);
if (result.has(normalized)) {
throw new Error(`图片素材路径重复:${normalized}`);
}
const content = Buffer.from(item.data, "base64");
if (content.length === 0 || content.length > MAXIMUM_IMAGE_BYTES) {
throw new Error(`图片素材大小无效:${normalized}`);
}
totalBytes += content.length;
if (totalBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("图片素材总大小超过限制");
}
result.set(normalized, {
content,
contentType: detectImageContentType(content)
});
}
return result;
}
export function createImageResourceResolver(
options: ImageResourceResolverOptions = {}
) {
const remoteLoader = options.remoteLoader ?? downloadRemoteImage;
const remoteCache = new Map<
string,
{ expiresAt: number; promise: Promise<LoadedImage> }
>();
function loadRemote(source: string) {
const cached = remoteCache.get(source);
if (cached && cached.expiresAt > Date.now()) {
return cached.promise;
}
const promise = remoteLoader(source);
remoteCache.set(source, {
expiresAt: Date.now() + 5 * 60_000,
promise
});
if (remoteCache.size > 100) {
const oldest = remoteCache.keys().next().value as string | undefined;
if (oldest) {
remoteCache.delete(oldest);
}
}
void promise.catch(() => {
if (remoteCache.get(source)?.promise === promise) {
remoteCache.delete(source);
}
});
return promise;
}
return async function resolve(
markdown: string,
resources: unknown,
context: ImageResolutionContext = {}
): Promise<ResolvedMarkdownImages> {
const references = extractMarkdownImageSources(markdown);
if (references.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const uploaded = parseUploadedResources(resources);
const sources = new Map<string, string>();
const warnings: string[] = [];
let resolvedBytes = 0;
for (const source of references) {
if (source.startsWith("data:")) {
continue;
}
try {
let image: LoadedImage;
if (/^https?:\/\//iu.test(source)) {
image = await loadRemote(source);
} else {
const normalized = normalizeDocumentAssetPath(source);
const uploadedImage = uploaded.get(normalized);
if (uploadedImage) {
image = uploadedImage;
} else if (context.localRoot) {
image = await loadLocalImage(context.localRoot, source);
} else {
throw new Error("未提供对应素材目录");
}
}
resolvedBytes += image.content.length;
if (resolvedBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("文档图片总大小超过限制");
}
sources.set(source, imageToDataUrl(image));
} catch (error) {
const message =
error instanceof Error ? error.message : "未知错误";
warnings.push(`图片 ${source} 加载失败:${message}`);
sources.set(source, failedImageDataUrl);
}
}
return { sources, warnings };
};
}
+27
View File
@@ -0,0 +1,27 @@
export {
ApplicationRequestError,
MAXIMUM_MARKDOWN_LENGTH,
createApplicationService,
type ApplicationService,
type ApplicationServiceOptions,
type MarkdownRenderRequest
} from "./application-service.js";
export {
MAXIMUM_IMAGE_BYTES,
MAXIMUM_IMAGE_COUNT,
MAXIMUM_TOTAL_IMAGE_BYTES,
REMOTE_IMAGE_TIMEOUT_MS,
createImageResourceResolver,
downloadRemoteImage,
normalizeDocumentAssetPath,
type ImageResolutionContext,
type ImageResourceResolverOptions,
type LoadedImage,
type MarkdownImageResource,
type ResolvedMarkdownImages
} from "./image-resources.js";
export {
createThemeRegistry,
type ThemeRecord,
type ThemeRegistryOptions
} from "./theme-registry.js";
@@ -28,6 +28,7 @@ export interface ThemeRegistryOptions {
localRoot: string;
onWarning?: (message: string) => void;
cacheTtlMs?: number;
createAssetUrl?: (themeId: string, assetPath: string) => string;
}
const assetContentTypes: Record<string, string> = {
@@ -163,7 +164,8 @@ function encodeAssetPath(path: string) {
function prepareCssSegment(
css: string,
themeId: string,
cssDirectory: string
cssDirectory: string,
createAssetUrl: (themeId: string, assetPath: string) => string
) {
return css.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
@@ -194,7 +196,7 @@ function prepareCssSegment(
throw new Error(`主题资源路径越界:${value}`);
}
return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}")`;
return `url("${createAssetUrl(themeId, assetPath)}")`;
}
);
}
@@ -219,6 +221,7 @@ function normalizeCssPath(relativePath: string) {
async function loadThemeCssFile(
theme: ThemeRecord,
relativePath: string,
createAssetUrl: (themeId: string, assetPath: string) => string,
importStack: string[] = []
): Promise<string> {
const normalizedPath = normalizeCssPath(relativePath);
@@ -246,7 +249,8 @@ async function loadThemeCssFile(
result += prepareCssSegment(
css.slice(previousEnd, matchStart),
theme.manifest.id,
cssDirectory
cssDirectory,
createAssetUrl
);
const importedPath = readImportedPath(match);
@@ -255,10 +259,12 @@ async function loadThemeCssFile(
}
const normalizedImport = normalizeCssPath(importedPath);
const importPath = posix.normalize(posix.join(cssDirectory, normalizedImport));
result += await loadThemeCssFile(theme, importPath, [
...importStack,
normalizedPath
]);
result += await loadThemeCssFile(
theme,
importPath,
createAssetUrl,
[...importStack, normalizedPath]
);
previousEnd = matchStart + match[0].length;
}
@@ -266,7 +272,8 @@ async function loadThemeCssFile(
result += prepareCssSegment(
remainder,
theme.manifest.id,
cssDirectory
cssDirectory,
createAssetUrl
);
if (/@import\b/i.test(result)) {
throw new Error("主题包含不支持的 CSS @import 语法");
@@ -276,6 +283,10 @@ async function loadThemeCssFile(
}
export function createThemeRegistry(options: ThemeRegistryOptions) {
const createAssetUrl =
options.createAssetUrl ??
((themeId: string, assetPath: string) =>
`/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(assetPath)}`);
const cacheTtlMs = options.cacheTtlMs ?? 1_000;
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) {
throw new Error("主题缓存有效期必须是非负有限数值");
@@ -293,7 +304,19 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
readThemeRoot(options.localRoot, "local", options.onWarning)
]);
const themes = [...bundledThemes, ...localThemes];
const bundledThemeIds = new Set(
bundledThemes.map((theme) => theme.manifest.id)
);
const uniqueLocalThemes = localThemes.filter((theme) => {
if (!bundledThemeIds.has(theme.manifest.id)) {
return true;
}
options.onWarning?.(
`已忽略与内置主题同 ID 的本地主题 ${theme.manifest.id}`
);
return false;
});
const themes = [...bundledThemes, ...uniqueLocalThemes];
const themeIds = new Set<string>();
for (const theme of themes) {
if (themeIds.has(theme.manifest.id)) {
@@ -347,7 +370,9 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
];
return (
await Promise.all(
cssFiles.map((path) => loadThemeCssFile(theme, path))
cssFiles.map((path) =>
loadThemeCssFile(theme, path, createAssetUrl)
)
)
).join("\n");
}
@@ -0,0 +1,223 @@
import {
afterEach,
describe,
expect,
it,
vi
} from "vitest";
import {
mkdtemp,
mkdir,
rm,
writeFile
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ApplicationRequestError,
createApplicationService
} from "../src/index.js";
let temporaryDirectory: string | undefined;
afterEach(async () => {
if (temporaryDirectory) {
await rm(temporaryDirectory, {
recursive: true,
force: true
});
}
temporaryDirectory = undefined;
});
async function createThemeFixture() {
temporaryDirectory = await mkdtemp(
join(tmpdir(), "md-to-pdf-application-")
);
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const themeRoot = join(bundledRoot, "test-theme");
await mkdir(join(themeRoot, "fonts"), { recursive: true });
await mkdir(localRoot, { recursive: true });
await writeFile(
join(themeRoot, "theme.json"),
JSON.stringify({
manifestVersion: 1,
id: "test-theme",
name: "测试主题",
version: "1.0.0",
description: "共享应用服务测试主题",
author: "test",
license: "MIT",
entry: "theme.css",
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["code", "table"],
bundled: true
}),
"utf8"
);
await writeFile(
join(themeRoot, "theme.css"),
[
"@font-face {",
" font-family: Test;",
" src: url('./fonts/test.woff2') format('woff2');",
"}",
"#write { font-family: Test; }"
].join("\n"),
"utf8"
);
await writeFile(
join(themeRoot, "fonts", "test.woff2"),
Buffer.from([0, 1, 2, 3])
);
return { bundledRoot, localRoot };
}
describe("共享应用服务", () => {
it("渲染安全 Markdown 文档", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const document = await service.render({
markdown: "# 文档\n\n<script>alert('xss')</script>",
language: "zh-CN"
});
expect(document.articleHtml).toContain('id="write"');
expect(document.articleHtml).not.toContain("<script");
expect(document.metadata.title).toBe("文档");
});
it("以稳定错误协议拒绝非法请求", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
await expect(service.render({ markdown: 42 })).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({
statusCode: 400,
code: "INVALID_MARKDOWN"
})
);
});
it("解析上传素材与桌面文档目录中的相对图片", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
const image = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
const assetRoot = join(temporaryDirectory!, "document");
await mkdir(join(assetRoot, "文档.assets"), { recursive: true });
await writeFile(join(assetRoot, "文档.assets", "本地.png"), image);
const local = await service.render(
{
markdown:
"![本地](./%E6%96%87%E6%A1%A3.assets/%E6%9C%AC%E5%9C%B0.png)"
},
{ localRoot: assetRoot }
);
const uploaded = await service.render({
markdown: "![网页](文档.assets/网页.png)",
resources: [
{
path: "文档.assets/网页.png",
data: image.toString("base64")
}
]
});
expect(local.articleHtml).toContain("data:image/png;base64,");
expect(uploaded.articleHtml).toContain("data:image/png;base64,");
expect(local.articleHtml).toContain("md-document-image");
expect(uploaded.warnings).toEqual([]);
});
it("列出主题并使用调用方提供的资源 URL", async () => {
const roots = await createThemeFixture();
const service = createApplicationService({
...roots,
createAssetUrl: (themeId, assetPath) =>
`mdpdf://theme/${themeId}/${assetPath}`
});
await expect(service.listThemes()).resolves.toEqual({
themes: [
expect.objectContaining({
id: "test-theme",
bundled: true,
source: "bundled"
})
]
});
await expect(
service.getThemeCss("test-theme")
).resolves.toContain(
"mdpdf://theme/test-theme/fonts/test.woff2"
);
});
it("安全读取主题二进制资源", async () => {
const roots = await createThemeFixture();
const service = createApplicationService(roots);
await expect(
service.getThemeAsset("test-theme", "fonts/test.woff2")
).resolves.toEqual({
contentType: "font/woff2",
content: Buffer.from([0, 1, 2, 3])
});
await expect(
service.getThemeAsset("test-theme", "../test.woff2")
).rejects.toThrow("主题资源路径不安全");
});
it("内置主题优先于同 ID 的旧本地副本", async () => {
const roots = await createThemeFixture();
const localThemeRoot = join(roots.localRoot, "test-theme");
await mkdir(localThemeRoot, { recursive: true });
await writeFile(
join(localThemeRoot, "theme.json"),
JSON.stringify({
manifestVersion: 1,
id: "test-theme",
name: "旧本地主题",
version: "local",
description: "迁移前的本地副本",
author: "test",
license: "local-only",
entry: "theme.css",
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["code", "table"],
bundled: false
}),
"utf8"
);
await writeFile(
join(localThemeRoot, "theme.css"),
"#write { color: red; }",
"utf8"
);
const onWarning = vi.fn();
const service = createApplicationService({
...roots,
onWarning
});
await expect(service.listThemes()).resolves.toEqual({
themes: [
expect.objectContaining({
id: "test-theme",
name: "测试主题",
bundled: true
})
]
});
expect(onWarning).toHaveBeenCalledWith(
"已忽略与内置主题同 ID 的本地主题 test-theme"
);
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import {
createImageResourceResolver,
downloadRemoteImage,
normalizeDocumentAssetPath
} from "../src/index.js";
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
]);
describe("Markdown 图片资源安全", () => {
it("解码合法相对路径并拒绝越界与绝对路径", () => {
expect(
normalizeDocumentAssetPath(
"./%E6%96%87%E6%A1%A3.assets/a%20b.png?raw=1"
)
).toBe("文档.assets/a b.png");
expect(() => normalizeDocumentAssetPath("../secret.png")).toThrow(
"越过文档目录"
);
expect(() => normalizeDocumentAssetPath("C:/secret.png")).toThrow(
"绝对图片路径"
);
});
it("在发起请求前拒绝直接指向本机的远程地址", async () => {
const fetcher = vi.fn<typeof fetch>();
await expect(
downloadRemoteImage("http://127.0.0.1/private.png", fetcher)
).rejects.toThrow("非公网 IP");
expect(fetcher).not.toHaveBeenCalled();
});
it("缓存成功的远程图片并把失败转换为可诊断占位", async () => {
const remoteLoader = vi
.fn()
.mockResolvedValueOnce({
content: png,
contentType: "image/png"
})
.mockRejectedValueOnce(new Error("网络不可用"));
const resolve = createImageResourceResolver({ remoteLoader });
const first = await resolve(
"![a](https://example.com/a.png)\n![a2](https://example.com/a.png)",
undefined
);
const failed = await resolve(
"![b](https://example.com/b.png)",
undefined
);
expect(remoteLoader).toHaveBeenCalledTimes(2);
expect(first.sources.get("https://example.com/a.png")).toMatch(
/^data:image\/png;base64,/u
);
expect(failed.sources.get("https://example.com/b.png")).toMatch(
/^data:image\/svg\+xml;base64,/u
);
expect(failed.warnings[0]).toContain("网络不可用");
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src"
]
}
+3
View File
@@ -52,6 +52,8 @@ export function createPagedDocumentPayload({
export interface PagedDocumentTimings {
setupMs: number;
echartsMs: number;
echartsFitMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
@@ -63,6 +65,7 @@ export interface PagedDocumentTimings {
export interface PagedDocumentRenderResult {
pageCount: number;
echartsErrors: string[];
mermaidErrors: string[];
timings: PagedDocumentTimings;
}
+2 -1
View File
@@ -8,7 +8,8 @@ export const themeFeatureSchema = z.enum([
"task-list",
"footnote",
"katex",
"mermaid"
"mermaid",
"echarts"
]);
export const themeManifestSchema = z.object({
+296
View File
@@ -0,0 +1,296 @@
# @md-to-pdf/markdown-echarts
为 Markdown 增加安全、可验证、可复用的 ECharts YAML 围栏,
并在浏览器中渲染为 SVG。
该包不依赖 React、Paged.js 或特定 PDF 引擎。宿主可以将生成的
HTML 用于网页预览,也可以在图表完成渲染后交给 Chromium 输出 PDF。
## 基本语法
````markdown
```echarts
height: 80mm
caption: 年度收入
option:
xAxis:
type: category
data: [2023, 2024]
yAxis:
type: value
series:
- type: bar
data: [120, 180]
```
````
围栏内容必须使用 YAML,并包含一个 ECharts `option` 对象。YAML
会先转换为安全的纯数据对象,再交给 ECharts;不能使用函数、
YAML 锚点、别名或自定义标签。
## 围栏字段
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `version` | `1` | `1` | 围栏协议版本 |
| `height` | CSS 长度 | `80mm` | 支持 `mm`、`cm`、`in`、`px` |
| `aspectRatio` | 正数 | 无 | 与 `height` 二选一;指定后不补默认高度 |
| `theme` | `document`、`light`、`dark` | `document` | ECharts 主题 |
| `caption` | 字符串 | 无 | 图表标题说明 |
| `option` | 对象 | 必填 | 经过安全和语义验证的 ECharts 配置 |
## 数据来源
每个系列必须从以下任一位置获得有效数据:
- 非空的 `series[].data`
- 非空的 `dataset.source`
- 引用具有有效上游数据源的内置 `filter` 或 `sort` transform。
只有表头的二维数组、所有列均为空的对象以及空数组不属于有效
数据。使用 `dataset` 时保留 ECharts 的默认维度映射,也可以通过
`series.encode` 显式声明映射。
```yaml
option:
dataset:
source:
- [year, sales]
- [2025, 120]
- [2026, 180]
series:
- type: line
encode:
x: year
y: sales
```
## 系列验证规则
当前支持以下系列:
| 系列 | 主要规则 |
| --- | --- |
| `line` | 需要有效数据;支持直角坐标系和极坐标系 |
| `bar` | 需要有效数据;支持直角坐标系和极坐标系 |
| `scatter` | 直接数据至少包含两个坐标值 |
| `pie` | 直接数据项的 `value` 必须是有限数值 |
| `radar` | 必须提供非空 `radar.indicator`;数据维数不得少于指标数 |
| `heatmap` | 直角坐标数据为 `[x, y, value]`;日历数据为 `[date, value]` |
| `boxplot` | 直接数据为 `[min, Q1, median, Q3, max]` |
| `candlestick` | 直接数据为 `[open, close, lowest, highest]` |
| `tree` | 必须提供非空层级 `data`;递归校验 `children` |
| `treemap` | 必须提供非空层级 `data`;叶节点必须具有有限数值 |
| `sunburst` | 必须提供非空层级 `data`;叶节点必须具有有限数值 |
| `graph` | 必须提供 `data` 或 `nodes`;校验边引用;缺少坐标时默认 `force` 布局 |
| `sankey` | 必须提供节点和关系边;边权重必须是非负有限数值 |
| `chord` | 使用 ECharts 6 和弦图;必须提供节点、关系边和非负权重 |
| `funnel` | 数据项必须是有限数值,或使用有效 `dataset` |
| `gauge` | 数据项必须是有限数值,或使用有效 `dataset` |
| `pictorialBar` | 需要有效数据和直角坐标系;允许安全的 `path://` 内嵌矢量符号 |
直角坐标系列缺少 `xAxis` 或 `yAxis` 时会补充空对象,让 ECharts
使用自身的轴默认值。极坐标、雷达和日历坐标系表达了明确语义,
因此不会猜测配置,缺少对应组件时会报告错误。
使用 `dataset` 时,验证器只验证数据源是否存在,不重复实现
ECharts 的维度推断和 `encode` 映射规则。
## 第二阶段系列示例
### 层级图
```yaml
height: 60mm
option:
series:
- type: treemap
nodeClick: false
breadcrumb:
show: false
label:
show: true
formatter: "{b}"
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
borderRadius: 3
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
levels:
- itemStyle:
borderWidth: 0
gapWidth: 2
- upperLabel:
show: true
height: 28
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
- label:
show: true
formatter: "{b}"
color: "#1f2937"
fontWeight: bold
backgroundColor: "rgba(255, 255, 255, 0.88)"
borderRadius: 3
padding: [3, 6]
itemStyle:
borderColor: "#ffffff"
borderWidth: 2
gapWidth: 2
data:
- name: 渲染
children:
- { name: Markdown, value: 32 }
- { name: ECharts, value: 24 }
- name: 导出
children:
- { name: PDF, value: 28 }
```
`tree`、`treemap` 和 `sunburst` 共用递归 `children` 结构。
`treemap` 与 `sunburst` 的叶节点需要数值;父节点可以省略数值,
由 ECharts 根据子节点汇总。
### 关系图
```yaml
height: 60mm
option:
series:
- type: sankey
data:
- { name: Markdown }
- { name: HTML }
- { name: PDF }
links:
- { source: Markdown, target: HTML, value: 100 }
- { source: HTML, target: PDF, value: 40 }
```
`graph` 的关系边可以省略;`sankey` 和 ECharts 6 `chord` 必须
具有非空 `links` 或 `edges`。`source`、`target` 必须引用节点
名称、ID 或数组索引。
### 业务图表
```yaml
height: 60mm
option:
series:
- type: gauge
min: 0
max: 100
progress:
show: true
detail:
formatter: "{value}%"
data:
- { name: 完成率, value: 78 }
```
`funnel` 和 `gauge` 使用有限数值数据;`pictorialBar` 复用柱图
坐标系和数据规则,并允许不加载外部资源的 `path://` SVG 路径。
## 错误行为
配置错误不会中断整篇 Markdown。对应围栏会变成局部错误占位:
```html
<div class="md-echarts-error" role="alert">
ECharts 图表配置无效:option.series.0.data 必须是非空数组……
</div>
```
错误消息包含配置路径,便于用户或 AI Agent 定位并修正 YAML。
宿主还可以通过 `onError` 收集结构化错误。
```ts
markdown.use(markdownItECharts, {
onError(error, context) {
console.warn(error.code, error.message, context.tokenIndex);
}
});
```
## Markdown-it 接入
```ts
import MarkdownIt from "markdown-it";
import {
markdownItECharts
} from "@md-to-pdf/markdown-echarts";
const markdown = new MarkdownIt().use(markdownItECharts);
const html = markdown.render(source);
```
如果宿主还会对 HTML 做安全过滤,需要保留包生成的 `figure`、
`figcaption`、隐藏配置节点及 `data-echarts-*` 属性。
## 浏览器渲染
```ts
import {
renderEChartsBlocks
} from "@md-to-pdf/markdown-echarts/browser";
import "@md-to-pdf/markdown-echarts/styles.css";
const outcomes = await renderEChartsBlocks(document, {
outputMode: "inline-svg",
concurrency: 2,
timeoutMs: 5000
});
```
输出模式:
- `interactive`:保留 ECharts 实例和响应式尺寸监听;
- `inline-svg`:冻结为内联 SVG,适合打印和 PDF;
- `svg-image`:冻结为 SVG Data URL 图片。
PDF 输出前应等待所有图表、字体和图片完成,再开始分页或调用
Chromium `page.pdf()`。
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
## 安全边界
默认策略会拒绝:
- `custom` 系列及 `renderItem`
- JavaScript、HTTP、文件和 Data URL 外部资源;
- HTML formatter
- 原型污染属性;
- 未允许的 toolbox 功能;
- 非内置数据转换;
- 超过节点数、深度、字符串、数组、系列、层级节点或关系边数量
上限的配置。
这些限制适用于不受信任的 Markdown。宿主如需扩大能力,应在明确
风险后扩展验证器,而不是直接执行 YAML 中的 JavaScript。
## 扩展系列验证
各系列规则注册在 `echartsSeriesValidators`。新增图形时需要同步:
1. 将系列类型加入协议支持列表;
2. 在浏览器引擎中注册对应 ECharts Chart
3. 注册该系列的语义验证器;
4. 增加有效、缺少数据、错误数据结构和真实 SVG 渲染测试。
参考:
- [ECharts 配置参考](https://echarts.apache.org/en/option.html)
- [ECharts Dataset](https://echarts.apache.org/handbook/en/concepts/dataset/)
- [ECharts Data Transform](https://echarts.apache.org/handbook/en/concepts/data-transform/)
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@md-to-pdf/markdown-echarts",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./browser": {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
},
"./styles.css": {
"default": "./styles/echarts.css"
}
},
"files": [
"dist",
"styles"
],
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"yaml": "^2.8.1",
"zod": "^4.1.13"
},
"peerDependencies": {
"echarts": "^6.1.0",
"markdown-it": "^14.3.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"echarts": "6.1.0",
"happy-dom": "^20.11.1",
"markdown-it": "^14.3.0",
"vitest": "^4.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./browser/engine.js";
export * from "./browser/freeze-svg.js";
export * from "./browser/render-blocks.js";
export * from "./browser/render-chart.js";
export * from "./browser/resource-policy.js";
export * from "./browser/types.js";
@@ -0,0 +1,94 @@
import * as echarts from "echarts/core";
import {
BarChart,
BoxplotChart,
CandlestickChart,
ChordChart,
FunnelChart,
GaugeChart,
GraphChart,
HeatmapChart,
LineChart,
PieChart,
PictorialBarChart,
RadarChart,
SankeyChart,
ScatterChart,
SunburstChart,
TreeChart,
TreemapChart
} from "echarts/charts";
import {
AriaComponent,
AxisPointerComponent,
CalendarComponent,
DataZoomComponent,
DatasetComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
MarkPointComponent,
PolarComponent,
RadarComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent,
VisualMapComponent
} from "echarts/components";
import { LabelLayout } from "echarts/features";
import { SVGRenderer } from "echarts/renderers";
import type {
EChartsEngine,
EChartsInstanceLike
} from "./types.js";
echarts.use([
LineChart,
BarChart,
PieChart,
ScatterChart,
RadarChart,
HeatmapChart,
BoxplotChart,
CandlestickChart,
TreeChart,
TreemapChart,
SunburstChart,
GraphChart,
SankeyChart,
ChordChart,
FunnelChart,
GaugeChart,
PictorialBarChart,
GridComponent,
PolarComponent,
RadarComponent,
CalendarComponent,
TitleComponent,
LegendComponent,
TooltipComponent,
AxisPointerComponent,
ToolboxComponent,
DatasetComponent,
TransformComponent,
DataZoomComponent,
VisualMapComponent,
MarkPointComponent,
MarkLineComponent,
MarkAreaComponent,
AriaComponent,
LabelLayout,
SVGRenderer
]);
export const defaultEChartsEngine: EChartsEngine = {
init(container, theme, options) {
return echarts.init(
container,
theme,
options
) as unknown as EChartsInstanceLike;
}
};
@@ -0,0 +1,36 @@
import { echartsDomContract } from "../dom-contract.js";
import { cloneSafeEChartsSvg } from "./resource-policy.js";
import type { EChartsOutputMode } from "./types.js";
function svgDataUrl(serialized: string) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serialized)}`;
}
export function freezeEChartsSvg(
host: HTMLElement,
source: SVGSVGElement,
outputMode: Exclude<EChartsOutputMode, "interactive">,
label: string
) {
const svg = cloneSafeEChartsSvg(source);
svg.removeAttribute("style");
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", label);
svg.style.width = "100%";
svg.style.height = "100%";
svg.style.display = "block";
if (outputMode === "inline-svg") {
return svg;
}
const image = host.ownerDocument.createElement("img");
image.className = `${echartsDomContract.figureClass}-svg-image`;
image.alt = label;
image.src = svgDataUrl(new XMLSerializer().serializeToString(svg));
image.style.width = "100%";
image.style.height = "100%";
image.style.objectFit = "contain";
image.style.display = "block";
return image;
}
@@ -0,0 +1,58 @@
import { echartsDomContract } from "../dom-contract.js";
import {
disposeEChartsBlock,
renderEChartsBlock
} from "./render-chart.js";
import type {
EChartsBlockRenderOutcome,
RenderEChartsBlocksOptions
} from "./types.js";
export async function renderEChartsBlocks(
root: ParentNode,
options: RenderEChartsBlocksOptions = {}
) {
const elements = Array.from(
root.querySelectorAll<HTMLElement>(
`.${echartsDomContract.figureClass}[${echartsDomContract.pendingAttribute}]`
)
);
const outcomes = new Array<EChartsBlockRenderOutcome>(
elements.length
);
const concurrency = Math.max(
1,
Math.min(options.concurrency ?? 2, elements.length || 1)
);
let nextIndex = 0;
async function worker() {
while (nextIndex < elements.length) {
const index = nextIndex;
nextIndex += 1;
const element = elements[index];
if (!element) {
continue;
}
const outcome = await renderEChartsBlock(element, options);
outcomes[index] = outcome;
if (!outcome.success && outcome.error) {
options.onError?.(outcome.error, element, index);
}
}
}
await Promise.all(
Array.from({ length: concurrency }, () => worker())
);
return outcomes;
}
export function disposeEChartsBlocks(root: ParentNode) {
const elements = root.querySelectorAll<HTMLElement>(
`.${echartsDomContract.figureClass}`
);
for (const element of elements) {
disposeEChartsBlock(element);
}
}
@@ -0,0 +1,351 @@
import { echartsDomContract } from "../dom-contract.js";
import {
DEFAULT_ECHARTS_FENCE_HEIGHT,
EChartsFenceError,
echartsFenceSpecSchema,
type NormalizedEChartsFenceSpec
} from "../protocol.js";
import {
normalizeAndValidateEChartsOption
} from "../security-policy.js";
import {
validateAndApplyEChartsOptionDefaults
} from "../semantic-validator.js";
import { defaultEChartsEngine } from "./engine.js";
import { freezeEChartsSvg } from "./freeze-svg.js";
import type {
EChartsBlockRenderOutcome,
EChartsInstanceLike,
EChartsOutputMode,
RenderEChartsBlockOptions
} from "./types.js";
interface ActiveChart {
instance: EChartsInstanceLike;
observer?: ResizeObserver;
}
const activeCharts = new WeakMap<HTMLElement, ActiveChart>();
function abortError() {
return new DOMException("ECharts 渲染已取消", "AbortError");
}
function throwIfAborted(signal: AbortSignal | undefined) {
if (signal?.aborted) {
throw abortError();
}
}
function normalizeDefinition(value: unknown): NormalizedEChartsFenceSpec {
const protocolResult = echartsFenceSpecSchema.safeParse(value);
if (!protocolResult.success) {
throw new EChartsFenceError(
"INVALID_PROTOCOL",
"ECharts 占位节点中的协议无效"
);
}
const normalized: NormalizedEChartsFenceSpec = {
version: protocolResult.data.version,
theme: protocolResult.data.theme,
option: validateAndApplyEChartsOptionDefaults(
normalizeAndValidateEChartsOption(
protocolResult.data.option
)
)
};
if (protocolResult.data.height !== undefined) {
normalized.height = protocolResult.data.height;
} else if (protocolResult.data.aspectRatio === undefined) {
normalized.height = DEFAULT_ECHARTS_FENCE_HEIGHT;
}
if (protocolResult.data.aspectRatio !== undefined) {
normalized.aspectRatio = protocolResult.data.aspectRatio;
}
if (protocolResult.data.caption !== undefined) {
normalized.caption = protocolResult.data.caption;
}
return normalized;
}
function readDefinition(element: HTMLElement) {
const definition = element.querySelector<HTMLElement>(
`.${echartsDomContract.definitionClass}`
);
if (!definition) {
throw new Error("缺少 ECharts 配置节点");
}
try {
return normalizeDefinition(
JSON.parse(definition.textContent ?? "")
);
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error("ECharts 配置节点无法解析");
}
}
function applyChartSize(
host: HTMLElement,
definition: NormalizedEChartsFenceSpec
) {
host.style.width = "100%";
if (definition.height !== undefined) {
host.style.height = definition.height;
host.style.removeProperty("aspect-ratio");
return;
}
host.style.removeProperty("height");
host.style.aspectRatio = String(definition.aspectRatio);
}
function resolveDefaultTheme(
theme: NormalizedEChartsFenceSpec["theme"]
) {
return theme === "dark" ? "dark" : undefined;
}
function nextFrame(element: HTMLElement) {
const requestFrame =
element.ownerDocument.defaultView?.requestAnimationFrame;
if (!requestFrame) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
requestFrame(() => resolve());
});
}
async function waitForRender(
instance: EChartsInstanceLike,
host: HTMLElement,
timeoutMs: number,
signal: AbortSignal | undefined,
setOption: () => void
) {
let finished = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let abortHandler: (() => void) | undefined;
let finishedHandler: (() => void) | undefined;
const completion = new Promise<void>((resolve, reject) => {
const finish = () => {
if (finished) {
return;
}
finished = true;
resolve();
};
const fail = (error: Error) => {
if (finished) {
return;
}
finished = true;
reject(error);
};
finishedHandler = finish;
instance.on("finished", finishedHandler);
timer = setTimeout(
() => fail(new Error(`ECharts 渲染超过 ${timeoutMs}ms`)),
timeoutMs
);
abortHandler = () => fail(abortError());
signal?.addEventListener("abort", abortHandler, { once: true });
try {
setOption();
} catch (error) {
fail(
error instanceof Error
? error
: new Error("ECharts setOption 失败")
);
}
});
try {
await Promise.race([
completion,
(async () => {
await nextFrame(host);
await nextFrame(host);
if (!host.querySelector("svg")) {
await completion;
}
})()
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
if (abortHandler) {
signal?.removeEventListener("abort", abortHandler);
}
if (finishedHandler) {
instance.off("finished", finishedHandler);
}
}
}
function storeInteractiveChart(
element: HTMLElement,
host: HTMLElement,
instance: EChartsInstanceLike
) {
let observer: ResizeObserver | undefined;
const ResizeObserverConstructor =
element.ownerDocument.defaultView?.ResizeObserver;
if (ResizeObserverConstructor) {
observer = new ResizeObserverConstructor(() => instance.resize());
observer.observe(host);
}
const active: ActiveChart = { instance };
if (observer !== undefined) {
active.observer = observer;
}
activeCharts.set(element, active);
}
function showRenderError(element: HTMLElement, error: Error) {
element.removeAttribute(echartsDomContract.pendingAttribute);
element.classList.add(echartsDomContract.errorClass);
const host = element.querySelector<HTMLElement>(
`.${echartsDomContract.hostClass}`
);
if (host) {
host.setAttribute("role", "alert");
host.textContent = `ECharts 图表渲染失败:${error.message}`;
}
}
export function disposeEChartsBlock(element: HTMLElement) {
const active = activeCharts.get(element);
if (!active) {
return;
}
active.observer?.disconnect();
active.instance.dispose();
activeCharts.delete(element);
}
export async function renderEChartsBlock(
element: HTMLElement,
options: RenderEChartsBlockOptions = {}
): Promise<EChartsBlockRenderOutcome> {
const startedAt = performance.now();
const outputMode: EChartsOutputMode =
options.outputMode ?? "inline-svg";
let instance: EChartsInstanceLike | undefined;
try {
throwIfAborted(options.signal);
disposeEChartsBlock(element);
const definition = readDefinition(element);
const host = element.querySelector<HTMLElement>(
`.${echartsDomContract.hostClass}`
);
if (!host) {
throw new Error("缺少 ECharts 渲染容器");
}
applyChartSize(host, definition);
const fonts = element.ownerDocument.fonts;
if (fonts) {
await fonts.ready;
}
throwIfAborted(options.signal);
const engine = options.engine ?? defaultEChartsEngine;
const theme =
options.resolveTheme?.(definition.theme, element) ??
resolveDefaultTheme(definition.theme);
instance = engine.init(host, theme, {
renderer: "svg",
useDirtyRect: false
});
const series = Array.isArray(definition.option.series)
? definition.option.series.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? {
...item,
animation: false,
animationDelay: 0,
animationDelayUpdate: 0,
animationDuration: 0,
animationDurationUpdate: 0
}
: item
)
: definition.option.series;
const option = {
...definition.option,
animation: false,
animationDelay: 0,
animationDelayUpdate: 0,
animationDuration: 0,
animationDurationUpdate: 0,
series
};
await waitForRender(
instance,
host,
options.timeoutMs ?? 5_000,
options.signal,
() => instance?.setOption(option, { notMerge: true })
);
throwIfAborted(options.signal);
if (outputMode === "interactive") {
storeInteractiveChart(element, host, instance);
element.removeAttribute(echartsDomContract.pendingAttribute);
element.dataset.echartsRendered = "interactive";
return {
element,
outputMode,
success: true,
durationMs: performance.now() - startedAt,
instance
};
}
const svg = host.querySelector<SVGSVGElement>("svg");
if (!svg) {
throw new Error("ECharts 未生成 SVG");
}
const frozenOutput = freezeEChartsSvg(
host,
svg,
outputMode,
definition.caption ?? "ECharts 图表"
);
instance.dispose();
instance = undefined;
host.replaceChildren(frozenOutput);
element.removeAttribute(echartsDomContract.pendingAttribute);
element.dataset.echartsRendered = outputMode;
return {
element,
outputMode,
success: true,
durationMs: performance.now() - startedAt
};
} catch (error) {
instance?.dispose();
const normalizedError =
error instanceof Error
? error
: new Error("未知 ECharts 渲染错误");
showRenderError(element, normalizedError);
return {
element,
outputMode,
success: false,
durationMs: performance.now() - startedAt,
error: normalizedError
};
}
}
@@ -0,0 +1,57 @@
const forbiddenSvgElements = [
"script",
"foreignObject",
"iframe",
"object",
"embed"
] as const;
const urlFunctionPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/giu;
function assertSafeUrlValue(value: string, context: string) {
for (const match of value.matchAll(urlFunctionPattern)) {
const target = match[2]?.trim() ?? "";
if (!target.startsWith("#")) {
throw new Error(`${context} 包含外部 SVG 资源`);
}
}
}
function assertSafeHref(value: string, context: string) {
const normalized = value.trim();
if (normalized && !normalized.startsWith("#")) {
throw new Error(`${context} 包含外部 SVG 链接`);
}
}
export function cloneSafeEChartsSvg(
source: SVGSVGElement
): SVGSVGElement {
const clone = source.cloneNode(true) as SVGSVGElement;
if (clone.querySelector(forbiddenSvgElements.join(","))) {
throw new Error("ECharts SVG 包含禁止元素");
}
for (const element of [clone, ...clone.querySelectorAll("*")]) {
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name.toLowerCase();
if (name.startsWith("on")) {
element.removeAttribute(attribute.name);
continue;
}
if (name === "href" || name === "xlink:href") {
assertSafeHref(attribute.value, attribute.name);
continue;
}
assertSafeUrlValue(attribute.value, attribute.name);
}
if (
element.tagName.toLowerCase() === "style" &&
/@import|url\(\s*(?!['"]?#)/iu.test(element.textContent ?? "")
) {
throw new Error("ECharts SVG 样式包含外部资源");
}
}
return clone;
}
@@ -0,0 +1,60 @@
import type {
NormalizedEChartsFenceSpec
} from "../protocol.js";
export type EChartsOutputMode =
| "interactive"
| "inline-svg"
| "svg-image";
export interface EChartsInstanceLike {
setOption(option: unknown, options?: { notMerge?: boolean }): void;
on(eventName: string, handler: () => void): void;
off(eventName: string, handler: () => void): void;
resize(): void;
dispose(): void;
}
export interface EChartsEngine {
init(
container: HTMLElement,
theme: string | object | null | undefined,
options: {
renderer: "svg";
width?: number | string;
height?: number | string;
devicePixelRatio?: number;
useDirtyRect?: boolean;
}
): EChartsInstanceLike;
}
export interface RenderEChartsBlockOptions {
engine?: EChartsEngine;
outputMode?: EChartsOutputMode;
timeoutMs?: number;
signal?: AbortSignal;
resolveTheme?: (
theme: NormalizedEChartsFenceSpec["theme"],
element: HTMLElement
) => string | object | null | undefined;
}
export interface RenderEChartsBlocksOptions
extends RenderEChartsBlockOptions {
concurrency?: number;
onError?: (
error: Error,
element: HTMLElement,
index: number
) => void;
}
export interface EChartsBlockRenderOutcome {
element: HTMLElement;
outputMode: EChartsOutputMode;
success: boolean;
durationMs: number;
error?: Error;
instance?: EChartsInstanceLike;
}
@@ -0,0 +1,8 @@
export const echartsDomContract = {
figureClass: "md-echarts",
hostClass: "md-echarts-host",
definitionClass: "md-echarts-definition",
errorClass: "md-echarts-error",
pendingAttribute: "data-echarts-pending",
protocolAttribute: "data-echarts-protocol"
} as const;
+6
View File
@@ -0,0 +1,6 @@
export * from "./dom-contract.js";
export * from "./markdown-it-plugin.js";
export * from "./parse-yaml.js";
export * from "./protocol.js";
export * from "./security-policy.js";
export * from "./semantic-validator.js";
@@ -0,0 +1,140 @@
import type MarkdownIt from "markdown-it";
import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs";
import type Token from "markdown-it/lib/token.mjs";
import { echartsDomContract } from "./dom-contract.js";
import {
ECHARTS_FENCE_VERSION,
EChartsFenceError,
type NormalizedEChartsFenceSpec
} from "./protocol.js";
import {
parseEChartsFence,
type ParseEChartsFenceOptions
} from "./parse-yaml.js";
export interface MarkdownItEChartsErrorContext {
source: string;
tokenIndex: number;
environment: unknown;
}
export interface MarkdownItEChartsOptions {
parse?: ParseEChartsFenceOptions;
onError?: (
error: EChartsFenceError,
context: MarkdownItEChartsErrorContext
) => void;
}
type FenceRenderer = (
tokens: Token[],
index: number,
options: MarkdownItOptions,
environment: unknown,
renderer: Renderer
) => string;
function renderError(markdown: MarkdownIt, error: EChartsFenceError) {
const message = markdown.utils.escapeHtml(error.message);
return `<div class="${echartsDomContract.errorClass}" role="alert">ECharts 图表配置无效:${message}</div>\n`;
}
function renderDefinition(
markdown: MarkdownIt,
definition: NormalizedEChartsFenceSpec
) {
const serialized = markdown.utils.escapeHtml(
JSON.stringify(definition)
);
const caption =
definition.caption === undefined
? ""
: `<figcaption>${markdown.utils.escapeHtml(definition.caption)}</figcaption>`;
const label = markdown.utils.escapeHtml(
definition.caption ?? "ECharts 图表"
);
return [
`<figure class="${echartsDomContract.figureClass}"`,
` ${echartsDomContract.pendingAttribute}="true"`,
` ${echartsDomContract.protocolAttribute}="${ECHARTS_FENCE_VERSION}">`,
`<div class="${echartsDomContract.hostClass}" role="img" aria-label="${label}"></div>`,
`<pre class="${echartsDomContract.definitionClass}" hidden="hidden">${serialized}</pre>`,
caption,
"</figure>\n"
].join("");
}
function renderDefaultFence(
defaultFenceRenderer: FenceRenderer | undefined,
tokens: Token[],
index: number,
options: MarkdownItOptions,
environment: unknown,
renderer: Renderer
) {
if (defaultFenceRenderer) {
return defaultFenceRenderer(
tokens,
index,
options,
environment,
renderer
);
}
return renderer.renderToken(tokens, index, options);
}
export function markdownItECharts(
markdown: MarkdownIt,
pluginOptions: MarkdownItEChartsOptions = {}
) {
const defaultFenceRenderer = markdown.renderer.rules.fence;
markdown.renderer.rules.fence = (
tokens,
index,
options,
environment,
renderer
) => {
const token = tokens[index];
const language = token?.info
.trim()
.split(/\s+/u)[0]
?.toLowerCase();
if (!token || language !== "echarts") {
return renderDefaultFence(
defaultFenceRenderer,
tokens,
index,
options,
environment,
renderer
);
}
try {
return renderDefinition(
markdown,
parseEChartsFence(token.content, pluginOptions.parse)
);
} catch (error) {
const normalizedError =
error instanceof EChartsFenceError
? error
: new EChartsFenceError(
"INVALID_YAML",
error instanceof Error ? error.message : "未知错误",
{ cause: error }
);
pluginOptions.onError?.(normalizedError, {
source: token.content,
tokenIndex: index,
environment
});
return renderError(markdown, normalizedError);
}
};
}

Some files were not shown because too many files have changed in this diff Show More