fix: 完善本地与网络图片渲染
This commit is contained in:
+115
-9
@@ -48,6 +48,9 @@ import {
|
||||
savePreviewZoom
|
||||
} from "./preview-zoom";
|
||||
import { useMarkdownRender } from "./use-markdown-render";
|
||||
import {
|
||||
type MarkdownImageResource
|
||||
} from "./application-backend";
|
||||
import {
|
||||
type ThemeSummary,
|
||||
useThemeResources
|
||||
@@ -59,6 +62,30 @@ const PrecisePdfPreview = lazy(async () => {
|
||||
});
|
||||
|
||||
type PreviewMode = "quick" | "precise";
|
||||
const imageFilePattern = /\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
const parts: string[] = [];
|
||||
const chunkSize = 32_768;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
parts.push(
|
||||
String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))
|
||||
);
|
||||
}
|
||||
return window.btoa(parts.join(""));
|
||||
}
|
||||
|
||||
async function readImageResources(files: FileList) {
|
||||
const images = Array.from(files).filter((file) =>
|
||||
imageFilePattern.test(file.name)
|
||||
);
|
||||
return Promise.all(
|
||||
images.map(async (file): Promise<MarkdownImageResource> => ({
|
||||
path: file.webkitRelativePath || file.name,
|
||||
data: bytesToBase64(new Uint8Array(await file.arrayBuffer()))
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const sampleMarkdown = `---
|
||||
title: Markdown PDF 示例
|
||||
@@ -86,6 +113,19 @@ keywords:
|
||||
|
||||
行内公式:$E = mc^2$
|
||||
|
||||
## 网络图片
|
||||
|
||||
### 中等尺寸
|
||||
|
||||

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

|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["Markdown"] --> B["统一 HTML"]
|
||||
@@ -346,6 +386,9 @@ option:
|
||||
export function App() {
|
||||
const [markdown, setMarkdown] = useState(sampleMarkdown);
|
||||
const [fileName, setFileName] = useState("示例文档.md");
|
||||
const [imageResources, setImageResources] = useState<
|
||||
MarkdownImageResource[]
|
||||
>([]);
|
||||
const [exportConfig, setExportConfig] =
|
||||
useState<ExportConfig>(loadExportConfig);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -407,6 +450,7 @@ export function App() {
|
||||
const { error: renderError, result } = useMarkdownRender(
|
||||
markdown,
|
||||
"zh-CN",
|
||||
imageResources,
|
||||
{
|
||||
onRenderStart: handleRenderStart,
|
||||
onStatusChange: setStatus
|
||||
@@ -469,9 +513,10 @@ export function App() {
|
||||
markdown,
|
||||
fileName,
|
||||
language: "zh-CN",
|
||||
resources: imageResources,
|
||||
exportConfig
|
||||
}),
|
||||
[exportConfig, fileName, markdown]
|
||||
[exportConfig, fileName, imageResources, markdown]
|
||||
);
|
||||
const pdfCacheKey = useMemo(
|
||||
() => createPdfExportCacheKey(pdfRequest),
|
||||
@@ -813,6 +858,8 @@ export function App() {
|
||||
setAppError("");
|
||||
setMarkdown(await file.text());
|
||||
setFileName(file.name);
|
||||
setImageResources([]);
|
||||
setStatus("Markdown 已载入;如含相对图片,请添加素材目录");
|
||||
} catch {
|
||||
setAppError("无法读取所选 Markdown 文件");
|
||||
} finally {
|
||||
@@ -820,6 +867,43 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssetDirectoryChange(
|
||||
event: ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAppError("");
|
||||
const resources = await readImageResources(files);
|
||||
setImageResources(resources);
|
||||
setStatus(`已载入 ${resources.length} 个图片素材`);
|
||||
} catch {
|
||||
setAppError("无法读取所选图片素材目录");
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDesktopOpenMarkdown() {
|
||||
try {
|
||||
setAppError("");
|
||||
const opened = await window.mdToPdfDesktop?.openMarkdown();
|
||||
if (!opened) {
|
||||
return;
|
||||
}
|
||||
setMarkdown(opened.markdown);
|
||||
setFileName(opened.fileName);
|
||||
setImageResources([]);
|
||||
setStatus("Markdown 与同目录图片素材已载入");
|
||||
} catch (reason) {
|
||||
setAppError(
|
||||
reason instanceof Error ? reason.message : "无法打开 Markdown 文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf(download: boolean) {
|
||||
if (exportingPdf) {
|
||||
return;
|
||||
@@ -920,14 +1004,36 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.markdown,text/markdown,text/plain"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
{window.mdToPdfDesktop ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDesktopOpenMarkdown()}
|
||||
>
|
||||
打开 Markdown
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<label className="file-button">
|
||||
选择 Markdown
|
||||
<input
|
||||
type="file"
|
||||
accept=".md,.markdown,text/markdown,text/plain"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
<label className="file-button">
|
||||
添加素材目录
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
// React 尚未声明 Chromium 的目录选择属性。
|
||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
||||
onChange={handleAssetDirectoryChange}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||
导出设置
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface ThemeSummary {
|
||||
export interface MarkdownRenderInput {
|
||||
markdown: string;
|
||||
language: string;
|
||||
resources?: MarkdownImageResource[];
|
||||
}
|
||||
|
||||
export interface MarkdownImageResource {
|
||||
path: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
calculateDiagramPageFit,
|
||||
getPageContentDimensions
|
||||
} from "./diagram-page-fit";
|
||||
|
||||
function getImageBlock(image: HTMLImageElement) {
|
||||
const parent = image.parentElement;
|
||||
if (parent?.classList.contains("md-document-image-block")) {
|
||||
return parent;
|
||||
}
|
||||
if (
|
||||
parent?.tagName === "P" &&
|
||||
Array.from(parent.childNodes).every(
|
||||
(node) =>
|
||||
node === image ||
|
||||
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
|
||||
)
|
||||
) {
|
||||
parent.classList.add("md-document-image-block");
|
||||
return parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function fitDocumentImagesToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
|
||||
for (const image of images) {
|
||||
const block = getImageBlock(image);
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
continue;
|
||||
}
|
||||
const imageHeight = image.getBoundingClientRect().height;
|
||||
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
||||
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
||||
const fit = calculateDiagramPageFit(
|
||||
{ width, height },
|
||||
{
|
||||
width: pageContent.width,
|
||||
height: Math.max(0, pageContent.height - nonImageHeight)
|
||||
}
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
if (block?.classList.contains("md-document-image-block")) {
|
||||
block.dataset.pageHeightFitted = "true";
|
||||
block.style.setProperty("margin-block", "0", "important");
|
||||
}
|
||||
image.dataset.pageHeightFitted = "true";
|
||||
image.style.setProperty("display", "block");
|
||||
image.style.setProperty("width", `${fit.width}px`, "important");
|
||||
image.style.setProperty("height", `${fit.height}px`, "important");
|
||||
image.style.setProperty("max-width", "100%", "important");
|
||||
image.style.setProperty(
|
||||
"max-height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
image.style.setProperty("object-fit", "contain");
|
||||
image.style.setProperty("margin-inline", "auto");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "@md-to-pdf/core";
|
||||
import { Previewer } from "pagedjs";
|
||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||
import { fitDocumentImagesToPage } from "./document-image-fit";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import {
|
||||
fitOversizedMermaidToPage,
|
||||
@@ -326,7 +327,8 @@ export class PagedDocumentRuntime {
|
||||
let mermaidConversionMs = 0;
|
||||
if (
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg")
|
||||
content.querySelector(".mermaid svg") ||
|
||||
content.querySelector("img.md-document-image")
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
documentRef,
|
||||
@@ -344,6 +346,10 @@ export class PagedDocumentRuntime {
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
fitDocumentImagesToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
|
||||
@@ -67,6 +67,25 @@ svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.md-document-image-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-document-image-block > .md-document-image {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.md-document-image-caption {
|
||||
margin-top: 0.55em;
|
||||
color: #64748b;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#write table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
@@ -313,6 +332,11 @@ ${buildFooterCss(config)}
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
#write .md-document-image-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
${
|
||||
config.print.pageBreakBeforeH1
|
||||
? `#write h1:not(:first-child) {
|
||||
|
||||
@@ -2,11 +2,13 @@ import type {
|
||||
ExportConfig,
|
||||
PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
import type { MarkdownImageResource } from "./application-backend";
|
||||
|
||||
export interface PdfExportRequest {
|
||||
markdown: string;
|
||||
fileName: string;
|
||||
language: string;
|
||||
resources: MarkdownImageResource[];
|
||||
exportConfig: ExportConfig;
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ export function createPdfExportCacheKey(request: PdfExportRequest) {
|
||||
request.markdown,
|
||||
request.fileName,
|
||||
request.language,
|
||||
request.resources,
|
||||
request.exportConfig
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RenderedMarkdownDocument } from "@md-to-pdf/core";
|
||||
import { renderMarkdownDocument } from "./application-backend";
|
||||
import type { MarkdownImageResource } from "./application-backend";
|
||||
|
||||
export interface MarkdownRenderCallbacks {
|
||||
onRenderStart?: () => void;
|
||||
@@ -10,6 +11,7 @@ export interface MarkdownRenderCallbacks {
|
||||
export function useMarkdownRender(
|
||||
markdown: string,
|
||||
language: string,
|
||||
resources: MarkdownImageResource[],
|
||||
callbacks: MarkdownRenderCallbacks = {}
|
||||
) {
|
||||
const [result, setResult] =
|
||||
@@ -25,7 +27,7 @@ export function useMarkdownRender(
|
||||
setError("");
|
||||
|
||||
void renderMarkdownDocument(
|
||||
{ markdown, language },
|
||||
{ markdown, language, resources },
|
||||
controller.signal
|
||||
)
|
||||
.then((payload) => {
|
||||
@@ -46,7 +48,13 @@ export function useMarkdownRender(
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [language, markdown, onRenderStart, onStatusChange]);
|
||||
}, [
|
||||
language,
|
||||
markdown,
|
||||
onRenderStart,
|
||||
onStatusChange,
|
||||
resources
|
||||
]);
|
||||
|
||||
return { error, result };
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -17,9 +17,20 @@ declare global {
|
||||
}
|
||||
|
||||
interface DesktopApplicationBridge {
|
||||
openMarkdown(): Promise<
|
||||
| {
|
||||
markdown: string;
|
||||
fileName: string;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
renderMarkdown(input: {
|
||||
markdown: string;
|
||||
language: string;
|
||||
resources?: Array<{
|
||||
path: string;
|
||||
data: string;
|
||||
}>;
|
||||
}): Promise<RenderedMarkdownDocument>;
|
||||
listThemes(): Promise<{
|
||||
themes: Array<{
|
||||
|
||||
Reference in New Issue
Block a user