feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"katex": "^0.16.28",
|
||||
"mermaid": "^11.12.3",
|
||||
"pagedjs": "^0.4.3",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
||||
+237
-15
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ChangeEvent,
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -25,8 +28,23 @@ import {
|
||||
getNearestPageNumber,
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX
|
||||
} from "./preview-page";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
downloadPdf,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "./pdf-export";
|
||||
import { getSyncedScrollTop } from "./scroll-sync";
|
||||
|
||||
const PrecisePdfPreview = lazy(async () => {
|
||||
const module = await import("./PrecisePdfPreview");
|
||||
return { default: module.PrecisePdfPreview };
|
||||
});
|
||||
|
||||
type PreviewMode = "quick" | "precise";
|
||||
|
||||
interface RenderedMarkdown {
|
||||
articleHtml: string;
|
||||
metadata: {
|
||||
@@ -100,6 +118,11 @@ export function App() {
|
||||
const [themeError, setThemeError] = useState("");
|
||||
const [mermaidError, setMermaidError] = useState("");
|
||||
const [paginationError, setPaginationError] = useState("");
|
||||
const [pdfError, setPdfError] = useState("");
|
||||
const [exportingPdf, setExportingPdf] = useState(false);
|
||||
const [previewMode, setPreviewMode] =
|
||||
useState<PreviewMode>("quick");
|
||||
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
|
||||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
||||
const [previewPagination, setPreviewPagination] = useState({
|
||||
current: 0,
|
||||
@@ -109,13 +132,19 @@ export function App() {
|
||||
const previewScrollRef = useRef<HTMLDivElement>(null);
|
||||
const previewFrameRef = useRef<HTMLIFrameElement>(null);
|
||||
const previewRequestIdRef = useRef(0);
|
||||
const pdfRequestKeyRef = useRef("");
|
||||
const previewModeRef = useRef<PreviewMode>("quick");
|
||||
const scrollSyncFrameRef = useRef<number | undefined>(undefined);
|
||||
const scrollSyncOriginRef = useRef<
|
||||
"editor" | "preview" | undefined
|
||||
>(undefined);
|
||||
|
||||
const error =
|
||||
renderError || themeError || mermaidError || paginationError;
|
||||
renderError ||
|
||||
themeError ||
|
||||
mermaidError ||
|
||||
paginationError ||
|
||||
pdfError;
|
||||
const themeId = exportConfig.themeId;
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||||
const paperDimensions = getPaperDimensionsMm(
|
||||
@@ -140,6 +169,22 @@ export function App() {
|
||||
: undefined,
|
||||
[exportConfig, fileName, result, themeCss]
|
||||
);
|
||||
const pdfRequest = useMemo<PdfExportRequest>(
|
||||
() => ({
|
||||
markdown,
|
||||
fileName,
|
||||
language: "zh-CN",
|
||||
exportConfig
|
||||
}),
|
||||
[exportConfig, fileName, markdown]
|
||||
);
|
||||
const pdfCacheKey = useMemo(
|
||||
() => createPdfExportCacheKey(pdfRequest),
|
||||
[pdfRequest]
|
||||
);
|
||||
const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey);
|
||||
previewModeRef.current = previewMode;
|
||||
pdfRequestKeyRef.current = pdfCacheKey;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -191,6 +236,13 @@ export function App() {
|
||||
}
|
||||
}, [exportConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pdfCache && pdfCache.key !== pdfCacheKey) {
|
||||
setPdfCache(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
}
|
||||
}, [pdfCache, pdfCacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setPreviewFrameReady(false);
|
||||
@@ -361,6 +413,9 @@ export function App() {
|
||||
}, [previewFrameReady, previewPayload]);
|
||||
|
||||
function getPreviewScroller() {
|
||||
if (previewModeRef.current === "precise") {
|
||||
return previewScrollRef.current;
|
||||
}
|
||||
return previewFrameRef.current?.contentDocument
|
||||
?.scrollingElement as HTMLElement | null;
|
||||
}
|
||||
@@ -392,6 +447,10 @@ export function App() {
|
||||
}
|
||||
|
||||
function updatePreviewCurrentPage() {
|
||||
if (previewModeRef.current === "precise") {
|
||||
updatePrecisePreviewCurrentPage();
|
||||
return;
|
||||
}
|
||||
const frame = previewFrameRef.current;
|
||||
const frameDocument = frame?.contentDocument;
|
||||
const scrollElement = getPreviewScroller();
|
||||
@@ -423,6 +482,42 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function updatePrecisePreviewCurrentPage() {
|
||||
const scrollElement = previewScrollRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
const viewportCenter =
|
||||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||
const pages = Array.from(
|
||||
scrollElement.querySelectorAll<HTMLElement>(
|
||||
".precise-pdf-page"
|
||||
)
|
||||
).map((page) => ({
|
||||
top: page.offsetTop,
|
||||
bottom: page.offsetTop + page.offsetHeight
|
||||
}));
|
||||
const current = getNearestPageNumber(viewportCenter, pages);
|
||||
|
||||
setPreviewPagination((pagination) =>
|
||||
pagination.current === current &&
|
||||
pagination.total === pages.length
|
||||
? pagination
|
||||
: {
|
||||
current,
|
||||
total: pages.length
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
||||
setPreviewPagination({
|
||||
current: pageCount > 0 ? 1 : 0,
|
||||
total: pageCount
|
||||
});
|
||||
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
|
||||
}, []);
|
||||
|
||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
@@ -439,6 +534,70 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf(download: boolean) {
|
||||
if (exportingPdf) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExportingPdf(true);
|
||||
setPdfError("");
|
||||
setStatus(
|
||||
download ? "正在生成 PDF…" : "正在生成精确预览…"
|
||||
);
|
||||
const requestedKey = pdfCacheKey;
|
||||
try {
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
pdfRequest,
|
||||
pdfCache
|
||||
);
|
||||
if (pdfRequestKeyRef.current !== requestedKey) {
|
||||
setStatus("文档已更新,已丢弃过期 PDF");
|
||||
return;
|
||||
}
|
||||
setPdfCache(resolved);
|
||||
const exported = resolved.result;
|
||||
if (download) {
|
||||
downloadPdf(exported.blob, exported.fileName);
|
||||
}
|
||||
const pageDescription =
|
||||
exported.pageCount === undefined
|
||||
? ""
|
||||
: `(${exported.pageCount} 页)`;
|
||||
const mermaidDescription =
|
||||
exported.mermaidErrorCount > 0
|
||||
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
||||
: "";
|
||||
setStatus(
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
|
||||
);
|
||||
} catch (reason) {
|
||||
setPdfError(
|
||||
reason instanceof Error ? reason.message : "PDF 导出失败"
|
||||
);
|
||||
setStatus(download ? "PDF 导出失败" : "精确预览失败");
|
||||
} finally {
|
||||
setExportingPdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewModeChange(mode: PreviewMode) {
|
||||
setPreviewMode(mode);
|
||||
setPreviewFrameReady(false);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||
if (mode === "precise" && !cachedPdf) {
|
||||
void generatePdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewScroll() {
|
||||
if (previewModeRef.current !== "precise") {
|
||||
return;
|
||||
}
|
||||
synchronizeScroll("preview");
|
||||
updatePrecisePreviewCurrentPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace">
|
||||
<header className="topbar">
|
||||
@@ -458,8 +617,12 @@ export function App() {
|
||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||
导出设置
|
||||
</button>
|
||||
<button type="button" disabled title="下一阶段实现">
|
||||
导出 PDF
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingPdf || !markdown.trim()}
|
||||
onClick={() => void generatePdf(true)}
|
||||
>
|
||||
{exportingPdf ? "正在导出…" : "导出 PDF"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -497,6 +660,33 @@ export function App() {
|
||||
第 {previewPagination.current} / {previewPagination.total} 页
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="preview-mode-control"
|
||||
role="group"
|
||||
aria-label="预览模式"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
previewMode === "quick" ? "is-active" : ""
|
||||
}
|
||||
aria-pressed={previewMode === "quick"}
|
||||
onClick={() => handlePreviewModeChange("quick")}
|
||||
>
|
||||
快速
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
previewMode === "precise" ? "is-active" : ""
|
||||
}
|
||||
aria-pressed={previewMode === "precise"}
|
||||
disabled={exportingPdf}
|
||||
onClick={() => handlePreviewModeChange("precise")}
|
||||
>
|
||||
精确
|
||||
</button>
|
||||
</span>
|
||||
<label className="theme-control">
|
||||
<span className="sr-only">预览主题</span>
|
||||
<select
|
||||
@@ -528,20 +718,52 @@ export function App() {
|
||||
<div
|
||||
ref={previewScrollRef}
|
||||
className="preview-scroll"
|
||||
onScroll={handlePreviewScroll}
|
||||
>
|
||||
<div className="paper" style={paperStyle}>
|
||||
{previewPayload ? (
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
className="preview-frame"
|
||||
title="文档内容预览"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
src="/preview-frame.html"
|
||||
{previewMode === "quick" ? (
|
||||
<div className="paper" style={paperStyle}>
|
||||
{previewPayload ? (
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
className="preview-frame"
|
||||
title="文档内容预览"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
src="/preview-frame.html"
|
||||
/>
|
||||
) : (
|
||||
<div className="preview-loading">
|
||||
正在生成文档预览…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : exportingPdf && !cachedPdf ? (
|
||||
<div className="precise-pdf-status">
|
||||
正在使用固定版本 Chromium 生成精确预览…
|
||||
</div>
|
||||
) : cachedPdf ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="precise-pdf-status">
|
||||
正在加载 PDF 查看器…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PrecisePdfPreview
|
||||
blob={cachedPdf.blob}
|
||||
onPageCount={handlePrecisePageCount}
|
||||
/>
|
||||
) : (
|
||||
<div className="preview-loading">正在生成文档预览…</div>
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="precise-pdf-status">
|
||||
<span>当前内容尚未生成精确预览</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generatePdf(false)}
|
||||
>
|
||||
生成精确预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
RenderingCancelledException,
|
||||
TextLayer,
|
||||
type PDFDocumentProxy,
|
||||
type PDFPageProxy,
|
||||
type RenderTask
|
||||
} from "pdfjs-dist";
|
||||
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions
|
||||
} from "./pdf-page-geometry";
|
||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
interface PdfPageDescriptor {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PrecisePdfPreviewProps {
|
||||
blob: Blob;
|
||||
onPageCount?: (pageCount: number) => void;
|
||||
}
|
||||
|
||||
interface PrecisePdfPageProps {
|
||||
document: PDFDocumentProxy;
|
||||
descriptor: PdfPageDescriptor;
|
||||
renderQueue: PdfPageRenderQueue;
|
||||
}
|
||||
|
||||
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
const [nearViewport, setNearViewport] = useState(false);
|
||||
const releaseTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
if (releaseTimerRef.current !== undefined) {
|
||||
window.clearTimeout(releaseTimerRef.current);
|
||||
releaseTimerRef.current = undefined;
|
||||
}
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseTimerRef.current = window.setTimeout(() => {
|
||||
releaseTimerRef.current = undefined;
|
||||
setNearViewport(false);
|
||||
}, 1_500);
|
||||
},
|
||||
{
|
||||
rootMargin: "1200px 0px"
|
||||
}
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (releaseTimerRef.current !== undefined) {
|
||||
window.clearTimeout(releaseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [elementRef]);
|
||||
|
||||
return nearViewport;
|
||||
}
|
||||
|
||||
function PrecisePdfPage({
|
||||
document,
|
||||
descriptor,
|
||||
renderQueue
|
||||
}: PrecisePdfPageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayWidth, setDisplayWidth] = useState(0);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [renderState, setRenderState] = useState<
|
||||
"idle" | "queued" | "rendering" | "rendered"
|
||||
>("idle");
|
||||
const nearViewport = useNearViewport(containerRef);
|
||||
const dimensions = getPdfPageCssDimensions(
|
||||
descriptor.width,
|
||||
descriptor.height
|
||||
);
|
||||
const pageStyle = {
|
||||
width: `${dimensions.width}px`,
|
||||
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
||||
} satisfies CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateWidth = () => setDisplayWidth(container.clientWidth);
|
||||
updateWidth();
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const updateVisibility = useCallback(
|
||||
(visible: boolean) => {
|
||||
renderQueue.setVisible(descriptor.pageNumber, visible);
|
||||
},
|
||||
[descriptor.pageNumber, renderQueue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
updateVisibility(true);
|
||||
return () => updateVisibility(false);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
updateVisibility(Boolean(entry?.isIntersecting));
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
updateVisibility(false);
|
||||
};
|
||||
}, [updateVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const textContainer = textLayerRef.current;
|
||||
if (
|
||||
!nearViewport ||
|
||||
displayWidth <= 0 ||
|
||||
!canvas ||
|
||||
!textContainer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let page: PDFPageProxy | undefined;
|
||||
let renderTask: RenderTask | undefined;
|
||||
let textLayer: TextLayer | undefined;
|
||||
|
||||
setRenderError("");
|
||||
setRenderState("queued");
|
||||
const cancelQueuedRender = renderQueue.schedule(
|
||||
descriptor.pageNumber,
|
||||
async () => {
|
||||
try {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRenderState("rendering");
|
||||
const loadedPage = await document.getPage(
|
||||
descriptor.pageNumber
|
||||
);
|
||||
page = loadedPage;
|
||||
const outputScale = Math.min(
|
||||
window.devicePixelRatio || 1,
|
||||
2
|
||||
);
|
||||
const renderScale = getPdfCanvasRenderScale(
|
||||
descriptor.width,
|
||||
displayWidth,
|
||||
outputScale
|
||||
);
|
||||
const displayScale = displayWidth / descriptor.width;
|
||||
const renderViewport = page.getViewport({
|
||||
scale: renderScale
|
||||
});
|
||||
const displayViewport = page.getViewport({
|
||||
scale: displayScale
|
||||
});
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("浏览器无法创建 PDF Canvas");
|
||||
}
|
||||
|
||||
canvas.width = Math.ceil(renderViewport.width);
|
||||
canvas.height = Math.ceil(renderViewport.height);
|
||||
canvas.style.width = `${displayViewport.width}px`;
|
||||
canvas.style.height = `${displayViewport.height}px`;
|
||||
textContainer.replaceChildren();
|
||||
|
||||
renderTask = page.render({
|
||||
canvas,
|
||||
canvasContext: context,
|
||||
viewport: renderViewport
|
||||
});
|
||||
await renderTask.promise;
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRenderState("rendered");
|
||||
|
||||
textLayer = new TextLayer({
|
||||
container: textContainer,
|
||||
textContentSource: page.streamTextContent(),
|
||||
viewport: displayViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
} catch (reason: unknown) {
|
||||
if (
|
||||
disposed ||
|
||||
reason instanceof RenderingCancelledException
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRenderError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "PDF 页面渲染失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelQueuedRender();
|
||||
renderTask?.cancel();
|
||||
textLayer?.cancel();
|
||||
page?.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
textContainer.replaceChildren();
|
||||
setRenderState("idle");
|
||||
};
|
||||
}, [
|
||||
descriptor.height,
|
||||
descriptor.pageNumber,
|
||||
descriptor.width,
|
||||
displayWidth,
|
||||
document,
|
||||
nearViewport,
|
||||
renderQueue
|
||||
]);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={containerRef}
|
||||
className="precise-pdf-page"
|
||||
style={pageStyle}
|
||||
aria-label={`PDF 第 ${descriptor.pageNumber} 页`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={renderState === "rendered" ? "is-rendered" : ""}
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={textLayerRef} className="textLayer" />
|
||||
{renderState !== "rendered" ? (
|
||||
<span className="precise-pdf-page-placeholder">
|
||||
{renderState === "rendering"
|
||||
? `正在渲染第 ${descriptor.pageNumber} 页…`
|
||||
: `第 ${descriptor.pageNumber} 页`}
|
||||
</span>
|
||||
) : null}
|
||||
{renderError ? (
|
||||
<span className="precise-pdf-page-error">{renderError}</span>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrecisePdfPreview({
|
||||
blob,
|
||||
onPageCount
|
||||
}: PrecisePdfPreviewProps) {
|
||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||
const [pages, setPages] = useState<PdfPageDescriptor[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const renderQueue = useMemo(
|
||||
() => new PdfPageRenderQueue(1),
|
||||
[document]
|
||||
);
|
||||
|
||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let loadingTask:
|
||||
| ReturnType<typeof getDocument>
|
||||
| undefined;
|
||||
|
||||
setDocument(undefined);
|
||||
setPages([]);
|
||||
setError("");
|
||||
|
||||
void blob
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
if (disposed) {
|
||||
return undefined;
|
||||
}
|
||||
loadingTask = getDocument({
|
||||
data: new Uint8Array(buffer)
|
||||
});
|
||||
return loadingTask.promise;
|
||||
})
|
||||
.then(async (loadedDocument) => {
|
||||
if (!loadedDocument || disposed) {
|
||||
return;
|
||||
}
|
||||
const descriptors = await Promise.all(
|
||||
Array.from(
|
||||
{ length: loadedDocument.numPages },
|
||||
async (_, index) => {
|
||||
const page = await loadedDocument.getPage(index + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
page.cleanup();
|
||||
return {
|
||||
pageNumber: index + 1,
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setDocument(loadedDocument);
|
||||
setPages(descriptors);
|
||||
onPageCount?.(loadedDocument.numPages);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!disposed) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "无法读取 PDF"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
void loadingTask?.destroy();
|
||||
};
|
||||
}, [blob, onPageCount]);
|
||||
|
||||
if (error) {
|
||||
return <div className="precise-pdf-status is-error">{error}</div>;
|
||||
}
|
||||
if (!document || pages.length === 0) {
|
||||
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="precise-pdf-document">
|
||||
{pages.map((page) => (
|
||||
<PrecisePdfPage
|
||||
key={page.pageNumber}
|
||||
document={document}
|
||||
descriptor={page}
|
||||
renderQueue={renderQueue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export function createSvgDataUrl(svgMarkup: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
|
||||
}
|
||||
|
||||
export interface MermaidBoxSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MermaidOutputMode = "inline-svg" | "svg-image";
|
||||
|
||||
export function resolveMermaidOutputMode(
|
||||
search: string
|
||||
): MermaidOutputMode {
|
||||
return new URLSearchParams(search).get("mermaid-output") ===
|
||||
"inline-svg"
|
||||
? "inline-svg"
|
||||
: "svg-image";
|
||||
}
|
||||
|
||||
export function getLockedMermaidBoxSize(
|
||||
size: MermaidBoxSize
|
||||
): MermaidBoxSize | undefined {
|
||||
if (
|
||||
!Number.isFinite(size.width) ||
|
||||
!Number.isFinite(size.height) ||
|
||||
size.width <= 0 ||
|
||||
size.height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export function getMermaidImageAlt(
|
||||
ariaLabel: string | null,
|
||||
title: string | null
|
||||
) {
|
||||
return ariaLabel?.trim() || title?.trim() || "Mermaid 图表";
|
||||
}
|
||||
|
||||
export function replaceMermaidSvgWithImages(container: ParentNode) {
|
||||
const diagrams = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
||||
);
|
||||
|
||||
for (const diagram of diagrams) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
||||
if (!svg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentWindow = svg.ownerDocument.defaultView;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const diagramRect = diagram.getBoundingClientRect();
|
||||
const computedStyle = documentWindow?.getComputedStyle(svg);
|
||||
const lockedSvgSize = getLockedMermaidBoxSize(svgRect);
|
||||
const lockedDiagramSize = getLockedMermaidBoxSize(diagramRect);
|
||||
const image = svg.ownerDocument.createElement("img");
|
||||
image.className = "mermaid-svg-image";
|
||||
image.alt = getMermaidImageAlt(
|
||||
svg.getAttribute("aria-label"),
|
||||
svg.querySelector("title")?.textContent ?? null
|
||||
);
|
||||
image.src = createSvgDataUrl(
|
||||
new XMLSerializer().serializeToString(svg)
|
||||
);
|
||||
image.style.cssText = svg.style.cssText;
|
||||
if (computedStyle?.display) {
|
||||
image.style.setProperty("display", computedStyle.display, "important");
|
||||
}
|
||||
if (computedStyle?.verticalAlign) {
|
||||
image.style.setProperty(
|
||||
"vertical-align",
|
||||
computedStyle.verticalAlign,
|
||||
"important"
|
||||
);
|
||||
}
|
||||
if (lockedSvgSize) {
|
||||
const width = `${lockedSvgSize.width}px`;
|
||||
const height = `${lockedSvgSize.height}px`;
|
||||
image.style.setProperty("width", width, "important");
|
||||
image.style.setProperty("height", height, "important");
|
||||
image.style.setProperty("max-width", width, "important");
|
||||
image.style.setProperty("max-height", height, "important");
|
||||
image.setAttribute("width", String(lockedSvgSize.width));
|
||||
image.setAttribute("height", String(lockedSvgSize.height));
|
||||
}
|
||||
if (lockedDiagramSize) {
|
||||
diagram.style.setProperty("box-sizing", "border-box", "important");
|
||||
diagram.style.setProperty(
|
||||
"height",
|
||||
`${lockedDiagramSize.height}px`,
|
||||
"important"
|
||||
);
|
||||
}
|
||||
svg.replaceWith(image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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 { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||||
import "./paged-table-handler";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
documentBaseCss,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import type { PagedRenderTarget } from "./paged-render-target";
|
||||
|
||||
export interface PagedDocumentRenderResult {
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
mermaidErrors: string[];
|
||||
timings: PagedDocumentTimings;
|
||||
}
|
||||
|
||||
export interface PagedDocumentTimings {
|
||||
setupMs: number;
|
||||
mermaidMs: number;
|
||||
mermaidFitMs: number;
|
||||
mermaidConversionMs: number;
|
||||
resourceWaitMs: number;
|
||||
paginationMs: number;
|
||||
finalizeMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface PagedDocumentRenderOptions {
|
||||
target: PagedRenderTarget;
|
||||
shouldContinue?: () => boolean;
|
||||
mermaidOutput?: MermaidOutputMode;
|
||||
}
|
||||
|
||||
function waitForImages(container: ParentNode) {
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
return 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(
|
||||
documentRef: Document,
|
||||
css: string,
|
||||
name: string
|
||||
) {
|
||||
return {
|
||||
[`${documentRef.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");
|
||||
}
|
||||
}
|
||||
|
||||
function removeTrailingPdfPageBreak(container: ParentNode) {
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
pages
|
||||
.at(-1)
|
||||
?.style.setProperty("break-after", "auto", "important");
|
||||
}
|
||||
|
||||
export class PagedDocumentRuntime {
|
||||
private activePreviewer: Previewer | undefined;
|
||||
private mermaidRenderSequence = 0;
|
||||
private mermaidPromise:
|
||||
| Promise<(typeof import("mermaid"))["default"]>
|
||||
| undefined;
|
||||
|
||||
constructor(private readonly root: HTMLElement) {}
|
||||
|
||||
destroy() {
|
||||
if (!this.activePreviewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activePreviewer.chunker.destroy();
|
||||
this.activePreviewer.polisher.destroy();
|
||||
this.activePreviewer = undefined;
|
||||
}
|
||||
|
||||
private loadMermaid() {
|
||||
if (!this.mermaidPromise) {
|
||||
this.mermaidPromise = import("mermaid").then(
|
||||
({ default: mermaid }) => {
|
||||
mermaid.initialize(createMermaidSiteConfig());
|
||||
return mermaid;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.mermaidPromise;
|
||||
}
|
||||
|
||||
private async 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 this.loadMermaid();
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
nodes.map((node) => node.textContent ?? ""),
|
||||
async (definition) => {
|
||||
this.mermaidRenderSequence += 1;
|
||||
return mermaid.render(
|
||||
`mermaid-paged-document-${this.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 render(
|
||||
payload: PagedPreviewPayload,
|
||||
options: PagedDocumentRenderOptions
|
||||
): Promise<PagedDocumentRenderResult | undefined> {
|
||||
const totalStartedAt = performance.now();
|
||||
const shouldContinue = options.shouldContinue ?? (() => true);
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const setupStartedAt = performance.now();
|
||||
const documentRef = this.root.ownerDocument;
|
||||
this.destroy();
|
||||
this.root.replaceChildren();
|
||||
documentRef.documentElement.dataset.renderTarget = options.target;
|
||||
documentRef.documentElement.lang =
|
||||
payload.metadata.language || "zh-CN";
|
||||
documentRef.title =
|
||||
payload.metadata.title || "Markdown 分页文档";
|
||||
|
||||
const template = documentRef.createElement("template");
|
||||
template.innerHTML = payload.articleHtml;
|
||||
const content = template.content;
|
||||
const setupMs = performance.now() - setupStartedAt;
|
||||
|
||||
const mermaidStartedAt = performance.now();
|
||||
const mermaidErrors = await this.renderMermaid(content, payload);
|
||||
const mermaidMs = performance.now() - mermaidStartedAt;
|
||||
|
||||
const mermaidFitStartedAt = performance.now();
|
||||
fitOversizedMermaidToPage(content, payload.exportConfig);
|
||||
const mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||||
|
||||
const mermaidConversionStartedAt = performance.now();
|
||||
if (options.mermaidOutput === "svg-image") {
|
||||
replaceMermaidSvgWithImages(content);
|
||||
}
|
||||
const mermaidConversionMs =
|
||||
performance.now() - mermaidConversionStartedAt;
|
||||
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resourceWaitStartedAt = performance.now();
|
||||
await documentRef.fonts.ready;
|
||||
await waitForImages(content);
|
||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
||||
|
||||
const paginationStartedAt = performance.now();
|
||||
const previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
const flow = await previewer.preview(
|
||||
content,
|
||||
[
|
||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||
stylesheet(documentRef, highlightCss, "highlight"),
|
||||
stylesheet(documentRef, katexCss, "katex"),
|
||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
documentGeometryCss,
|
||||
"document-geometry"
|
||||
),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
buildPagedMediaCss(payload.exportConfig, payload),
|
||||
"paged-media"
|
||||
)
|
||||
],
|
||||
this.root
|
||||
);
|
||||
const paginationMs = performance.now() - paginationStartedAt;
|
||||
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const finalizeStartedAt = performance.now();
|
||||
applyPageNumbers(this.root, payload, flow.total);
|
||||
if (options.target === "pdf") {
|
||||
removeTrailingPdfPageBreak(this.root);
|
||||
}
|
||||
const finalizeMs = performance.now() - finalizeStartedAt;
|
||||
|
||||
return {
|
||||
pageCount: flow.total,
|
||||
contentHeight: Math.ceil(
|
||||
Math.max(
|
||||
documentRef.documentElement.scrollHeight,
|
||||
documentRef.body.scrollHeight,
|
||||
this.root.scrollHeight
|
||||
)
|
||||
),
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
mermaidMs,
|
||||
mermaidFitMs,
|
||||
mermaidConversionMs,
|
||||
resourceWaitMs,
|
||||
paginationMs,
|
||||
finalizeMs,
|
||||
totalMs: performance.now() - totalStartedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mdToPdfRender?: (
|
||||
payload: PagedPreviewPayload,
|
||||
target?: PagedRenderTarget
|
||||
) => Promise<PagedDocumentRenderResult>;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
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 { PagedDocumentRuntime } from "./paged-document-runtime";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
documentBaseCss,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type PagedPreviewFrameMessage,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
||||
|
||||
function getPreviewRoot() {
|
||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||
@@ -25,151 +17,18 @@ function getPreviewRoot() {
|
||||
}
|
||||
|
||||
const previewRoot = getPreviewRoot();
|
||||
document.documentElement.dataset.renderTarget = "preview";
|
||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot);
|
||||
document.documentElement.dataset.renderTarget = renderTarget;
|
||||
|
||||
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
|
||||
@@ -184,65 +43,38 @@ async function renderPagedPreview(
|
||||
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) {
|
||||
const result = await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
if (!result) {
|
||||
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
|
||||
...result
|
||||
});
|
||||
}
|
||||
|
||||
window.__mdToPdfRender = async (
|
||||
payload,
|
||||
target = renderTarget
|
||||
) => {
|
||||
const result = await runtime.render(payload, {
|
||||
target,
|
||||
mermaidOutput
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("分页文档渲染已取消");
|
||||
}
|
||||
return result;
|
||||
};
|
||||
document.documentElement.dataset.runtimeReady = "true";
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
|
||||
@@ -368,6 +368,9 @@ html[data-render-target="preview"] body {
|
||||
|
||||
html[data-render-target="pdf"],
|
||||
html[data-render-target="pdf"] body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
@@ -382,10 +385,6 @@ 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type PagedRenderTarget = "preview" | "pdf";
|
||||
|
||||
export function resolvePagedRenderTarget(
|
||||
search: string
|
||||
): PagedRenderTarget {
|
||||
const target = new URLSearchParams(search).get("target");
|
||||
return target === "pdf" ? "pdf" : "preview";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
|
||||
export interface PdfExportRequest {
|
||||
markdown: string;
|
||||
fileName: string;
|
||||
language: string;
|
||||
exportConfig: ExportConfig;
|
||||
}
|
||||
|
||||
export interface PdfExportResult {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
pageCount: number | undefined;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
export interface CachedPdfExport {
|
||||
key: string;
|
||||
result: PdfExportResult;
|
||||
}
|
||||
|
||||
export function createPdfExportCacheKey(request: PdfExportRequest) {
|
||||
return JSON.stringify([
|
||||
request.markdown,
|
||||
request.fileName,
|
||||
request.language,
|
||||
request.exportConfig
|
||||
]);
|
||||
}
|
||||
|
||||
export function getCachedPdfExport(
|
||||
cache: CachedPdfExport | undefined,
|
||||
key: string
|
||||
) {
|
||||
return cache?.key === key ? cache.result : undefined;
|
||||
}
|
||||
|
||||
export async function getOrRequestPdfExport(
|
||||
request: PdfExportRequest,
|
||||
cache: CachedPdfExport | undefined,
|
||||
requester: (
|
||||
request: PdfExportRequest
|
||||
) => Promise<PdfExportResult> = requestPdfExport
|
||||
): Promise<CachedPdfExport> {
|
||||
const key = createPdfExportCacheKey(request);
|
||||
const cached = getCachedPdfExport(cache, key);
|
||||
return {
|
||||
key,
|
||||
result: cached ?? (await requester(request))
|
||||
};
|
||||
}
|
||||
|
||||
function parseNumericHeader(value: string | null) {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function parseContentDispositionFileName(
|
||||
contentDisposition: string | null
|
||||
) {
|
||||
const encoded = contentDisposition?.match(
|
||||
/filename\*=UTF-8''([^;]+)/i
|
||||
)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
} catch {
|
||||
// 使用兼容文件名回退。
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
contentDisposition
|
||||
?.match(/filename="([^"]+)"/i)?.[1]
|
||||
?.trim() || "document.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as {
|
||||
message?: unknown;
|
||||
};
|
||||
if (typeof payload.message === "string") {
|
||||
return payload.message;
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 错误响应使用通用提示。
|
||||
}
|
||||
return "PDF 导出失败";
|
||||
}
|
||||
|
||||
export async function requestPdfExport(
|
||||
request: PdfExportRequest,
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<PdfExportResult> {
|
||||
const response = await fetcher("/api/pdf", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: parseContentDispositionFileName(
|
||||
response.headers.get("content-disposition")
|
||||
),
|
||||
pageCount: parseNumericHeader(
|
||||
response.headers.get("x-pdf-page-count")
|
||||
),
|
||||
mermaidErrorCount:
|
||||
parseNumericHeader(
|
||||
response.headers.get("x-mermaid-error-count")
|
||||
) ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadPdf(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const PDF_POINTS_PER_INCH = 72;
|
||||
export const CSS_PIXELS_PER_INCH = 96;
|
||||
export const PDF_PAGE_CSS_SCALE =
|
||||
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
||||
|
||||
export function getPdfPageCssDimensions(
|
||||
widthPoints: number,
|
||||
heightPoints: number
|
||||
) {
|
||||
return {
|
||||
width: widthPoints * PDF_PAGE_CSS_SCALE,
|
||||
height: heightPoints * PDF_PAGE_CSS_SCALE
|
||||
};
|
||||
}
|
||||
|
||||
export function getPdfCanvasRenderScale(
|
||||
widthPoints: number,
|
||||
displayWidthCssPixels: number,
|
||||
devicePixelRatio: number
|
||||
) {
|
||||
if (
|
||||
widthPoints <= 0 ||
|
||||
displayWidthCssPixels <= 0 ||
|
||||
devicePixelRatio <= 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return (displayWidthCssPixels / widthPoints) * devicePixelRatio;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export type PdfPageRenderTask = () => Promise<void>;
|
||||
|
||||
interface QueuedRender {
|
||||
id: symbol;
|
||||
pageNumber: number;
|
||||
run: PdfPageRenderTask;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export class PdfPageRenderQueue {
|
||||
private readonly pending: QueuedRender[] = [];
|
||||
private readonly visiblePages = new Set<number>();
|
||||
private active = 0;
|
||||
private focusPage = 1;
|
||||
private scrollDirection: 1 | -1 = 1;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly concurrency = 1) {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
||||
throw new Error("PDF 页面渲染并发数必须是正整数");
|
||||
}
|
||||
}
|
||||
|
||||
schedule(pageNumber: number, run: PdfPageRenderTask) {
|
||||
const task: QueuedRender = {
|
||||
id: Symbol(`pdf-page-${pageNumber}`),
|
||||
pageNumber,
|
||||
run,
|
||||
cancelled: false
|
||||
};
|
||||
this.pending.push(task);
|
||||
this.pump();
|
||||
|
||||
return () => {
|
||||
task.cancelled = true;
|
||||
const index = this.pending.findIndex(
|
||||
(candidate) => candidate.id === task.id
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.pending.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setVisible(pageNumber: number, visible: boolean) {
|
||||
if (visible) {
|
||||
if (pageNumber !== this.focusPage) {
|
||||
this.scrollDirection =
|
||||
pageNumber > this.focusPage ? 1 : -1;
|
||||
this.focusPage = pageNumber;
|
||||
}
|
||||
this.visiblePages.add(pageNumber);
|
||||
} else {
|
||||
this.visiblePages.delete(pageNumber);
|
||||
}
|
||||
this.pump();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
for (const task of this.pending) {
|
||||
task.cancelled = true;
|
||||
}
|
||||
this.pending.length = 0;
|
||||
this.visiblePages.clear();
|
||||
}
|
||||
|
||||
private comparePriority(left: QueuedRender, right: QueuedRender) {
|
||||
const leftPriority = this.priority(left.pageNumber);
|
||||
const rightPriority = this.priority(right.pageNumber);
|
||||
return (
|
||||
leftPriority.group - rightPriority.group ||
|
||||
leftPriority.distance - rightPriority.distance ||
|
||||
left.pageNumber - right.pageNumber
|
||||
);
|
||||
}
|
||||
|
||||
private priority(pageNumber: number) {
|
||||
if (this.visiblePages.has(pageNumber)) {
|
||||
return { group: 0, distance: 0 };
|
||||
}
|
||||
|
||||
const delta = pageNumber - this.focusPage;
|
||||
const isAhead = delta * this.scrollDirection >= 0;
|
||||
return {
|
||||
group: isAhead ? 1 : 2,
|
||||
distance: Math.abs(delta)
|
||||
};
|
||||
}
|
||||
|
||||
private pump() {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.active < this.concurrency) {
|
||||
this.pending.sort((left, right) =>
|
||||
this.comparePriority(left, right)
|
||||
);
|
||||
const task = this.pending.shift();
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
if (task.cancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
void task
|
||||
.run()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
this.active -= 1;
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,31 @@ h1 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-mode-control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 8px;
|
||||
background: #eef2ef;
|
||||
}
|
||||
|
||||
.preview-mode-control button {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #68766f;
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.preview-mode-control button.is-active {
|
||||
background: #fff;
|
||||
color: #356c55;
|
||||
box-shadow: 0 1px 4px rgb(37 49 43 / 12%);
|
||||
}
|
||||
|
||||
.theme-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -290,6 +315,145 @@ textarea:focus {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.precise-pdf-document {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 34px 30px;
|
||||
}
|
||||
|
||||
.precise-pdf-page {
|
||||
position: relative;
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas,
|
||||
.precise-pdf-page .textLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 80ms ease-out;
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas.is-rendered {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer {
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
color-scheme: only light;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor:
|
||||
calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer :is(span, br) {
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer > :not(.markedContent),
|
||||
.precise-pdf-page
|
||||
.textLayer
|
||||
.markedContent
|
||||
span:not(.markedContent) {
|
||||
z-index: 1;
|
||||
font-size:
|
||||
calc(var(--text-scale-factor) * var(--font-height, 0));
|
||||
transform:
|
||||
rotate(var(--rotate, 0deg))
|
||||
scaleX(var(--scale-x, 1))
|
||||
scale(var(--min-font-size-inv));
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer .markedContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer span[role="img"] {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer ::selection {
|
||||
color: transparent;
|
||||
background: rgb(0 100 255 / 25%);
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer br::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer .endOfContent {
|
||||
position: absolute;
|
||||
display: block;
|
||||
z-index: 0;
|
||||
inset: 100% 0 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer.selecting .endOfContent {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page-placeholder,
|
||||
.precise-pdf-page-error {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
color: #7a8580;
|
||||
background: #fff;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.precise-pdf-page-error {
|
||||
z-index: 2;
|
||||
color: #a23d3d;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
}
|
||||
|
||||
.precise-pdf-status {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 14px;
|
||||
min-height: 500px;
|
||||
place-items: center;
|
||||
color: #6f7d76;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.precise-pdf-status.is-error {
|
||||
color: #a23d3d;
|
||||
}
|
||||
|
||||
.settings-layer {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
@@ -607,6 +771,10 @@ textarea:focus {
|
||||
max-width: 62%;
|
||||
}
|
||||
|
||||
.preview-heading-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.theme-control select {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSvgDataUrl,
|
||||
getLockedMermaidBoxSize,
|
||||
getMermaidImageAlt,
|
||||
resolveMermaidOutputMode
|
||||
} from "../src/mermaid-static-image";
|
||||
|
||||
describe("Mermaid 静态 SVG 图片", () => {
|
||||
it("生成无需外部请求的 UTF-8 Data URL", () => {
|
||||
const url = createSvgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>中文 & A</text></svg>'
|
||||
);
|
||||
|
||||
expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
expect(decodeURIComponent(url.split(",")[1] ?? "")).toContain(
|
||||
"<text>中文 & A</text>"
|
||||
);
|
||||
});
|
||||
|
||||
it("保留渲染后的精确小数尺寸", () => {
|
||||
expect(
|
||||
getLockedMermaidBoxSize({
|
||||
width: 742.375,
|
||||
height: 386.625
|
||||
})
|
||||
).toEqual({
|
||||
width: 742.375,
|
||||
height: 386.625
|
||||
});
|
||||
expect(
|
||||
getLockedMermaidBoxSize({
|
||||
width: 0,
|
||||
height: 386.625
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("优先使用无障碍标签并回退到 SVG 标题", () => {
|
||||
expect(getMermaidImageAlt(" 数据链路 ", "备用标题")).toBe(
|
||||
"数据链路"
|
||||
);
|
||||
expect(getMermaidImageAlt(null, " 数据关系图 ")).toBe(
|
||||
"数据关系图"
|
||||
);
|
||||
expect(getMermaidImageAlt(" ", null)).toBe("Mermaid 图表");
|
||||
});
|
||||
|
||||
it("默认使用静态 SVG 并允许内部切回内联模式", () => {
|
||||
expect(resolveMermaidOutputMode("")).toBe("svg-image");
|
||||
expect(resolveMermaidOutputMode("?target=pdf")).toBe("svg-image");
|
||||
expect(
|
||||
resolveMermaidOutputMode(
|
||||
"?target=pdf&mermaid-output=inline-svg"
|
||||
)
|
||||
).toBe("inline-svg");
|
||||
expect(
|
||||
resolveMermaidOutputMode("?mermaid-output=unknown")
|
||||
).toBe("svg-image");
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,8 @@ describe("分页预览协议", () => {
|
||||
expect(css).toContain("overflow-x: hidden");
|
||||
expect(css).toContain("overflow-y: scroll");
|
||||
expect(css).toContain('html[data-render-target="pdf"]');
|
||||
expect(css).toContain("height: auto !important");
|
||||
expect(css).toContain("overflow: visible !important");
|
||||
expect(css).toContain("padding: 0");
|
||||
expect(css).toContain("break-after: page");
|
||||
expect(css).not.toContain("@media print");
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePagedRenderTarget } from "../src/paged-render-target";
|
||||
|
||||
describe("分页文档渲染目标", () => {
|
||||
it("默认使用网页预览目标", () => {
|
||||
expect(resolvePagedRenderTarget("")).toBe("preview");
|
||||
expect(resolvePagedRenderTarget("?target=unknown")).toBe("preview");
|
||||
});
|
||||
|
||||
it("识别 PDF 渲染目标", () => {
|
||||
expect(resolvePagedRenderTarget("?target=pdf")).toBe("pdf");
|
||||
expect(resolvePagedRenderTarget("?foo=1&target=pdf")).toBe("pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
parseContentDispositionFileName,
|
||||
requestPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "../src/pdf-export";
|
||||
|
||||
describe("PDF 导出客户端", () => {
|
||||
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
|
||||
const request: PdfExportRequest = {
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
const key = createPdfExportCacheKey(request);
|
||||
const result = {
|
||||
blob: new Blob(["%PDF-test"]),
|
||||
fileName: "测试.pdf",
|
||||
pageCount: 1,
|
||||
mermaidErrorCount: 0
|
||||
};
|
||||
const cache: CachedPdfExport = { key, result };
|
||||
|
||||
expect(getCachedPdfExport(cache, key)).toBe(result);
|
||||
expect(
|
||||
getCachedPdfExport(
|
||||
cache,
|
||||
createPdfExportCacheKey({
|
||||
...request,
|
||||
markdown: "# 已修改"
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getCachedPdfExport(
|
||||
cache,
|
||||
createPdfExportCacheKey({
|
||||
...request,
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
orientation: "landscape"
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("命中缓存时复用同一个 PDF Blob 且不重复请求", async () => {
|
||||
const request: PdfExportRequest = {
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
const result = {
|
||||
blob: new Blob(["%PDF-cached"]),
|
||||
fileName: "测试.pdf",
|
||||
pageCount: 1,
|
||||
mermaidErrorCount: 0
|
||||
};
|
||||
const cache: CachedPdfExport = {
|
||||
key: createPdfExportCacheKey(request),
|
||||
result
|
||||
};
|
||||
const requester = vi.fn();
|
||||
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
request,
|
||||
cache,
|
||||
requester
|
||||
);
|
||||
|
||||
expect(requester).not.toHaveBeenCalled();
|
||||
expect(resolved).toEqual(cache);
|
||||
expect(resolved.result.blob).toBe(result.blob);
|
||||
});
|
||||
|
||||
it("缓存失效时请求并保存当前 PDF", async () => {
|
||||
const request: PdfExportRequest = {
|
||||
markdown: "# 新内容",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
const result = {
|
||||
blob: new Blob(["%PDF-new"]),
|
||||
fileName: "测试.pdf",
|
||||
pageCount: 2,
|
||||
mermaidErrorCount: 0
|
||||
};
|
||||
const requester = vi.fn(async () => result);
|
||||
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
request,
|
||||
undefined,
|
||||
requester
|
||||
);
|
||||
|
||||
expect(requester).toHaveBeenCalledOnce();
|
||||
expect(resolved.key).toBe(createPdfExportCacheKey(request));
|
||||
expect(resolved.result).toBe(result);
|
||||
});
|
||||
|
||||
it("优先解析 UTF-8 下载文件名", () => {
|
||||
expect(
|
||||
parseContentDispositionFileName(
|
||||
"attachment; filename=\"document.pdf\"; " +
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95.pdf"
|
||||
)
|
||||
).toBe("测试.pdf");
|
||||
});
|
||||
|
||||
it("请求 PDF 并读取生成信息", async () => {
|
||||
const fetcher = vi.fn(async () => {
|
||||
return new Response(new Blob(["%PDF-test"]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition":
|
||||
"attachment; filename=\"document.pdf\"",
|
||||
"x-pdf-page-count": "4",
|
||||
"x-mermaid-error-count": "1"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const result = await requestPdfExport(
|
||||
{
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
},
|
||||
fetcher
|
||||
);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"/api/pdf",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(result.fileName).toBe("document.pdf");
|
||||
expect(result.pageCount).toBe(4);
|
||||
expect(result.mermaidErrorCount).toBe(1);
|
||||
expect(await result.blob.text()).toBe("%PDF-test");
|
||||
});
|
||||
|
||||
it("显示服务端返回的错误信息", async () => {
|
||||
const fetcher = vi.fn(async () => {
|
||||
return Response.json(
|
||||
{ message: "PDF 生成队列已满" },
|
||||
{ status: 503 }
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestPdfExport(
|
||||
{
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
},
|
||||
fetcher
|
||||
)
|
||||
).rejects.toThrow("PDF 生成队列已满");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions,
|
||||
PDF_PAGE_CSS_SCALE
|
||||
} from "../src/pdf-page-geometry";
|
||||
|
||||
describe("精确 PDF 页面尺寸", () => {
|
||||
it("按 96 CSS PPI 映射 PDF 点尺寸", () => {
|
||||
expect(PDF_PAGE_CSS_SCALE).toBeCloseTo(4 / 3);
|
||||
expect(
|
||||
getPdfPageCssDimensions(595.275591, 841.889764)
|
||||
).toEqual({
|
||||
width: expect.closeTo(793.700788, 5),
|
||||
height: expect.closeTo(1122.519685, 5)
|
||||
});
|
||||
});
|
||||
|
||||
it("结合显示宽度和设备像素比计算 Canvas 比例", () => {
|
||||
expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo(
|
||||
8 / 3
|
||||
);
|
||||
expect(getPdfCanvasRenderScale(0, 800, 2)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PdfPageRenderQueue } from "../src/pdf-render-queue";
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("PdfPageRenderQueue", () => {
|
||||
it("限制并发并优先渲染可见页", async () => {
|
||||
const queue = new PdfPageRenderQueue(1);
|
||||
const order: number[] = [];
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
queue.schedule(
|
||||
1,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
order.push(1);
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
queue.schedule(2, async () => {
|
||||
order.push(2);
|
||||
});
|
||||
queue.schedule(8, async () => {
|
||||
order.push(8);
|
||||
});
|
||||
queue.setVisible(8, true);
|
||||
|
||||
expect(order).toEqual([1]);
|
||||
releaseFirst?.();
|
||||
await flushPromises();
|
||||
expect(order).toEqual([1, 8, 2]);
|
||||
});
|
||||
|
||||
it("优先预渲染滚动方向上的相邻页面", async () => {
|
||||
const queue = new PdfPageRenderQueue(1);
|
||||
const order: number[] = [];
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
queue.schedule(
|
||||
5,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
order.push(5);
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
queue.setVisible(5, true);
|
||||
queue.setVisible(5, false);
|
||||
queue.setVisible(6, true);
|
||||
queue.schedule(4, async () => {
|
||||
order.push(4);
|
||||
});
|
||||
queue.schedule(7, async () => {
|
||||
order.push(7);
|
||||
});
|
||||
|
||||
releaseFirst?.();
|
||||
await flushPromises();
|
||||
expect(order).toEqual([5, 7, 4]);
|
||||
});
|
||||
|
||||
it("取消尚未开始的任务", async () => {
|
||||
const queue = new PdfPageRenderQueue(1);
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const cancelledTask = vi.fn();
|
||||
|
||||
queue.schedule(
|
||||
1,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
const cancel = queue.schedule(2, async () => {
|
||||
cancelledTask();
|
||||
});
|
||||
cancel();
|
||||
|
||||
releaseFirst?.();
|
||||
await flushPromises();
|
||||
expect(cancelledTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user