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", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"pagedjs": "^0.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^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"; } from "react";
import { import {
getPaperDimensionsMm, getPaperDimensionsMm,
millimetersToCssPixels,
type ExportConfig type ExportConfig
} from "@md-to-pdf/core"; } 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 { ExportSettingsDrawer } from "./ExportSettingsDrawer";
import { import {
loadExportConfig, loadExportConfig,
resetExportConfig, resetExportConfig,
saveExportConfig saveExportConfig
} from "./export-settings"; } from "./export-settings";
import { createMermaidSiteConfig } from "./mermaid-config"; import {
import { renderMermaidDefinitions } from "./mermaid-renderer"; createPagedPreviewRenderRequest,
import { enablePrintMediaForPreview } from "./preview-styles"; isPagedPreviewFrameMessage,
type PagedPreviewPayload
} from "./paged-preview";
interface RenderedMarkdown { interface RenderedMarkdown {
articleHtml: string; articleHtml: string;
@@ -81,126 +82,6 @@ flowchart LR
> 预览和 PDF 将共用同一份 HTML 与 CSS。 > 预览和 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() { export function App() {
const [markdown, setMarkdown] = useState(sampleMarkdown); const [markdown, setMarkdown] = useState(sampleMarkdown);
const [fileName, setFileName] = useState("示例文档.md"); const [fileName, setFileName] = useState("示例文档.md");
@@ -214,9 +95,14 @@ export function App() {
const [renderError, setRenderError] = useState(""); const [renderError, setRenderError] = useState("");
const [themeError, setThemeError] = useState(""); const [themeError, setThemeError] = useState("");
const [mermaidError, setMermaidError] = 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 previewFrameRef = useRef<HTMLIFrameElement>(null);
const previewRequestIdRef = useRef(0);
const error = renderError || themeError || mermaidError; const error =
renderError || themeError || mermaidError || paginationError;
const themeId = exportConfig.themeId; const themeId = exportConfig.themeId;
const selectedTheme = themes.find((theme) => theme.id === themeId); const selectedTheme = themes.find((theme) => theme.id === themeId);
const paperDimensions = getPaperDimensionsMm( const paperDimensions = getPaperDimensionsMm(
@@ -226,16 +112,21 @@ export function App() {
const paperStyle = { const paperStyle = {
width: `${paperDimensions.width}mm`, width: `${paperDimensions.width}mm`,
minWidth: `${paperDimensions.width}mm`, minWidth: `${paperDimensions.width}mm`,
minHeight: `${paperDimensions.height}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
} satisfies CSSProperties; } 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(() => { useEffect(() => {
@@ -290,6 +181,7 @@ export function App() {
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
setPreviewFrameReady(false);
setThemeCss(""); setThemeCss("");
setThemeError(""); setThemeError("");
@@ -359,83 +251,80 @@ export function App() {
}; };
}, [markdown]); }, [markdown]);
async function handlePreviewLoad() { useEffect(() => {
const frame = previewFrameRef.current; function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
const document = frame?.contentDocument; const frame = previewFrameRef.current;
if (!frame || !document || !result) { 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; return;
} }
const resizeFrame = () => { previewRequestIdRef.current += 1;
frame.style.height = `${Math.max( setPreviewHeight(
document.documentElement.scrollHeight, Math.ceil(millimetersToCssPixels(paperDimensions.height))
document.body.scrollHeight, );
500 frameWindow.postMessage(
)}px`; createPagedPreviewRenderRequest(
}; previewRequestIdRef.current,
previewPayload
resizeFrame(); ),
await document.fonts.ready; window.location.origin
);
if (result.features.includes("mermaid")) { }, [
const nodes = Array.from( paperDimensions.height,
document.querySelectorAll<HTMLElement>( previewFrameReady,
".mermaid[data-mermaid-pending]" previewPayload
) ]);
);
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();
}
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) { async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -532,14 +421,14 @@ export function App() {
</div> </div>
<div className="preview-scroll"> <div className="preview-scroll">
<div className="paper" style={paperStyle}> <div className="paper" style={paperStyle}>
{previewDocument ? ( {previewPayload ? (
<iframe <iframe
ref={previewFrameRef} ref={previewFrameRef}
className="preview-frame" className="preview-frame"
title="文档内容预览" title="文档内容预览"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
srcDoc={previewDocument} src="/preview-frame.html"
onLoad={() => void handlePreviewLoad()} style={{ height: `${previewHeight}px` }}
/> />
) : ( ) : (
<div className="preview-loading"></div> <div className="preview-loading"></div>
+1 -1
View File
@@ -470,7 +470,7 @@ export function ExportSettingsDrawer({
<span>线</span> <span>线</span>
</label> </label>
<p className="setting-hint"> <p className="setting-hint">
</p> </p>
</section> </section>
</div> </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 { .workspace {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; height: 100vh;
min-height: 0;
overflow: hidden;
} }
.topbar { .topbar {
@@ -109,7 +111,7 @@ h1 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: calc(100vh - 92px); min-height: 0;
} }
.editor-panel { .editor-panel {
@@ -216,7 +218,7 @@ h1 {
textarea { textarea {
flex: 1; flex: 1;
width: 100%; width: 100%;
min-height: 560px; min-height: 0;
padding: 24px; padding: 24px;
border: 0; border: 0;
outline: 0; outline: 0;
@@ -249,8 +251,7 @@ textarea:focus {
.paper { .paper {
margin: 0 auto; margin: 0 auto;
background: #fff; background: transparent;
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
} }
.preview-frame { .preview-frame {
@@ -258,7 +259,7 @@ textarea:focus {
width: 100%; width: 100%;
min-height: 500px; min-height: 500px;
border: 0; border: 0;
background: #fff; background: transparent;
} }
.preview-loading { .preview-loading {
@@ -541,6 +542,12 @@ textarea:focus {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.workspace {
height: auto;
min-height: 100vh;
overflow: visible;
}
.topbar { .topbar {
align-items: flex-start; align-items: flex-start;
padding: 16px; padding: 16px;
@@ -559,6 +566,10 @@ textarea:focus {
min-height: 650px; min-height: 650px;
} }
textarea {
min-height: 560px;
}
.editor-panel { .editor-panel {
border-right: 0; border-right: 0;
border-bottom: 1px solid #cbd2ce; 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 { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
export default defineConfig({ export default defineConfig({
plugins: [react()], 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: { server: {
port: 5173, port: 5173,
proxy: { proxy: {
+56 -50
View File
@@ -1,6 +1,6 @@
# Markdown PDF 导出器进度 # Markdown PDF 导出器进度
最后更新:2026-07-25 最后更新:2026-07-26
## 1. 当前概况 ## 1. 当前概况
@@ -19,13 +19,14 @@ main
已有提交: 已有提交:
```text ```text
52cf816 feat: 完善导出设置与打印预览
ec14f79 feat: 扩展本地主题兼容能力 ec14f79 feat: 扩展本地主题兼容能力
fa07472 feat: 实现网页实时预览 fa07472 feat: 实现网页实时预览
7224dfd feat: 实现 Markdown 渲染核心 7224dfd feat: 实现 Markdown 渲染核心
ec48bce chore: 初始化项目骨架 ec48bce chore: 初始化项目骨架
``` ```
当前工作区存在未提交修改,主要是导出配置版本 2、导出设置抽屉、浏览器缓存和实时纸张尺寸及页边距预览。接手时必须保留并审查这些修改,不要重置工作区。 当前工作区存在未提交修改,主要是基于 Paged.js 的真实分页预览、页眉页码、跨页表格和超高 Mermaid 图表适配。接手时必须保留并审查这些修改,不要重置工作区。
## 2. 已完成 ## 2. 已完成
@@ -119,53 +120,58 @@ ec48bce chore: 初始化项目骨架
- 主题开发指南; - 主题开发指南;
- 后端测试增至 6 项。 - 后端测试增至 6 项。
### 2.7 导出设置与打印预览
提交 `52cf816` 已完成:
- 导出配置升级到版本 2
- 纸张收敛为 A3、A4、A5、US-Letter 和 US-Legal
- 默认 A4 纵向和 16mm 四边页边距;
- 统一 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算;
- 响应式导出设置抽屉;
- 纸张、方向、页边距、页眉、页码和主题的版本化浏览器缓存;
- 页眉左中右内容和变量;
- 五种页码样式、对齐和起始页码;
- Mermaid 官方配置 frontmatter、默认主题与错误隔离;
- Markdown 表格对齐属性保留和自适应列宽;
- 纸张尺寸和页边距实时预览。
## 3. 当前未提交工作 ## 3. 当前未提交工作
以下内容已写入工作区,但尚未提交: 以下内容已写入工作区,但尚未提交:
### 3.1 导出配置版本 2 ### 3.1 Paged.js 真实分页预览
- 纸张只保留 A3、A4、A5、US-Letter 和 US-Legal - 引入 MIT 许可证的 Paged.js 0.4.3
- 固定纸张尺寸分别为 297×420、210×297、148×210、216×279 和 216×356mm - 使用独立同源沙箱 iframe 执行分页,父页面通过严格消息协议传递渲染载荷
- 默认 A4 纵向,四边页边距均为 16mm - 每张纸以真实物理尺寸独立显示,页面间保留 24px 间隔和预览阴影
- 统一采用 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算 - 主题 CSS 原样交给 Paged.js,使主题 `@media print` 规则与未来 PDF 一致
- 保留横向和纵向 - GitHub 主题打印字号已与 Typora PDF 对齐:正文 13px、一级标题 29.25px、二级标题 22.75px
- 页脚模型收敛为页码设置,支持五种页码样式、对齐和起始页码 - W30 对照文档在 Typora 和网页预览中均为 4 页
- 校验页边距之和必须为正文保留正尺寸区域 - 主题切换会重置 iframe 就绪握手,GitHub、Pixyll 和 Whitey 可连续切换且无需刷新
- 增加 6 项共享配置测试。
### 3.2 导出设置和浏览器缓存 ### 3.2 页眉、页码与跨页内容
- 增加响应式右侧导出设置抽屉 - 页眉支持左中右三栏、标题、作者和文件名变量及分隔线
- 支持五种纸张、方向和四边页边距 - 页脚支持五种页码格式、左中右位置、起始页码和分隔线
- 已提供页眉左中右内容、页脚页码样式、位置、起始页码和分隔线控件 - 当前页码逐页递增,总页数使用真实分页结果
- 配置和主题选择写入版本化 `localStorage`,不缓存 Markdown 正文 - 标题避免孤立在页尾,段落使用孤行和寡行约束
- 缓存缺失、损坏或版本过期时恢复默认配置 - 长表格允许跨页,并通过 Paged.js 处理器重复表头
- 切换到更小纸张时自动等比例收敛过大的旧页边距 - 表格行、代码块、引用、公式、图片和 Mermaid 默认避免从中间断开
- 增加 3 项浏览器缓存测试和 3 项打印媒体预览测试。
### 3.3 实时纸张预览 ### 3.3 Mermaid 单页适配
- 纸张尺寸、方向和四边页边距实时作用于预览 - Mermaid、字体和图片完成后再启动分页
- 删除本地主题专用的 8mm 外边距特例 - 普通 Mermaid 保持主题原有尺寸和布局
- 在主题 CSS 后追加共享几何 CSS,统一清除 `body``#write` 的页面宽度、内外边距限制 - 根据纸张和页边距计算正文区域的 CSS 像素宽高
- 网页预览将主题的 `@media print` 规则按原始位置转换为屏幕预览规则,使 Typora GitHub 等主题使用与 PDF 相同的打印字号和行高 - 当 SVG 按正文宽度适配后仍高于一页时,读取 `viewBox` 并改为按正文高度等比例缩小
- 固定逻辑页面后续按 96 CSS px/in 分页,预览显示倍率只缩放外层,不参与正文换行和分页计算 - 超高 Mermaid 整体换页、水平居中,不跨页裁切
- 页眉和页码配置将在下一阶段分页预览中显示 - 浏览器实测 A4 超高图高度约 1000.56px,正文可用高度约 1001.57px,未侵入上下页边距
- 增加 SVG `viewBox` 解析、正文尺寸和缩放算法测试。
### 3.4 Mermaid 配置与错误隔离
- 使用 Mermaid 官方 npm 包,当前锁定安装版本为 11.16.0。
- 无区块配置时显式使用 `default` 主题和 `classic` 外观。
- Mermaid 区块内容原样交给官方解析器,支持区块内部的 YAML `config` frontmatter。
- 允许区块覆盖主题、外观、主题变量、布局及图表专属配置。
- 固定 `strict` 安全级别、自动启动开关、文本和边数量限制、错误输出策略,并禁止区块注入原始 `themeCSS`
- 单个 Mermaid 图表解析或渲染失败时显示局部错误,其余图表继续渲染。
- 增加 Mermaid 站点配置、单图错误隔离及区块 frontmatter 保留测试。
## 4. 已执行验证 ## 4. 已执行验证
2026-07-25 在当前完整工作区成功执行: 2026-07-26 在当前完整工作区成功执行:
```text ```text
npm test npm test
@@ -177,14 +183,17 @@ git diff --check
结果: 结果:
- 共享配置测试:6 项通过; - 共享配置测试:6 项通过;
- 渲染器测试:6 项通过; - 渲染器测试:7 项通过;
- 前端测试:10 项通过; - 前端测试:21 项通过;
- 后端测试:6 项通过; - 后端测试:6 项通过;
- 全项目类型检查通过; - 全项目类型检查通过;
- 生产构建通过; - 生产构建通过;
- `git diff --check` 通过,仅出现工作区 LF 将来可能转为 CRLF 的提示; - `git diff --check` 通过,仅出现工作区 LF 将来可能转为 CRLF 的提示;
- 导入器默认的已存在目标保护通过; - 导入器默认的已存在目标保护通过;
- GitHub、Pixyll 和 Whitey 三套本地主题的 CSS API 均返回成功,组合结果无残留 `@import` - GitHub、Pixyll 和 Whitey 三套本地主题的 CSS API 均返回成功,组合结果无残留 `@import`
- 同一份 W30 Markdown 在 GitHub 分页预览和 Typora PDF 中均为 4 页。
- 三个本地主题无需刷新即可连续切换并重新分页。
- 超高 Mermaid 整体缩放到单页正文区域,普通 Mermaid 尺寸不受影响。
已使用浏览器插件完成视觉验证: 已使用浏览器插件完成视觉验证:
@@ -213,7 +222,11 @@ git diff --check
## 5. 当前注意事项 ## 5. 当前注意事项
- 工作区不是干净状态,禁止重置。 - 工作区不是干净状态,禁止重置。
- `apps/web/src/App.tsx` 是当前预览实现的主要文件 - `apps/web/src/App.tsx` 负责分页 iframe 生命周期和父页面消息处理
- `apps/web/src/paged-preview-frame.ts` 负责 iframe 内 Mermaid、资源等待和 Paged.js 分页。
- `apps/web/src/paged-preview.ts` 负责分页协议、共享文档 CSS、页眉和页码 CSS。
- `apps/web/src/mermaid-page-fit.ts` 负责超高 Mermaid 的单页等比例适配。
- `apps/web/src/paged-table-handler.ts` 负责跨页表格表头处理。
- `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。 - `apps/web/src/ExportSettingsDrawer.tsx` 是导出设置界面。
- `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。 - `apps/web/src/export-settings.ts` 负责版本化浏览器缓存。
- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。 - `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。
@@ -229,20 +242,13 @@ git diff --check
## 6. 推荐接手顺序 ## 6. 推荐接手顺序
### 阶段一:完成分页预览 ### 阶段一:提交分页预览
导出设置、缓存和实时纸张几何已经完成。下一步按已确认设计引入 Paged.js,并实现: 当前分页预览已经实现并通过验证。检查完整差异并创建一个清晰的阶段提交后,再进入真实 PDF。
- 逐页预览;
- 页眉左中右内容及变量替换;
- 页脚页码、总页数、对齐和起始页码;
- Mermaid 整体换页和超高图按页面正文高度等比例缩放;
- 字体、图片和 Mermaid 完成后再分页;
- 预览与未来 PDF 共用分页 HTML 和 CSS。
### 阶段二:实现真实 PDF ### 阶段二:实现真实 PDF
分页预览完成并提交后再实现: 分页预览提交后再实现:
1. 增加 Playwright 和固定版本 Chromium。 1. 增加 Playwright 和固定版本 Chromium。
2. 抽取可复用的完整 HTML 文档组装器。 2. 抽取可复用的完整 HTML 文档组装器。
+181
View File
@@ -41,6 +41,7 @@
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"pagedjs": "^0.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },
@@ -316,6 +317,26 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/polyfill": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz",
"integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==",
"deprecated": "🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.",
"license": "MIT",
"dependencies": {
"core-js": "^2.6.5",
"regenerator-runtime": "^0.13.4"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2266,6 +2287,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/clear-cut": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/clear-cut/-/clear-cut-2.0.2.tgz",
"integrity": "sha512-WVgn/gSejQ+0aoR8ucbKIdo6icduPZW6AbWwyUmAUgxy63rUYjwa5rj/HeoNPhf0/XPrl82X8bO/hwBkSmsFtg==",
"license": "MIT"
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -2355,6 +2382,14 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/cose-base": { "node_modules/cose-base": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -2364,6 +2399,19 @@
"layout-base": "^1.0.0" "layout-base": "^1.0.0"
} }
}, },
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2420,6 +2468,19 @@
"integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/d3": { "node_modules/d3": {
"version": "7.9.0", "version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
@@ -3058,6 +3119,46 @@
"tests/types" "tests/types"
] ]
}, },
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -3122,6 +3223,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esprima": { "node_modules/esprima": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -3145,6 +3261,16 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -3155,6 +3281,15 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/extend-shallow": { "node_modules/extend-shallow": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
@@ -3749,6 +3884,12 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/mdurl": { "node_modules/mdurl": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
@@ -3825,6 +3966,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.51", "version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
@@ -3864,6 +4011,19 @@
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pagedjs": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/pagedjs/-/pagedjs-0.4.3.tgz",
"integrity": "sha512-YtAN9JAjsQw1142gxEjEAwXvOF5nYQuDwnQ67RW2HZDkMLI+b4RsBE37lULZa9gAr6kDAOGBOhXI4wGMoY3raw==",
"license": "MIT",
"dependencies": {
"@babel/polyfill": "^7.10.1",
"@babel/runtime": "^7.21.0",
"clear-cut": "^2.0.2",
"css-tree": "^1.1.3",
"event-emitter": "^0.3.5"
}
},
"node_modules/parse-srcset": { "node_modules/parse-srcset": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
@@ -4056,6 +4216,12 @@
"node": ">= 12.13.0" "node": ">= 12.13.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4415,6 +4581,15 @@
"atomic-sleep": "^1.0.0" "atomic-sleep": "^1.0.0"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4627,6 +4802,12 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",