fix: 完善本地与网络图片渲染

This commit is contained in:
SkyJourney
2026-07-27 22:12:16 +08:00
parent 06b30d084e
commit 32a37293d0
31 changed files with 1204 additions and 71 deletions
+18 -8
View File
@@ -16,11 +16,12 @@
- Chromium PDF 下载、精确 PDF.js 预览与导出性能观测; - Chromium PDF 下载、精确 PDF.js 预览与导出性能观测;
- 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存; - 五种纸张、页边距、页眉、页码、Mermaid 风格和浏览器配置缓存;
- 快速预览与精确预览共用的 50%~400% 显示缩放; - 快速预览与精确预览共用的 50%~400% 显示缩放;
- 内置 Typora 风格主题,以及仅供本机使用的三套白色 Typora 默认主题导入工具 - 内置 Typora 风格、GitHub、Pixyll 和 Whitey 四套主题
- Web 素材目录上传、桌面同目录相对图片和受限公网图片下载;
- 图片标题、整块分页及超高图片单页等比例适配;
- 无本地 Server 的 Electron 桌面端与 Windows 安装包;
- 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。 - 前端、Fastify、Nginx 和 Playwright Chromium 的 AIO 容器部署。
本地相对资源文件处理将在后续阶段实现。
## 本地开发 ## 本地开发
要求 Node.js 22 或更高版本。 要求 Node.js 22 或更高版本。
@@ -36,7 +37,14 @@ npm run dev
- 后端:http://localhost:3001 - 后端:http://localhost:3001
- 健康检查:http://localhost:3001/api/health - 健康检查: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 ```powershell
npm run theme:import-typora npm run theme:import-typora
@@ -146,5 +155,6 @@ git diff --check
## 隐私原则 ## 隐私原则
文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。
相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。 相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程
地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。
+8 -4
View File
@@ -1,8 +1,9 @@
# Desktop 桌面端 # Desktop 桌面端
`apps/desktop``v0.4.0` 新增的 Electron 桌面应用。当前已经完成 `apps/desktop``v0.4.0` 新增的 Electron 桌面应用。当前已经完成
Electron 壳、受限 preload、进程内应用服务、隐藏分页窗口、Electron Electron 壳、受限 preload、进程内应用服务、原生 Markdown 文件选择、
Chromium PDF 输出、原生 PDF 另存为和 Windows 安装包验证。 同目录图片解析、隐藏分页窗口、Electron Chromium PDF 输出、原生 PDF
另存为和 Windows 安装包验证。
## 开发 ## 开发
@@ -26,7 +27,7 @@ npm run make -w @md-to-pdf/desktop
``` ```
版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到 版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到
`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以 `apps/desktop/out/v0.4.1/`。该目录被 Git 忽略。公司内部分发以
`Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名, `Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名,
Windows 首次运行可能显示“未知发布者”提示。 Windows 首次运行可能显示“未知发布者”提示。
@@ -42,7 +43,10 @@ Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产
- 应用窗口和 PDF 窗口均关闭 Node Integration - 应用窗口和 PDF 窗口均关闭 Node Integration
- 启用 Context Isolation 和 Chromium Sandbox - 启用 Context Isolation 和 Chromium Sandbox
- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口; - preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需
的窄接口;
- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号
链接越界;
- 主进程校验 IPC 发送者和分页载荷; - 主进程校验 IPC 发送者和分页载荷;
- PDF 窗口使用独立内存 Session,并阻止外部网络资源; - PDF 窗口使用独立内存 Session,并阻止外部网络资源;
- 应用页面使用自定义协议,不使用 `file://` - 应用页面使用自定义协议,不使用 `file://`
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.4.0", "version": "0.4.1",
"private": true, "private": true,
"productName": "Markdown PDF 导出器", "productName": "Markdown PDF 导出器",
"description": "Markdown PDF 导出器桌面端", "description": "Markdown PDF 导出器桌面端",
@@ -8,7 +8,7 @@
"type": "module", "type": "module",
"main": "dist/main.js", "main": "dist/main.js",
"dependencies": { "dependencies": {
"@md-to-pdf/application": "0.4.0" "@md-to-pdf/application": "0.4.1"
}, },
"scripts": { "scripts": {
"build": "npm run clean && npm run build:main && npm run build:preload", "build": "npm run clean && npm run build:main && npm run build:preload",
+2
View File
@@ -5,11 +5,13 @@ import {
DESKTOP_GET_THEME_CSS, DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF, DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES, DESKTOP_LIST_THEMES,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_RENDER_MARKDOWN, DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF DESKTOP_SAVE_PDF
} from "./channels.js"; } from "./channels.js";
contextBridge.exposeInMainWorld("mdToPdfDesktop", { contextBridge.exposeInMainWorld("mdToPdfDesktop", {
openMarkdown: () => ipcRenderer.invoke(DESKTOP_OPEN_MARKDOWN),
renderMarkdown: (input: MarkdownRenderRequest) => renderMarkdown: (input: MarkdownRenderRequest) =>
ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input), ipcRenderer.invoke(DESKTOP_RENDER_MARKDOWN, input),
listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES), listThemes: () => ipcRenderer.invoke(DESKTOP_LIST_THEMES),
+2 -1
View File
@@ -12,7 +12,8 @@ export function parseMarkdownRenderRequest(
} }
return { return {
markdown: value.markdown, markdown: value.markdown,
language: value.language language: value.language,
resources: value.resources
}; };
} }
+2
View File
@@ -1,5 +1,7 @@
export const DESKTOP_RENDER_MARKDOWN = export const DESKTOP_RENDER_MARKDOWN =
"md-to-pdf: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 = export const DESKTOP_LIST_THEMES =
"md-to-pdf:desktop:list-themes"; "md-to-pdf:desktop:list-themes";
export const DESKTOP_GET_THEME_CSS = export const DESKTOP_GET_THEME_CSS =
+29 -2
View File
@@ -8,7 +8,7 @@ import {
session, session,
type IpcMainInvokeEvent type IpcMainInvokeEvent
} from "electron"; } from "electron";
import { access, writeFile } from "node:fs/promises"; import { access, readFile, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { import {
@@ -19,6 +19,7 @@ import {
DESKTOP_GET_THEME_CSS, DESKTOP_GET_THEME_CSS,
DESKTOP_GENERATE_PDF, DESKTOP_GENERATE_PDF,
DESKTOP_LIST_THEMES, DESKTOP_LIST_THEMES,
DESKTOP_OPEN_MARKDOWN,
DESKTOP_RENDER_MARKDOWN, DESKTOP_RENDER_MARKDOWN,
DESKTOP_SAVE_PDF DESKTOP_SAVE_PDF
} from "./channels.js"; } from "./channels.js";
@@ -229,7 +230,31 @@ async function createApplication(
event.senderFrame !== null && event.senderFrame !== null &&
new URL(event.senderFrame.url).origin === new URL(event.senderFrame.url).origin ===
new URL(applicationUrl).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( ipcMain.handle(
DESKTOP_RENDER_MARKDOWN, DESKTOP_RENDER_MARKDOWN,
async (event, unsafeRequest: unknown) => { async (event, unsafeRequest: unknown) => {
@@ -237,7 +262,8 @@ async function createApplication(
throw new Error("拒绝未授权的桌面渲染请求"); throw new Error("拒绝未授权的桌面渲染请求");
} }
return applicationService.render( return applicationService.render(
parseMarkdownRenderRequest(unsafeRequest) parseMarkdownRenderRequest(unsafeRequest),
documentRoot ? { localRoot: documentRoot } : {}
); );
} }
); );
@@ -317,6 +343,7 @@ async function createApplication(
window.once("ready-to-show", () => window.show()); window.once("ready-to-show", () => window.show());
window.on("closed", () => { window.on("closed", () => {
ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN);
ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN); ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN);
ipcMain.removeHandler(DESKTOP_LIST_THEMES); ipcMain.removeHandler(DESKTOP_LIST_THEMES);
ipcMain.removeHandler(DESKTOP_GET_THEME_CSS); ipcMain.removeHandler(DESKTOP_GET_THEME_CSS);
+1 -1
View File
@@ -11,7 +11,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false" "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
}, },
"dependencies": { "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/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0", "@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
+4 -3
View File
@@ -26,6 +26,7 @@ import {
interface RenderRequestBody { interface RenderRequestBody {
markdown?: unknown; markdown?: unknown;
language?: unknown; language?: unknown;
resources?: unknown;
} }
interface PdfRequestBody extends RenderRequestBody { interface PdfRequestBody extends RenderRequestBody {
@@ -108,7 +109,7 @@ function createPdfServerTiming(
export function buildApp(options: BuildAppOptions = {}) { export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({ const app = Fastify({
logger: options.logger ?? true, logger: options.logger ?? true,
bodyLimit: 2 * 1024 * 1024 bodyLimit: 24 * 1024 * 1024
}); });
const applicationService = const applicationService =
options.applicationService ?? options.applicationService ??
@@ -214,7 +215,7 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/render", "/api/render",
async (request, reply) => { async (request, reply) => {
try { try {
return applicationService.render(request.body ?? {}); return await applicationService.render(request.body ?? {});
} catch (error) { } catch (error) {
if (error instanceof ApplicationRequestError) { if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({ return reply.code(error.statusCode).send({
@@ -267,7 +268,7 @@ export function buildApp(options: BuildAppOptions = {}) {
const markdownStartedAt = performance.now(); const markdownStartedAt = performance.now();
let rendered; let rendered;
try { try {
rendered = applicationService.render(request.body ?? {}); rendered = await applicationService.render(request.body ?? {});
} catch (error) { } catch (error) {
if (error instanceof ApplicationRequestError) { if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({ return reply.code(error.statusCode).send({
+115 -9
View File
@@ -48,6 +48,9 @@ import {
savePreviewZoom savePreviewZoom
} from "./preview-zoom"; } from "./preview-zoom";
import { useMarkdownRender } from "./use-markdown-render"; import { useMarkdownRender } from "./use-markdown-render";
import {
type MarkdownImageResource
} from "./application-backend";
import { import {
type ThemeSummary, type ThemeSummary,
useThemeResources useThemeResources
@@ -59,6 +62,30 @@ const PrecisePdfPreview = lazy(async () => {
}); });
type PreviewMode = "quick" | "precise"; 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 = `--- const sampleMarkdown = `---
title: Markdown PDF 示例 title: Markdown PDF 示例
@@ -86,6 +113,19 @@ keywords:
行内公式:$E = mc^2$ 行内公式:$E = mc^2$
## 网络图片
### 中等尺寸
![NASA 地球中等尺寸示例](https://images-assets.nasa.gov/image/PIA18033/PIA18033~medium.jpg)
### 超高尺寸
下图用于验证图片高度超过单页内容区时,会等比例缩放并作为完整块放置,
不会跨页切割。
![超高网络图片分页示例](https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&q=80)
\`\`\`mermaid \`\`\`mermaid
flowchart LR flowchart LR
A["Markdown"] --> B["统一 HTML"] A["Markdown"] --> B["统一 HTML"]
@@ -346,6 +386,9 @@ option:
export function App() { export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown); const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md"); const [fileName, setFileName] = useState("示例文档.md");
const [imageResources, setImageResources] = useState<
MarkdownImageResource[]
>([]);
const [exportConfig, setExportConfig] = const [exportConfig, setExportConfig] =
useState<ExportConfig>(loadExportConfig); useState<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
@@ -407,6 +450,7 @@ export function App() {
const { error: renderError, result } = useMarkdownRender( const { error: renderError, result } = useMarkdownRender(
markdown, markdown,
"zh-CN", "zh-CN",
imageResources,
{ {
onRenderStart: handleRenderStart, onRenderStart: handleRenderStart,
onStatusChange: setStatus onStatusChange: setStatus
@@ -469,9 +513,10 @@ export function App() {
markdown, markdown,
fileName, fileName,
language: "zh-CN", language: "zh-CN",
resources: imageResources,
exportConfig exportConfig
}), }),
[exportConfig, fileName, markdown] [exportConfig, fileName, imageResources, markdown]
); );
const pdfCacheKey = useMemo( const pdfCacheKey = useMemo(
() => createPdfExportCacheKey(pdfRequest), () => createPdfExportCacheKey(pdfRequest),
@@ -813,6 +858,8 @@ export function App() {
setAppError(""); setAppError("");
setMarkdown(await file.text()); setMarkdown(await file.text());
setFileName(file.name); setFileName(file.name);
setImageResources([]);
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
} catch { } catch {
setAppError("无法读取所选 Markdown 文件"); setAppError("无法读取所选 Markdown 文件");
} finally { } 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) { async function generatePdf(download: boolean) {
if (exportingPdf) { if (exportingPdf) {
return; return;
@@ -920,14 +1004,36 @@ export function App() {
</div> </div>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<label className="file-button"> {window.mdToPdfDesktop ? (
Markdown <button
<input type="button"
type="file" onClick={() => void handleDesktopOpenMarkdown()}
accept=".md,.markdown,text/markdown,text/plain" >
onChange={handleFileChange} Markdown
/> </button>
</label> ) : (
<>
<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 type="button" onClick={() => setSettingsOpen(true)}>
</button> </button>
+6
View File
@@ -12,6 +12,12 @@ export interface ThemeSummary {
export interface MarkdownRenderInput { export interface MarkdownRenderInput {
markdown: string; markdown: string;
language: string; language: string;
resources?: MarkdownImageResource[];
}
export interface MarkdownImageResource {
path: string;
data: string;
} }
function throwIfAborted(signal?: AbortSignal) { function throwIfAborted(signal?: AbortSignal) {
+72
View File
@@ -0,0 +1,72 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
calculateDiagramPageFit,
getPageContentDimensions
} from "./diagram-page-fit";
function getImageBlock(image: HTMLImageElement) {
const parent = image.parentElement;
if (parent?.classList.contains("md-document-image-block")) {
return parent;
}
if (
parent?.tagName === "P" &&
Array.from(parent.childNodes).every(
(node) =>
node === image ||
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
)
) {
parent.classList.add("md-document-image-block");
return parent;
}
return undefined;
}
export function fitDocumentImagesToPage(
root: ParentNode,
config: ExportConfig
) {
const pageContent = getPageContentDimensions(config);
const images = Array.from(
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
);
for (const image of images) {
const block = getImageBlock(image);
const width = image.naturalWidth;
const height = image.naturalHeight;
if (width <= 0 || height <= 0) {
continue;
}
const imageHeight = image.getBoundingClientRect().height;
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
const fit = calculateDiagramPageFit(
{ width, height },
{
width: pageContent.width,
height: Math.max(0, pageContent.height - nonImageHeight)
}
);
if (!fit.scaled) {
continue;
}
if (block?.classList.contains("md-document-image-block")) {
block.dataset.pageHeightFitted = "true";
block.style.setProperty("margin-block", "0", "important");
}
image.dataset.pageHeightFitted = "true";
image.style.setProperty("display", "block");
image.style.setProperty("width", `${fit.width}px`, "important");
image.style.setProperty("height", `${fit.height}px`, "important");
image.style.setProperty("max-width", "100%", "important");
image.style.setProperty(
"max-height",
`${fit.height}px`,
"important"
);
image.style.setProperty("object-fit", "contain");
image.style.setProperty("margin-inline", "auto");
}
}
+7 -1
View File
@@ -7,6 +7,7 @@ import type {
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { Previewer } from "pagedjs"; import { Previewer } from "pagedjs";
import { fitOversizedEChartsToPage } from "./echarts-page-fit"; import { fitOversizedEChartsToPage } from "./echarts-page-fit";
import { fitDocumentImagesToPage } from "./document-image-fit";
import { createMermaidSiteConfig } from "./mermaid-config"; import { createMermaidSiteConfig } from "./mermaid-config";
import { import {
fitOversizedMermaidToPage, fitOversizedMermaidToPage,
@@ -326,7 +327,8 @@ export class PagedDocumentRuntime {
let mermaidConversionMs = 0; let mermaidConversionMs = 0;
if ( if (
payload.features.includes("echarts") || payload.features.includes("echarts") ||
content.querySelector(".mermaid svg") content.querySelector(".mermaid svg") ||
content.querySelector("img.md-document-image")
) { ) {
const measurement = mountMeasurementContainer( const measurement = mountMeasurementContainer(
documentRef, documentRef,
@@ -344,6 +346,10 @@ export class PagedDocumentRuntime {
); );
echartsMs = performance.now() - echartsStartedAt; echartsMs = performance.now() - echartsStartedAt;
await waitForImages(measurement.host); await waitForImages(measurement.host);
fitDocumentImagesToPage(
measurement.host,
payload.exportConfig
);
const echartsFitStartedAt = performance.now(); const echartsFitStartedAt = performance.now();
fitOversizedEChartsToPage( fitOversizedEChartsToPage(
+24
View File
@@ -67,6 +67,25 @@ svg {
max-width: 100%; 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 { #write table {
width: 100%; width: 100%;
table-layout: auto; table-layout: auto;
@@ -313,6 +332,11 @@ ${buildFooterCss(config)}
page-break-inside: avoid; page-break-inside: avoid;
} }
#write .md-document-image-block {
break-inside: avoid;
page-break-inside: avoid;
}
${ ${
config.print.pageBreakBeforeH1 config.print.pageBreakBeforeH1
? `#write h1:not(:first-child) { ? `#write h1:not(:first-child) {
+3
View File
@@ -2,11 +2,13 @@ import type {
ExportConfig, ExportConfig,
PagedDocumentPayload PagedDocumentPayload
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import type { MarkdownImageResource } from "./application-backend";
export interface PdfExportRequest { export interface PdfExportRequest {
markdown: string; markdown: string;
fileName: string; fileName: string;
language: string; language: string;
resources: MarkdownImageResource[];
exportConfig: ExportConfig; exportConfig: ExportConfig;
} }
@@ -28,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
request.markdown, request.markdown,
request.fileName, request.fileName,
request.language, request.language,
request.resources,
request.exportConfig request.exportConfig
]); ]);
} }
+10 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RenderedMarkdownDocument } from "@md-to-pdf/core"; import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
import { renderMarkdownDocument } from "./application-backend"; import { renderMarkdownDocument } from "./application-backend";
import type { MarkdownImageResource } from "./application-backend";
export interface MarkdownRenderCallbacks { export interface MarkdownRenderCallbacks {
onRenderStart?: () => void; onRenderStart?: () => void;
@@ -10,6 +11,7 @@ export interface MarkdownRenderCallbacks {
export function useMarkdownRender( export function useMarkdownRender(
markdown: string, markdown: string,
language: string, language: string,
resources: MarkdownImageResource[],
callbacks: MarkdownRenderCallbacks = {} callbacks: MarkdownRenderCallbacks = {}
) { ) {
const [result, setResult] = const [result, setResult] =
@@ -25,7 +27,7 @@ export function useMarkdownRender(
setError(""); setError("");
void renderMarkdownDocument( void renderMarkdownDocument(
{ markdown, language }, { markdown, language, resources },
controller.signal controller.signal
) )
.then((payload) => { .then((payload) => {
@@ -46,7 +48,13 @@ export function useMarkdownRender(
window.clearTimeout(timer); window.clearTimeout(timer);
controller.abort(); controller.abort();
}; };
}, [language, markdown, onRenderStart, onStatusChange]); }, [
language,
markdown,
onRenderStart,
onStatusChange,
resources
]);
return { error, result }; return { error, result };
} }
+11
View File
@@ -17,9 +17,20 @@ declare global {
} }
interface DesktopApplicationBridge { interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
renderMarkdown(input: { renderMarkdown(input: {
markdown: string; markdown: string;
language: string; language: string;
resources?: Array<{
path: string;
data: string;
}>;
}): Promise<RenderedMarkdownDocument>; }): Promise<RenderedMarkdownDocument>;
listThemes(): Promise<{ listThemes(): Promise<{
themes: Array<{ themes: Array<{
+2 -2
View File
@@ -6,7 +6,7 @@ import {
describe("应用版本", () => { describe("应用版本", () => {
it("从统一构建版本生成标题徽标", () => { it("从统一构建版本生成标题徽标", () => {
expect(APP_VERSION).toBe("0.4.0"); expect(APP_VERSION).toBe("0.4.1");
expect(APP_VERSION_LABEL).toBe("v0.4.0"); expect(APP_VERSION_LABEL).toBe("v0.4.1");
}); });
}); });
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitDocumentImagesToPage } from "../src/document-image-fit";
describe("Markdown 图片单页适配", () => {
it("标记独立图片段落并将超高图片缩放到单页", () => {
const root = document.createElement("div");
root.innerHTML =
'<p> <img class="md-document-image" src="data:image/png;base64,AA=="> </p>';
const image = root.querySelector("img")!;
Object.defineProperties(image, {
naturalWidth: { value: 1000 },
naturalHeight: { value: 3000 }
});
fitDocumentImagesToPage(root, defaultExportConfig);
expect(image.parentElement?.classList).toContain(
"md-document-image-block"
);
expect(image.dataset.pageHeightFitted).toBe("true");
expect(image.parentElement?.dataset.pageHeightFitted).toBe("true");
expect(
image.parentElement?.style.getPropertyValue("margin-block")
).toBe("0");
expect(Number.parseFloat(image.style.height)).toBeCloseTo(
1000.5748,
3
);
expect(Number.parseFloat(image.style.width)).toBeCloseTo(
333.5249,
3
);
});
it("普通横图保持主题控制的尺寸", () => {
const root = document.createElement("div");
root.innerHTML =
'<p><img class="md-document-image" src="data:image/png;base64,AA=="></p>';
const image = root.querySelector("img")!;
Object.defineProperties(image, {
naturalWidth: { value: 1600 },
naturalHeight: { value: 900 }
});
fitDocumentImagesToPage(root, defaultExportConfig);
expect(image.dataset.pageHeightFitted).toBeUndefined();
expect(image.style.height).toBe("");
});
});
+3 -3
View File
@@ -37,7 +37,7 @@ docker compose -f deploy\compose.yaml down
可以直接指定版本化镜像名称构建: 可以直接指定版本化镜像名称构建:
```powershell ```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 docker compose -f deploy\compose.yaml build
``` ```
@@ -47,13 +47,13 @@ docker compose -f deploy\compose.yaml build
已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层: 已经保留同版本系列的已验证镜像,可在内网或软件源较慢时复用其运行层:
```powershell ```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_RUNTIME_BASE_IMAGE = 'yixiong/md-to-pdf:v0.3.1'
$env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1' $env:MD_TO_PDF_REUSE_PLAYWRIGHT_RUNTIME = '1'
docker compose -f deploy\compose.yaml build docker compose -f deploy\compose.yaml build
``` ```
复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.0 应用代码和生产 复用模式会校验基础镜像中存在 Chromium,再覆盖 v0.4.1 应用代码和生产
依赖。正式跨机器构建仍建议使用默认完整路径。 依赖。正式跨机器构建仍建议使用默认完整路径。
## 主题挂载 ## 主题挂载
+52 -17
View File
@@ -31,11 +31,9 @@ fa07472 feat: 实现网页实时预览
ec48bce chore: 初始化项目骨架 ec48bce chore: 初始化项目骨架
``` ```
最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页, `v0.4.1` 图片资源阶段已完成Web 素材目录、Desktop 同目录资源、
并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。 受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用
`v0.4.0` 桌面端阶段已经完成:共享应用服务、无 Server 的 Electron 统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。
运行时、三套内置主题、统一品牌资源、Windows 安装包以及 Docker Compose
发布候选均已通过验证。
## 2. 已完成 ## 2. 已完成
@@ -376,6 +374,29 @@ ec48bce chore: 初始化项目骨架
- Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过 - Dockerfile 默认保留完整 Playwright Chromium 安装路径,同时支持通过
构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。 构建参数复用本机已验证的旧版本运行层,适合内网软件源较慢的环境。
### 3.14 v0.4.1 图片资源与标题分页
- `packages/application` 统一提取 Markdown 图片引用,将验证后的本地或
远程图片转换为 Data URL,再交给 Web 预览、Playwright PDF 和 Electron
PDF 共用的 HTML。
- Web 增加素材目录选择,支持把 Markdown 的关联图片作为受限 Base64
资源随渲染和 PDF 请求提交;请求体上限同步提高到 24 MiB。
- Desktop 增加原生“打开 Markdown”,主进程记录文档目录并直接读取相对
图片,不启动 Server,也不允许读取文档目录之外的文件。
- 相对路径支持中文、空格和百分号编码;拒绝绝对路径、`..` 穿越和符号
链接越界。
- 远程图片支持 HTTP/HTTPS、逐跳重定向校验、10 秒超时、格式嗅探与
5 分钟缓存;阻止 localhost、私网和链路本地目标,并兼容企业代理常用
的域名 Fake-IP 网段。
- 每篇文档最多 50 个图片引用,单图不超过 8 MiB,总图片不超过 15 MiB
支持 PNG、JPEG、GIF、WebP、AVIF 和受限 SVG。
- 独立图片使用 `figure + img + figcaption``[]` 中的文本显示为图片
标题;行内混排图片继续保持普通段落语义。
- 图片与标题整块分页。超高图先扣除标题区实际高度,再等比例缩放到单页,
与 Mermaid 和 ECharts 遵循相同的不可跨页规则。
- 默认示例增加 NASA 中等尺寸图与 Unsplash 超高竖图,直接覆盖正常图片
和超高图片分页场景。
## 4. 已执行验证 ## 4. 已执行验证
2026-07-27 在当前完整工作区成功执行: 2026-07-27 在当前完整工作区成功执行:
@@ -396,7 +417,7 @@ git diff --check
- 前端测试:67 项通过; - 前端测试:67 项通过;
- 后端测试:29 项通过; - 后端测试:29 项通过;
- 桌面端测试:7 项通过; - 桌面端测试:7 项通过;
- 全项目共 163 项测试通过; - 全项目共 171 项测试通过;
- 全项目类型检查通过; - 全项目类型检查通过;
- 生产构建通过; - 生产构建通过;
- `git diff --check` 通过。 - `git diff --check` 通过。
@@ -546,6 +567,28 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
- Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB, - Forge Squirrel 安装包、NUPKG 和 ZIP 均已生成,安装包约 137.48 MiB,
当前 Authenticode 状态为未签名。 当前 Authenticode 状态为未签名。
2026-07-27 已完成 `v0.4.1` 图片资源发布候选验证:
- 用户提供的 `tmp/数据工程规划.md` 成功解析 2 张本地 PNG,生成约
5.0 MB 内嵌 HTML,图片自然宽度均为 8192px,零资源告警。
- Web 真实选择 Markdown 与 `.assets` 目录后生成 4 页快速预览,两张
本地图片均完整位于各自页面内容区。
- Wikimedia SVG、NASA JPEG 和 Unsplash JPEG 三种公网图片均完成下载、
格式识别、Data URL 内嵌和自然尺寸解码。
- 1200×1800 超高网络图在扣除标题高度后缩放;最终图片约 977px 高,
图加标题约 1001px 高,完整位于同一页且未复制、裁切或跨页。
- 默认示例的快速预览和精确预览均为 7 页,两个图片标题可见;容器
Playwright PDF 的精确预览同样为 7 页。
- Electron 目录版显示 `v0.4.1`,使用 Electron 内置 Chromium 成功渲染
两张默认网络图片;Windows 主程序仍为 `md-to-pdf.exe`
- 最终 Squirrel 安装包为
`Markdown PDF 导出器-0.4.1 Setup.exe`144,166,400 字节,SHA-256
`FC6687494255EE21224B65C1CA2D520316D568CCE8EB99A608A0DEF160611F4E`
Authenticode 状态为未签名。
- 本地镜像 `yixiong/md-to-pdf:v0.4.1` 内容 ID 为
`sha256:1ded8a8a11ecb8b69eaa116bb9129dd4ec039e9918b047169fe5f8add2af3a80`
Compose 容器健康运行在 `http://localhost:8080`
## 5. 当前注意事项 ## 5. 当前注意事项
- `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。 - `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。
@@ -587,7 +630,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
未来改变分发范围前必须重新完成许可证审查。 未来改变分发范围前必须重新完成许可证审查。
- 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响; - 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响;
对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。 对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。
- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。 - 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档
目录读取;远程图片仅保留有界内存缓存。
- 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF - 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF
导出,不依赖 Fastify 或本地 HTTP Server。 导出,不依赖 Fastify 或本地 HTTP Server。
- v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证, - v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证,
@@ -595,16 +639,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
## 6. 推荐接手顺序 ## 6. 推荐接手顺序
### 阶段一:桌面文档工作区与资源安全 ### 阶段一:导出配置扩展
- Markdown 文件夹或 ZIP
- 相对图片解析;
- 每请求独立临时目录;
- 路径穿越和符号链接越界防护;
- 文件数量、单文件大小、总大小和超时限制;
- 请求结束可靠清理。
### 阶段二:导出配置扩展
- Front Matter 元数据覆盖; - Front Matter 元数据覆盖;
- 浏览器本地配置预设; - 浏览器本地配置预设;
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.4.0", "version": "0.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.4.0", "version": "0.4.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -21,9 +21,9 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@md-to-pdf/desktop", "name": "@md-to-pdf/desktop",
"version": "0.4.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@md-to-pdf/application": "0.4.0" "@md-to-pdf/application": "0.4.1"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "7.11.2", "@electron-forge/cli": "7.11.2",
@@ -525,7 +525,7 @@
"name": "@md-to-pdf/server", "name": "@md-to-pdf/server",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0", "@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2", "fastify": "^5.6.2",
@@ -12272,7 +12272,7 @@
}, },
"packages/application": { "packages/application": {
"name": "@md-to-pdf/application", "name": "@md-to-pdf/application",
"version": "0.4.0", "version": "0.4.1",
"dependencies": { "dependencies": {
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0" "@md-to-pdf/renderer": "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "md-to-pdf", "name": "md-to-pdf",
"version": "0.4.0", "version": "0.4.1",
"private": true, "private": true,
"description": "可配置、可主题化的 Markdown PDF 导出工具", "description": "可配置、可主题化的 Markdown PDF 导出工具",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@md-to-pdf/application", "name": "@md-to-pdf/application",
"version": "0.4.0", "version": "0.4.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -6,12 +6,18 @@ import {
createThemeRegistry, createThemeRegistry,
type ThemeRegistryOptions type ThemeRegistryOptions
} from "./theme-registry.js"; } from "./theme-registry.js";
import {
createImageResourceResolver,
type ImageResolutionContext,
type ImageResourceResolverOptions
} from "./image-resources.js";
export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000; export const MAXIMUM_MARKDOWN_LENGTH = 1_500_000;
export interface MarkdownRenderRequest { export interface MarkdownRenderRequest {
markdown?: unknown; markdown?: unknown;
language?: unknown; language?: unknown;
resources?: unknown;
} }
export class ApplicationRequestError extends Error { 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( export function createApplicationService(
options: ApplicationServiceOptions options: ApplicationServiceOptions
) { ) {
const themes = createThemeRegistry(options); const themes = createThemeRegistry(options);
const resolveImages = createImageResourceResolver(options);
function render(request: MarkdownRenderRequest) { async function render(
const { markdown, language } = request; request: MarkdownRenderRequest,
context: ImageResolutionContext = {}
) {
const { markdown, language, resources } = request;
if (typeof markdown !== "string") { if (typeof markdown !== "string") {
throw new ApplicationRequestError( throw new ApplicationRequestError(
400, 400,
@@ -57,8 +69,11 @@ export function createApplicationService(
} }
try { try {
const images = await resolveImages(markdown, resources, context);
return renderMarkdown(markdown, { return renderMarkdown(markdown, {
...(language ? { language } : {}) ...(language ? { language } : {}),
imageSourceMap: images.sources,
warnings: images.warnings
}); });
} catch (error) { } catch (error) {
if (error instanceof MarkdownDocumentParseError) { if (error instanceof MarkdownDocumentParseError) {
@@ -68,6 +83,16 @@ export function createApplicationService(
error.message 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; throw error;
} }
} }
+440
View File
@@ -0,0 +1,440 @@
import { lookup } from "node:dns/promises";
import { readFile, realpath, stat } from "node:fs/promises";
import { isIP } from "node:net";
import path from "node:path";
import { extractMarkdownImageSources } from "@md-to-pdf/renderer";
export const MAXIMUM_IMAGE_COUNT = 50;
export const MAXIMUM_IMAGE_BYTES = 8 * 1024 * 1024;
export const MAXIMUM_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
export const REMOTE_IMAGE_TIMEOUT_MS = 10_000;
export const MAXIMUM_REMOTE_REDIRECTS = 3;
const failedImageDataUrl = `data:image/svg+xml;base64,${Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="120" viewBox="0 0 640 120"><rect width="640" height="120" fill="#f8fafc"/><rect x="1" y="1" width="638" height="118" fill="none" stroke="#cbd5e1"/><text x="320" y="67" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#64748b">图片加载失败</text></svg>`
).toString("base64")}`;
export interface MarkdownImageResource {
path: string;
contentType?: string;
data: string;
}
export interface ImageResolutionContext {
localRoot?: string;
}
export interface ResolvedMarkdownImages {
sources: ReadonlyMap<string, string>;
warnings: string[];
}
export interface ImageResourceResolverOptions {
remoteLoader?: (url: string) => Promise<LoadedImage>;
}
export interface LoadedImage {
content: Buffer;
contentType: string;
}
function isPrivateIpv4(
address: string,
allowProxyBenchmarkRange = false
) {
const parts = address.split(".").map(Number);
const [first, second] = parts;
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) ||
first === undefined ||
second === undefined
) {
return true;
}
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(!allowProxyBenchmarkRange &&
first === 198 &&
(second === 18 || second === 19)) ||
first >= 224
);
}
function isPrivateIpAddress(
address: string,
allowProxyBenchmarkRange = false
) {
const normalized = address.toLowerCase();
if (isIP(normalized) === 4) {
return isPrivateIpv4(normalized, allowProxyBenchmarkRange);
}
if (isIP(normalized) !== 6) {
return true;
}
if (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
/^fe[89ab]/u.test(normalized)
) {
return true;
}
const mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/u)?.[1];
return mapped
? isPrivateIpv4(mapped, allowProxyBenchmarkRange)
: false;
}
async function assertPublicRemoteUrl(url: URL) {
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("仅支持 HTTP/HTTPS 图片");
}
if (
url.username ||
url.password ||
url.hostname.toLowerCase() === "localhost"
) {
throw new Error("图片地址包含不允许的认证或本地主机");
}
if (isIP(url.hostname)) {
if (isPrivateIpAddress(url.hostname)) {
throw new Error("图片地址指向非公网 IP");
}
return;
}
const addresses = await lookup(url.hostname, {
all: true,
verbatim: true
});
if (
addresses.length === 0 ||
addresses.some(({ address }) => isPrivateIpAddress(address, true))
) {
throw new Error("图片域名解析到非公网 IP");
}
}
function detectImageContentType(content: Buffer) {
if (
content.length >= 8 &&
content.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return "image/png";
}
if (
content.length >= 3 &&
content[0] === 0xff &&
content[1] === 0xd8 &&
content[2] === 0xff
) {
return "image/jpeg";
}
const prefix = content.subarray(0, 12).toString("ascii");
if (prefix.startsWith("GIF87a") || prefix.startsWith("GIF89a")) {
return "image/gif";
}
if (prefix.startsWith("RIFF") && prefix.endsWith("WEBP")) {
return "image/webp";
}
if (
content.length >= 12 &&
content.subarray(4, 12).toString("ascii").startsWith("ftyp") &&
/(?:avif|avis)/u.test(content.subarray(8, 32).toString("ascii"))
) {
return "image/avif";
}
const textPrefix = content.subarray(0, Math.min(content.length, 4096))
.toString("utf8")
.replace(/^\uFEFF/u, "")
.trimStart();
if (
/^(?:<\?xml[\s\S]*?\?>\s*)?<svg[\s>]/iu.test(textPrefix)
) {
const svg = content.toString("utf8");
if (
/<script\b|<foreignObject\b|\son\w+\s*=|(?:href|src)\s*=\s*["'](?:https?:|\/\/)/iu.test(
svg
)
) {
throw new Error("SVG 包含不安全的脚本或外部资源");
}
return "image/svg+xml";
}
throw new Error("资源不是受支持的图片格式");
}
function imageToDataUrl(image: LoadedImage) {
return `data:${image.contentType};base64,${image.content.toString("base64")}`;
}
async function readResponseBody(response: Response) {
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAXIMUM_IMAGE_BYTES
) {
throw new Error("远程图片超过单文件大小限制");
}
if (!response.body) {
throw new Error("远程图片响应为空");
}
const chunks: Buffer[] = [];
let total = 0;
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
total += value.byteLength;
if (total > MAXIMUM_IMAGE_BYTES) {
await reader.cancel();
throw new Error("远程图片超过单文件大小限制");
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, total);
}
export async function downloadRemoteImage(
source: string,
fetcher: typeof fetch = fetch
): Promise<LoadedImage> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
REMOTE_IMAGE_TIMEOUT_MS
);
try {
let current = new URL(source);
for (
let redirectCount = 0;
redirectCount <= MAXIMUM_REMOTE_REDIRECTS;
redirectCount += 1
) {
await assertPublicRemoteUrl(current);
const response = await fetcher(current, {
redirect: "manual",
signal: controller.signal,
headers: {
accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml",
"user-agent": "md-to-pdf/0.4.1"
}
});
if (
response.status >= 300 &&
response.status < 400 &&
response.headers.has("location")
) {
if (redirectCount === MAXIMUM_REMOTE_REDIRECTS) {
throw new Error("远程图片重定向次数过多");
}
current = new URL(response.headers.get("location")!, current);
continue;
}
if (!response.ok) {
throw new Error(`远程图片返回 HTTP ${response.status}`);
}
const content = await readResponseBody(response);
return {
content,
contentType: detectImageContentType(content)
};
}
throw new Error("远程图片重定向次数过多");
} finally {
clearTimeout(timeout);
}
}
export function normalizeDocumentAssetPath(source: string) {
const pathOnly = source.split(/[?#]/u, 1)[0] ?? "";
let decoded: string;
try {
decoded = decodeURIComponent(pathOnly);
} catch {
throw new Error("图片路径 URL 编码无效");
}
const normalizedSeparators = decoded.replace(/\\/gu, "/");
if (
normalizedSeparators.startsWith("/") ||
/^[a-z]:\//iu.test(normalizedSeparators) ||
normalizedSeparators.includes("\0")
) {
throw new Error("不允许绝对图片路径");
}
const segments = normalizedSeparators
.split("/")
.filter((segment) => segment && segment !== ".");
if (segments.length === 0 || segments.includes("..")) {
throw new Error("图片路径为空或越过文档目录");
}
return segments.join("/");
}
function isContainedPath(root: string, target: string) {
const relative = path.relative(root, target);
return (
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
async function loadLocalImage(root: string, source: string) {
const normalized = normalizeDocumentAssetPath(source);
const realRoot = await realpath(root);
const target = path.resolve(realRoot, ...normalized.split("/"));
const realTarget = await realpath(target);
if (!isContainedPath(realRoot, realTarget)) {
throw new Error("图片路径越过文档目录");
}
const targetStat = await stat(realTarget);
if (!targetStat.isFile()) {
throw new Error("图片资源不是文件");
}
if (targetStat.size > MAXIMUM_IMAGE_BYTES) {
throw new Error("本地图片超过单文件大小限制");
}
const content = await readFile(realTarget);
return {
content,
contentType: detectImageContentType(content)
};
}
function parseUploadedResources(value: unknown) {
if (value === undefined) {
return new Map<string, LoadedImage>();
}
if (!Array.isArray(value)) {
throw new Error("图片素材必须是数组");
}
if (value.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const result = new Map<string, LoadedImage>();
let totalBytes = 0;
for (const item of value) {
if (
typeof item !== "object" ||
item === null ||
!("path" in item) ||
!("data" in item) ||
typeof item.path !== "string" ||
typeof item.data !== "string" ||
item.data.length > Math.ceil(MAXIMUM_IMAGE_BYTES * 4 / 3) + 16
) {
throw new Error("图片素材参数无效");
}
const normalized = normalizeDocumentAssetPath(item.path);
if (result.has(normalized)) {
throw new Error(`图片素材路径重复:${normalized}`);
}
const content = Buffer.from(item.data, "base64");
if (content.length === 0 || content.length > MAXIMUM_IMAGE_BYTES) {
throw new Error(`图片素材大小无效:${normalized}`);
}
totalBytes += content.length;
if (totalBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("图片素材总大小超过限制");
}
result.set(normalized, {
content,
contentType: detectImageContentType(content)
});
}
return result;
}
export function createImageResourceResolver(
options: ImageResourceResolverOptions = {}
) {
const remoteLoader = options.remoteLoader ?? downloadRemoteImage;
const remoteCache = new Map<
string,
{ expiresAt: number; promise: Promise<LoadedImage> }
>();
function loadRemote(source: string) {
const cached = remoteCache.get(source);
if (cached && cached.expiresAt > Date.now()) {
return cached.promise;
}
const promise = remoteLoader(source);
remoteCache.set(source, {
expiresAt: Date.now() + 5 * 60_000,
promise
});
if (remoteCache.size > 100) {
const oldest = remoteCache.keys().next().value as string | undefined;
if (oldest) {
remoteCache.delete(oldest);
}
}
void promise.catch(() => {
if (remoteCache.get(source)?.promise === promise) {
remoteCache.delete(source);
}
});
return promise;
}
return async function resolve(
markdown: string,
resources: unknown,
context: ImageResolutionContext = {}
): Promise<ResolvedMarkdownImages> {
const references = extractMarkdownImageSources(markdown);
if (references.length > MAXIMUM_IMAGE_COUNT) {
throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`);
}
const uploaded = parseUploadedResources(resources);
const sources = new Map<string, string>();
const warnings: string[] = [];
let resolvedBytes = 0;
for (const source of references) {
if (source.startsWith("data:")) {
continue;
}
try {
let image: LoadedImage;
if (/^https?:\/\//iu.test(source)) {
image = await loadRemote(source);
} else {
const normalized = normalizeDocumentAssetPath(source);
const uploadedImage = uploaded.get(normalized);
if (uploadedImage) {
image = uploadedImage;
} else if (context.localRoot) {
image = await loadLocalImage(context.localRoot, source);
} else {
throw new Error("未提供对应素材目录");
}
}
resolvedBytes += image.content.length;
if (resolvedBytes > MAXIMUM_TOTAL_IMAGE_BYTES) {
throw new Error("文档图片总大小超过限制");
}
sources.set(source, imageToDataUrl(image));
} catch (error) {
const message =
error instanceof Error ? error.message : "未知错误";
warnings.push(`图片 ${source} 加载失败:${message}`);
sources.set(source, failedImageDataUrl);
}
}
return { sources, warnings };
};
}
+14
View File
@@ -6,6 +6,20 @@ export {
type ApplicationServiceOptions, type ApplicationServiceOptions,
type MarkdownRenderRequest type MarkdownRenderRequest
} from "./application-service.js"; } 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 { export {
createThemeRegistry, createThemeRegistry,
type ThemeRecord, type ThemeRecord,
@@ -80,7 +80,7 @@ describe("共享应用服务", () => {
const roots = await createThemeFixture(); const roots = await createThemeFixture();
const service = createApplicationService(roots); const service = createApplicationService(roots);
const document = service.render({ const document = await service.render({
markdown: "# 文档\n\n<script>alert('xss')</script>", markdown: "# 文档\n\n<script>alert('xss')</script>",
language: "zh-CN" language: "zh-CN"
}); });
@@ -94,7 +94,7 @@ describe("共享应用服务", () => {
const roots = await createThemeFixture(); const roots = await createThemeFixture();
const service = createApplicationService(roots); const service = createApplicationService(roots);
expect(() => service.render({ markdown: 42 })).toThrow( await expect(service.render({ markdown: 42 })).rejects.toEqual(
expect.objectContaining<ApplicationRequestError>({ expect.objectContaining<ApplicationRequestError>({
statusCode: 400, statusCode: 400,
code: "INVALID_MARKDOWN" 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:
"![本地](./%E6%96%87%E6%A1%A3.assets/%E6%9C%AC%E5%9C%B0.png)"
},
{ localRoot: assetRoot }
);
const uploaded = await service.render({
markdown: "![网页](文档.assets/网页.png)",
resources: [
{
path: "文档.assets/网页.png",
data: image.toString("base64")
}
]
});
expect(local.articleHtml).toContain("data:image/png;base64,");
expect(uploaded.articleHtml).toContain("data:image/png;base64,");
expect(local.articleHtml).toContain("md-document-image");
expect(uploaded.warnings).toEqual([]);
});
it("列出主题并使用调用方提供的资源 URL", async () => { it("列出主题并使用调用方提供的资源 URL", async () => {
const roots = await createThemeFixture(); const roots = await createThemeFixture();
const service = createApplicationService({ 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(
"![a](https://example.com/a.png)\n![a2](https://example.com/a.png)",
undefined
);
const failed = await resolve(
"![b](https://example.com/b.png)",
undefined
);
expect(remoteLoader).toHaveBeenCalledTimes(2);
expect(first.sources.get("https://example.com/a.png")).toMatch(
/^data:image\/png;base64,/u
);
expect(failed.sources.get("https://example.com/b.png")).toMatch(
/^data:image\/svg\+xml;base64,/u
);
expect(failed.warnings[0]).toContain("网络不可用");
});
});
+140 -2
View File
@@ -34,6 +34,8 @@ export type { MarkdownDocumentMetadata } from "@md-to-pdf/core";
export interface RenderMarkdownOptions { export interface RenderMarkdownOptions {
language?: string; language?: string;
imageSourceMap?: ReadonlyMap<string, string>;
warnings?: readonly string[];
} }
export interface RenderedMarkdown export interface RenderedMarkdown
@@ -84,9 +86,83 @@ const markdown = new MarkdownIt(markdownOptions)
}); });
const defaultFenceRenderer = markdown.renderer.rules.fence; 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 lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u;
const tableTextAlignStylePattern = /^(?:left|center|right)$/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 = ( markdown.renderer.rules.fence = (
tokens, tokens,
index, index,
@@ -114,6 +190,46 @@ markdown.renderer.rules.fence = (
return renderFallbackFence(tokens, index, renderer); 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, { markdown.use(markdownItECharts, {
onError(error, context: MarkdownItEChartsErrorContext) { onError(error, context: MarkdownItEChartsErrorContext) {
const environment = context.environment as const environment = context.environment as
@@ -193,10 +309,11 @@ export function renderMarkdown(
source: string, source: string,
options: RenderMarkdownOptions = {} options: RenderMarkdownOptions = {}
): RenderedMarkdown { ): RenderedMarkdown {
const warnings: string[] = []; const warnings = [...(options.warnings ?? [])];
const parsed = parseFrontMatter(source); const parsed = parseFrontMatter(source);
const environment = { const environment = {
warnings warnings,
imageSourceMap: options.imageSourceMap
}; };
const rendered = markdown.render(parsed.content, environment); const rendered = markdown.render(parsed.content, environment);
const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions); 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) { function parseFrontMatter(source: string) {
try { try {
return matter(source); return matter(source);
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
extractMarkdownImageSources,
MarkdownDocumentParseError, MarkdownDocumentParseError,
renderMarkdown renderMarkdown
} from "../src/render-markdown.js"; } from "../src/render-markdown.js";
@@ -210,6 +211,55 @@ option:
expect(result.bodyHtml).toContain("&lt;script&gt;"); expect(result.bodyHtml).toContain("&lt;script&gt;");
}); });
it("提取图片引用并使用受控资源地址替换", () => {
const source =
"![本地](./文档.assets/a%20b.png)\n\n![远程](https://example.com/a.png)";
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(
"文字 ![行内图](inline.png) 继续\n\n![独立标题](block.png)"
);
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("为重复中文标题生成唯一锚点", () => { it("为重复中文标题生成唯一锚点", () => {
const result = renderMarkdown(` const result = renderMarkdown(`
# 章节 # 章节