feat: 实现真实分页预览

This commit is contained in:
SkyJourney
2026-07-26 07:27:13 +08:00
parent 52cf816683
commit 1d93f31f85
15 changed files with 1614 additions and 269 deletions
+1
View File
@@ -14,6 +14,7 @@
"highlight.js": "^11.11.1",
"katex": "^0.16.28",
"mermaid": "^11.12.3",
"pagedjs": "^0.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; connect-src 'self';"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>分页预览</title>
</head>
<body>
<div id="preview-root" aria-live="polite"></div>
<script type="module" src="/src/paged-preview-frame.ts"></script>
</body>
</html>
+101 -212
View File
@@ -8,19 +8,20 @@ import {
} from "react";
import {
getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import {
loadExportConfig,
resetExportConfig,
saveExportConfig
} from "./export-settings";
import { createMermaidSiteConfig } from "./mermaid-config";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import { enablePrintMediaForPreview } from "./preview-styles";
import {
createPagedPreviewRenderRequest,
isPagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
interface RenderedMarkdown {
articleHtml: string;
@@ -81,126 +82,6 @@ flowchart LR
> 预览和 PDF 将共用同一份 HTML 与 CSS。
`;
const previewBaseCss = `
:root {
color-scheme: light;
background: #fff;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-width: 0;
background: #fff;
}
img,
svg {
max-width: 100%;
}
#write table {
width: 100%;
table-layout: auto;
}
#write th,
#write td {
overflow-wrap: anywhere;
}
.mermaid {
display: flex;
justify-content: center;
margin: 1.5em 0;
overflow: auto;
}
.mermaid-error {
display: block;
padding: 0.85em 1em;
border: 1px solid #dc2626;
color: #991b1b;
background: #fef2f2;
white-space: pre-wrap;
}
`;
const previewGeometryCss = `
html,
body {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
#write {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
`;
let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined;
let mermaidRenderSequence = 0;
async function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
});
}
return mermaidPromise;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeStyleContent(value: string) {
return value.replace(/<\/style/gi, "<\\/style");
}
function buildPreviewDocument(
result: RenderedMarkdown,
themeCss: string
) {
const previewThemeCss = enablePrintMediaForPreview(themeCss);
return `<!doctype html>
<html lang="${escapeHtml(result.metadata.language || "zh-CN")}">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:;"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(result.metadata.title || "Markdown 预览")}</title>
<style>${escapeStyleContent(previewBaseCss)}</style>
<style>${escapeStyleContent(highlightCss)}</style>
<style>${escapeStyleContent(katexCss)}</style>
<style>${escapeStyleContent(previewThemeCss)}</style>
<style>${escapeStyleContent(previewGeometryCss)}</style>
</head>
<body>
${result.articleHtml}
</body>
</html>`;
}
export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md");
@@ -214,9 +95,14 @@ export function App() {
const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState("");
const [mermaidError, setMermaidError] = useState("");
const [paginationError, setPaginationError] = useState("");
const [previewFrameReady, setPreviewFrameReady] = useState(false);
const [previewHeight, setPreviewHeight] = useState(500);
const previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
const error = renderError || themeError || mermaidError;
const error =
renderError || themeError || mermaidError || paginationError;
const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm(
@@ -226,16 +112,21 @@ export function App() {
const paperStyle = {
width: `${paperDimensions.width}mm`,
minWidth: `${paperDimensions.width}mm`,
minHeight: `${paperDimensions.height}mm`,
paddingTop: exportConfig.paper.margins.top,
paddingRight: exportConfig.paper.margins.right,
paddingBottom: exportConfig.paper.margins.bottom,
paddingLeft: exportConfig.paper.margins.left
minHeight: `${paperDimensions.height}mm`
} satisfies CSSProperties;
const previewDocument = useMemo(
const previewPayload = useMemo<PagedPreviewPayload | undefined>(
() =>
result && themeCss ? buildPreviewDocument(result, themeCss) : undefined,
[result, themeCss]
result && themeCss
? {
articleHtml: result.articleHtml,
fileName,
metadata: result.metadata,
features: result.features,
themeCss,
exportConfig
}
: undefined,
[exportConfig, fileName, result, themeCss]
);
useEffect(() => {
@@ -290,6 +181,7 @@ export function App() {
useEffect(() => {
const controller = new AbortController();
setPreviewFrameReady(false);
setThemeCss("");
setThemeError("");
@@ -359,83 +251,80 @@ export function App() {
};
}, [markdown]);
async function handlePreviewLoad() {
const frame = previewFrameRef.current;
const document = frame?.contentDocument;
if (!frame || !document || !result) {
useEffect(() => {
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
const frame = previewFrameRef.current;
if (
event.origin !== window.location.origin ||
event.source !== frame?.contentWindow ||
!isPagedPreviewFrameMessage(event.data)
) {
return;
}
if (event.data.type === "ready") {
setPreviewFrameReady(true);
return;
}
if (event.data.requestId !== previewRequestIdRef.current) {
return;
}
if (event.data.type === "rendering") {
setPaginationError("");
setMermaidError("");
setStatus("正在分页…");
return;
}
if (event.data.type === "error") {
setPaginationError(event.data.message);
setStatus("分页失败");
return;
}
setPreviewHeight(
Math.max(
event.data.contentHeight,
millimetersToCssPixels(paperDimensions.height)
)
);
setMermaidError(
event.data.mermaidErrors.length > 0
? `Mermaid${event.data.mermaidErrors.join("")}`
: ""
);
setStatus(`预览已分页(${event.data.pageCount} 页)`);
}
window.addEventListener("message", handlePreviewFrameMessage);
return () =>
window.removeEventListener("message", handlePreviewFrameMessage);
}, [paperDimensions.height]);
useEffect(() => {
const frameWindow = previewFrameRef.current?.contentWindow;
if (!frameWindow || !previewFrameReady || !previewPayload) {
return;
}
const resizeFrame = () => {
frame.style.height = `${Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
500
)}px`;
};
resizeFrame();
await document.fonts.ready;
if (result.features.includes("mermaid")) {
const nodes = Array.from(
document.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length > 0) {
try {
const mermaid = await loadMermaid();
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-preview-${mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error
? outcome.error.message
: "未知错误";
node.classList.add("mermaid-error");
node.textContent = `Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
if (errors.length > 0) {
setMermaidError(`Mermaid${errors.join("")}`);
}
} catch (reason: unknown) {
setMermaidError(
reason instanceof Error
? `Mermaid${reason.message}`
: "Mermaid 渲染失败"
);
}
}
}
resizeFrame();
}
previewRequestIdRef.current += 1;
setPreviewHeight(
Math.ceil(millimetersToCssPixels(paperDimensions.height))
);
frameWindow.postMessage(
createPagedPreviewRenderRequest(
previewRequestIdRef.current,
previewPayload
),
window.location.origin
);
}, [
paperDimensions.height,
previewFrameReady,
previewPayload
]);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
@@ -532,14 +421,14 @@ export function App() {
</div>
<div className="preview-scroll">
<div className="paper" style={paperStyle}>
{previewDocument ? (
{previewPayload ? (
<iframe
ref={previewFrameRef}
className="preview-frame"
title="文档内容预览"
sandbox="allow-same-origin"
srcDoc={previewDocument}
onLoad={() => void handlePreviewLoad()}
sandbox="allow-same-origin allow-scripts"
src="/preview-frame.html"
style={{ height: `${previewHeight}px` }}
/>
) : (
<div className="preview-loading"></div>
+1 -1
View File
@@ -470,7 +470,7 @@ export function ExportSettingsDrawer({
<span>线</span>
</label>
<p className="setting-hint">
</p>
</section>
</div>
+123
View File
@@ -0,0 +1,123 @@
import {
getPaperDimensionsMm,
lengthToMillimeters,
millimetersToCssPixels,
type ExportConfig
} from "@md-to-pdf/core";
export interface MermaidDimensions {
width: number;
height: number;
}
export interface MermaidPageFit extends MermaidDimensions {
scaled: boolean;
}
const PAGE_FIT_EPSILON_PX = 1;
export function parseSvgViewBox(
value: string | null
): MermaidDimensions | undefined {
if (!value) {
return undefined;
}
const values = value
.trim()
.split(/[\s,]+/)
.map((part) => Number(part));
const [, , width = Number.NaN, height = Number.NaN] = values;
if (
values.length !== 4 ||
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return undefined;
}
return { width, height };
}
export function getPageContentDimensions(
config: ExportConfig
): MermaidDimensions {
const paper = getPaperDimensionsMm(
config.paper.format,
config.paper.orientation
);
const width =
paper.width -
lengthToMillimeters(config.paper.margins.left) -
lengthToMillimeters(config.paper.margins.right);
const height =
paper.height -
lengthToMillimeters(config.paper.margins.top) -
lengthToMillimeters(config.paper.margins.bottom);
return {
width: millimetersToCssPixels(width),
height: millimetersToCssPixels(height)
};
}
export function calculateMermaidPageFit(
intrinsic: MermaidDimensions,
pageContent: MermaidDimensions
): MermaidPageFit {
const heightAfterWidthFit =
(pageContent.width * intrinsic.height) / intrinsic.width;
if (heightAfterWidthFit <= pageContent.height) {
return {
width: pageContent.width,
height: heightAfterWidthFit,
scaled: false
};
}
const height = Math.max(
0,
pageContent.height - PAGE_FIT_EPSILON_PX
);
return {
width: (height * intrinsic.width) / intrinsic.height,
height,
scaled: true
};
}
export function fitOversizedMermaidToPage(
container: ParentNode,
config: ExportConfig
) {
const pageContent = getPageContentDimensions(config);
const diagrams = Array.from(
container.querySelectorAll<HTMLElement>(".mermaid")
);
for (const diagram of diagrams) {
const svg = diagram.querySelector<SVGSVGElement>("svg");
const intrinsic = parseSvgViewBox(svg?.getAttribute("viewBox") ?? null);
if (!svg || !intrinsic) {
continue;
}
const fit = calculateMermaidPageFit(intrinsic, pageContent);
if (!fit.scaled) {
continue;
}
diagram.dataset.pageHeightFitted = "true";
diagram.style.setProperty("margin-block", "0", "important");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.setProperty("display", "block");
svg.style.setProperty("width", `${fit.width}px`, "important");
svg.style.setProperty("height", `${fit.height}px`, "important");
svg.style.setProperty("max-width", "100%", "important");
svg.style.setProperty("max-height", `${fit.height}px`, "important");
}
}
+279
View File
@@ -0,0 +1,279 @@
import highlightCss from "highlight.js/styles/github.css?inline";
import katexCss from "katex/dist/katex.min.css?inline";
import { Previewer } from "pagedjs";
import { createMermaidSiteConfig } from "./mermaid-config";
import { fitOversizedMermaidToPage } from "./mermaid-page-fit";
import { renderMermaidDefinitions } from "./mermaid-renderer";
import "./paged-table-handler";
import {
buildPagedMediaCss,
documentBaseCss,
documentGeometryCss,
formatPageNumber,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
type PagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
function getPreviewRoot() {
const element = document.querySelector<HTMLElement>("#preview-root");
if (!element) {
throw new Error("分页预览容器不存在");
}
return element;
}
const previewRoot = getPreviewRoot();
let latestRequestId = 0;
let renderQueue = Promise.resolve();
let activePreviewer: Previewer | undefined;
let mermaidRenderSequence = 0;
let mermaidPromise: Promise<
(typeof import("mermaid"))["default"]
> | undefined;
function post(message: PagedPreviewFrameMessage) {
window.parent.postMessage(message, window.location.origin);
}
function destroyActivePreviewer() {
if (!activePreviewer) {
return;
}
activePreviewer.chunker.destroy();
activePreviewer.polisher.destroy();
activePreviewer = undefined;
}
function loadMermaid() {
if (!mermaidPromise) {
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize(createMermaidSiteConfig());
return mermaid;
});
}
return mermaidPromise;
}
async function renderMermaid(
container: DocumentFragment,
payload: PagedPreviewPayload
) {
if (!payload.features.includes("mermaid")) {
return [];
}
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(
".mermaid[data-mermaid-pending]"
)
);
if (nodes.length === 0) {
return [];
}
const mermaid = await loadMermaid();
const outcomes = await renderMermaidDefinitions(
nodes.map((node) => node.textContent ?? ""),
async (definition) => {
mermaidRenderSequence += 1;
return mermaid.render(
`mermaid-paged-preview-${mermaidRenderSequence}`,
definition
);
}
);
const errors: string[] = [];
for (const [index, outcome] of outcomes.entries()) {
const node = nodes[index];
if (!node) {
continue;
}
node.removeAttribute("data-mermaid-pending");
if (outcome.success) {
node.innerHTML = outcome.svg;
outcome.bindFunctions?.(node);
continue;
}
const message =
outcome.error instanceof Error ? outcome.error.message : "未知错误";
node.classList.add("mermaid-error");
node.textContent = `Mermaid 图表 ${index + 1} 渲染失败:${message}`;
errors.push(`图表 ${index + 1}${message}`);
}
return errors;
}
async function waitForImages(container: ParentNode) {
const images = Array.from(container.querySelectorAll("img"));
await Promise.all(
images.map(async (image) => {
if (image.complete) {
return;
}
try {
await image.decode();
} catch {
await new Promise<void>((resolve) => {
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
});
}
})
);
}
function stylesheet(css: string, name: string) {
return {
[`${window.location.href}#${name}`]: css
};
}
function applyPageNumbers(
container: ParentNode,
payload: PagedPreviewPayload,
totalPages: number
) {
if (!payload.exportConfig.footer.enabled) {
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
if (!content) {
continue;
}
content.textContent = formatPageNumber(
payload.exportConfig.footer,
pageIndex,
totalPages
);
content.setAttribute("data-page-number-rendered", "true");
}
}
async function renderPagedPreview(
requestId: number,
payload: PagedPreviewPayload
) {
if (requestId !== latestRequestId) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendering",
requestId
});
destroyActivePreviewer();
previewRoot.replaceChildren();
document.documentElement.lang = payload.metadata.language || "zh-CN";
document.title = payload.metadata.title || "Markdown 分页预览";
const template = document.createElement("template");
template.innerHTML = payload.articleHtml;
const content = template.content;
const mermaidErrors = await renderMermaid(content, payload);
fitOversizedMermaidToPage(content, payload.exportConfig);
if (requestId !== latestRequestId) {
return;
}
await document.fonts.ready;
await waitForImages(content);
const previewer = new Previewer();
activePreviewer = previewer;
const flow = await previewer.preview(
content,
[
stylesheet(documentBaseCss, "document-base"),
stylesheet(highlightCss, "highlight"),
stylesheet(katexCss, "katex"),
stylesheet(payload.themeCss, "theme"),
stylesheet(documentGeometryCss, "document-geometry"),
stylesheet(
buildPagedMediaCss(payload.exportConfig, payload),
"paged-media"
)
],
previewRoot
);
applyPageNumbers(previewRoot, payload, flow.total);
if (requestId !== latestRequestId) {
return;
}
const contentHeight = Math.ceil(
Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight,
previewRoot.scrollHeight
)
);
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId,
pageCount: flow.total,
contentHeight,
mermaidErrors
});
}
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||
event.source !== window.parent ||
!isPagedPreviewRenderRequest(event.data)
) {
return;
}
latestRequestId = Math.max(latestRequestId, event.data.requestId);
const { requestId, payload } = event.data;
renderQueue = renderQueue
.catch(() => undefined)
.then(async () => {
try {
await renderPagedPreview(requestId, payload);
} catch (reason: unknown) {
if (requestId !== latestRequestId) {
return;
}
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "error",
requestId,
message:
reason instanceof Error ? reason.message : "分页预览失败"
});
}
});
});
post({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "ready"
});
+414
View File
@@ -0,0 +1,414 @@
import {
getPaperDimensionsMm,
type ExportConfig
} from "@md-to-pdf/core";
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
export interface PreviewMetadata {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
}
export interface PagedPreviewPayload {
articleHtml: string;
fileName: string;
metadata: PreviewMetadata;
features: string[];
themeCss: string;
exportConfig: ExportConfig;
}
export interface PagedPreviewRenderRequest {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "render";
requestId: number;
payload: PagedPreviewPayload;
}
export type PagedPreviewFrameMessage =
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "ready";
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "rendering";
requestId: number;
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "rendered";
requestId: number;
pageCount: number;
contentHeight: number;
mermaidErrors: string[];
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "error";
requestId: number;
message: string;
};
export const documentBaseCss = `
:root {
color-scheme: light;
background: transparent;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
min-width: 0;
margin: 0;
padding: 0;
overflow: hidden;
background: transparent;
}
img,
svg {
max-width: 100%;
}
#write table {
width: 100%;
table-layout: auto;
}
#write th,
#write td {
overflow-wrap: anywhere;
}
.mermaid {
display: flex;
justify-content: center;
margin: 1.5em 0;
overflow: visible;
}
.mermaid-error {
display: block;
padding: 0.85em 1em;
border: 1px solid #dc2626;
color: #991b1b;
background: #fef2f2;
white-space: pre-wrap;
}
`;
export const documentGeometryCss = `
html,
body,
#preview-root {
background: transparent !important;
}
#write {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
`;
function cssString(value: string) {
return JSON.stringify(value)
.replace(/\u2028/g, "\\2028 ")
.replace(/\u2029/g, "\\2029 ");
}
function resolveHeaderContent(
template: string,
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
) {
const values: Record<string, string> = {
title: payload.metadata.title,
author: payload.metadata.author,
filename: payload.fileName
};
return template.replace(
/\$\{(title|author|filename)\}/g,
(_, name: string) => values[name] ?? ""
);
}
export function formatPageNumber(
config: ExportConfig["footer"],
pageIndex: number,
totalPages: number
) {
const page = config.startFrom + pageIndex;
if (config.format === "page") {
return String(page);
}
if (config.format === "page-total") {
return `${page} / ${totalPages}`;
}
if (config.format === "chinese-page-total") {
return `${page} 页 / 共 ${totalPages}`;
}
if (config.format === "dash-page") {
return `- ${page} -`;
}
return (config.template || "${page} / ${pages}")
.replace(/\$\{page\}/g, String(page))
.replace(/\$\{pages\}/g, String(totalPages));
}
function marginBox(
position: "top" | "bottom",
alignment: "left" | "center" | "right",
content: string,
options: {
color: string;
fontSize: string;
height: string;
showDivider: boolean;
}
) {
const border =
options.showDivider && position === "top"
? "border-bottom: 0.2mm solid currentColor;"
: options.showDivider
? "border-top: 0.2mm solid currentColor;"
: "";
const verticalAlignment = position === "top" ? "bottom" : "top";
return `
@${position}-${alignment} {
content: ${content};
height: ${options.height};
color: ${options.color};
font-family: inherit;
font-size: ${options.fontSize};
font-weight: 400;
line-height: 1.25;
text-align: ${alignment};
vertical-align: ${verticalAlignment};
${border}
}`;
}
function buildHeaderCss(
config: ExportConfig,
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
) {
if (!config.header.enabled) {
return "";
}
const options = {
color: config.header.color,
fontSize: config.header.fontSize,
height: config.header.height,
showDivider: config.header.showDivider
};
return (["left", "center", "right"] as const)
.map((alignment) => {
const slot = config.header[alignment];
const value = slot.enabled
? resolveHeaderContent(slot.content, payload)
: "";
return marginBox(
"top",
alignment,
cssString(value),
options
);
})
.join("\n");
}
function buildFooterCss(config: ExportConfig) {
if (!config.footer.enabled) {
return "";
}
const options = {
color: config.footer.color,
fontSize: config.footer.fontSize,
height: config.footer.height,
showDivider: config.footer.showDivider
};
return (["left", "center", "right"] as const)
.map((alignment) =>
marginBox(
"bottom",
alignment,
alignment === config.footer.alignment
? cssString("")
: cssString(""),
options
)
)
.join("\n");
}
export function buildPagedMediaCss(
config: ExportConfig,
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
) {
const dimensions = getPaperDimensionsMm(
config.paper.format,
config.paper.orientation
);
return `
@page {
size: ${dimensions.width}mm ${dimensions.height}mm;
margin: ${config.paper.margins.top} ${config.paper.margins.right}
${config.paper.margins.bottom} ${config.paper.margins.left};
${buildHeaderCss(config, payload)}
${buildFooterCss(config)}
}
#write p,
#write li {
orphans: 3;
widows: 3;
}
#write h1,
#write h2,
#write h3,
#write h4,
#write h5,
#write h6 {
break-after: avoid;
break-inside: avoid;
}
#write thead {
display: table-header-group;
}
#write tfoot {
display: table-footer-group;
}
#write tr,
#write pre,
#write blockquote,
#write .katex-display,
#write .mermaid {
break-inside: avoid;
}
#write table[data-empty-split-table="true"] {
display: none;
}
#write img,
#write svg {
break-inside: avoid;
page-break-inside: avoid;
}
${
config.print.pageBreakBeforeH1
? `#write h1:not(:first-child) {
break-before: page;
}`
: ""
}
.pagedjs_pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
width: max-content;
min-width: 100%;
margin: 0 auto;
padding: 0;
}
.pagedjs_page {
flex: none;
margin: 0 !important;
background: #fff;
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
}
html[data-render-target="pdf"],
html[data-render-target="pdf"] body {
background: #fff !important;
}
html[data-render-target="pdf"] .pagedjs_pages {
display: block;
width: auto;
min-width: 0;
}
html[data-render-target="pdf"] .pagedjs_page {
box-shadow: none;
break-after: page;
}
html[data-render-target="pdf"] .pagedjs_page:last-child {
break-after: auto;
}
`;
}
export function createPagedPreviewRenderRequest(
requestId: number,
payload: PagedPreviewPayload
): PagedPreviewRenderRequest {
return {
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "render",
requestId,
payload
};
}
export function isPagedPreviewRenderRequest(
value: unknown
): value is PagedPreviewRenderRequest {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<PagedPreviewRenderRequest>;
return (
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
candidate.type === "render" &&
Number.isInteger(candidate.requestId) &&
Boolean(candidate.payload) &&
typeof candidate.payload?.articleHtml === "string" &&
typeof candidate.payload?.themeCss === "string"
);
}
export function isPagedPreviewFrameMessage(
value: unknown
): value is PagedPreviewFrameMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<PagedPreviewFrameMessage>;
return (
candidate.scope === PAGED_PREVIEW_MESSAGE_SCOPE &&
["ready", "rendering", "rendered", "error"].includes(
candidate.type ?? ""
)
);
}
+135
View File
@@ -0,0 +1,135 @@
import { Handler, registerHandlers } from "pagedjs";
interface PagedBreakToken {
node?: Element;
}
interface PagedChunker {
source: ParentNode;
}
interface PagedHandlerContext {
chunker: PagedChunker;
}
function elementAncestors(
element: Element,
selector: string
) {
const ancestors: Element[] = [];
let current = element.parentElement;
while (current) {
if (current.matches(selector)) {
ancestors.unshift(current);
}
current = current.parentElement;
}
return ancestors;
}
class RepeatTableHeadersHandler extends Handler {
declare chunker: PagedChunker;
private splitTableRefs: string[] = [];
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
) {
super(chunker, polisher, caller);
}
afterPageLayout(
pageElement: HTMLElement,
_page: unknown,
breakToken?: PagedBreakToken
) {
this.splitTableRefs = [];
const node = breakToken?.node;
if (!node) {
return;
}
const tables = elementAncestors(node, "table");
if (node.matches("table")) {
tables.push(node);
}
this.splitTableRefs = Array.from(
new Set(
tables
.map((table) => table.getAttribute("data-ref"))
.filter((ref): ref is string => Boolean(ref))
)
);
for (const ref of this.splitTableRefs) {
const renderedTable =
pageElement.querySelector<HTMLElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (!renderedTable?.querySelector("tbody > tr")) {
renderedTable?.setAttribute(
"data-empty-split-table",
"true"
);
}
}
}
layout(rendered: HTMLElement) {
for (const ref of this.splitTableRefs) {
const renderedTable =
rendered.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (
!renderedTable ||
renderedTable.hasAttribute("data-repeated-header")
) {
continue;
}
const sourceTable =
this.chunker.source.querySelector<HTMLTableElement>(
`table[data-ref="${CSS.escape(ref)}"]`
);
if (!sourceTable) {
continue;
}
const firstChild = renderedTable.firstChild;
for (const colgroup of sourceTable.querySelectorAll("colgroup")) {
renderedTable.insertBefore(
colgroup.cloneNode(true),
firstChild
);
}
if (!renderedTable.querySelector("thead")) {
const sourceHeader = sourceTable.querySelector("thead");
if (sourceHeader) {
renderedTable.insertBefore(
sourceHeader.cloneNode(true),
renderedTable.firstChild
);
}
}
renderedTable.setAttribute("data-repeated-header", "true");
}
}
}
const handlerRegistration = globalThis as typeof globalThis & {
__mdToPdfTableHandlerRegistered?: boolean;
};
if (!handlerRegistration.__mdToPdfTableHandlerRegistered) {
registerHandlers(RepeatTableHeadersHandler);
handlerRegistration.__mdToPdfTableHandlerRegistered = true;
}
export { RepeatTableHeadersHandler };
+41
View File
@@ -0,0 +1,41 @@
declare module "pagedjs" {
export interface PagedFlow {
total: number;
pages: HTMLElement[];
performance: number;
size: {
width: { value: number; unit: string };
height: { value: number; unit: string };
format?: string;
orientation?: string;
};
}
export type PagedStylesheet = string | Record<string, string>;
export class Previewer {
chunker: {
destroy(): void;
};
polisher: {
destroy(): void;
};
preview(
content?: HTMLElement | DocumentFragment | string,
stylesheets?: PagedStylesheet[],
renderTo?: HTMLElement | string
): Promise<PagedFlow>;
}
export class Handler {
constructor(
chunker: unknown,
polisher: unknown,
caller: unknown
);
}
export function registerHandlers(
...handlers: Array<typeof Handler>
): void;
}
+17 -6
View File
@@ -47,7 +47,9 @@ button:disabled {
.workspace {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
min-height: 0;
overflow: hidden;
}
.topbar {
@@ -109,7 +111,7 @@ h1 {
display: flex;
flex-direction: column;
min-width: 0;
min-height: calc(100vh - 92px);
min-height: 0;
}
.editor-panel {
@@ -216,7 +218,7 @@ h1 {
textarea {
flex: 1;
width: 100%;
min-height: 560px;
min-height: 0;
padding: 24px;
border: 0;
outline: 0;
@@ -249,8 +251,7 @@ textarea:focus {
.paper {
margin: 0 auto;
background: #fff;
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
background: transparent;
}
.preview-frame {
@@ -258,7 +259,7 @@ textarea:focus {
width: 100%;
min-height: 500px;
border: 0;
background: #fff;
background: transparent;
}
.preview-loading {
@@ -541,6 +542,12 @@ textarea:focus {
}
@media (max-width: 900px) {
.workspace {
height: auto;
min-height: 100vh;
overflow: visible;
}
.topbar {
align-items: flex-start;
padding: 16px;
@@ -559,6 +566,10 @@ textarea:focus {
min-height: 650px;
}
textarea {
min-height: 560px;
}
.editor-panel {
border-right: 0;
border-bottom: 1px solid #cbd2ce;
+53
View File
@@ -0,0 +1,53 @@
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import {
calculateMermaidPageFit,
getPageContentDimensions,
parseSvgViewBox
} from "../src/mermaid-page-fit";
describe("Mermaid 单页高度适配", () => {
it("解析 SVG viewBox 尺寸", () => {
expect(parseSvgViewBox("0 0 1200 2400")).toEqual({
width: 1200,
height: 2400
});
expect(parseSvgViewBox("0,0,800,600")).toEqual({
width: 800,
height: 600
});
expect(parseSvgViewBox("0 0 0 600")).toBeUndefined();
expect(parseSvgViewBox(null)).toBeUndefined();
});
it("使用纸张和页边距计算正文区域", () => {
const dimensions = getPageContentDimensions(defaultExportConfig);
expect(dimensions.width).toBeCloseTo(672.7559, 3);
expect(dimensions.height).toBeCloseTo(1001.5748, 3);
});
it("宽度适配后未超高时保持普通 Mermaid 布局", () => {
const fit = calculateMermaidPageFit(
{ width: 1600, height: 900 },
{ width: 672, height: 1000 }
);
expect(fit).toEqual({
width: 672,
height: 378,
scaled: false
});
});
it("宽度适配后超高时按页面高度等比例缩小", () => {
const fit = calculateMermaidPageFit(
{ width: 1000, height: 2000 },
{ width: 672, height: 1000 }
);
expect(fit.scaled).toBe(true);
expect(fit.height).toBe(999);
expect(fit.width).toBe(499.5);
});
});
+185
View File
@@ -0,0 +1,185 @@
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import {
buildPagedMediaCss,
createPagedPreviewRenderRequest,
documentGeometryCss,
formatPageNumber,
isPagedPreviewFrameMessage,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
type PagedPreviewPayload
} from "../src/paged-preview";
const payload: PagedPreviewPayload = {
articleHtml: '<article id="write"><h1>分页测试</h1></article>',
fileName: "分页测试.md",
metadata: {
title: "分页测试",
author: "",
subject: "",
keywords: [],
language: "zh-CN"
},
features: [],
themeCss: "#write { color: #333; }",
exportConfig: defaultExportConfig
};
describe("分页预览协议", () => {
it("生成真实纸张尺寸和页边距 CSS", () => {
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("size: 210mm 297mm");
expect(css).toContain("margin: 16mm 16mm");
expect(css).toContain("@bottom-center");
expect(css).not.toContain("counter-reset: page");
expect(css).toContain("#write thead");
expect(css).toContain("display: table-header-group");
expect(css).toContain("break-inside: avoid");
expect(css).toContain(".pagedjs_pages");
expect(css).toContain('html[data-render-target="pdf"]');
expect(css).toContain("break-after: page");
expect(css).not.toContain("@media print");
});
it("安全替换页眉变量并生成三栏页边距盒", () => {
const css = buildPagedMediaCss(
{
...defaultExportConfig,
header: {
...defaultExportConfig.header,
enabled: true,
showDivider: true,
left: {
enabled: true,
content: '${title} — ${filename} "测试"'
},
center: {
enabled: true,
content: "${author}"
}
}
},
{
...payload,
metadata: {
...payload.metadata,
author: "内网团队"
}
}
);
expect(css).toContain("@top-left");
expect(css).toContain("@top-center");
expect(css).toContain("@top-right");
expect(css).toContain(
'content: "分页测试 — 分页测试.md \\"测试\\""'
);
expect(css).toContain('content: "内网团队"');
expect(css).toContain("border-bottom: 0.2mm solid currentColor");
});
it("支持自定义页码模板和起始页码", () => {
const css = buildPagedMediaCss(
{
...defaultExportConfig,
footer: {
...defaultExportConfig.footer,
format: "custom",
template: "第 ${page} / ${pages} 页",
alignment: "right",
startFrom: 5
}
},
payload
);
expect(css).toContain("@bottom-right");
expect(
formatPageNumber(
{
...defaultExportConfig.footer,
format: "custom",
template: "第 ${page} / ${pages} 页",
startFrom: 5
},
2,
8
)
).toBe("第 7 / 8 页");
});
it("逐页递增内置页码格式但保持真实总页数", () => {
expect(
formatPageNumber(defaultExportConfig.footer, 0, 5)
).toBe("1 / 5");
expect(
formatPageNumber(defaultExportConfig.footer, 1, 5)
).toBe("2 / 5");
expect(
formatPageNumber(
{
...defaultExportConfig.footer,
format: "chinese-page-total",
startFrom: 5
},
3,
5
)
).toBe("第 8 页 / 共 5 页");
expect(
formatPageNumber(
{
...defaultExportConfig.footer,
format: "dash-page"
},
4,
5
)
).toBe("- 5 -");
});
it("在主题 CSS 之后强制分页画布保持透明", () => {
expect(documentGeometryCss).toContain(
"background: transparent !important"
);
});
it("生成并识别分页渲染请求", () => {
const request = createPagedPreviewRenderRequest(7, payload);
expect(request).toEqual({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "render",
requestId: 7,
payload
});
expect(isPagedPreviewRenderRequest(request)).toBe(true);
expect(
isPagedPreviewRenderRequest({
...request,
requestId: "7"
})
).toBe(false);
});
it("只接受分页 iframe 协议消息", () => {
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
pageCount: 2,
contentHeight: 2300,
mermaidErrors: []
})
).toBe(true);
expect(
isPagedPreviewFrameMessage({
scope: "other",
type: "ready"
})
).toBe(false);
});
});
+11
View File
@@ -1,8 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: {
main: fileURLToPath(new URL("./index.html", import.meta.url)),
previewFrame: fileURLToPath(
new URL("./preview-frame.html", import.meta.url)
)
}
}
},
server: {
port: 5173,
proxy: {