Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac54ce46be | ||
|
|
06407650e0 | ||
|
|
8d91d08809 | ||
|
|
32a37293d0 |
@@ -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
|
||||
|
||||
## 隐私原则
|
||||
|
||||
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
||||
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。
|
||||
文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
|
||||
相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程
|
||||
地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。
|
||||
|
||||
+22
-7
@@ -1,8 +1,9 @@
|
||||
# Desktop 桌面端
|
||||
|
||||
`apps/desktop` 是 `v0.4.0` 新增的 Electron 桌面应用。当前已经完成
|
||||
Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron
|
||||
Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。
|
||||
Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择、
|
||||
同目录图片解析、隐藏分页窗口、Electron Chromium PDF 输出、原生 PDF
|
||||
另存为和 Windows 安装包验证。
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -25,10 +26,18 @@ npm run desktop:package
|
||||
npm run make -w @md-to-pdf/desktop
|
||||
```
|
||||
|
||||
版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到
|
||||
`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以
|
||||
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名,
|
||||
Windows 首次运行可能显示“未知发布者”提示。
|
||||
版本化目录包、NSIS `Setup.exe` 和 ZIP 输出到
|
||||
`apps/desktop/out/v0.4.4/`。该目录被 Git 忽略。公司内部分发以
|
||||
`md-to-pdf-0.4.4-x86_64-Setup.exe` 为正式安装包,ZIP 作为免安装
|
||||
辅助包;当前未配置代码签名,Windows 首次运行可能显示“未知发布者”
|
||||
提示。
|
||||
|
||||
NSIS 使用标准辅助安装模式,支持选择当前用户/所有用户安装和自定义安装
|
||||
目录,并创建桌面及开始菜单快捷方式。正式安装包作为 Gitea Release 附件
|
||||
发布,不提交进 Git 历史。
|
||||
|
||||
桌面和开始菜单快捷方式由 `build/installer.nsh` 显式创建,安装完成后的
|
||||
“运行”操作直接启动 `md-to-pdf.exe`,不依赖快捷方式文件。
|
||||
|
||||
产品名称、窗口标题、安装包名称和开始菜单名称保持为
|
||||
“Markdown PDF 导出器”,实际 Windows 主程序与进程文件固定为
|
||||
@@ -38,11 +47,17 @@ Windows 首次运行可能显示“未知发布者”提示。
|
||||
Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产环境通过
|
||||
`mdpdf://bundle/` 自定义协议加载。
|
||||
|
||||
发行包只保留 Electron 的简体中文和英文语言包,其他 Chromium 运行时
|
||||
组件保持完整,以兼顾安装体积和不同 Windows 图形环境下的运行可靠性。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 应用窗口和 PDF 窗口均关闭 Node Integration;
|
||||
- 启用 Context Isolation 和 Chromium Sandbox;
|
||||
- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口;
|
||||
- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
|
||||
的窄接口;
|
||||
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
|
||||
链接越界;
|
||||
- 主进程校验 IPC 发送者和分页载荷;
|
||||
- PDF 窗口使用独立内存 Session,并阻止外部网络资源;
|
||||
- 应用页面使用自定义协议,不使用 `file://`。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
!macro customInstall
|
||||
CreateDirectory "$SMPROGRAMS\Markdown PDF 导出器"
|
||||
CreateShortCut \
|
||||
"$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk" \
|
||||
"$INSTDIR\md-to-pdf.exe" \
|
||||
"" \
|
||||
"$INSTDIR\md-to-pdf.exe"
|
||||
CreateShortCut \
|
||||
"$DESKTOP\Markdown PDF 导出器.lnk" \
|
||||
"$INSTDIR\md-to-pdf.exe" \
|
||||
"" \
|
||||
"$INSTDIR\md-to-pdf.exe"
|
||||
StrCpy $launchLink "$INSTDIR\md-to-pdf.exe"
|
||||
!macroend
|
||||
|
||||
!macro customUnInstall
|
||||
Delete "$SMPROGRAMS\Markdown PDF 导出器\Markdown PDF 导出器.lnk"
|
||||
RMDir "$SMPROGRAMS\Markdown PDF 导出器"
|
||||
Delete "$DESKTOP\Markdown PDF 导出器.lnk"
|
||||
!macroend
|
||||
@@ -0,0 +1,72 @@
|
||||
const path = require("node:path");
|
||||
const { version } = require("./package.json");
|
||||
|
||||
const windowsIcon = path.resolve(
|
||||
__dirname,
|
||||
"../../logos/desktop/windows/app.ico"
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
appId: "com.md-to-pdf.desktop",
|
||||
productName: "Markdown PDF 导出器",
|
||||
copyright: "Copyright © YIXIONG Tech.ltd",
|
||||
asar: true,
|
||||
electronLanguages: ["zh-CN", "en-US"],
|
||||
directories: {
|
||||
output: `out/v${version}`
|
||||
},
|
||||
files: [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "../web/dist",
|
||||
to: "dist",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: "../../themes",
|
||||
to: "themes",
|
||||
filter: ["**/*"]
|
||||
},
|
||||
{
|
||||
from: windowsIcon,
|
||||
to: "app.ico"
|
||||
},
|
||||
{
|
||||
from: "../../logos/desktop/macos/app.icns",
|
||||
to: "app.icns"
|
||||
}
|
||||
],
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: "nsis",
|
||||
arch: ["x64"]
|
||||
},
|
||||
{
|
||||
target: "zip",
|
||||
arch: ["x64"]
|
||||
}
|
||||
],
|
||||
icon: windowsIcon,
|
||||
executableName: "md-to-pdf",
|
||||
artifactName: `md-to-pdf-${version}-x86_64.\${ext}`
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
perMachine: false,
|
||||
allowElevation: true,
|
||||
createDesktopShortcut: false,
|
||||
createStartMenuShortcut: false,
|
||||
shortcutName: "Markdown PDF 导出器",
|
||||
uninstallDisplayName: "Markdown PDF 导出器",
|
||||
installerIcon: windowsIcon,
|
||||
uninstallerIcon: windowsIcon,
|
||||
installerLanguages: ["zh_CN"],
|
||||
include: "build/installer.nsh",
|
||||
artifactName: `md-to-pdf-${version}-x86_64-Setup.\${ext}`
|
||||
}
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
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"]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,32 +1,28 @@
|
||||
{
|
||||
"name": "@md-to-pdf/desktop",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"productName": "Markdown PDF 导出器",
|
||||
"description": "Markdown PDF 导出器桌面端",
|
||||
"author": "md-to-pdf contributors",
|
||||
"author": "YIXIONG Tech.ltd",
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run clean && npm run build:main && npm run build:preload",
|
||||
"build:main": "node scripts/build-main.mjs",
|
||||
"build:preload": "esbuild src/app-preload.ts src/pdf-preload.ts --bundle --platform=node --format=cjs --external:electron --outdir=dist --out-extension:.js=.cjs",
|
||||
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
||||
"dev": "npm run build && wait-on http://localhost:5173 && electron dist/main.js --web-url=http://localhost:5173",
|
||||
"package": "npm run build && electron-forge package",
|
||||
"make": "npm run build && electron-forge make",
|
||||
"package": "npm run build && electron-builder --dir --config electron-builder.config.cjs --x64",
|
||||
"make": "npm run build && electron-builder --win nsis zip --config electron-builder.config.cjs --x64",
|
||||
"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",
|
||||
"@md-to-pdf/application": "0.4.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"electron": "43.2.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10",
|
||||
|
||||
@@ -5,11 +5,13 @@ 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),
|
||||
|
||||
@@ -12,7 +12,8 @@ export function parseMarkdownRenderRequest(
|
||||
}
|
||||
return {
|
||||
markdown: value.markdown,
|
||||
language: value.language
|
||||
language: value.language,
|
||||
resources: value.resources
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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 =
|
||||
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
net,
|
||||
protocol,
|
||||
session,
|
||||
type IpcMainInvokeEvent
|
||||
} from "electron";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
DESKTOP_GET_THEME_CSS,
|
||||
DESKTOP_GENERATE_PDF,
|
||||
DESKTOP_LIST_THEMES,
|
||||
DESKTOP_OPEN_MARKDOWN,
|
||||
DESKTOP_RENDER_MARKDOWN,
|
||||
DESKTOP_SAVE_PDF
|
||||
} from "./channels.js";
|
||||
@@ -210,6 +212,7 @@ async function createApplication(
|
||||
height: 960,
|
||||
minWidth: 1024,
|
||||
minHeight: 720,
|
||||
autoHideMenuBar: true,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
@@ -229,7 +232,31 @@ async function createApplication(
|
||||
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) => {
|
||||
@@ -237,7 +264,8 @@ async function createApplication(
|
||||
throw new Error("拒绝未授权的桌面渲染请求");
|
||||
}
|
||||
return applicationService.render(
|
||||
parseMarkdownRenderRequest(unsafeRequest)
|
||||
parseMarkdownRenderRequest(unsafeRequest),
|
||||
documentRoot ? { localRoot: documentRoot } : {}
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -317,6 +345,7 @@ async function createApplication(
|
||||
|
||||
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);
|
||||
@@ -330,6 +359,7 @@ async function createApplication(
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
Menu.setApplicationMenu(null);
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId("com.md-to-pdf.desktop");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@md-to-pdf/application": "0.4.0",
|
||||
"@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",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
interface RenderRequestBody {
|
||||
markdown?: unknown;
|
||||
language?: unknown;
|
||||
resources?: unknown;
|
||||
}
|
||||
|
||||
interface PdfRequestBody extends RenderRequestBody {
|
||||
@@ -108,7 +109,7 @@ function createPdfServerTiming(
|
||||
export function buildApp(options: BuildAppOptions = {}) {
|
||||
const app = Fastify({
|
||||
logger: options.logger ?? true,
|
||||
bodyLimit: 2 * 1024 * 1024
|
||||
bodyLimit: 24 * 1024 * 1024
|
||||
});
|
||||
const applicationService =
|
||||
options.applicationService ??
|
||||
@@ -214,7 +215,7 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
"/api/render",
|
||||
async (request, reply) => {
|
||||
try {
|
||||
return applicationService.render(request.body ?? {});
|
||||
return await applicationService.render(request.body ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
@@ -267,7 +268,7 @@ export function buildApp(options: BuildAppOptions = {}) {
|
||||
const markdownStartedAt = performance.now();
|
||||
let rendered;
|
||||
try {
|
||||
rendered = applicationService.render(request.body ?? {});
|
||||
rendered = await applicationService.render(request.body ?? {});
|
||||
} catch (error) {
|
||||
if (error instanceof ApplicationRequestError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
|
||||
+115
-9
@@ -48,6 +48,9 @@ import {
|
||||
savePreviewZoom
|
||||
} from "./preview-zoom";
|
||||
import { useMarkdownRender } from "./use-markdown-render";
|
||||
import {
|
||||
type MarkdownImageResource
|
||||
} from "./application-backend";
|
||||
import {
|
||||
type ThemeSummary,
|
||||
useThemeResources
|
||||
@@ -59,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 示例
|
||||
@@ -86,6 +113,19 @@ keywords:
|
||||
|
||||
行内公式:$E = mc^2$
|
||||
|
||||
## 网络图片
|
||||
|
||||
### 中等尺寸
|
||||
|
||||

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

|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["Markdown"] --> B["统一 HTML"]
|
||||
@@ -346,6 +386,9 @@ option:
|
||||
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);
|
||||
@@ -407,6 +450,7 @@ export function App() {
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
markdown,
|
||||
"zh-CN",
|
||||
imageResources,
|
||||
{
|
||||
onRenderStart: handleRenderStart,
|
||||
onStatusChange: setStatus
|
||||
@@ -469,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),
|
||||
@@ -813,6 +858,8 @@ export function App() {
|
||||
setAppError("");
|
||||
setMarkdown(await file.text());
|
||||
setFileName(file.name);
|
||||
setImageResources([]);
|
||||
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
|
||||
} catch {
|
||||
setAppError("无法读取所选 Markdown 文件");
|
||||
} finally {
|
||||
@@ -820,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;
|
||||
@@ -920,14 +1004,36 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.markdown,text/markdown,text/plain"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
{window.mdToPdfDesktop ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDesktopOpenMarkdown()}
|
||||
>
|
||||
打开 Markdown
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.markdown,text/markdown,text/plain"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
<label className="file-button">
|
||||
添加素材目录
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
// React 尚未声明 Chromium 的目录选择属性。
|
||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
||||
onChange={handleAssetDirectoryChange}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||
导出设置
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface ThemeSummary {
|
||||
export interface MarkdownRenderInput {
|
||||
markdown: string;
|
||||
language: string;
|
||||
resources?: MarkdownImageResource[];
|
||||
}
|
||||
|
||||
export interface MarkdownImageResource {
|
||||
path: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
|
||||
@@ -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,
|
||||
@@ -326,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,
|
||||
@@ -344,6 +346,10 @@ export class PagedDocumentRuntime {
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
fitDocumentImagesToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,11 +2,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
|
||||
request.markdown,
|
||||
request.fileName,
|
||||
request.language,
|
||||
request.resources,
|
||||
request.exportConfig
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,6 +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;
|
||||
@@ -10,6 +11,7 @@ export interface MarkdownRenderCallbacks {
|
||||
export function useMarkdownRender(
|
||||
markdown: string,
|
||||
language: string,
|
||||
resources: MarkdownImageResource[],
|
||||
callbacks: MarkdownRenderCallbacks = {}
|
||||
) {
|
||||
const [result, setResult] =
|
||||
@@ -25,7 +27,7 @@ export function useMarkdownRender(
|
||||
setError("");
|
||||
|
||||
void renderMarkdownDocument(
|
||||
{ markdown, language },
|
||||
{ markdown, language, resources },
|
||||
controller.signal
|
||||
)
|
||||
.then((payload) => {
|
||||
@@ -46,7 +48,13 @@ export function useMarkdownRender(
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [language, markdown, onRenderStart, onStatusChange]);
|
||||
}, [
|
||||
language,
|
||||
markdown,
|
||||
onRenderStart,
|
||||
onStatusChange,
|
||||
resources
|
||||
]);
|
||||
|
||||
return { error, result };
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -17,9 +17,20 @@ declare global {
|
||||
}
|
||||
|
||||
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<{
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.4.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.0");
|
||||
expect(APP_VERSION).toBe("0.4.4");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
|
||||
可以直接指定版本化镜像名称构建:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.0'
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.1'
|
||||
docker compose -f deploy\compose.yaml build
|
||||
```
|
||||
|
||||
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
|
||||
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
|
||||
|
||||
```powershell
|
||||
$env:MD_TO_PDF_IMAGE = 'yixiong/md-to-pdf:v0.4.0'
|
||||
$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.0 应用代码和生产
|
||||
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.1 应用代码和生产
|
||||
依赖。正式跨机器构建仍建议使用默认完整路径。
|
||||
|
||||
## 主题挂载
|
||||
|
||||
+138
-17
@@ -31,11 +31,18 @@ fa07472 feat: 实现网页实时预览
|
||||
ec48bce chore: 初始化项目骨架
|
||||
```
|
||||
|
||||
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页,
|
||||
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。
|
||||
`v0.4.0` 桌面端阶段已经完成:共享应用服务、无 Server 的 Electron
|
||||
运行时、三套内置主题、统一品牌资源、Windows 安装包以及 Docker Compose
|
||||
发布候选均已通过验证。
|
||||
`v0.4.1` 图片资源阶段已经完成:Web 素材目录、Desktop 同目录资源、
|
||||
受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
|
||||
统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。
|
||||
|
||||
`v0.4.2` 为仅面向桌面端的维护版本:移除 Windows 原生应用菜单栏;
|
||||
Web/Compose 发布仍保持 `v0.4.1`。
|
||||
|
||||
`v0.4.3` 将桌面发行包迁移为标准 NSIS 安装向导,并使用全英文、无空格、
|
||||
包含版本与平台的安装包文件名;Web/Compose 发布仍保持 `v0.4.1`。
|
||||
|
||||
`v0.4.4` 仅精简 Electron 桌面发行包的语言资源,保留简体中文和英文;
|
||||
Web/Compose 发布仍保持 `v0.4.1`。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
@@ -376,6 +383,60 @@ ec48bce chore: 初始化项目骨架
|
||||
- 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`。
|
||||
|
||||
### 3.16 v0.4.3 标准 Windows 安装向导
|
||||
|
||||
- 桌面发行链路由 Electron Forge Squirrel 迁移到 electron-builder
|
||||
26.15.3 的 NSIS assisted installer。
|
||||
- 安装向导提供当前用户/所有用户安装选项、安装目录选择、安装进度和完成
|
||||
页面;主程序继续使用 `md-to-pdf.exe`。
|
||||
- 正式安装包固定命名为
|
||||
`md-to-pdf-0.4.3-x86_64-Setup.exe`,免安装包命名为
|
||||
`md-to-pdf-0.4.3-x86_64.zip`。
|
||||
- 项目自有 `build/installer.nsh` 显式创建桌面和开始菜单快捷方式,
|
||||
安装完成后直接启动主程序,不依赖 `.lnk`。
|
||||
- 卸载时显式清理桌面快捷方式、开始菜单快捷方式及其目录。
|
||||
- 安装包和校验文件作为 Gitea `v0.4.3` Release 附件发布,不写入 Git
|
||||
历史。
|
||||
|
||||
### 3.17 v0.4.4 桌面语言包精简
|
||||
|
||||
- Electron 发行包只保留 `zh-CN` 和 `en-US` 两种 Chromium 语言资源。
|
||||
- 不裁剪 ICU、ANGLE、Direct3D、Dawn、SwiftShader 或其他 Chromium
|
||||
运行时组件,保证桌面预览与 PDF 导出在不同图形环境下的可靠性。
|
||||
- 本版本只重新生成桌面安装包和免安装 ZIP;Web/Compose 发布继续保持
|
||||
`v0.4.1`。
|
||||
|
||||
## 4. 已执行验证
|
||||
|
||||
2026-07-27 在当前完整工作区成功执行:
|
||||
@@ -396,7 +457,7 @@ git diff --check
|
||||
- 前端测试:67 项通过;
|
||||
- 后端测试:29 项通过;
|
||||
- 桌面端测试:7 项通过;
|
||||
- 全项目共 163 项测试通过;
|
||||
- 全项目共 171 项测试通过;
|
||||
- 全项目类型检查通过;
|
||||
- 生产构建通过;
|
||||
- `git diff --check` 通过。
|
||||
@@ -546,6 +607,74 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
- 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 状态为未签名。
|
||||
|
||||
2026-07-27 已完成 `v0.4.3` 标准安装向导验证:
|
||||
|
||||
- NSIS 中文辅助安装向导正确显示安装范围和安装目录选择,版本显示为
|
||||
`0.4.3`。
|
||||
- 当前用户安装成功,安装目录为
|
||||
`%LOCALAPPDATA%\Programs\md-to-pdf`,卸载项版本为 `0.4.3`。
|
||||
- 桌面和开始菜单快捷方式均存在,实际目标均为安装目录中的
|
||||
`md-to-pdf.exe`;从开始菜单快捷方式启动成功。
|
||||
- 静默卸载返回 0,安装目录、桌面快捷方式和开始菜单目录全部清理;
|
||||
随后重新安装成功,最终本机保持已安装状态。
|
||||
- 正式安装包为 `md-to-pdf-0.4.3-x86_64-Setup.exe`,
|
||||
103,346,230 字节,SHA-256 为
|
||||
`C027B4AC604889C7E7E00BA3CE08A12616312BC9250268775B1B7B578A7C4817`,
|
||||
Authenticode 状态为未签名。
|
||||
- 免安装包为 `md-to-pdf-0.4.3-x86_64.zip`,143,784,617 字节,
|
||||
SHA-256 为
|
||||
`91BED88A7CB623B98383408D5553D80A6135FB94F7FE2D77A5FCED339CCF5BC6`。
|
||||
|
||||
2026-07-27 已完成 `v0.4.4` 桌面语言包精简验证:
|
||||
|
||||
- 目录版只包含 `zh-CN.pak` 和 `en-US.pak`,语言资源从 55 个、
|
||||
48,911,653 字节降为 2 个、1,137,843 字节。
|
||||
- 目录版总大小从 378,084,454 字节降为 330,310,644 字节,减少
|
||||
47,773,810 字节;Chromium 图形与软件渲染后备组件均保持完整。
|
||||
- Windows 主程序版本为 `0.4.4`,目录版成功启动并保持应用、GPU、
|
||||
渲染器等 4 个 Electron 进程正常运行。
|
||||
- 正式安装包为 `md-to-pdf-0.4.4-x86_64-Setup.exe`,94,991,759 字节,
|
||||
SHA-256 为
|
||||
`0827FD87A8EEC8B034DF738909CB6A20231245B507AE4A0BAA579AFA03A942DE`,
|
||||
Authenticode 状态为未签名。
|
||||
- 免安装包为 `md-to-pdf-0.4.4-x86_64.zip`,132,470,241 字节,
|
||||
SHA-256 为
|
||||
`D6CDB8DAE30676BAB6B2A037567164476AD294588B102A6FBBE9458ABCE5CCC0`。
|
||||
|
||||
## 5. 当前注意事项
|
||||
|
||||
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
|
||||
@@ -587,7 +716,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
未来改变分发范围前必须重新完成许可证审查。
|
||||
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
|
||||
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
|
||||
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。
|
||||
- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
|
||||
目录读取;远程图片仅保留有界内存缓存。
|
||||
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
|
||||
导出,不依赖 Fastify 或本地 HTTP Server。
|
||||
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
|
||||
@@ -595,16 +725,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
||||
|
||||
## 6. 推荐接手顺序
|
||||
|
||||
### 阶段一:桌面文档工作区与资源安全
|
||||
|
||||
- Markdown 文件夹或 ZIP;
|
||||
- 相对图片解析;
|
||||
- 每请求独立临时目录;
|
||||
- 路径穿越和符号链接越界防护;
|
||||
- 文件数量、单文件大小、总大小和超时限制;
|
||||
- 请求结束可靠清理。
|
||||
|
||||
### 阶段二:导出配置扩展
|
||||
### 阶段一:导出配置扩展
|
||||
|
||||
- Front Matter 元数据覆盖;
|
||||
- 浏览器本地配置预设;
|
||||
|
||||
Generated
+1331
-4030
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "md-to-pdf",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.4",
|
||||
"private": true,
|
||||
"description": "可配置、可主题化的 Markdown PDF 导出工具",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@md-to-pdf/application",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -6,12 +6,18 @@ 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 {
|
||||
@@ -25,15 +31,21 @@ export class ApplicationRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplicationServiceOptions extends ThemeRegistryOptions {}
|
||||
export interface ApplicationServiceOptions
|
||||
extends ThemeRegistryOptions,
|
||||
ImageResourceResolverOptions {}
|
||||
|
||||
export function createApplicationService(
|
||||
options: ApplicationServiceOptions
|
||||
) {
|
||||
const themes = createThemeRegistry(options);
|
||||
const resolveImages = createImageResourceResolver(options);
|
||||
|
||||
function render(request: MarkdownRenderRequest) {
|
||||
const { markdown, language } = request;
|
||||
async function render(
|
||||
request: MarkdownRenderRequest,
|
||||
context: ImageResolutionContext = {}
|
||||
) {
|
||||
const { markdown, language, resources } = request;
|
||||
if (typeof markdown !== "string") {
|
||||
throw new ApplicationRequestError(
|
||||
400,
|
||||
@@ -57,8 +69,11 @@ export function createApplicationService(
|
||||
}
|
||||
|
||||
try {
|
||||
const images = await resolveImages(markdown, resources, context);
|
||||
return renderMarkdown(markdown, {
|
||||
...(language ? { language } : {})
|
||||
...(language ? { language } : {}),
|
||||
imageSourceMap: images.sources,
|
||||
warnings: images.warnings
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownDocumentParseError) {
|
||||
@@ -68,6 +83,16 @@ export function createApplicationService(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,20 @@ export {
|
||||
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,
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("共享应用服务", () => {
|
||||
const roots = await createThemeFixture();
|
||||
const service = createApplicationService(roots);
|
||||
|
||||
const document = service.render({
|
||||
const document = await service.render({
|
||||
markdown: "# 文档\n\n<script>alert('xss')</script>",
|
||||
language: "zh-CN"
|
||||
});
|
||||
@@ -94,7 +94,7 @@ describe("共享应用服务", () => {
|
||||
const roots = await createThemeFixture();
|
||||
const service = createApplicationService(roots);
|
||||
|
||||
expect(() => service.render({ markdown: 42 })).toThrow(
|
||||
await expect(service.render({ markdown: 42 })).rejects.toEqual(
|
||||
expect.objectContaining<ApplicationRequestError>({
|
||||
statusCode: 400,
|
||||
code: "INVALID_MARKDOWN"
|
||||
@@ -102,6 +102,39 @@ describe("共享应用服务", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
|
||||
@@ -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("网络不可用");
|
||||
});
|
||||
});
|
||||
@@ -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(`
|
||||
# 章节
|
||||
|
||||
Reference in New Issue
Block a user