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
+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("");
});
});