From 32a37293d03912054f2e3b844f471c71f3cf85ba Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Mon, 27 Jul 2026 22:12:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=B8=8E=E7=BD=91=E7=BB=9C=E5=9B=BE=E7=89=87=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 26 +- apps/desktop/README.md | 12 +- apps/desktop/package.json | 4 +- apps/desktop/src/app-preload.ts | 2 + apps/desktop/src/application-contract.ts | 3 +- apps/desktop/src/channels.ts | 2 + apps/desktop/src/main.ts | 31 +- apps/server/package.json | 2 +- apps/server/src/app.ts | 7 +- apps/web/src/App.tsx | 124 ++++- apps/web/src/application-backend.ts | 6 + apps/web/src/document-image-fit.ts | 72 +++ apps/web/src/paged-document-runtime.ts | 8 +- apps/web/src/paged-preview.ts | 24 + apps/web/src/pdf-export.ts | 3 + apps/web/src/use-markdown-render.ts | 12 +- apps/web/src/vite-env.d.ts | 11 + apps/web/tests/app-version.test.ts | 4 +- apps/web/tests/document-image-fit.test.ts | 53 +++ deploy/README.md | 6 +- docs/PROGRESS.md | 69 ++- package-lock.json | 12 +- package.json | 2 +- packages/application/package.json | 2 +- .../application/src/application-service.ts | 33 +- packages/application/src/image-resources.ts | 440 ++++++++++++++++++ packages/application/src/index.ts | 14 + .../tests/application-service.test.ts | 37 +- .../application/tests/image-resources.test.ts | 62 +++ packages/renderer/src/render-markdown.ts | 142 +++++- .../renderer/tests/render-markdown.test.ts | 50 ++ 31 files changed, 1204 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/document-image-fit.ts create mode 100644 apps/web/tests/document-image-fit.test.ts create mode 100644 packages/application/src/image-resources.ts create mode 100644 packages/application/tests/image-resources.test.ts diff --git a/README.md b/README.md index b602136..13fc255 100644 --- a/README.md +++ b/README.md @@ -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 ## 隐私原则 -文档内容仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 -相对资源文件支持将在后续阶段通过隔离临时目录和请求结束清理实现。 +文档内容与图片仅用于当前渲染和转换请求,不接入数据库或文档历史存储。 +相对路径会规范化并限制在所选目录内;路径穿越、符号链接越界、内网远程 +地址、超出数量或大小上限的图片会被拒绝或替换为失败占位。 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0c8d4e3..0cdfc4c 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 安装包验证。 ## 开发 @@ -26,7 +27,7 @@ npm run make -w @md-to-pdf/desktop ``` 版本化目录包、Squirrel `Setup.exe` 和 ZIP 输出到 -`apps/desktop/out/v0.4.0/`。该目录被 Git 忽略。公司内部分发以 +`apps/desktop/out/v0.4.1/`。该目录被 Git 忽略。公司内部分发以 `Setup.exe` 为正式安装包,ZIP 作为免安装辅助包;当前未配置代码签名, Windows 首次运行可能显示“未知发布者”提示。 @@ -42,7 +43,10 @@ Web 静态资源作为只读资源放在 Electron `resources/dist` 下,生产 - 应用窗口和 PDF 窗口均关闭 Node Integration; - 启用 Context Isolation 和 Chromium Sandbox; -- preload 只暴露渲染、主题、生成 PDF 和保存 PDF 所需的窄接口; +- preload 只暴露打开 Markdown、渲染、主题、生成 PDF 和保存 PDF 所需 + 的窄接口; +- 本地图片只能从当前 Markdown 所在目录内读取,并拒绝路径穿越和符号 + 链接越界; - 主进程校验 IPC 发送者和分页载荷; - PDF 窗口使用独立内存 Session,并阻止外部网络资源; - 应用页面使用自定义协议,不使用 `file://`。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bd0e1a7..fb8fecd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@md-to-pdf/desktop", - "version": "0.4.0", + "version": "0.4.1", "private": true, "productName": "Markdown PDF 导出器", "description": "Markdown PDF 导出器桌面端", @@ -8,7 +8,7 @@ "type": "module", "main": "dist/main.js", "dependencies": { - "@md-to-pdf/application": "0.4.0" + "@md-to-pdf/application": "0.4.1" }, "scripts": { "build": "npm run clean && npm run build:main && npm run build:preload", diff --git a/apps/desktop/src/app-preload.ts b/apps/desktop/src/app-preload.ts index 2424839..a4aeda8 100644 --- a/apps/desktop/src/app-preload.ts +++ b/apps/desktop/src/app-preload.ts @@ -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), diff --git a/apps/desktop/src/application-contract.ts b/apps/desktop/src/application-contract.ts index e8200f7..d26deb4 100644 --- a/apps/desktop/src/application-contract.ts +++ b/apps/desktop/src/application-contract.ts @@ -12,7 +12,8 @@ export function parseMarkdownRenderRequest( } return { markdown: value.markdown, - language: value.language + language: value.language, + resources: value.resources }; } diff --git a/apps/desktop/src/channels.ts b/apps/desktop/src/channels.ts index e00c2dd..1878b13 100644 --- a/apps/desktop/src/channels.ts +++ b/apps/desktop/src/channels.ts @@ -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 = diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b2d5627..2aea0d0 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -8,7 +8,7 @@ import { 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 +19,7 @@ import { DESKTOP_GET_THEME_CSS, DESKTOP_GENERATE_PDF, DESKTOP_LIST_THEMES, + DESKTOP_OPEN_MARKDOWN, DESKTOP_RENDER_MARKDOWN, DESKTOP_SAVE_PDF } from "./channels.js"; @@ -229,7 +230,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 +262,8 @@ async function createApplication( throw new Error("拒绝未授权的桌面渲染请求"); } 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.on("closed", () => { + ipcMain.removeHandler(DESKTOP_OPEN_MARKDOWN); ipcMain.removeHandler(DESKTOP_RENDER_MARKDOWN); ipcMain.removeHandler(DESKTOP_LIST_THEMES); ipcMain.removeHandler(DESKTOP_GET_THEME_CSS); diff --git a/apps/server/package.json b/apps/server/package.json index 1caf88a..90daf8c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index f16820d..d651749 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 479fb8b..a55c674 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 => ({ + 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$ +## 网络图片 + +### 中等尺寸 + +![NASA 地球中等尺寸示例](https://images-assets.nasa.gov/image/PIA18033/PIA18033~medium.jpg) + +### 超高尺寸 + +下图用于验证图片高度超过单页内容区时,会等比例缩放并作为完整块放置, +不会跨页切割。 + +![超高网络图片分页示例](https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&q=80) + \`\`\`mermaid flowchart LR A["Markdown"] --> B["统一 HTML"] @@ -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(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 + ) { + 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() {
- + {window.mdToPdfDesktop ? ( + + ) : ( + <> + + + + )} diff --git a/apps/web/src/application-backend.ts b/apps/web/src/application-backend.ts index e97a01e..c58cf5d 100644 --- a/apps/web/src/application-backend.ts +++ b/apps/web/src/application-backend.ts @@ -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) { diff --git a/apps/web/src/document-image-fit.ts b/apps/web/src/document-image-fit.ts new file mode 100644 index 0000000..329d5c3 --- /dev/null +++ b/apps/web/src/document-image-fit.ts @@ -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("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"); + } +} diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts index 43142f1..50c7895 100644 --- a/apps/web/src/paged-document-runtime.ts +++ b/apps/web/src/paged-document-runtime.ts @@ -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( diff --git a/apps/web/src/paged-preview.ts b/apps/web/src/paged-preview.ts index 46aa258..877935c 100644 --- a/apps/web/src/paged-preview.ts +++ b/apps/web/src/paged-preview.ts @@ -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) { diff --git a/apps/web/src/pdf-export.ts b/apps/web/src/pdf-export.ts index 774ea11..f0ac65f 100644 --- a/apps/web/src/pdf-export.ts +++ b/apps/web/src/pdf-export.ts @@ -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 ]); } diff --git a/apps/web/src/use-markdown-render.ts b/apps/web/src/use-markdown-render.ts index 711d6b9..8b19433 100644 --- a/apps/web/src/use-markdown-render.ts +++ b/apps/web/src/use-markdown-render.ts @@ -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 }; } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 733a1c0..1da88e4 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -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; listThemes(): Promise<{ themes: Array<{ diff --git a/apps/web/tests/app-version.test.ts b/apps/web/tests/app-version.test.ts index 1eb0820..4340327 100644 --- a/apps/web/tests/app-version.test.ts +++ b/apps/web/tests/app-version.test.ts @@ -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.1"); + expect(APP_VERSION_LABEL).toBe("v0.4.1"); }); }); diff --git a/apps/web/tests/document-image-fit.test.ts b/apps/web/tests/document-image-fit.test.ts new file mode 100644 index 0000000..afe2adc --- /dev/null +++ b/apps/web/tests/document-image-fit.test.ts @@ -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 = + '

'; + 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 = + '

'; + 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(""); + }); +}); diff --git a/deploy/README.md b/deploy/README.md index 38c40c9..396a04c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 应用代码和生产 依赖。正式跨机器构建仍建议使用默认完整路径。 ## 主题挂载 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index f275e6a..741bc1c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -31,11 +31,9 @@ fa07472 feat: 实现网页实时预览 ec48bce chore: 初始化项目骨架 ``` -最新阶段已完成 Markdown ECharts YAML 围栏、统一 SVG 渲染与分页, -并完善默认示例、源文本收起、顶栏文档状态和快速页码跳转。 -`v0.4.0` 桌面端阶段已经完成:共享应用服务、无 Server 的 Electron -运行时、三套内置主题、统一品牌资源、Windows 安装包以及 Docker Compose -发布候选均已通过验证。 +`v0.4.1` 图片资源阶段已经完成:Web 素材目录、Desktop 同目录资源、 +受限公网图片下载、可见图片标题、图片整块分页与超高图片单页适配均复用 +统一渲染链路。Electron 安装包和 Docker Compose 发布候选均已通过验证。 ## 2. 已完成 @@ -376,6 +374,29 @@ 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 超高竖图,直接覆盖正常图片 + 和超高图片分页场景。 + ## 4. 已执行验证 2026-07-27 在当前完整工作区成功执行: @@ -396,7 +417,7 @@ git diff --check - 前端测试:67 项通过; - 后端测试:29 项通过; - 桌面端测试:7 项通过; -- 全项目共 163 项测试通过; +- 全项目共 171 项测试通过; - 全项目类型检查通过; - 生产构建通过; - `git diff --check` 通过。 @@ -546,6 +567,28 @@ 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`。 + ## 5. 当前注意事项 - `output/` 可能包含本地 PDF 验证产物,已被 Git 忽略,不得提交。 @@ -587,7 +630,8 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 未来改变分发范围前必须重新完成许可证审查。 - 快速预览会受到客户端 Windows 显示缩放、浏览器缩放和设备像素比影响; 对页数和分页边界有严格要求时,以精确预览和最终 PDF 为准。 -- 资源目录上传、本地相对图片、临时目录和路径安全尚未实现。 +- 图片资源不落盘:Web 使用当前请求内 Base64 资源,Desktop 从受限文档 + 目录读取;远程图片仅保留有界内存缓存。 - 桌面端已通过进程内共享服务实现离线 Markdown 渲染、主题读取和 PDF 导出,不依赖 Fastify 或本地 HTTP Server。 - v0.4.0 AIO 容器已经通过 Docker Desktop 构建、端到端和浏览器验证, @@ -595,16 +639,7 @@ ECharts 第二阶段浏览器与 PDF 验证结果: ## 6. 推荐接手顺序 -### 阶段一:桌面文档工作区与资源安全 - -- Markdown 文件夹或 ZIP; -- 相对图片解析; -- 每请求独立临时目录; -- 路径穿越和符号链接越界防护; -- 文件数量、单文件大小、总大小和超时限制; -- 请求结束可靠清理。 - -### 阶段二:导出配置扩展 +### 阶段一:导出配置扩展 - Front Matter 元数据覆盖; - 浏览器本地配置预设; diff --git a/package-lock.json b/package-lock.json index 4e7d232..c86a4e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "md-to-pdf", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "md-to-pdf", - "version": "0.4.0", + "version": "0.4.1", "workspaces": [ "apps/*", "packages/*" @@ -21,9 +21,9 @@ }, "apps/desktop": { "name": "@md-to-pdf/desktop", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { - "@md-to-pdf/application": "0.4.0" + "@md-to-pdf/application": "0.4.1" }, "devDependencies": { "@electron-forge/cli": "7.11.2", @@ -525,7 +525,7 @@ "name": "@md-to-pdf/server", "version": "0.1.0", "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", @@ -12272,7 +12272,7 @@ }, "packages/application": { "name": "@md-to-pdf/application", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@md-to-pdf/core": "0.1.0", "@md-to-pdf/renderer": "0.1.0" diff --git a/package.json b/package.json index 6f07ce1..fd84806 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "md-to-pdf", - "version": "0.4.0", + "version": "0.4.1", "private": true, "description": "可配置、可主题化的 Markdown PDF 导出工具", "workspaces": [ diff --git a/packages/application/package.json b/packages/application/package.json index 3edfc8a..b8494b2 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -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", diff --git a/packages/application/src/application-service.ts b/packages/application/src/application-service.ts index 0c18e4d..eeee748 100644 --- a/packages/application/src/application-service.ts +++ b/packages/application/src/application-service.ts @@ -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; } } diff --git a/packages/application/src/image-resources.ts b/packages/application/src/image-resources.ts new file mode 100644 index 0000000..dd4cb25 --- /dev/null +++ b/packages/application/src/image-resources.ts @@ -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( + `图片加载失败` +).toString("base64")}`; + +export interface MarkdownImageResource { + path: string; + contentType?: string; + data: string; +} + +export interface ImageResolutionContext { + localRoot?: string; +} + +export interface ResolvedMarkdownImages { + sources: ReadonlyMap; + warnings: string[]; +} + +export interface ImageResourceResolverOptions { + remoteLoader?: (url: string) => Promise; +} + +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*)?]/iu.test(textPrefix) + ) { + const svg = content.toString("utf8"); + if ( + / 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 { + 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(); + } + if (!Array.isArray(value)) { + throw new Error("图片素材必须是数组"); + } + if (value.length > MAXIMUM_IMAGE_COUNT) { + throw new Error(`图片数量不能超过 ${MAXIMUM_IMAGE_COUNT}`); + } + const result = new Map(); + 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 } + >(); + + 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 { + const references = extractMarkdownImageSources(markdown); + if (references.length > MAXIMUM_IMAGE_COUNT) { + throw new Error(`图片引用数量不能超过 ${MAXIMUM_IMAGE_COUNT}`); + } + const uploaded = parseUploadedResources(resources); + const sources = new Map(); + 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 }; + }; +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index a7edaa8..7eaf126 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -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, diff --git a/packages/application/tests/application-service.test.ts b/packages/application/tests/application-service.test.ts index 41452a0..a0218ff 100644 --- a/packages/application/tests/application-service.test.ts +++ b/packages/application/tests/application-service.test.ts @@ -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", 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({ 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: + "![本地](./%E6%96%87%E6%A1%A3.assets/%E6%9C%AC%E5%9C%B0.png)" + }, + { localRoot: assetRoot } + ); + const uploaded = await service.render({ + markdown: "![网页](文档.assets/网页.png)", + resources: [ + { + path: "文档.assets/网页.png", + data: image.toString("base64") + } + ] + }); + + expect(local.articleHtml).toContain("data:image/png;base64,"); + expect(uploaded.articleHtml).toContain("data:image/png;base64,"); + expect(local.articleHtml).toContain("md-document-image"); + expect(uploaded.warnings).toEqual([]); + }); + it("列出主题并使用调用方提供的资源 URL", async () => { const roots = await createThemeFixture(); const service = createApplicationService({ diff --git a/packages/application/tests/image-resources.test.ts b/packages/application/tests/image-resources.test.ts new file mode 100644 index 0000000..d228744 --- /dev/null +++ b/packages/application/tests/image-resources.test.ts @@ -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(); + 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("网络不可用"); + }); +}); diff --git a/packages/renderer/src/render-markdown.ts b/packages/renderer/src/render-markdown.ts index 7bb8d0a..818fab5 100644 --- a/packages/renderer/src/render-markdown.ts +++ b/packages/renderer/src/render-markdown.ts @@ -34,6 +34,8 @@ export type { MarkdownDocumentMetadata } from "@md-to-pdf/core"; export interface RenderMarkdownOptions { language?: string; + imageSourceMap?: ReadonlyMap; + 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; + 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 '
\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 "
\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 + + `
${markdown.utils.escapeHtml(token.content)}
` + ); + } + 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) { + 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(); + collectImageSources(markdown.parse(parsed.content, {}), sources); + return [...sources]; +} + function parseFrontMatter(source: string) { try { return matter(source); diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts index 7e8d250..8bd056c 100644 --- a/packages/renderer/tests/render-markdown.test.ts +++ b/packages/renderer/tests/render-markdown.test.ts @@ -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 = + "![本地](./文档.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( + '
' + ); + expect(result.bodyHtml).toContain( + '
本地
' + ); + 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( + '

文字 行内图 继续

' + ); + expect(result.bodyHtml).not.toContain( + "md-document-image-caption\">行内图" + ); + expect(result.bodyHtml).toContain( + '
独立标题
' + ); + }); + it("为重复中文标题生成唯一锚点", () => { const result = renderMarkdown(` # 章节