Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d91d08809 | ||
|
|
32a37293d0 | ||
|
|
06b30d084e | ||
|
|
4d60741f5a | ||
|
|
92a5bfd016 |
@@ -13,4 +13,5 @@ tmp/
|
||||
playwright-report/
|
||||
test-results/
|
||||
output/
|
||||
/apps/desktop/out/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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
|
||||
|
||||
## 隐私原则
|
||||
|
||||
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
||||
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
|
||||
文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
||||
相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程
|
||||
地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。
|
||||
|
||||
@@ -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.2/`。该目录被 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://`。
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.4.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
external: ["electron"],
|
||||
outfile: "dist/main.js",
|
||||
banner: {
|
||||
js: [
|
||||
'import { createRequire as __mdToPdfCreateRequire } from "node:module";',
|
||||
"const require = __mdToPdfCreateRequire(import.meta.url);"
|
||||
].join("\n")
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,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)
|
||||
});
|
||||
@@ -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("/")
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
type IpcMainEvent
|
||||
} from "electron";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
PDF_RUNTIME_FAIL,
|
||||
PDF_RUNTIME_READY,
|
||||
PDF_RUNTIME_RENDER
|
||||
} from "./channels.js";
|
||||
import {
|
||||
createElectronPdfPrintOptions,
|
||||
isAllowedPdfRuntimeUrl
|
||||
} from "./pdf-contract.js";
|
||||
|
||||
export const DESKTOP_PDF_PARTITION =
|
||||
"md-to-pdf-desktop-pdf";
|
||||
|
||||
export interface ElectronPdfGenerationResult {
|
||||
pdf: Buffer;
|
||||
pageCount: number;
|
||||
echartsErrors: string[];
|
||||
mermaidErrors: string[];
|
||||
timings: {
|
||||
documentRenderMs: number;
|
||||
pdfPrintMs: number;
|
||||
totalMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronPdfGeneratorOptions {
|
||||
renderUrl: string;
|
||||
preloadPath: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface PendingRender {
|
||||
requestId: number;
|
||||
resolve: (result: PagedDocumentRenderResult) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
|
||||
async function waitWithTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string
|
||||
) {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error(message)),
|
||||
timeoutMs
|
||||
);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronPdfGenerator {
|
||||
private readonly renderUrl: string;
|
||||
private readonly preloadPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
private windowPromise: Promise<BrowserWindow> | undefined;
|
||||
private renderWindow: BrowserWindow | undefined;
|
||||
private pendingRender: PendingRender | undefined;
|
||||
private nextRequestId = 1;
|
||||
private queue = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(options: ElectronPdfGeneratorOptions) {
|
||||
this.renderUrl = options.renderUrl;
|
||||
this.preloadPath = options.preloadPath;
|
||||
this.timeoutMs = options.timeoutMs ?? 60_000;
|
||||
|
||||
ipcMain.on(PDF_RUNTIME_COMPLETE, this.handleRenderComplete);
|
||||
ipcMain.on(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||
}
|
||||
|
||||
generate(payload: PagedDocumentPayload) {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||
}
|
||||
|
||||
const operation = this.queue.then(() => this.generateNow(payload));
|
||||
this.queue = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
ipcMain.removeListener(
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
this.handleRenderComplete
|
||||
);
|
||||
ipcMain.removeListener(PDF_RUNTIME_FAIL, this.handleRenderFailure);
|
||||
this.pendingRender?.reject(new Error("桌面 PDF 引擎已关闭"));
|
||||
this.pendingRender = undefined;
|
||||
|
||||
const windowPromise = this.windowPromise;
|
||||
this.windowPromise = undefined;
|
||||
this.renderWindow = undefined;
|
||||
if (!windowPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const window = await windowPromise;
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
} catch {
|
||||
// 创建失败时没有需要关闭的窗口。
|
||||
}
|
||||
}
|
||||
|
||||
private async generateNow(payload: PagedDocumentPayload) {
|
||||
const startedAt = performance.now();
|
||||
const window = await this.getWindow();
|
||||
const documentRenderStartedAt = performance.now();
|
||||
const renderResult = await this.requestRender(window, payload);
|
||||
const documentRenderMs =
|
||||
performance.now() - documentRenderStartedAt;
|
||||
await window.webContents.executeJavaScript(
|
||||
"new Promise((resolve) => setTimeout(resolve, 0))"
|
||||
);
|
||||
|
||||
const pdfPrintStartedAt = performance.now();
|
||||
window.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
let pdf: Buffer;
|
||||
try {
|
||||
pdf = await waitWithTimeout(
|
||||
window.webContents.printToPDF(
|
||||
createElectronPdfPrintOptions(payload, renderResult)
|
||||
),
|
||||
this.timeoutMs,
|
||||
`桌面 PDF 打印超过 ${this.timeoutMs}ms`
|
||||
);
|
||||
} catch (error) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
|
||||
|
||||
return {
|
||||
pdf,
|
||||
pageCount: renderResult.pageCount,
|
||||
echartsErrors: renderResult.echartsErrors,
|
||||
mermaidErrors: renderResult.mermaidErrors,
|
||||
timings: {
|
||||
documentRenderMs,
|
||||
pdfPrintMs,
|
||||
totalMs: performance.now() - startedAt
|
||||
}
|
||||
} satisfies ElectronPdfGenerationResult;
|
||||
}
|
||||
|
||||
private getWindow() {
|
||||
if (!this.windowPromise) {
|
||||
this.windowPromise = this.createWindow().catch((error) => {
|
||||
this.windowPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return this.windowPromise;
|
||||
}
|
||||
|
||||
private async createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
focusable: false,
|
||||
opacity: 0,
|
||||
skipTaskbar: true,
|
||||
x: -32_000,
|
||||
y: -32_000,
|
||||
width: 1280,
|
||||
height: 900,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: DESKTOP_PDF_PARTITION,
|
||||
preload: this.preloadPath
|
||||
}
|
||||
});
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
window.webContents.on("will-navigate", (event, url) => {
|
||||
const target = new URL(url);
|
||||
const renderTarget = new URL(this.renderUrl);
|
||||
if (
|
||||
target.origin !== renderTarget.origin ||
|
||||
target.pathname !== renderTarget.pathname
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
window.webContents.on("destroyed", () => {
|
||||
if (this.windowPromise) {
|
||||
this.windowPromise = undefined;
|
||||
}
|
||||
this.renderWindow = undefined;
|
||||
this.pendingRender?.reject(
|
||||
new Error("桌面 PDF 渲染窗口已关闭")
|
||||
);
|
||||
this.pendingRender = undefined;
|
||||
});
|
||||
window.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ["*://*/*"] },
|
||||
(details, callback) => {
|
||||
callback({
|
||||
cancel: !isAllowedPdfRuntimeUrl(details.url, this.renderUrl)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("桌面 PDF 分页运行时启动超时"));
|
||||
}, this.timeoutMs);
|
||||
const handleReady = (event: IpcMainEvent) => {
|
||||
if (event.sender !== window.webContents) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
ipcMain.removeListener(PDF_RUNTIME_READY, handleReady);
|
||||
};
|
||||
ipcMain.on(PDF_RUNTIME_READY, handleReady);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([window.loadURL(this.renderUrl), ready]);
|
||||
window.webContents.setZoomFactor(1);
|
||||
this.renderWindow = window;
|
||||
return window;
|
||||
} catch (error) {
|
||||
window.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private requestRender(
|
||||
window: BrowserWindow,
|
||||
payload: PagedDocumentPayload
|
||||
) {
|
||||
if (this.pendingRender) {
|
||||
throw new Error("桌面 PDF 渲染器状态异常");
|
||||
}
|
||||
|
||||
const requestId = this.nextRequestId++;
|
||||
return new Promise<PagedDocumentRenderResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pendingRender?.requestId === requestId) {
|
||||
this.pendingRender = undefined;
|
||||
reject(new Error(`桌面 PDF 生成超过 ${this.timeoutMs}ms`));
|
||||
if (!window.isDestroyed()) {
|
||||
window.destroy();
|
||||
}
|
||||
}
|
||||
}, this.timeoutMs);
|
||||
|
||||
this.pendingRender = {
|
||||
requestId,
|
||||
resolve: (result) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
window.webContents.send(PDF_RUNTIME_RENDER, requestId, payload);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly handleRenderComplete = (
|
||||
event: IpcMainEvent,
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
) => {
|
||||
const pending = this.pendingRender;
|
||||
if (
|
||||
!pending ||
|
||||
pending.requestId !== requestId ||
|
||||
event.sender !== this.renderWindow?.webContents
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingRender = undefined;
|
||||
pending.resolve(result);
|
||||
};
|
||||
|
||||
private readonly handleRenderFailure = (
|
||||
event: IpcMainEvent,
|
||||
requestId: number,
|
||||
message: string
|
||||
) => {
|
||||
const pending = this.pendingRender;
|
||||
if (
|
||||
!pending ||
|
||||
pending.requestId !== requestId ||
|
||||
event.sender !== this.renderWindow?.webContents
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingRender = undefined;
|
||||
pending.reject(
|
||||
new Error(
|
||||
typeof message === "string" ? message : "桌面 PDF 分页失败"
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
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,
|
||||
autoHideMenuBar: true,
|
||||
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 () => {
|
||||
Menu.setApplicationMenu(null);
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||
}
|
||||
const applicationService = createDesktopApplicationService();
|
||||
await registerApplicationProtocol(applicationService);
|
||||
await createApplication(applicationService);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("桌面应用启动失败", error);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
exportConfigSchema,
|
||||
themeFeatureSchema,
|
||||
type PagedDocumentPayload,
|
||||
type PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
const MAX_ARTICLE_HTML_LENGTH = 20 * 1024 * 1024;
|
||||
const MAX_THEME_CSS_LENGTH = 10 * 1024 * 1024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readString(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
maximum: number
|
||||
) {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.length > maximum) {
|
||||
throw new Error(`${key} 无效`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown, key: string, maximum: number) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length > maximum ||
|
||||
value.some((item) => typeof item !== "string")
|
||||
) {
|
||||
throw new Error(`${key} 无效`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
export function parsePagedDocumentPayload(
|
||||
value: unknown
|
||||
): PagedDocumentPayload {
|
||||
if (!isRecord(value) || !isRecord(value.metadata)) {
|
||||
throw new Error("PDF 分页载荷无效");
|
||||
}
|
||||
|
||||
const metadata = value.metadata;
|
||||
const features = readStringArray(value.features, "features", 20);
|
||||
const parsedFeatures = features.map((feature) =>
|
||||
themeFeatureSchema.parse(feature)
|
||||
);
|
||||
|
||||
return {
|
||||
articleHtml: readString(
|
||||
value,
|
||||
"articleHtml",
|
||||
MAX_ARTICLE_HTML_LENGTH
|
||||
),
|
||||
fileName: readString(value, "fileName", 500),
|
||||
metadata: {
|
||||
title: readString(metadata, "title", 500),
|
||||
author: readString(metadata, "author", 500),
|
||||
subject: readString(metadata, "subject", 1_000),
|
||||
keywords: readStringArray(
|
||||
metadata.keywords,
|
||||
"metadata.keywords",
|
||||
100
|
||||
),
|
||||
language: readString(metadata, "language", 50)
|
||||
},
|
||||
features: parsedFeatures,
|
||||
themeCss: readString(
|
||||
value,
|
||||
"themeCss",
|
||||
MAX_THEME_CSS_LENGTH
|
||||
),
|
||||
exportConfig: exportConfigSchema.parse(value.exportConfig)
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElectronPdfPrintOptions {
|
||||
displayHeaderFooter: false;
|
||||
margins: {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
};
|
||||
pageRanges: string;
|
||||
preferCSSPageSize: true;
|
||||
printBackground: boolean;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function createElectronPdfPrintOptions(
|
||||
payload: PagedDocumentPayload,
|
||||
renderResult: PagedDocumentRenderResult
|
||||
): ElectronPdfPrintOptions {
|
||||
return {
|
||||
displayHeaderFooter: false,
|
||||
margins: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
|
||||
preferCSSPageSize: true,
|
||||
printBackground: payload.exportConfig.print.background,
|
||||
scale: payload.exportConfig.print.scale
|
||||
};
|
||||
}
|
||||
|
||||
export function isAllowedPdfRuntimeUrl(
|
||||
requestUrl: string,
|
||||
renderUrl: string
|
||||
) {
|
||||
const requested = new URL(requestUrl);
|
||||
if (["about:", "blob:", "data:"].includes(requested.protocol)) {
|
||||
return true;
|
||||
}
|
||||
if (requested.protocol === "mdpdf:" && requested.host === "theme") {
|
||||
return true;
|
||||
}
|
||||
return requested.origin === new URL(renderUrl).origin;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
contextBridge,
|
||||
ipcRenderer,
|
||||
type IpcRendererEvent
|
||||
} from "electron";
|
||||
import type {
|
||||
PagedDocumentPayload,
|
||||
PagedDocumentRenderResult
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
PDF_RUNTIME_COMPLETE,
|
||||
PDF_RUNTIME_FAIL,
|
||||
PDF_RUNTIME_READY,
|
||||
PDF_RUNTIME_RENDER
|
||||
} from "./channels.js";
|
||||
|
||||
type RenderListener = (
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => Promise<void> | void;
|
||||
|
||||
contextBridge.exposeInMainWorld("__mdToPdfDesktopPdf", {
|
||||
ready: () => ipcRenderer.send(PDF_RUNTIME_READY),
|
||||
onRender: (listener: RenderListener) => {
|
||||
const handler = (
|
||||
_event: IpcRendererEvent,
|
||||
requestId: number,
|
||||
payload: PagedDocumentPayload
|
||||
) => {
|
||||
void listener(requestId, payload);
|
||||
};
|
||||
ipcRenderer.on(PDF_RUNTIME_RENDER, handler);
|
||||
return () => ipcRenderer.removeListener(PDF_RUNTIME_RENDER, handler);
|
||||
},
|
||||
complete: (
|
||||
requestId: number,
|
||||
result: PagedDocumentRenderResult
|
||||
) => ipcRenderer.send(PDF_RUNTIME_COMPLETE, requestId, result),
|
||||
fail: (requestId: number, message: string) =>
|
||||
ipcRenderer.send(PDF_RUNTIME_FAIL, requestId, message)
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createElectronPdfPrintOptions,
|
||||
isAllowedPdfRuntimeUrl,
|
||||
parsePagedDocumentPayload
|
||||
} from "../src/pdf-contract.js";
|
||||
import {
|
||||
parseMarkdownRenderRequest,
|
||||
parseThemeId,
|
||||
parseThemeResourceUrl
|
||||
} from "../src/application-contract.js";
|
||||
|
||||
const payload = createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||
bodyHtml: "<h1>测试</h1>",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: ["mermaid"],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "测试.md",
|
||||
themeCss: "#write { color: black; }",
|
||||
exportConfig: defaultExportConfig
|
||||
});
|
||||
|
||||
describe("桌面 PDF 载荷", () => {
|
||||
it("校验并保留合法的共享分页载荷", () => {
|
||||
expect(parsePagedDocumentPayload(payload)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("拒绝无效功能标识", () => {
|
||||
expect(() =>
|
||||
parsePagedDocumentPayload({
|
||||
...payload,
|
||||
features: ["remote-script"]
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("映射为与 Playwright 对齐的 Electron 打印参数", () => {
|
||||
expect(
|
||||
createElectronPdfPrintOptions(payload, {
|
||||
pageCount: 3,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: [],
|
||||
timings: {
|
||||
setupMs: 0,
|
||||
echartsMs: 0,
|
||||
echartsFitMs: 0,
|
||||
mermaidMs: 0,
|
||||
mermaidFitMs: 0,
|
||||
mermaidConversionMs: 0,
|
||||
resourceWaitMs: 0,
|
||||
paginationMs: 0,
|
||||
finalizeMs: 0,
|
||||
totalMs: 0
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
displayHeaderFooter: false,
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
pageRanges: "1-3",
|
||||
preferCSSPageSize: true,
|
||||
printBackground: true,
|
||||
scale: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面 PDF 网络边界", () => {
|
||||
const renderUrl =
|
||||
"http://localhost:5173/preview-frame.html?target=pdf";
|
||||
|
||||
it("允许同源和内嵌资源", () => {
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"http://localhost:5173/assets/runtime.js",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isAllowedPdfRuntimeUrl("data:image/png;base64,AA", renderUrl))
|
||||
.toBe(true);
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl(
|
||||
"mdpdf://theme/typora-like/font.woff2",
|
||||
renderUrl
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝外部网络资源", () => {
|
||||
expect(
|
||||
isAllowedPdfRuntimeUrl("https://example.com/image.png", renderUrl)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("桌面应用服务 IPC 载荷", () => {
|
||||
it("只提取 Markdown 渲染所需字段", () => {
|
||||
expect(
|
||||
parseMarkdownRenderRequest({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN",
|
||||
ignored: true
|
||||
})
|
||||
).toEqual({
|
||||
markdown: "# 测试",
|
||||
language: "zh-CN"
|
||||
});
|
||||
});
|
||||
|
||||
it("校验主题 ID 与主题资源地址", () => {
|
||||
expect(parseThemeId("typora-like")).toBe("typora-like");
|
||||
expect(
|
||||
parseThemeResourceUrl(
|
||||
"mdpdf://theme/typora-like/fonts/test.woff2"
|
||||
)
|
||||
).toEqual({
|
||||
themeId: "typora-like",
|
||||
assetPath: "fonts/test.woff2"
|
||||
});
|
||||
expect(() => parseThemeId("../outside")).toThrow("主题 ID 无效");
|
||||
expect(
|
||||
parseThemeResourceUrl("mdpdf://bundle/index.html")
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0",
|
||||
"fastify": "^5.6.2",
|
||||
|
||||
@@ -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) {
|
||||
@@ -167,9 +109,11 @@ function createPdfServerTiming(
|
||||
export function buildApp(options: BuildAppOptions = {}) {
|
||||
const app = Fastify({
|
||||
logger: options.logger ?? true,
|
||||
bodyLimit: 2 * 1024 * 1024
|
||||
bodyLimit: 24 * 1024 * 1024
|
||||
});
|
||||
const themes = createThemeRegistry({
|
||||
const applicationService =
|
||||
options.applicationService ??
|
||||
createApplicationService({
|
||||
bundledRoot: resolve(projectRoot, "themes"),
|
||||
localRoot:
|
||||
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"),
|
||||
@@ -213,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",
|
||||
@@ -248,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["*"]
|
||||
);
|
||||
@@ -277,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
|
||||
});
|
||||
}
|
||||
|
||||
const rendered = renderMarkdownRequest(
|
||||
validated.markdown,
|
||||
validated.language
|
||||
);
|
||||
if (!rendered.success) {
|
||||
return reply.code(400).send({
|
||||
error: rendered.error,
|
||||
message: rendered.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return rendered.document;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -303,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
|
||||
);
|
||||
@@ -333,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({
|
||||
@@ -343,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
|
||||
});
|
||||
}
|
||||
const rendered = renderedResult.document;
|
||||
throw error;
|
||||
}
|
||||
const markdownMs = performance.now() - markdownStartedAt;
|
||||
|
||||
try {
|
||||
const generated = await pdfGenerator.generate(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createPdfContentDisposition,
|
||||
createPdfFileName
|
||||
} from "../src/app.js";
|
||||
import { createThemeRegistry } from "../src/theme-registry.js";
|
||||
import { createThemeRegistry } from "@md-to-pdf/application";
|
||||
import {
|
||||
PdfEngineOverloadedError,
|
||||
type PdfGenerator
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/apple-touch-icon.png"
|
||||
sizes="180x180"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: blob: mdpdf:; connect-src 'self' ws://localhost:5173; frame-src 'self' blob:; worker-src 'self' blob:;"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
|
||||
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data: mdpdf:; img-src 'self' data: mdpdf:; connect-src 'self';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
|
||||
@@ -37,15 +37,20 @@ import {
|
||||
downloadPdf,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
requestDesktopPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "./pdf-export";
|
||||
import { APP_VERSION_LABEL } from "./app-version";
|
||||
import { getSyncedScrollTop } from "./scroll-sync";
|
||||
import {
|
||||
loadPreviewZoom,
|
||||
savePreviewZoom
|
||||
} from "./preview-zoom";
|
||||
import { useMarkdownRender } from "./use-markdown-render";
|
||||
import {
|
||||
type MarkdownImageResource
|
||||
} from "./application-backend";
|
||||
import {
|
||||
type ThemeSummary,
|
||||
useThemeResources
|
||||
@@ -57,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 示例
|
||||
@@ -84,6 +113,19 @@ keywords:
|
||||
|
||||
行内公式:$E = mc^2$
|
||||
|
||||
## 网络图片
|
||||
|
||||
### 中等尺寸
|
||||
|
||||

|
||||
|
||||
### 超高尺寸
|
||||
|
||||
下图用于验证图片高度超过单页内容区时,会等比例缩放并作为完整块放置,
|
||||
不会跨页切割。
|
||||
|
||||

|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["Markdown"] --> B["统一 HTML"]
|
||||
@@ -94,9 +136,11 @@ flowchart LR
|
||||
|
||||
## ECharts 图表
|
||||
|
||||
### 基础柱状图
|
||||
|
||||
\`\`\`echarts
|
||||
caption: 各模块文档数量
|
||||
height: 72mm
|
||||
height: 58mm
|
||||
option:
|
||||
xAxis:
|
||||
type: category
|
||||
@@ -108,12 +152,243 @@ option:
|
||||
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);
|
||||
@@ -175,6 +450,7 @@ export function App() {
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
markdown,
|
||||
"zh-CN",
|
||||
imageResources,
|
||||
{
|
||||
onRenderStart: handleRenderStart,
|
||||
onStatusChange: setStatus
|
||||
@@ -237,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),
|
||||
@@ -581,6 +858,8 @@ export function App() {
|
||||
setAppError("");
|
||||
setMarkdown(await file.text());
|
||||
setFileName(file.name);
|
||||
setImageResources([]);
|
||||
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
|
||||
} catch {
|
||||
setAppError("无法读取所选 Markdown 文件");
|
||||
} finally {
|
||||
@@ -588,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;
|
||||
@@ -602,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");
|
||||
@@ -611,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
|
||||
@@ -663,7 +986,10 @@ export function App() {
|
||||
<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>
|
||||
@@ -678,6 +1004,15 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
{window.mdToPdfDesktop ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDesktopOpenMarkdown()}
|
||||
>
|
||||
打开 Markdown
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
<input
|
||||
@@ -686,6 +1021,19 @@ export function App() {
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const APP_VERSION = __APP_VERSION__;
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} 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,
|
||||
@@ -216,7 +217,6 @@ export class PagedDocumentRuntime {
|
||||
const { renderEChartsBlocks } = await this.loadECharts();
|
||||
const outcomes = await renderEChartsBlocks(container, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5_000
|
||||
});
|
||||
@@ -327,7 +327,8 @@ export class PagedDocumentRuntime {
|
||||
let mermaidConversionMs = 0;
|
||||
if (
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg")
|
||||
content.querySelector(".mermaid svg") ||
|
||||
content.querySelector("img.md-document-image")
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
documentRef,
|
||||
@@ -345,6 +346,10 @@ export class PagedDocumentRuntime {
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
fitDocumentImagesToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
|
||||
@@ -87,6 +87,30 @@ window.__mdToPdfRender = async (
|
||||
};
|
||||
document.documentElement.dataset.runtimeReady = "true";
|
||||
|
||||
const desktopPdfBridge = window.__mdToPdfDesktopPdf;
|
||||
if (desktopPdfBridge) {
|
||||
desktopPdfBridge.onRender(async (requestId, payload) => {
|
||||
const requestAnimationFrame = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (callback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0);
|
||||
try {
|
||||
const result = await window.__mdToPdfRender?.(payload, "pdf");
|
||||
if (!result) {
|
||||
throw new Error("PDF 分页运行时不可用");
|
||||
}
|
||||
desktopPdfBridge.complete(requestId, result);
|
||||
} catch (reason: unknown) {
|
||||
desktopPdfBridge.fail(
|
||||
requestId,
|
||||
reason instanceof Error ? reason.message : "PDF 分页失败"
|
||||
);
|
||||
} finally {
|
||||
window.requestAnimationFrame = requestAnimationFrame;
|
||||
}
|
||||
});
|
||||
desktopPdfBridge.ready();
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
|
||||
@@ -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;
|
||||
@@ -313,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
|
||||
request.markdown,
|
||||
request.fileName,
|
||||
request.language,
|
||||
request.resources,
|
||||
request.exportConfig
|
||||
]);
|
||||
}
|
||||
@@ -129,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;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ button:disabled {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.topbar-brand-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #c8d5cf;
|
||||
border-radius: 999px;
|
||||
background: #edf3f0;
|
||||
color: #527061;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.035em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1029,6 +1047,12 @@ textarea:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.topbar-brand-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.settings-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.2");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.2");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from "vitest";
|
||||
import {
|
||||
createPagedDocumentPayload,
|
||||
defaultExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
downloadPdf,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
parseContentDispositionFileName,
|
||||
requestDesktopPdfExport,
|
||||
requestPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "../src/pdf-export";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PDF 导出客户端", () => {
|
||||
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
|
||||
const request: PdfExportRequest = {
|
||||
@@ -178,4 +193,49 @@ describe("PDF 导出客户端", () => {
|
||||
)
|
||||
).rejects.toThrow("PDF 生成队列已满");
|
||||
});
|
||||
|
||||
it("桌面端使用共享分页载荷生成并保存 PDF", async () => {
|
||||
const payload = createPagedDocumentPayload({
|
||||
document: {
|
||||
rendererVersion: 1,
|
||||
articleHtml: '<article id="write">测试</article>',
|
||||
bodyHtml: "测试",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
warnings: []
|
||||
},
|
||||
fileName: "测试.md",
|
||||
themeCss: "",
|
||||
exportConfig: defaultExportConfig
|
||||
});
|
||||
const generatePdf = vi.fn(async () => ({
|
||||
pdf: new TextEncoder().encode("%PDF-desktop"),
|
||||
pageCount: 2,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: ["图表 1"]
|
||||
}));
|
||||
const savePdf = vi.fn(async () => true);
|
||||
vi.stubGlobal("window", {
|
||||
mdToPdfDesktop: { generatePdf, savePdf }
|
||||
});
|
||||
|
||||
const generated = await requestDesktopPdfExport(payload);
|
||||
const saved = await downloadPdf(generated.blob, generated.fileName);
|
||||
|
||||
expect(generatePdf).toHaveBeenCalledWith(payload);
|
||||
expect(generated.fileName).toBe("测试.pdf");
|
||||
expect(generated.pageCount).toBe(2);
|
||||
expect(generated.mermaidErrorCount).toBe(1);
|
||||
expect(savePdf).toHaveBeenCalledWith(
|
||||
"测试.pdf",
|
||||
expect.any(Uint8Array)
|
||||
);
|
||||
expect(saved).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootPackage = JSON.parse(
|
||||
readFileSync(
|
||||
fileURLToPath(new URL("../../package.json", import.meta.url)),
|
||||
"utf8"
|
||||
)
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
publicDir: fileURLToPath(
|
||||
new URL("../../logos/web", import.meta.url)
|
||||
),
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(rootPackage.version)
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
||||
@@ -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,6 +11,7 @@ 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
|
||||
@@ -16,7 +19,13 @@ 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
|
||||
|
||||
@@ -27,21 +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 \
|
||||
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
|
||||
&& useradd --system --create-home --gid pwuser --shell /bin/bash pwuser; \
|
||||
fi
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -49,16 +65,23 @@ 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
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
.github
|
||||
.local
|
||||
.vscode
|
||||
apps/desktop
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
out
|
||||
output
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
@@ -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 任务数 |
|
||||
|
||||
@@ -5,6 +5,9 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
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:
|
||||
|
||||
@@ -31,8 +31,12 @@ fa07472 feat: 实现网页实时预览
|
||||
ec48bce chore: 初始化项目骨架
|
||||
```
|
||||
|
||||
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
|
||||
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
|
||||
`v0.4.1` 图片资源阶段已经完成:Web 素材目录、Desktop 同目录资源、
|
||||
受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
|
||||
统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。
|
||||
|
||||
`v0.4.2` 为仅面向桌面端的维护版本:移除 Windows 原生应用菜单栏;
|
||||
Web/Compose 发布仍保持 `v0.4.1`。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
@@ -78,7 +82,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 渲染核心
|
||||
|
||||
@@ -294,6 +301,113 @@ ec48bce chore: 初始化项目骨架
|
||||
- 快速预览的页码识别和跳转会合并外层滚动位置、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 Manifest,Electron 使用 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 MiB;SHA-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 超高竖图,直接覆盖正常图片
|
||||
和超高图片分页场景。
|
||||
|
||||
### 3.15 v0.4.2 桌面窗口精简
|
||||
|
||||
- Electron 主进程不再创建默认应用菜单,移除窗口顶部的
|
||||
`File / Edit / View / Window` 原生菜单栏。
|
||||
- 应用窗口同时启用菜单栏自动隐藏,避免 Windows 恢复默认菜单。
|
||||
- 该维护版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 镜像继续
|
||||
使用已验证的 `v0.4.1`。
|
||||
|
||||
## 4. 已执行验证
|
||||
|
||||
2026-07-27 在当前完整工作区成功执行:
|
||||
@@ -307,15 +421,35 @@ git diff --check
|
||||
|
||||
结果:
|
||||
|
||||
- ECharts 围栏测试:27 项通过;
|
||||
- ECharts 围栏测试:36 项通过;
|
||||
- 共享配置测试:9 项通过;
|
||||
- 渲染器测试:10 项通过;
|
||||
- 前端测试:63 项通过;
|
||||
- 应用服务测试: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 PDF,Mermaid 和
|
||||
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。
|
||||
|
||||
已使用浏览器插件完成视觉验证:
|
||||
|
||||
- 默认示例中的 ECharts 柱状图生成 1 个内联 SVG,无错误占位;
|
||||
@@ -356,6 +490,25 @@ git diff --check
|
||||
- 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` 构建
|
||||
@@ -393,7 +546,71 @@ 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 KiB,ECharts 和 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`。
|
||||
|
||||
2026-07-27 已完成 `v0.4.2` 桌面维护版本验证:
|
||||
|
||||
- 仅重新构建桌面端及其内嵌 Web 静态资源,没有重新构建或替换
|
||||
Web/Compose 发布镜像。
|
||||
- Windows 目录版视觉检查通过:默认原生菜单栏已经移除,应用内容直接
|
||||
位于系统标题栏下方,版本徽标显示 `v0.4.2`。
|
||||
- Windows 主程序继续使用英文文件名 `md-to-pdf.exe`。
|
||||
- Squirrel 安装包为 `Markdown PDF 导出器-0.4.2 Setup.exe`,
|
||||
144,166,400 字节,SHA-256 为
|
||||
`59DA57522C99EB6B16A9591AFDEC349B785BF9B76950504B314273520152D41B`,
|
||||
Authenticode 状态为未签名。
|
||||
|
||||
## 5. 当前注意事项
|
||||
|
||||
@@ -421,27 +638,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、容器或发布包。
|
||||
- Typora 复制主题按公司内部私有项目决策内置,不面向外部公开或商用;
|
||||
未来改变分发范围前必须重新完成许可证审查。
|
||||
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
||||
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
|
||||
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
|
||||
- AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证。
|
||||
- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
|
||||
目录读取;远程图片仅保留有界内存缓存。
|
||||
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
||||
导出,不依赖 Fastify 或本地 HTTP Server。
|
||||
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
|
||||
当前 Compose 容器保持运行。
|
||||
|
||||
## 6. 推荐接手顺序
|
||||
|
||||
### 阶段一:资源与安全
|
||||
|
||||
- Markdown 文件夹或 ZIP;
|
||||
- 相对图片解析;
|
||||
- 每请求独立临时目录;
|
||||
- 路径穿越和符号链接越界防护;
|
||||
- 文件数量、单文件大小、总大小和超时限制;
|
||||
- 请求结束可靠清理。
|
||||
|
||||
### 阶段二:导出配置扩展
|
||||
### 阶段一:导出配置扩展
|
||||
|
||||
- Front Matter 元数据覆盖;
|
||||
- 浏览器本地配置预设;
|
||||
|
||||
@@ -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` 通过。
|
||||
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -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 |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 330 B |
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 591 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
||||
"workspaces": [
|
||||
@@ -8,16 +8,20 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"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/web && npm run build -w @md-to-pdf/server",
|
||||
"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 && concurrently -k -n markdown-echarts,core,renderer,server,web \"npm:dev:markdown-echarts\" \"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/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/web && npm run test -w @md-to-pdf/server",
|
||||
"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/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"
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
""
|
||||
},
|
||||
{ localRoot: assetRoot }
|
||||
);
|
||||
const uploaded = await service.render({
|
||||
markdown: "",
|
||||
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(
|
||||
"\n",
|
||||
undefined
|
||||
);
|
||||
const failed = await resolve(
|
||||
"",
|
||||
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("网络不可用");
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -67,7 +67,7 @@ option:
|
||||
|
||||
## 系列验证规则
|
||||
|
||||
当前首版支持以下系列:
|
||||
当前支持以下系列:
|
||||
|
||||
| 系列 | 主要规则 |
|
||||
| --- | --- |
|
||||
@@ -79,6 +79,15 @@ option:
|
||||
| `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
|
||||
使用自身的轴默认值。极坐标、雷达和日历坐标系表达了明确语义,
|
||||
@@ -87,6 +96,111 @@ option:
|
||||
使用 `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。对应围栏会变成局部错误占位:
|
||||
@@ -133,7 +247,6 @@ import "@md-to-pdf/markdown-echarts/styles.css";
|
||||
|
||||
const outcomes = await renderEChartsBlocks(document, {
|
||||
outputMode: "inline-svg",
|
||||
animation: false,
|
||||
concurrency: 2,
|
||||
timeoutMs: 5000
|
||||
});
|
||||
@@ -148,6 +261,9 @@ const outcomes = await renderEChartsBlocks(document, {
|
||||
PDF 输出前应等待所有图表、字体和图片完成,再开始分页或调用
|
||||
Chromium `page.pdf()`。
|
||||
|
||||
浏览器引擎会强制关闭全局及各系列动画,Markdown 中的动画参数不会改变
|
||||
静态 SVG、快速预览、精确预览或 PDF,避免输出停留在动画中间帧。
|
||||
|
||||
## 安全边界
|
||||
|
||||
默认策略会拒绝:
|
||||
@@ -158,7 +274,8 @@ Chromium `page.pdf()`。
|
||||
- 原型污染属性;
|
||||
- 未允许的 toolbox 功能;
|
||||
- 非内置数据转换;
|
||||
- 超过节点数、深度、字符串、数组和系列数量上限的配置。
|
||||
- 超过节点数、深度、字符串、数组、系列、层级节点或关系边数量
|
||||
上限的配置。
|
||||
|
||||
这些限制适用于不受信任的 Markdown。宿主如需扩大能力,应在明确
|
||||
风险后扩展验证器,而不是直接执行 YAML 中的 JavaScript。
|
||||
|
||||
@@ -3,11 +3,20 @@ import {
|
||||
BarChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
ChordChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
GraphChart,
|
||||
HeatmapChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
PictorialBarChart,
|
||||
RadarChart,
|
||||
ScatterChart
|
||||
SankeyChart,
|
||||
ScatterChart,
|
||||
SunburstChart,
|
||||
TreeChart,
|
||||
TreemapChart
|
||||
} from "echarts/charts";
|
||||
import {
|
||||
AriaComponent,
|
||||
@@ -44,6 +53,15 @@ echarts.use([
|
||||
HeatmapChart,
|
||||
BoxplotChart,
|
||||
CandlestickChart,
|
||||
TreeChart,
|
||||
TreemapChart,
|
||||
SunburstChart,
|
||||
GraphChart,
|
||||
SankeyChart,
|
||||
ChordChart,
|
||||
FunnelChart,
|
||||
GaugeChart,
|
||||
PictorialBarChart,
|
||||
GridComponent,
|
||||
PolarComponent,
|
||||
RadarComponent,
|
||||
|
||||
@@ -267,15 +267,28 @@ export async function renderEChartsBlock(
|
||||
useDirtyRect: false
|
||||
});
|
||||
|
||||
const shouldAnimate =
|
||||
options.animation ??
|
||||
(outputMode === "interactive"
|
||||
? definition.option.animation
|
||||
: 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:
|
||||
typeof shouldAnimate === "boolean" ? shouldAnimate : false
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0,
|
||||
series
|
||||
};
|
||||
await waitForRender(
|
||||
instance,
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface RenderEChartsBlockOptions {
|
||||
outputMode?: EChartsOutputMode;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
animation?: boolean;
|
||||
resolveTheme?: (
|
||||
theme: NormalizedEChartsFenceSpec["theme"],
|
||||
element: HTMLElement
|
||||
|
||||
@@ -11,7 +11,16 @@ export const supportedEChartsSeriesTypes = [
|
||||
"radar",
|
||||
"heatmap",
|
||||
"boxplot",
|
||||
"candlestick"
|
||||
"candlestick",
|
||||
"tree",
|
||||
"treemap",
|
||||
"sunburst",
|
||||
"graph",
|
||||
"sankey",
|
||||
"chord",
|
||||
"funnel",
|
||||
"gauge",
|
||||
"pictorialBar"
|
||||
] as const;
|
||||
|
||||
export type SupportedEChartsSeriesType =
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface EChartsSecurityLimits {
|
||||
maxArrayLength: number;
|
||||
maxStringLength: number;
|
||||
maxSeries: number;
|
||||
maxHierarchyNodes: number;
|
||||
maxGraphNodes: number;
|
||||
maxGraphEdges: number;
|
||||
}
|
||||
|
||||
export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
||||
@@ -19,7 +22,10 @@ export const defaultEChartsSecurityLimits: EChartsSecurityLimits = {
|
||||
maxObjectKeys: 1_000,
|
||||
maxArrayLength: 20_000,
|
||||
maxStringLength: 100_000,
|
||||
maxSeries: 32
|
||||
maxSeries: 32,
|
||||
maxHierarchyNodes: 5_000,
|
||||
maxGraphNodes: 5_000,
|
||||
maxGraphEdges: 10_000
|
||||
};
|
||||
|
||||
const forbiddenPropertyNames = new Set([
|
||||
@@ -257,6 +263,86 @@ function assertSupportedSeries(
|
||||
`option.series.${index}.type 暂不支持 ${String(type)}`
|
||||
);
|
||||
}
|
||||
assertSeriesStructureLimits(item, index, type, limits);
|
||||
}
|
||||
}
|
||||
|
||||
function countHierarchyNodes(
|
||||
value: JsonValue | undefined,
|
||||
limit: number,
|
||||
path: string
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
let count = 0;
|
||||
const pending = [...value];
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop();
|
||||
count += 1;
|
||||
if (count > limit) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${path} 层级节点数量不能超过 ${limit}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
node !== null &&
|
||||
!Array.isArray(node) &&
|
||||
typeof node === "object" &&
|
||||
Array.isArray(node.children)
|
||||
) {
|
||||
pending.push(...node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function arrayLength(value: JsonValue | undefined) {
|
||||
return Array.isArray(value) ? value.length : 0;
|
||||
}
|
||||
|
||||
function assertSeriesStructureLimits(
|
||||
series: Record<string, JsonValue>,
|
||||
seriesIndex: number,
|
||||
type: string,
|
||||
limits: EChartsSecurityLimits
|
||||
) {
|
||||
const path = `option.series.${seriesIndex}`;
|
||||
if (
|
||||
type === "tree" ||
|
||||
type === "treemap" ||
|
||||
type === "sunburst"
|
||||
) {
|
||||
countHierarchyNodes(
|
||||
series.data,
|
||||
limits.maxHierarchyNodes,
|
||||
`${path}.data`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type !== "graph" &&
|
||||
type !== "sankey" &&
|
||||
type !== "chord"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeCount =
|
||||
arrayLength(series.data) + arrayLength(series.nodes);
|
||||
if (nodeCount > limits.maxGraphNodes) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${path} 节点数量不能超过 ${limits.maxGraphNodes}`
|
||||
);
|
||||
}
|
||||
const edgeCount =
|
||||
arrayLength(series.links) + arrayLength(series.edges);
|
||||
if (edgeCount > limits.maxGraphEdges) {
|
||||
throw new EChartsFenceError(
|
||||
"LIMIT_EXCEEDED",
|
||||
`${path} 边数量不能超过 ${limits.maxGraphEdges}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +333,326 @@ function validateCandlestick(
|
||||
);
|
||||
}
|
||||
|
||||
function requireDirectArray(
|
||||
context: EChartsSeriesValidationContext,
|
||||
key: "data" | "nodes" | "links" | "edges",
|
||||
description: string
|
||||
): JsonValue[] {
|
||||
const value = context.series[key];
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.${key}`,
|
||||
`必须是${description}`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateHierarchyNodes(
|
||||
context: EChartsSeriesValidationContext,
|
||||
requireLeafValue: boolean
|
||||
) {
|
||||
const roots = requireDirectArray(
|
||||
context,
|
||||
"data",
|
||||
"非空层级节点数组"
|
||||
);
|
||||
const pending = roots.map((node, index) => ({
|
||||
node,
|
||||
path: `option.series.${context.seriesIndex}.data.${index}`
|
||||
}));
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
if (!isObject(current.node)) {
|
||||
fail(current.path, "必须是层级节点对象");
|
||||
}
|
||||
const children = current.node.children;
|
||||
if (children !== undefined && !Array.isArray(children)) {
|
||||
fail(`${current.path}.children`, "必须是数组");
|
||||
}
|
||||
const childNodes = Array.isArray(children) ? children : [];
|
||||
childNodes.forEach((node, index) =>
|
||||
pending.push({
|
||||
node,
|
||||
path: `${current.path}.children.${index}`
|
||||
})
|
||||
);
|
||||
|
||||
if (!requireLeafValue || childNodes.length > 0) {
|
||||
continue;
|
||||
}
|
||||
const value = current.node.value;
|
||||
const valid =
|
||||
typeof value === "number"
|
||||
? Number.isFinite(value)
|
||||
: Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item) =>
|
||||
typeof item === "number" && Number.isFinite(item)
|
||||
);
|
||||
if (!valid) {
|
||||
fail(
|
||||
`${current.path}.value`,
|
||||
"必须是有限数值或非空有限数值数组"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateTree(context: EChartsSeriesValidationContext) {
|
||||
validateHierarchyNodes(context, false);
|
||||
}
|
||||
|
||||
function validateTreemapOrSunburst(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
validateHierarchyNodes(context, true);
|
||||
}
|
||||
|
||||
function relationNodes(
|
||||
context: EChartsSeriesValidationContext
|
||||
): { values: JsonValue[]; key: "data" | "nodes" } {
|
||||
if (
|
||||
Array.isArray(context.series.data) &&
|
||||
context.series.data.length > 0
|
||||
) {
|
||||
return { values: context.series.data, key: "data" };
|
||||
}
|
||||
return {
|
||||
values: requireDirectArray(
|
||||
context,
|
||||
"nodes",
|
||||
"非空节点数组"
|
||||
),
|
||||
key: "nodes"
|
||||
};
|
||||
}
|
||||
|
||||
function relationEdges(
|
||||
context: EChartsSeriesValidationContext,
|
||||
required: boolean
|
||||
): { values: JsonValue[]; key: "links" | "edges" } {
|
||||
if (
|
||||
Array.isArray(context.series.links) &&
|
||||
context.series.links.length > 0
|
||||
) {
|
||||
return { values: context.series.links, key: "links" };
|
||||
}
|
||||
if (
|
||||
Array.isArray(context.series.edges) &&
|
||||
context.series.edges.length > 0
|
||||
) {
|
||||
return { values: context.series.edges, key: "edges" };
|
||||
}
|
||||
if (required) {
|
||||
return {
|
||||
values: requireDirectArray(
|
||||
context,
|
||||
"links",
|
||||
"非空关系边数组"
|
||||
),
|
||||
key: "links"
|
||||
};
|
||||
}
|
||||
return { values: [], key: "links" };
|
||||
}
|
||||
|
||||
function nodeIdentifiers(node: JsonValue, index: number) {
|
||||
const identifiers = new Set<string>([`#${index}`]);
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
identifiers.add(String(node));
|
||||
return identifiers;
|
||||
}
|
||||
if (!isObject(node)) {
|
||||
return identifiers;
|
||||
}
|
||||
if (
|
||||
typeof node.id === "string" ||
|
||||
typeof node.id === "number"
|
||||
) {
|
||||
identifiers.add(String(node.id));
|
||||
}
|
||||
if (
|
||||
typeof node.name === "string" ||
|
||||
typeof node.name === "number"
|
||||
) {
|
||||
identifiers.add(String(node.name));
|
||||
}
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
function edgeEndpointExists(
|
||||
value: JsonValue | undefined,
|
||||
identifiers: Set<string>,
|
||||
nodeCount: number
|
||||
) {
|
||||
if (
|
||||
typeof value !== "string" &&
|
||||
typeof value !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 0 &&
|
||||
value < nodeCount
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return identifiers.has(String(value));
|
||||
}
|
||||
|
||||
function validateRelationSeries(
|
||||
context: EChartsSeriesValidationContext,
|
||||
options: {
|
||||
requireEdges: boolean;
|
||||
requireEdgeValue: boolean;
|
||||
defaultGraphLayout?: boolean;
|
||||
}
|
||||
) {
|
||||
const nodes = relationNodes(context);
|
||||
const identifiers = new Set<string>();
|
||||
nodes.values.forEach((node, index) => {
|
||||
if (
|
||||
node === null ||
|
||||
Array.isArray(node) ||
|
||||
(typeof node !== "object" &&
|
||||
typeof node !== "string" &&
|
||||
typeof node !== "number")
|
||||
) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.${nodes.key}.${index}`,
|
||||
"必须是节点对象、名称或数值"
|
||||
);
|
||||
}
|
||||
nodeIdentifiers(node, index).forEach((identifier) =>
|
||||
identifiers.add(identifier)
|
||||
);
|
||||
});
|
||||
|
||||
if (
|
||||
options.defaultGraphLayout &&
|
||||
context.series.layout === undefined
|
||||
) {
|
||||
const hasCoordinates = nodes.values.every(
|
||||
(node) =>
|
||||
isObject(node) &&
|
||||
typeof node.x === "number" &&
|
||||
Number.isFinite(node.x) &&
|
||||
typeof node.y === "number" &&
|
||||
Number.isFinite(node.y)
|
||||
);
|
||||
context.series.layout = hasCoordinates ? "none" : "force";
|
||||
}
|
||||
|
||||
const edges = relationEdges(context, options.requireEdges);
|
||||
edges.values.forEach((edge, edgeIndex) => {
|
||||
const path = `option.series.${context.seriesIndex}.${edges.key}.${edgeIndex}`;
|
||||
if (!isObject(edge)) {
|
||||
fail(path, "必须是关系边对象");
|
||||
}
|
||||
if (
|
||||
!edgeEndpointExists(
|
||||
edge.source,
|
||||
identifiers,
|
||||
nodes.values.length
|
||||
)
|
||||
) {
|
||||
fail(`${path}.source`, "必须引用已存在的节点");
|
||||
}
|
||||
if (
|
||||
!edgeEndpointExists(
|
||||
edge.target,
|
||||
identifiers,
|
||||
nodes.values.length
|
||||
)
|
||||
) {
|
||||
fail(`${path}.target`, "必须引用已存在的节点");
|
||||
}
|
||||
if (options.requireEdgeValue || edge.value !== undefined) {
|
||||
if (
|
||||
typeof edge.value !== "number" ||
|
||||
!Number.isFinite(edge.value) ||
|
||||
edge.value < 0
|
||||
) {
|
||||
fail(`${path}.value`, "必须是非负有限数值");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateGraph(context: EChartsSeriesValidationContext) {
|
||||
const coordinate = context.series.coordinateSystem;
|
||||
if (coordinate !== undefined && coordinate !== "none") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${String(coordinate)}`
|
||||
);
|
||||
}
|
||||
validateRelationSeries(context, {
|
||||
requireEdges: false,
|
||||
requireEdgeValue: false,
|
||||
defaultGraphLayout: true
|
||||
});
|
||||
}
|
||||
|
||||
function validateSankey(context: EChartsSeriesValidationContext) {
|
||||
const coordinate = context.series.coordinateSystem;
|
||||
if (coordinate !== undefined && coordinate !== "view") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${String(coordinate)}`
|
||||
);
|
||||
}
|
||||
validateRelationSeries(context, {
|
||||
requireEdges: true,
|
||||
requireEdgeValue: true
|
||||
});
|
||||
}
|
||||
|
||||
function validateChord(context: EChartsSeriesValidationContext) {
|
||||
const coordinate = context.series.coordinateSystem;
|
||||
if (coordinate !== undefined && coordinate !== "none") {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.coordinateSystem`,
|
||||
`暂不支持 ${String(coordinate)}`
|
||||
);
|
||||
}
|
||||
validateRelationSeries(context, {
|
||||
requireEdges: true,
|
||||
requireEdgeValue: true
|
||||
});
|
||||
}
|
||||
|
||||
function validateFiniteValueData(
|
||||
context: EChartsSeriesValidationContext
|
||||
) {
|
||||
requireData(context);
|
||||
if (context.hasDataset) {
|
||||
return;
|
||||
}
|
||||
for (const [dataIndex, item] of dataValues(
|
||||
context.series
|
||||
).entries()) {
|
||||
const value = itemValue(item);
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isFinite(value)
|
||||
) {
|
||||
fail(
|
||||
`option.series.${context.seriesIndex}.data.${dataIndex}.value`,
|
||||
"必须是有限数值"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const echartsSeriesValidators: ReadonlyMap<
|
||||
SupportedEChartsSeriesType,
|
||||
EChartsSeriesValidator
|
||||
@@ -344,7 +664,16 @@ export const echartsSeriesValidators: ReadonlyMap<
|
||||
["radar", validateRadar],
|
||||
["heatmap", validateHeatmap],
|
||||
["boxplot", validateBoxplot],
|
||||
["candlestick", validateCandlestick]
|
||||
["candlestick", validateCandlestick],
|
||||
["tree", validateTree],
|
||||
["treemap", validateTreemapOrSunburst],
|
||||
["sunburst", validateTreemapOrSunburst],
|
||||
["graph", validateGraph],
|
||||
["sankey", validateSankey],
|
||||
["chord", validateChord],
|
||||
["funnel", validateFiniteValueData],
|
||||
["gauge", validateFiniteValueData],
|
||||
["pictorialBar", validateLineOrBar]
|
||||
]);
|
||||
|
||||
export function validateAndApplyEChartsOptionDefaults(
|
||||
|
||||
@@ -37,12 +37,14 @@ function fakeEngine(
|
||||
) {
|
||||
const dispose = vi.fn();
|
||||
const resize = vi.fn();
|
||||
const setOption = vi.fn();
|
||||
const init = vi.fn(
|
||||
(container: HTMLElement): EChartsInstanceLike => {
|
||||
dispose.mockImplementation(() => container.replaceChildren());
|
||||
const handlers = new Map<string, () => void>();
|
||||
return {
|
||||
setOption() {
|
||||
setOption(option, setOptionOptions) {
|
||||
setOption(option, setOptionOptions);
|
||||
const svg = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"svg"
|
||||
@@ -73,7 +75,8 @@ function fakeEngine(
|
||||
engine: { init } satisfies EChartsEngine,
|
||||
init,
|
||||
dispose,
|
||||
resize
|
||||
resize,
|
||||
setOption
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +106,36 @@ describe("浏览器 ECharts 渲染", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("全局及系列级动画始终关闭", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine, setOption } = fakeEngine();
|
||||
|
||||
await renderEChartsBlocks(document, {
|
||||
engine,
|
||||
outputMode: "interactive"
|
||||
});
|
||||
|
||||
expect(setOption).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0,
|
||||
series: [
|
||||
expect.objectContaining({
|
||||
animation: false,
|
||||
animationDelay: 0,
|
||||
animationDelayUpdate: 0,
|
||||
animationDuration: 0,
|
||||
animationDurationUpdate: 0
|
||||
})
|
||||
]
|
||||
}),
|
||||
{ notMerge: true }
|
||||
);
|
||||
});
|
||||
|
||||
it("生成 SVG Data URL 图片", async () => {
|
||||
createDocumentWithChart();
|
||||
const { engine } = fakeEngine();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultEChartsSecurityLimits,
|
||||
normalizeAndValidateEChartsOption
|
||||
} from "../src/index.js";
|
||||
|
||||
@@ -47,6 +48,73 @@ describe("normalizeAndValidateEChartsOption", () => {
|
||||
).toThrow(/暂不支持 custom/u);
|
||||
});
|
||||
|
||||
it("接受第二阶段系列和安全的内嵌矢量路径", () => {
|
||||
const types = [
|
||||
"tree",
|
||||
"treemap",
|
||||
"sunburst",
|
||||
"graph",
|
||||
"sankey",
|
||||
"chord",
|
||||
"funnel",
|
||||
"gauge",
|
||||
"pictorialBar"
|
||||
];
|
||||
expect(
|
||||
normalizeAndValidateEChartsOption({
|
||||
series: types.map((type) => ({
|
||||
type,
|
||||
...(type === "pictorialBar"
|
||||
? { symbol: "path://M0,0 L10,0 L10,10 Z" }
|
||||
: {})
|
||||
}))
|
||||
}).series
|
||||
).toHaveLength(types.length);
|
||||
});
|
||||
|
||||
it("限制层级节点和关系边数量", () => {
|
||||
const limits = {
|
||||
...defaultEChartsSecurityLimits,
|
||||
maxHierarchyNodes: 2,
|
||||
maxGraphEdges: 1
|
||||
};
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
type: "tree",
|
||||
data: [
|
||||
{
|
||||
children: [{ value: 1 }, { value: 2 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
limits
|
||||
)
|
||||
).toThrow(/层级节点数量不能超过 2/u);
|
||||
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "B", target: "A" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
limits
|
||||
)
|
||||
).toThrow(/边数量不能超过 1/u);
|
||||
});
|
||||
|
||||
it("拒绝外部资源和危险链接", () => {
|
||||
expect(() =>
|
||||
normalizeAndValidateEChartsOption({
|
||||
|
||||
@@ -182,4 +182,184 @@ describe("ECharts 系列语义验证", () => {
|
||||
})
|
||||
).toThrow(/option\.polar/u);
|
||||
});
|
||||
|
||||
it("支持树图并校验递归 children 结构", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "tree",
|
||||
data: [
|
||||
{
|
||||
name: "根节点",
|
||||
children: [{ name: "子节点" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
series: [{ type: "tree" }]
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "tree",
|
||||
data: [{ name: "根节点", children: "错误" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/data\.0\.children 必须是数组/u);
|
||||
});
|
||||
|
||||
it("矩形树图和旭日图要求叶节点数值", () => {
|
||||
for (const type of ["treemap", "sunburst"]) {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [
|
||||
{
|
||||
name: "分类",
|
||||
children: [{ name: "项目", value: 12 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ series: [{ type }] });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "无数值叶节点" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/data\.0\.value 必须是有限数值/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("关系图默认选择可渲染布局并校验边引用", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [{ source: "A", target: "B" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
series: [{ type: "graph", layout: "force" }]
|
||||
});
|
||||
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
data: [
|
||||
{ name: "A", x: 10, y: 20 },
|
||||
{ name: "B", x: 30, y: 40 }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
series: [{ type: "graph", layout: "none" }]
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "graph",
|
||||
nodes: [{ name: "A" }],
|
||||
edges: [{ source: "A", target: "B" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/edges\.0\.target 必须引用已存在的节点/u);
|
||||
});
|
||||
|
||||
it("桑基图和和弦图要求有效节点、关系边和权重", () => {
|
||||
for (const type of ["sankey", "chord"]) {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [
|
||||
{ source: "A", target: "B", value: 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ series: [{ type }] });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "A" }, { name: "B" }],
|
||||
links: [{ source: "A", target: "B" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/links\.0\.value 必须是非负有限数值/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("漏斗图和仪表盘要求有限数值", () => {
|
||||
for (const type of ["funnel", "gauge"]) {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "完成率", value: 72 }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ series: [{ type }] });
|
||||
|
||||
expect(() =>
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type,
|
||||
data: [{ name: "完成率", value: "72" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/data\.0\.value 必须是有限数值/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("象形柱图复用柱图数据和坐标轴默认值", () => {
|
||||
expect(
|
||||
validate({
|
||||
series: [
|
||||
{
|
||||
type: "pictorialBar",
|
||||
symbol: "path://M0,0 L10,0 L10,10 Z",
|
||||
data: [12, 18]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
xAxis: {},
|
||||
yAxis: {},
|
||||
series: [{ type: "pictorialBar" }]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
|
||||
|
||||
export interface RenderMarkdownOptions {
|
||||
language?: string;
|
||||
imageSourceMap?: ReadonlyMap<string, string>;
|
||||
warnings?: readonly string[];
|
||||
}
|
||||
|
||||
export interface RenderedMarkdown
|
||||
@@ -84,9 +86,83 @@ const markdown = new MarkdownIt(markdownOptions)
|
||||
});
|
||||
|
||||
const defaultFenceRenderer = markdown.renderer.rules.fence;
|
||||
const defaultImageRenderer = markdown.renderer.rules.image;
|
||||
const defaultParagraphOpenRenderer =
|
||||
markdown.renderer.rules.paragraph_open;
|
||||
const defaultParagraphCloseRenderer =
|
||||
markdown.renderer.rules.paragraph_close;
|
||||
const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u;
|
||||
const tableTextAlignStylePattern = /^(?:left|center|right)$/u;
|
||||
|
||||
interface MarkdownRenderEnvironment {
|
||||
warnings?: string[];
|
||||
imageSourceMap?: ReadonlyMap<string, string>;
|
||||
standaloneImage?: boolean;
|
||||
}
|
||||
|
||||
function getStandaloneImage(
|
||||
token: Token | undefined
|
||||
): Token | undefined {
|
||||
if (token?.type !== "inline") {
|
||||
return undefined;
|
||||
}
|
||||
const meaningful = (token.children ?? []).filter(
|
||||
(child) =>
|
||||
child.type !== "text" || child.content.trim().length > 0
|
||||
);
|
||||
return meaningful.length === 1 && meaningful[0]?.type === "image"
|
||||
? meaningful[0]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
markdown.renderer.rules.paragraph_open = (
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
) => {
|
||||
const standaloneImage = getStandaloneImage(tokens[index + 1]);
|
||||
(environment as MarkdownRenderEnvironment).standaloneImage =
|
||||
Boolean(standaloneImage);
|
||||
if (standaloneImage) {
|
||||
return '<figure class="md-document-image-block">\n';
|
||||
}
|
||||
return defaultParagraphOpenRenderer
|
||||
? defaultParagraphOpenRenderer(
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
)
|
||||
: renderer.renderToken(tokens, index, options);
|
||||
};
|
||||
|
||||
markdown.renderer.rules.paragraph_close = (
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
) => {
|
||||
const renderEnvironment =
|
||||
environment as MarkdownRenderEnvironment;
|
||||
if (renderEnvironment.standaloneImage) {
|
||||
renderEnvironment.standaloneImage = false;
|
||||
return "</figure>\n";
|
||||
}
|
||||
return defaultParagraphCloseRenderer
|
||||
? defaultParagraphCloseRenderer(
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
)
|
||||
: renderer.renderToken(tokens, index, options);
|
||||
};
|
||||
|
||||
markdown.renderer.rules.fence = (
|
||||
tokens,
|
||||
index,
|
||||
@@ -114,6 +190,46 @@ markdown.renderer.rules.fence = (
|
||||
return renderFallbackFence(tokens, index, renderer);
|
||||
};
|
||||
|
||||
markdown.renderer.rules.image = (
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
) => {
|
||||
const token = tokens[index];
|
||||
const renderEnvironment =
|
||||
environment as MarkdownRenderEnvironment;
|
||||
const imageSourceMap = renderEnvironment.imageSourceMap;
|
||||
const source = token?.attrGet("src");
|
||||
const resolvedSource = source
|
||||
? imageSourceMap?.get(source)
|
||||
: undefined;
|
||||
if (token) {
|
||||
if (resolvedSource) {
|
||||
token.attrSet("src", resolvedSource);
|
||||
}
|
||||
token.attrJoin("class", "md-document-image");
|
||||
}
|
||||
|
||||
const imageHtml = defaultImageRenderer
|
||||
? defaultImageRenderer(
|
||||
tokens,
|
||||
index,
|
||||
options,
|
||||
environment,
|
||||
renderer
|
||||
)
|
||||
: renderer.renderToken(tokens, index, options);
|
||||
if (renderEnvironment.standaloneImage && token) {
|
||||
return (
|
||||
imageHtml +
|
||||
`<figcaption class="md-document-image-caption">${markdown.utils.escapeHtml(token.content)}</figcaption>`
|
||||
);
|
||||
}
|
||||
return imageHtml;
|
||||
};
|
||||
|
||||
markdown.use(markdownItECharts, {
|
||||
onError(error, context: MarkdownItEChartsErrorContext) {
|
||||
const environment = context.environment as
|
||||
@@ -193,10 +309,11 @@ export function renderMarkdown(
|
||||
source: string,
|
||||
options: RenderMarkdownOptions = {}
|
||||
): RenderedMarkdown {
|
||||
const warnings: string[] = [];
|
||||
const warnings = [...(options.warnings ?? [])];
|
||||
const parsed = parseFrontMatter(source);
|
||||
const environment = {
|
||||
warnings
|
||||
warnings,
|
||||
imageSourceMap: options.imageSourceMap
|
||||
};
|
||||
const rendered = markdown.render(parsed.content, environment);
|
||||
const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions);
|
||||
@@ -217,6 +334,27 @@ export function renderMarkdown(
|
||||
};
|
||||
}
|
||||
|
||||
function collectImageSources(tokens: readonly Token[], sources: Set<string>) {
|
||||
for (const token of tokens) {
|
||||
if (token.type === "image") {
|
||||
const source = token.attrGet("src");
|
||||
if (source) {
|
||||
sources.add(source);
|
||||
}
|
||||
}
|
||||
if (token.children) {
|
||||
collectImageSources(token.children, sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractMarkdownImageSources(source: string) {
|
||||
const parsed = parseFrontMatter(source);
|
||||
const sources = new Set<string>();
|
||||
collectImageSources(markdown.parse(parsed.content, {}), sources);
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
function parseFrontMatter(source: string) {
|
||||
try {
|
||||
return matter(source);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractMarkdownImageSources,
|
||||
MarkdownDocumentParseError,
|
||||
renderMarkdown
|
||||
} from "../src/render-markdown.js";
|
||||
@@ -210,6 +211,55 @@ option:
|
||||
expect(result.bodyHtml).toContain("<script>");
|
||||
});
|
||||
|
||||
it("提取图片引用并使用受控资源地址替换", () => {
|
||||
const source =
|
||||
"\n\n";
|
||||
expect(extractMarkdownImageSources(source)).toEqual([
|
||||
"./%E6%96%87%E6%A1%A3.assets/a%20b.png",
|
||||
"https://example.com/a.png"
|
||||
]);
|
||||
|
||||
const result = renderMarkdown(source, {
|
||||
imageSourceMap: new Map([
|
||||
[
|
||||
"./%E6%96%87%E6%A1%A3.assets/a%20b.png",
|
||||
"data:image/png;base64,iVBORw0KGgo="
|
||||
]
|
||||
]),
|
||||
warnings: ["远程图片下载失败"]
|
||||
});
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
'class="md-document-image"'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<figure class="md-document-image-block">'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<figcaption class="md-document-image-caption">本地</figcaption>'
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'src="data:image/png;base64,iVBORw0KGgo="'
|
||||
);
|
||||
expect(result.warnings).toContain("远程图片下载失败");
|
||||
});
|
||||
|
||||
it("只为独立图片块生成可见标题,行内图片保持段落语义", () => {
|
||||
const result = renderMarkdown(
|
||||
"文字  继续\n\n"
|
||||
);
|
||||
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<p>文字 <img src="inline.png" alt="行内图" class="md-document-image" /> 继续</p>'
|
||||
);
|
||||
expect(result.bodyHtml).not.toContain(
|
||||
"md-document-image-caption\">行内图"
|
||||
);
|
||||
expect(result.bodyHtml).toContain(
|
||||
'<figcaption class="md-document-image-caption">独立标题</figcaption>'
|
||||
);
|
||||
});
|
||||
|
||||
it("为重复中文标题生成唯一锚点", () => {
|
||||
const result = renderMarkdown(`
|
||||
# 章节
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 内部主题资源说明
|
||||
|
||||
`typora-github`、`typora-pixyll` 和 `typora-whitey` 来自公司内部已安装的
|
||||
Typora 1.14.7,仅用于本项目的公司内部私有部署。
|
||||
|
||||
- 不对外发布、销售或宣称为项目原创;
|
||||
- 保留主题清单中的原作者和来源信息;
|
||||
- Pixyll 的上游项目采用 MIT License;
|
||||
- 若项目未来转为公开发布,必须先移除这些目录或重新完成分发授权审查。
|
||||
@@ -0,0 +1,416 @@
|
||||
:root {
|
||||
--side-bar-bg-color: #fafafa;
|
||||
--control-text-color: #777;
|
||||
}
|
||||
|
||||
@include-when-export url(https://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,700,400&subset=latin,latin-ext);
|
||||
|
||||
/* open-sans-regular - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: local('Open Sans Regular'), local('OpenSans-Regular'), url('./github/open-sans-v17-latin-ext_latin-regular.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* open-sans-italic - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
src: local('Open Sans Italic'), local('OpenSans-Italic'), url('./github/open-sans-v17-latin-ext_latin-italic.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* open-sans-700 - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: local('Open Sans Bold'), local('OpenSans-Bold'), url('./github/open-sans-v17-latin-ext_latin-700.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* open-sans-700italic - latin-ext_latin */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), url('./github/open-sans-v17-latin-ext_latin-700italic.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD, U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Open Sans","Clear Sans", "Helvetica Neue", Helvetica, Arial, 'Segoe UI Emoji', 'SF Pro', sans-serif;
|
||||
color: rgb(51, 51, 51);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#write {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1400px) {
|
||||
#write {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1800px) {
|
||||
#write {
|
||||
max-width: 1200px;
|
||||
}
|
||||
}
|
||||
|
||||
#write > ul:first-child,
|
||||
#write > ol:first-child{
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #4183C4;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
position: relative;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.4;
|
||||
cursor: text;
|
||||
}
|
||||
h1:hover a.anchor,
|
||||
h2:hover a.anchor,
|
||||
h3:hover a.anchor,
|
||||
h4:hover a.anchor,
|
||||
h5:hover a.anchor,
|
||||
h6:hover a.anchor {
|
||||
text-decoration: none;
|
||||
}
|
||||
h1 tt,
|
||||
h1 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h2 tt,
|
||||
h2 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h3 tt,
|
||||
h3 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h4 tt,
|
||||
h4 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h5 tt,
|
||||
h5 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h6 tt,
|
||||
h6 code {
|
||||
font-size: inherit;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25em;
|
||||
line-height: 1.2;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.75em;
|
||||
line-height: 1.225;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
/*@media print {
|
||||
.typora-export h1,
|
||||
.typora-export h2 {
|
||||
border-bottom: none;
|
||||
padding-bottom: initial;
|
||||
}
|
||||
|
||||
.typora-export h1::after,
|
||||
.typora-export h2::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 100px;
|
||||
margin-top: -96px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
}*/
|
||||
|
||||
h3 {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.43;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1em;
|
||||
}
|
||||
h6 {
|
||||
font-size: 1em;
|
||||
color: #777;
|
||||
}
|
||||
p,
|
||||
blockquote,
|
||||
ul,
|
||||
ol,
|
||||
dl,
|
||||
table{
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
li>ol,
|
||||
li>ul {
|
||||
margin: 0 0;
|
||||
}
|
||||
hr {
|
||||
height: 2px;
|
||||
padding: 0;
|
||||
margin: 16px 0;
|
||||
background-color: #e7e7e7;
|
||||
border: 0 none;
|
||||
overflow: hidden;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
li p.first {
|
||||
display: inline-block;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 30px;
|
||||
}
|
||||
ul:first-child,
|
||||
ol:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
ul:last-child,
|
||||
ol:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #dfe2e5;
|
||||
padding: 0 15px;
|
||||
color: #777777;
|
||||
}
|
||||
blockquote blockquote {
|
||||
padding-right: 0;
|
||||
}
|
||||
table {
|
||||
padding: 0;
|
||||
word-break: initial;
|
||||
}
|
||||
table tr {
|
||||
border: 1px solid #dfe2e5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
table tr:nth-child(2n),
|
||||
thead {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
table th {
|
||||
font-weight: bold;
|
||||
border: 1px solid #dfe2e5;
|
||||
border-bottom: 0;
|
||||
margin: 0;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
table td {
|
||||
border: 1px solid #dfe2e5;
|
||||
margin: 0;
|
||||
padding: 6px 13px;
|
||||
}
|
||||
table th:first-child,
|
||||
table td:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
table th:last-child,
|
||||
table td:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.CodeMirror-lines {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.code-tooltip {
|
||||
box-shadow: 0 1px 1px 0 rgba(0,28,36,.3);
|
||||
border-top: 1px solid #eef2f2;
|
||||
}
|
||||
|
||||
.md-fences,
|
||||
code,
|
||||
tt {
|
||||
border: 1px solid #e7eaed;
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
padding: 2px 4px 0px 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #f3f4f4;
|
||||
padding: 0 2px 0 2px;
|
||||
}
|
||||
|
||||
.md-fences {
|
||||
margin-bottom: 15px;
|
||||
margin-top: 15px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
|
||||
.md-task-list-item > input {
|
||||
margin-left: -1.3em;
|
||||
}
|
||||
|
||||
@media print {
|
||||
html {
|
||||
font-size: 13px;
|
||||
}
|
||||
pre {
|
||||
page-break-inside: avoid;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.md-fences {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
#write pre.md-meta-block {
|
||||
padding: 1rem;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f7f7f7;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: #777777;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mathjax-block>.code-tooltip {
|
||||
bottom: .375rem;
|
||||
}
|
||||
|
||||
.md-mathjax-midline {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
#write>h3.md-focus:before{
|
||||
left: -1.5625rem;
|
||||
top: .375rem;
|
||||
}
|
||||
#write>h4.md-focus:before{
|
||||
left: -1.5625rem;
|
||||
top: .285714286rem;
|
||||
}
|
||||
#write>h5.md-focus:before{
|
||||
left: -1.5625rem;
|
||||
top: .285714286rem;
|
||||
}
|
||||
#write>h6.md-focus:before{
|
||||
left: -1.5625rem;
|
||||
top: .285714286rem;
|
||||
}
|
||||
.md-image>.md-meta {
|
||||
/*border: 1px solid #ddd;*/
|
||||
border-radius: 3px;
|
||||
padding: 2px 0px 0px 4px;
|
||||
font-size: 0.9em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.md-tag {
|
||||
color: #a7a7a7;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.md-toc {
|
||||
margin-top:20px;
|
||||
padding-bottom:20px;
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#typora-quick-open {
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
#typora-quick-open-item {
|
||||
background-color: #FAFAFA;
|
||||
border-color: #FEFEFE #e5e5e5 #e5e5e5 #eee;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
/** focus mode */
|
||||
.on-focus-mode blockquote {
|
||||
border-left-color: rgba(85, 85, 85, 0.12);
|
||||
}
|
||||
|
||||
header, .context-menu, .megamenu-content, footer{
|
||||
font-family: "Segoe UI", "Arial", sans-serif;
|
||||
}
|
||||
|
||||
.file-node-content:hover .file-node-icon,
|
||||
.file-node-content:hover .file-node-open-state{
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mac-seamless-mode #typora-sidebar {
|
||||
background-color: #fafafa;
|
||||
background-color: var(--side-bar-bg-color);
|
||||
}
|
||||
|
||||
.mac-os #write{
|
||||
caret-color: AccentColor;
|
||||
}
|
||||
|
||||
.md-lang {
|
||||
color: #b4654d;
|
||||
}
|
||||
|
||||
/*.html-for-mac {
|
||||
--item-hover-bg-color: #E6F0FE;
|
||||
}*/
|
||||
|
||||
#md-notification .btn {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dropdown-menu .divider {
|
||||
border-color: #e5e5e5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ty-preferences .window-content {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.ty-preferences .nav-group-item.active {
|
||||
color: white;
|
||||
background: #999;
|
||||
}
|
||||
|
||||
.menu-item-container a.menu-style-btn {
|
||||
background-color: #f5f8fa;
|
||||
background-image: linear-gradient( 180deg , hsla(0, 0%, 100%, 0.8), hsla(0, 0%, 100%, 0));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "typora-github",
|
||||
"name": "Typora 默认(GitHub)",
|
||||
"version": "1.14.7",
|
||||
"description": "从 Typora 1.14.7 导入,供公司内部私有部署使用。",
|
||||
"author": "Typora",
|
||||
"license": "公司内部使用,不对外分发",
|
||||
"base": "typora-base.css",
|
||||
"entry": "github.css",
|
||||
"domPreset": "typora",
|
||||
"defaultFontSize": "16px",
|
||||
"supportedFeatures": [
|
||||
"code",
|
||||
"table",
|
||||
"task-list",
|
||||
"footnote",
|
||||
"katex",
|
||||
"mermaid"
|
||||
],
|
||||
"bundled": true
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
@include-when-export url(https://fonts.googleapis.com/css?family=Merriweather:900,900italic,300,300italic&subset=latin-ext);
|
||||
@include-when-export url(https://fonts.googleapis.com/css?family=Lato:900,300&subset=latin-ext);
|
||||
|
||||
:root {
|
||||
--control-text-color: #777;
|
||||
}
|
||||
|
||||
/**
|
||||
* forked from pixyll.com
|
||||
* MIT license
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Merriweather';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: local('Merriweather Light'), local('Merriweather-Light'), url('./pixyll/merriweather-v19-latin-300.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Merriweather';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: local('Merriweather Heavy'), local('Merriweather-Heavy'), url('./pixyll/merriweather-v19-latin-700.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Merriweather';
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
src: local('Merriweather Light Italic'), local('Merriweather-LightItalic'), url('./pixyll/merriweather-v19-latin-300italic.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Merriweather';
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
src: local('Merriweather Heavy Italic'), local('Merriweather-HeavyItalic'), url('./pixyll/merriweather-v19-latin-700italic.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: local('Lato Light'), local('Lato-Light'), url('./pixyll/lato-v14-latin-300.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: local('Lato Black'), local('Lato-Blcak'), url('./pixyll/lato-v14-latin-900.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
src: local('Lato LightItalic'), local('Lato-LightItalic'), url('./pixyll/lato-v14-latin-300italic.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
src: local('Lato BlackItalic'), local('Lato-BlackItalic'), url('./pixyll/lato-v14-latin-900italic.woff') format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
h1,
|
||||
.h1,
|
||||
.f1 {
|
||||
font-size: 2rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
h2,
|
||||
.h2,
|
||||
.f2 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
h3,
|
||||
.h3,
|
||||
.f3 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
p,
|
||||
.p,
|
||||
.f4,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
dl,
|
||||
ol,
|
||||
ul,
|
||||
pre[cid],
|
||||
div[cid],
|
||||
#typora-source {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.13rem;
|
||||
}
|
||||
/*
|
||||
Pixyll
|
||||
A simple, beautiful theme for Jekyll that emphasizes content rather than aesthetic fluff.
|
||||
Best served with BASSCSS (http://jxnblk.github.io/basscss)
|
||||
Crafted with <3 by John Otander (@4lpine) - ©2015 John Otander MIT License http://opensource.org/licenses/MIT
|
||||
|
||||
*/
|
||||
|
||||
body {
|
||||
font-family: "Merriweather", "PT Serif", Georgia, "Times New Roman", "STSong", 'Segoe UI Emoji','SF Pro', Serif;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#write {
|
||||
max-width: 914px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1400px) {
|
||||
#write {
|
||||
max-width: 1100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1700px) {
|
||||
#write {
|
||||
max-width: 1200px;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
body {
|
||||
font-size: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ty-table-edit {
|
||||
background: #ededed;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
table > thead > tr > th,
|
||||
table > thead > tr > td,
|
||||
table > tbody > tr > th,
|
||||
table > tbody > tr > td,
|
||||
table > tfoot > tr > th,
|
||||
table > tfoot > tr > td {
|
||||
padding: 12px;
|
||||
line-height: 1.2;
|
||||
vertical-align: top;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
table > thead > tr > th {
|
||||
vertical-align: bottom;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
table > caption + thead > tr:first-child > th,
|
||||
table > caption + thead > tr:first-child > td,
|
||||
table > colgroup + thead > tr:first-child > th,
|
||||
table > colgroup + thead > tr:first-child > td,
|
||||
table > thead:first-child > tr:first-child > th,
|
||||
table > thead:first-child > tr:first-child > td {
|
||||
border-top: 0;
|
||||
}
|
||||
table > tbody + tbody {
|
||||
border-top: 2px solid #333;
|
||||
}
|
||||
p {
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
}
|
||||
abbr {
|
||||
border-bottom: 1px black dotted;
|
||||
cursor: help;
|
||||
}
|
||||
pre,
|
||||
code {
|
||||
font-family: Menlo, Monaco, "Courier New", monospace;
|
||||
}
|
||||
|
||||
code,
|
||||
.md-fences {
|
||||
color: #7a7a7a;
|
||||
}
|
||||
|
||||
.md-fences {
|
||||
padding: 1.125em;
|
||||
margin-bottom: 0.88em;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #7a7a7a;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 1.33em;
|
||||
font-style: italic;
|
||||
border-left: 5px solid #7a7a7a;
|
||||
color: #555;
|
||||
}
|
||||
blockquote em {
|
||||
color: #000;
|
||||
}
|
||||
blockquote footer {
|
||||
font-size: .85rem;
|
||||
font-style: normal;
|
||||
background-color: #fff;
|
||||
color: #7a7a7a;
|
||||
border-color: transparent;
|
||||
}
|
||||
h1,
|
||||
.h1,
|
||||
h2,
|
||||
.h2,
|
||||
h3,
|
||||
.h3,
|
||||
h4,
|
||||
.h4,
|
||||
h5,
|
||||
.h5,
|
||||
h6,
|
||||
.h6 {
|
||||
font-family: "Lato", 'Helvetica Neue', Helvetica, sans-serif;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
@media screen and (min-width: 48em) {
|
||||
.h1,
|
||||
h1 {
|
||||
font-size: 3.250rem;
|
||||
}
|
||||
.h2,
|
||||
h2 {
|
||||
font-size: 2.298rem;
|
||||
}
|
||||
.h3,
|
||||
h3 {
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
.h4,
|
||||
h4 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
#write>h4.md-focus:before,
|
||||
#write>h5.md-focus:before,
|
||||
#write>h6.md-focus:before{
|
||||
top: 1px;
|
||||
}
|
||||
.p,
|
||||
p,
|
||||
li {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
table {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 48em) {
|
||||
blockquote {
|
||||
margin-left: 1rem;
|
||||
margin-right: 0;
|
||||
padding: 0.5em;
|
||||
}
|
||||
.h1,
|
||||
h1 {
|
||||
font-size: 2.827rem;
|
||||
}
|
||||
.h2,
|
||||
h2 {
|
||||
font-size: 1.999rem;
|
||||
}
|
||||
.h3,
|
||||
h3 {
|
||||
font-size: 1.413rem;
|
||||
}
|
||||
.h4,
|
||||
h4 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 64em) {
|
||||
.h1,
|
||||
h1 {
|
||||
font-size: 4.498rem;
|
||||
}
|
||||
.h2,
|
||||
h2 {
|
||||
font-size: 2.29rem;
|
||||
}
|
||||
.h3,
|
||||
h3 {
|
||||
font-size: 1.9rem;
|
||||
}
|
||||
.h4,
|
||||
h4 {
|
||||
font-size: 1.591rem;
|
||||
}
|
||||
#write>h4.md-focus:before{
|
||||
top:4px;
|
||||
}
|
||||
}
|
||||
a {
|
||||
color: #463F5C;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#write {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
#write pre.md-meta-block {
|
||||
min-height: 35px;
|
||||
padding: 0.5em 1em;
|
||||
white-space: pre;
|
||||
border: 0px;
|
||||
|
||||
border-left: 30px #f8f8f8 solid;
|
||||
border-right: 30px #f8f8f8 solid;
|
||||
width: 100vw;
|
||||
max-width: calc(100% + 60px);
|
||||
|
||||
margin-left: -30px;
|
||||
margin-bottom: 2em;
|
||||
margin-top: -2010px;
|
||||
padding-top: 2000px;
|
||||
padding-bottom: 10px;
|
||||
line-height: 1.5em;
|
||||
color: #7a7a7a;
|
||||
background-color: #fafafa;
|
||||
font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
|
||||
font-weight: 300;
|
||||
clear: both;
|
||||
padding-left: 0;
|
||||
font-size:1.125rem;
|
||||
}
|
||||
.md-image>.md-meta {
|
||||
color: #463F5C
|
||||
}
|
||||
.footnotes {
|
||||
font-size:1.1rem;
|
||||
}
|
||||
.md-tag {
|
||||
font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
|
||||
}
|
||||
.code-tooltip {
|
||||
background: white;
|
||||
}
|
||||
.code-tooltip-content {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.task-list{
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.md-task-list-item {
|
||||
padding-left:34px;
|
||||
}
|
||||
|
||||
.md-task-list-item > input{
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: block;
|
||||
-webkit-appearance: initial;
|
||||
top: -0.2rem;
|
||||
margin-left: -1.6em;
|
||||
margin-top: calc(1rem - 7px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.md-task-list-item > input:focus{
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.md-task-list-item > input:before{
|
||||
border: 1px solid #555;
|
||||
border-radius: 1.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
background: #fff;
|
||||
content: ' ';
|
||||
transition: background-color 200ms ease-in-out;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-task-list-item > input:checked:before,
|
||||
.md-task-list-item > input[checked]:before{
|
||||
background: #333;
|
||||
border-width: 2px;
|
||||
display:inline-block;
|
||||
transition: background-color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.md-task-list-item > input:checked:after,
|
||||
.md-task-list-item > input[checked]:after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.md-task-list-item > input:after {
|
||||
opacity: 1;
|
||||
-webkit-transition: opacity 0.05s ease-in-out;
|
||||
-moz-transition: opacity 0.05s ease-in-out;
|
||||
transition: opacity 0.05s ease-in-out;
|
||||
-webkit-transform: rotate(-45deg);
|
||||
-moz-transform: rotate(-45deg);
|
||||
transform: rotate(-45deg);
|
||||
position: absolute;
|
||||
top: 0.4375rem;
|
||||
left: 0.28125rem;
|
||||
width: 0.9375rem;
|
||||
height: 0.5rem;
|
||||
border: 3px solid #fff;
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
content: ' ';
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.md-tag {
|
||||
color:inherit;
|
||||
}
|
||||
|
||||
.md-toc:focus .md-toc-content{
|
||||
margin-top: 19px;
|
||||
}
|
||||
|
||||
#typora-sidebar {
|
||||
font-size:1rem !important;
|
||||
}
|
||||
|
||||
.html-for-mac #typora-sidebar {
|
||||
background-color:white;
|
||||
}
|
||||
|
||||
.outline-content li, .outline-content ul {
|
||||
font-size:1rem !important;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
line-height: inherit;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.outline-expander {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.outline-expander:before {
|
||||
content: "+";
|
||||
font-family: inherit;
|
||||
color: rgb(108, 108, 108);
|
||||
font-size: 1.5rem;
|
||||
top: -0.1rem;
|
||||
}
|
||||
|
||||
.outline-expander:hover:before {
|
||||
content: "+";
|
||||
}
|
||||
|
||||
.outline-item-open>.outline-item>.outline-expander:before{
|
||||
content: "-";
|
||||
}
|
||||
|
||||
/** source code mode */
|
||||
#typora-source {
|
||||
font-family: Courier, monospace;
|
||||
color: #6A6A6A;
|
||||
}
|
||||
|
||||
.os-windows #typora-source {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cm-s-typora-default .cm-header,
|
||||
.cm-s-typora-default .cm-property,
|
||||
.CodeMirror.cm-s-typora-default div.CodeMirror-cursor {
|
||||
color: #428bca;
|
||||
}
|
||||
|
||||
.cm-s-typora-default .cm-atom, .cm-s-typora-default .cm-number {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
.md-diagram-panel {
|
||||
margin-top: 24px;
|
||||
margin-left: -1.2em;
|
||||
}
|
||||
|
||||
.md-mathjax-midline {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.enable-diagrams pre.md-fences[lang="sequence"] .code-tooltip,
|
||||
.enable-diagrams pre.md-fences[lang="flow"] .code-tooltip,
|
||||
.enable-diagrams pre.md-fences[lang="mermaid"] .code-tooltip {
|
||||
bottom: -3.4em;
|
||||
}
|
||||
|
||||
.dropdown-menu .divider {
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
li > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||