feat: 实现真实分页预览
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
+91
-202
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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,84 +251,81 @@ export function App() {
|
||||
};
|
||||
}, [markdown]);
|
||||
|
||||
async function handlePreviewLoad() {
|
||||
useEffect(() => {
|
||||
function handlePreviewFrameMessage(event: MessageEvent<unknown>) {
|
||||
const frame = previewFrameRef.current;
|
||||
const document = frame?.contentDocument;
|
||||
if (!frame || !document || !result) {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== frame?.contentWindow ||
|
||||
!isPagedPreviewFrameMessage(event.data)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeFrame = () => {
|
||||
frame.style.height = `${Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
500
|
||||
)}px`;
|
||||
};
|
||||
if (event.data.type === "ready") {
|
||||
setPreviewFrameReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
resizeFrame();
|
||||
await document.fonts.ready;
|
||||
if (event.data.requestId !== previewRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.features.includes("mermaid")) {
|
||||
const nodes = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
".mermaid[data-mermaid-pending]"
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
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 渲染失败"
|
||||
event.data.mermaidErrors.length > 0
|
||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||
: ""
|
||||
);
|
||||
}
|
||||
}
|
||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
||||
}
|
||||
|
||||
resizeFrame();
|
||||
window.addEventListener("message", handlePreviewFrameMessage);
|
||||
return () =>
|
||||
window.removeEventListener("message", handlePreviewFrameMessage);
|
||||
}, [paperDimensions.height]);
|
||||
|
||||
useEffect(() => {
|
||||
const frameWindow = previewFrameRef.current?.contentWindow;
|
||||
if (!frameWindow || !previewFrameReady || !previewPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
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];
|
||||
if (!file) {
|
||||
@@ -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>
|
||||
|
||||
@@ -470,7 +470,7 @@ export function ExportSettingsDrawer({
|
||||
<span>显示页脚分隔线</span>
|
||||
</label>
|
||||
<p className="setting-hint">
|
||||
页眉和页码将在下一阶段的分页预览中显示。
|
||||
页眉和页码会实时显示在分页预览中。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
@@ -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 ?? ""
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
Vendored
+41
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
+56
-50
@@ -1,6 +1,6 @@
|
||||
# Markdown PDF 导出器进度
|
||||
|
||||
最后更新:2026-07-25
|
||||
最后更新:2026-07-26
|
||||
|
||||
## 1. 当前概况
|
||||
|
||||
@@ -19,13 +19,14 @@ main
|
||||
已有提交:
|
||||
|
||||
```text
|
||||
52cf816 feat: 完善导出设置与打印预览
|
||||
ec14f79 feat: 扩展本地主题兼容能力
|
||||
fa07472 feat: 实现网页实时预览
|
||||
7224dfd feat: 实现 Markdown 渲染核心
|
||||
ec48bce chore: 初始化项目骨架
|
||||
```
|
||||
|
||||
当前工作区存在未提交修改,主要是导出配置版本 2、导出设置抽屉、浏览器缓存和实时纸张尺寸及页边距预览。接手时必须保留并审查这些修改,不要重置工作区。
|
||||
当前工作区存在未提交修改,主要是基于 Paged.js 的真实分页预览、页眉页码、跨页表格和超高 Mermaid 图表适配。接手时必须保留并审查这些修改,不要重置工作区。
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
@@ -119,53 +120,58 @@ ec48bce chore: 初始化项目骨架
|
||||
- 主题开发指南;
|
||||
- 后端测试增至 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.1 导出配置版本 2
|
||||
### 3.1 Paged.js 真实分页预览
|
||||
|
||||
- 纸张只保留 A3、A4、A5、US-Letter 和 US-Legal。
|
||||
- 固定纸张尺寸分别为 297×420、210×297、148×210、216×279 和 216×356mm。
|
||||
- 默认 A4 纵向,四边页边距均为 16mm。
|
||||
- 统一采用 96 CSS px/in、72 PDF pt/in 和 25.4mm/in 的物理单位换算。
|
||||
- 保留横向和纵向。
|
||||
- 页脚模型收敛为页码设置,支持五种页码样式、对齐和起始页码。
|
||||
- 校验页边距之和必须为正文保留正尺寸区域。
|
||||
- 增加 6 项共享配置测试。
|
||||
- 引入 MIT 许可证的 Paged.js 0.4.3。
|
||||
- 使用独立同源沙箱 iframe 执行分页,父页面通过严格消息协议传递渲染载荷。
|
||||
- 每张纸以真实物理尺寸独立显示,页面间保留 24px 间隔和预览阴影。
|
||||
- 主题 CSS 原样交给 Paged.js,使主题 `@media print` 规则与未来 PDF 一致。
|
||||
- GitHub 主题打印字号已与 Typora PDF 对齐:正文 13px、一级标题 29.25px、二级标题 22.75px。
|
||||
- W30 对照文档在 Typora 和网页预览中均为 4 页。
|
||||
- 主题切换会重置 iframe 就绪握手,GitHub、Pixyll 和 Whitey 可连续切换且无需刷新。
|
||||
|
||||
### 3.2 导出设置和浏览器缓存
|
||||
### 3.2 页眉、页码与跨页内容
|
||||
|
||||
- 增加响应式右侧导出设置抽屉。
|
||||
- 支持五种纸张、方向和四边页边距。
|
||||
- 已提供页眉左中右内容、页脚页码样式、位置、起始页码和分隔线控件。
|
||||
- 配置和主题选择写入版本化 `localStorage`,不缓存 Markdown 正文。
|
||||
- 缓存缺失、损坏或版本过期时恢复默认配置。
|
||||
- 切换到更小纸张时自动等比例收敛过大的旧页边距。
|
||||
- 增加 3 项浏览器缓存测试和 3 项打印媒体预览测试。
|
||||
- 页眉支持左中右三栏、标题、作者和文件名变量及分隔线。
|
||||
- 页脚支持五种页码格式、左中右位置、起始页码和分隔线。
|
||||
- 当前页码逐页递增,总页数使用真实分页结果。
|
||||
- 标题避免孤立在页尾,段落使用孤行和寡行约束。
|
||||
- 长表格允许跨页,并通过 Paged.js 处理器重复表头。
|
||||
- 表格行、代码块、引用、公式、图片和 Mermaid 默认避免从中间断开。
|
||||
|
||||
### 3.3 实时纸张预览
|
||||
### 3.3 Mermaid 单页适配
|
||||
|
||||
- 纸张尺寸、方向和四边页边距实时作用于预览。
|
||||
- 删除本地主题专用的 8mm 外边距特例。
|
||||
- 在主题 CSS 后追加共享几何 CSS,统一清除 `body` 和 `#write` 的页面宽度、内外边距限制。
|
||||
- 网页预览将主题的 `@media print` 规则按原始位置转换为屏幕预览规则,使 Typora GitHub 等主题使用与 PDF 相同的打印字号和行高。
|
||||
- 固定逻辑页面后续按 96 CSS px/in 分页,预览显示倍率只缩放外层,不参与正文换行和分页计算。
|
||||
- 页眉和页码配置将在下一阶段分页预览中显示。
|
||||
|
||||
### 3.4 Mermaid 配置与错误隔离
|
||||
|
||||
- 使用 Mermaid 官方 npm 包,当前锁定安装版本为 11.16.0。
|
||||
- 无区块配置时显式使用 `default` 主题和 `classic` 外观。
|
||||
- Mermaid 区块内容原样交给官方解析器,支持区块内部的 YAML `config` frontmatter。
|
||||
- 允许区块覆盖主题、外观、主题变量、布局及图表专属配置。
|
||||
- 固定 `strict` 安全级别、自动启动开关、文本和边数量限制、错误输出策略,并禁止区块注入原始 `themeCSS`。
|
||||
- 单个 Mermaid 图表解析或渲染失败时显示局部错误,其余图表继续渲染。
|
||||
- 增加 Mermaid 站点配置、单图错误隔离及区块 frontmatter 保留测试。
|
||||
- Mermaid、字体和图片完成后再启动分页。
|
||||
- 普通 Mermaid 保持主题原有尺寸和布局。
|
||||
- 根据纸张和页边距计算正文区域的 CSS 像素宽高。
|
||||
- 当 SVG 按正文宽度适配后仍高于一页时,读取 `viewBox` 并改为按正文高度等比例缩小。
|
||||
- 超高 Mermaid 整体换页、水平居中,不跨页裁切。
|
||||
- 浏览器实测 A4 超高图高度约 1000.56px,正文可用高度约 1001.57px,未侵入上下页边距。
|
||||
- 增加 SVG `viewBox` 解析、正文尺寸和缩放算法测试。
|
||||
|
||||
## 4. 已执行验证
|
||||
|
||||
2026-07-25 在当前完整工作区成功执行:
|
||||
2026-07-26 在当前完整工作区成功执行:
|
||||
|
||||
```text
|
||||
npm test
|
||||
@@ -177,14 +183,17 @@ git diff --check
|
||||
结果:
|
||||
|
||||
- 共享配置测试:6 项通过;
|
||||
- 渲染器测试:6 项通过;
|
||||
- 前端测试:10 项通过;
|
||||
- 渲染器测试:7 项通过;
|
||||
- 前端测试:21 项通过;
|
||||
- 后端测试:6 项通过;
|
||||
- 全项目类型检查通过;
|
||||
- 生产构建通过;
|
||||
- `git diff --check` 通过,仅出现工作区 LF 将来可能转为 CRLF 的提示;
|
||||
- 导入器默认的已存在目标保护通过;
|
||||
- GitHub、Pixyll 和 Whitey 三套本地主题的 CSS API 均返回成功,组合结果无残留 `@import`。
|
||||
- 同一份 W30 Markdown 在 GitHub 分页预览和 Typora PDF 中均为 4 页。
|
||||
- 三个本地主题无需刷新即可连续切换并重新分页。
|
||||
- 超高 Mermaid 整体缩放到单页正文区域,普通 Mermaid 尺寸不受影响。
|
||||
|
||||
已使用浏览器插件完成视觉验证:
|
||||
|
||||
@@ -213,7 +222,11 @@ git diff --check
|
||||
## 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/export-settings.ts` 负责版本化浏览器缓存。
|
||||
- `packages/core/src/export-config.ts` 是纸张、页边距、页眉页脚和页码的共享模型。
|
||||
@@ -229,20 +242,13 @@ git diff --check
|
||||
|
||||
## 6. 推荐接手顺序
|
||||
|
||||
### 阶段一:完成分页预览
|
||||
### 阶段一:提交分页预览
|
||||
|
||||
导出设置、缓存和实时纸张几何已经完成。下一步按已确认设计引入 Paged.js,并实现:
|
||||
|
||||
- 逐页预览;
|
||||
- 页眉左中右内容及变量替换;
|
||||
- 页脚页码、总页数、对齐和起始页码;
|
||||
- Mermaid 整体换页和超高图按页面正文高度等比例缩放;
|
||||
- 字体、图片和 Mermaid 完成后再分页;
|
||||
- 预览与未来 PDF 共用分页 HTML 和 CSS。
|
||||
当前分页预览已经实现并通过验证。检查完整差异并创建一个清晰的阶段提交后,再进入真实 PDF。
|
||||
|
||||
### 阶段二:实现真实 PDF
|
||||
|
||||
分页预览完成并提交后再实现:
|
||||
分页预览提交后再实现:
|
||||
|
||||
1. 增加 Playwright 和固定版本 Chromium。
|
||||
2. 抽取可复用的完整 HTML 文档组装器。
|
||||
|
||||
Generated
+181
@@ -41,6 +41,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"
|
||||
},
|
||||
@@ -316,6 +317,26 @@
|
||||
"@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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -2266,6 +2287,12 @@
|
||||
"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": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -2355,6 +2382,14 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
|
||||
@@ -2364,6 +2399,19 @@
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2420,6 +2468,19 @@
|
||||
"integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
|
||||
"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": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
@@ -3058,6 +3119,46 @@
|
||||
"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": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
@@ -3122,6 +3223,21 @@
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
@@ -3145,6 +3261,16 @@
|
||||
"@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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
@@ -3155,6 +3281,15 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
@@ -3749,6 +3884,12 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"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_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": {
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
@@ -3864,6 +4011,19 @@
|
||||
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
@@ -4056,6 +4216,12 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -4415,6 +4581,15 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -4627,6 +4802,12 @@
|
||||
"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": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
Reference in New Issue
Block a user