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
+8 -4
View File
@@ -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://`
+2 -2
View File
@@ -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",
+2
View File
@@ -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),
+2 -1
View File
@@ -12,7 +12,8 @@ export function parseMarkdownRenderRequest(
}
return {
markdown: value.markdown,
language: value.language
language: value.language,
resources: value.resources
};
}
+2
View File
@@ -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 =
+29 -2
View File
@@ -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);
+1 -1
View File
@@ -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",
+4 -3
View File
@@ -26,6 +26,7 @@ import {
interface RenderRequestBody {
markdown?: unknown;
language?: unknown;
resources?: unknown;
}
interface PdfRequestBody extends RenderRequestBody {
@@ -108,7 +109,7 @@ function createPdfServerTiming(
export function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: options.logger ?? true,
bodyLimit: 2 * 1024 * 1024
bodyLimit: 24 * 1024 * 1024
});
const applicationService =
options.applicationService ??
@@ -214,7 +215,7 @@ export function buildApp(options: BuildAppOptions = {}) {
"/api/render",
async (request, reply) => {
try {
return applicationService.render(request.body ?? {});
return await applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
@@ -267,7 +268,7 @@ export function buildApp(options: BuildAppOptions = {}) {
const markdownStartedAt = performance.now();
let rendered;
try {
rendered = applicationService.render(request.body ?? {});
rendered = await applicationService.render(request.body ?? {});
} catch (error) {
if (error instanceof ApplicationRequestError) {
return reply.code(error.statusCode).send({
+115 -9
View File
@@ -48,6 +48,9 @@ import {
savePreviewZoom
} from "./preview-zoom";
import { useMarkdownRender } from "./use-markdown-render";
import {
type MarkdownImageResource
} from "./application-backend";
import {
type ThemeSummary,
useThemeResources
@@ -59,6 +62,30 @@ const PrecisePdfPreview = lazy(async () => {
});
type PreviewMode = "quick" | "precise";
const imageFilePattern = /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
function bytesToBase64(bytes: Uint8Array) {
const parts: string[] = [];
const chunkSize = 32_768;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
parts.push(
String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
);
}
return window.btoa(parts.join(""));
}
async function readImageResources(files: FileList) {
const images = Array.from(files).filter((file) =>
imageFilePattern.test(file.name)
);
return Promise.all(
images.map(async (file): Promise<MarkdownImageResource> => ({
path: file.webkitRelativePath || file.name,
data: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
}))
);
}
const sampleMarkdown = `---
title: Markdown PDF 示例
@@ -86,6 +113,19 @@ keywords:
行内公式:$E = mc^2$
## 网络图片
### 中等尺寸
![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<ExportConfig>(loadExportConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -407,6 +450,7 @@ export function App() {
const { error: renderError, result } = useMarkdownRender(
markdown,
"zh-CN",
imageResources,
{
onRenderStart: handleRenderStart,
onStatusChange: setStatus
@@ -469,9 +513,10 @@ export function App() {
markdown,
fileName,
language: "zh-CN",
resources: imageResources,
exportConfig
}),
[exportConfig, fileName, markdown]
[exportConfig, fileName, imageResources, markdown]
);
const pdfCacheKey = useMemo(
() => createPdfExportCacheKey(pdfRequest),
@@ -813,6 +858,8 @@ export function App() {
setAppError("");
setMarkdown(await file.text());
setFileName(file.name);
setImageResources([]);
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
} catch {
setAppError("无法读取所选 Markdown 文件");
} finally {
@@ -820,6 +867,43 @@ export function App() {
}
}
async function handleAssetDirectoryChange(
event: ChangeEvent<HTMLInputElement>
) {
const files = event.target.files;
if (!files?.length) {
return;
}
try {
setAppError("");
const resources = await readImageResources(files);
setImageResources(resources);
setStatus(`已载入 ${resources.length} 个图片素材`);
} catch {
setAppError("无法读取所选图片素材目录");
} finally {
event.target.value = "";
}
}
async function handleDesktopOpenMarkdown() {
try {
setAppError("");
const opened = await window.mdToPdfDesktop?.openMarkdown();
if (!opened) {
return;
}
setMarkdown(opened.markdown);
setFileName(opened.fileName);
setImageResources([]);
setStatus("Markdown 与同目录图片素材已载入");
} catch (reason) {
setAppError(
reason instanceof Error ? reason.message : "无法打开 Markdown 文件"
);
}
}
async function generatePdf(download: boolean) {
if (exportingPdf) {
return;
@@ -920,14 +1004,36 @@ export function App() {
</div>
</div>
<div className="topbar-actions">
<label className="file-button">
Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
{window.mdToPdfDesktop ? (
<button
type="button"
onClick={() => void handleDesktopOpenMarkdown()}
>
Markdown
</button>
) : (
<>
<label className="file-button">
Markdown
<input
type="file"
accept=".md,.markdown,text/markdown,text/plain"
onChange={handleFileChange}
/>
</label>
<label className="file-button">
<input
type="file"
accept="image/*"
multiple
// React 尚未声明 Chromium 的目录选择属性。
{...({ webkitdirectory: "" } as Record<string, string>)}
onChange={handleAssetDirectoryChange}
/>
</label>
</>
)}
<button type="button" onClick={() => setSettingsOpen(true)}>
</button>
+6
View File
@@ -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) {
+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";
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(
+24
View File
@@ -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) {
+3
View File
@@ -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
]);
}
+10 -2
View File
@@ -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 };
}
+11
View File
@@ -17,9 +17,20 @@ declare global {
}
interface DesktopApplicationBridge {
openMarkdown(): Promise<
| {
markdown: string;
fileName: string;
}
| undefined
>;
renderMarkdown(input: {
markdown: string;
language: string;
resources?: Array<{
path: string;
data: string;
}>;
}): Promise<RenderedMarkdownDocument>;
listThemes(): Promise<{
themes: Array<{
+2 -2
View File
@@ -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");
});
});
+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("");
});
});