release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。 完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。 统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。 发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。 验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
This commit is contained in:
@@ -12,11 +12,9 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/markdown-echarts": "0.1.0",
|
||||
"@mermaid-js/layout-elk": "0.2.2",
|
||||
"@md-to-pdf/preview-engine": "0.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"mermaid": "11.16.0",
|
||||
"pagedjs": "^0.4.3",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
|
||||
+154
-16
@@ -27,7 +27,7 @@ import {
|
||||
createPagedPreviewRenderRequest,
|
||||
isPagedPreviewFrameMessage,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
} from "@md-to-pdf/preview-engine";
|
||||
import {
|
||||
getNearestPageNumber,
|
||||
getPreviewPageScrollTop
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
downloadMarkdown,
|
||||
ensureMarkdownFileName
|
||||
} from "./document-file";
|
||||
import { resolveWebDocumentLinkAction } from "./document-link";
|
||||
import {
|
||||
type ThemeSummary,
|
||||
useThemeResources
|
||||
@@ -64,7 +65,7 @@ const PrecisePdfPreview = lazy(async () => {
|
||||
return { default: module.PrecisePdfPreview };
|
||||
});
|
||||
|
||||
type PreviewMode = "quick" | "precise";
|
||||
type PreviewMode = "quick" | "precise" | "continuous";
|
||||
type DocumentKind = "sample" | "new" | "file";
|
||||
const noImageResources: never[] = [];
|
||||
|
||||
@@ -121,8 +122,11 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const consumeOpenedMarkdown = async () => {
|
||||
const consumeOpenedMarkdown = async (
|
||||
reason: "open" | "reload" | "replace" = "open"
|
||||
) => {
|
||||
if (
|
||||
reason === "open" &&
|
||||
documentDirtyRef.current &&
|
||||
!window.confirm("当前文档有未保存修改,是否放弃并打开其他文件?")
|
||||
) {
|
||||
@@ -160,8 +164,8 @@ export function App() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const removeListener = bridge.onMarkdownOpened(() => {
|
||||
void consumeOpenedMarkdown();
|
||||
const removeListener = bridge.onMarkdownOpened((reason) => {
|
||||
void consumeOpenedMarkdown(reason);
|
||||
});
|
||||
void consumeOpenedMarkdown();
|
||||
return () => {
|
||||
@@ -170,6 +174,18 @@ export function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = window.mdToPdfDesktop;
|
||||
if (!bridge) {
|
||||
return;
|
||||
}
|
||||
void bridge.setDocumentDirty(documentDirty).catch(
|
||||
(reason: unknown) => {
|
||||
console.warn("无法同步桌面文档修改状态", reason);
|
||||
}
|
||||
);
|
||||
}, [documentDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreMenuOpen) {
|
||||
return;
|
||||
@@ -359,6 +375,11 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === "link") {
|
||||
handlePagedPreviewLink(event.data.href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.requestId !== previewRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -369,7 +390,11 @@ export function App() {
|
||||
setMermaidError("");
|
||||
setQuickPreviewContentHeight(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
setStatus("正在分页…");
|
||||
setStatus(
|
||||
event.data.layout === "continuous"
|
||||
? "正在更新连续预览…"
|
||||
: "正在分页…"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -389,12 +414,20 @@ export function App() {
|
||||
? `Mermaid:${event.data.mermaidErrors.join(";")}`
|
||||
: ""
|
||||
);
|
||||
setStatus(`预览已分页(${event.data.pageCount} 页)`);
|
||||
setStatus(
|
||||
event.data.layout === "continuous"
|
||||
? "连续预览已更新"
|
||||
: `预览已分页(${event.data.pageCount} 页)`
|
||||
);
|
||||
setQuickPreviewContentHeight(event.data.contentHeight);
|
||||
setPreviewPagination({
|
||||
current: event.data.pageCount > 0 ? 1 : 0,
|
||||
total: event.data.pageCount
|
||||
});
|
||||
setPreviewPagination(
|
||||
event.data.layout === "continuous"
|
||||
? { current: 0, total: 0 }
|
||||
: {
|
||||
current: event.data.pageCount > 0 ? 1 : 0,
|
||||
total: event.data.pageCount
|
||||
}
|
||||
);
|
||||
window.requestAnimationFrame(() => {
|
||||
synchronizeScroll("editor");
|
||||
window.requestAnimationFrame(updatePreviewCurrentPage);
|
||||
@@ -435,11 +468,14 @@ export function App() {
|
||||
frameWindow.postMessage(
|
||||
createPagedPreviewRenderRequest(
|
||||
previewRequestIdRef.current,
|
||||
previewPayload
|
||||
previewPayload,
|
||||
previewMode === "continuous"
|
||||
? "continuous"
|
||||
: "paged"
|
||||
),
|
||||
window.location.origin
|
||||
);
|
||||
}, [previewFrameReady, previewPayload]);
|
||||
}, [previewFrameReady, previewMode, previewPayload]);
|
||||
|
||||
function getOuterPreviewScroller() {
|
||||
const previewScroller = previewScrollRef.current;
|
||||
@@ -456,6 +492,79 @@ export function App() {
|
||||
return getOuterPreviewScroller();
|
||||
}
|
||||
|
||||
function handlePagedPreviewLink(href: string) {
|
||||
const action = resolveWebDocumentLinkAction(href);
|
||||
if (action.type !== "anchor" && window.mdToPdfDesktop) {
|
||||
void window.mdToPdfDesktop.openDocumentLink(href).catch(
|
||||
(reason: unknown) => {
|
||||
setAppError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "无法打开文档链接"
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action.type === "open") {
|
||||
window.open(
|
||||
action.href,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action.type !== "anchor") {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = previewFrameRef.current;
|
||||
const frameDocument = frame?.contentDocument;
|
||||
const scrollElement = getPreviewScroller();
|
||||
if (!frame || !frameDocument || !scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawAnchorId = action.href.slice(1);
|
||||
const anchorIds = [rawAnchorId];
|
||||
try {
|
||||
const decodedAnchorId = decodeURIComponent(rawAnchorId);
|
||||
if (decodedAnchorId !== rawAnchorId) {
|
||||
anchorIds.push(decodedAnchorId);
|
||||
}
|
||||
} catch {
|
||||
// 保留原始片段继续查找,兼容不完整的百分号编码。
|
||||
}
|
||||
const target = anchorIds
|
||||
.map(
|
||||
(anchorId) =>
|
||||
frameDocument.getElementById(anchorId) ??
|
||||
frameDocument.getElementsByName(anchorId).item(0)
|
||||
)
|
||||
.find((element) => Boolean(element));
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollerViewportTop =
|
||||
scrollElement === document.scrollingElement
|
||||
? 0
|
||||
: scrollElement.getBoundingClientRect().top;
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const frameScale =
|
||||
frame.offsetWidth > 0
|
||||
? frameRect.width / frame.offsetWidth
|
||||
: 1;
|
||||
const targetTop = getPreviewPageScrollTop(
|
||||
scrollElement.scrollTop,
|
||||
frameRect.top +
|
||||
target.getBoundingClientRect().top * frameScale,
|
||||
scrollerViewportTop
|
||||
);
|
||||
scrollElement.scrollTo({ top: Math.max(0, targetTop) });
|
||||
window.requestAnimationFrame(updatePreviewCurrentPage);
|
||||
}
|
||||
|
||||
function synchronizeScroll(origin: "editor" | "preview") {
|
||||
const source =
|
||||
origin === "editor" ? editorRef.current : getPreviewScroller();
|
||||
@@ -487,6 +596,9 @@ export function App() {
|
||||
updatePrecisePreviewCurrentPage();
|
||||
return;
|
||||
}
|
||||
if (previewModeRef.current === "continuous") {
|
||||
return;
|
||||
}
|
||||
const frame = previewFrameRef.current;
|
||||
const frameDocument = frame?.contentDocument;
|
||||
const scrollElement = getPreviewScroller();
|
||||
@@ -840,8 +952,12 @@ export function App() {
|
||||
if (mode === previewMode) {
|
||||
return;
|
||||
}
|
||||
const keepsFrame =
|
||||
previewMode !== "precise" && mode !== "precise";
|
||||
setPreviewMode(mode);
|
||||
setPreviewFrameReady(false);
|
||||
if (!keepsFrame) {
|
||||
setPreviewFrameReady(false);
|
||||
}
|
||||
setQuickPreviewContentHeight(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||
@@ -1041,6 +1157,18 @@ export function App() {
|
||||
>
|
||||
精确
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
previewMode === "continuous" ? "is-active" : ""
|
||||
}
|
||||
aria-pressed={previewMode === "continuous"}
|
||||
onClick={() =>
|
||||
handlePreviewModeChange("continuous")
|
||||
}
|
||||
>
|
||||
连续
|
||||
</button>
|
||||
</span>
|
||||
<label className="theme-control">
|
||||
<span className="sr-only">预览主题</span>
|
||||
@@ -1090,12 +1218,20 @@ export function App() {
|
||||
className="preview-scroll"
|
||||
onScroll={handlePreviewScroll}
|
||||
>
|
||||
{previewMode === "quick" ? (
|
||||
<div className="paper" style={paperStyle}>
|
||||
{previewMode !== "precise" ? (
|
||||
<div
|
||||
className={`paper${
|
||||
previewMode === "continuous"
|
||||
? " continuous-paper"
|
||||
: ""
|
||||
}`}
|
||||
style={paperStyle}
|
||||
>
|
||||
{previewPayload ? (
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
className="preview-frame"
|
||||
scrolling="no"
|
||||
style={previewFrameStyle}
|
||||
title="文档内容预览"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
@@ -1122,6 +1258,8 @@ export function App() {
|
||||
<PrecisePdfPreview
|
||||
blob={cachedPdf.blob}
|
||||
zoomPercent={previewZoom}
|
||||
onExternalLink={handlePagedPreviewLink}
|
||||
onNavigateToPage={handlePreviewPageChange}
|
||||
onPageCount={handlePrecisePageCount}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
AnnotationLayer,
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
RenderingCancelledException,
|
||||
@@ -20,10 +21,15 @@ import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions
|
||||
} from "./pdf-page-geometry";
|
||||
import { createPdfAnnotationLinkService } from "./pdf-annotation-links";
|
||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
type PdfJsAnnotationLinkService = Parameters<
|
||||
AnnotationLayer["render"]
|
||||
>[0]["linkService"];
|
||||
|
||||
interface PdfPageDescriptor {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
@@ -33,12 +39,16 @@ interface PdfPageDescriptor {
|
||||
export interface PrecisePdfPreviewProps {
|
||||
blob: Blob;
|
||||
zoomPercent: number;
|
||||
onExternalLink?: (href: string) => void;
|
||||
onNavigateToPage?: (pageNumber: number) => void;
|
||||
onPageCount?: (pageCount: number) => void;
|
||||
}
|
||||
|
||||
interface PrecisePdfPageProps {
|
||||
document: PDFDocumentProxy;
|
||||
descriptor: PdfPageDescriptor;
|
||||
onExternalLink: (href: string) => void;
|
||||
onNavigateToPage: (pageNumber: number) => void;
|
||||
renderQueue: PdfPageRenderQueue;
|
||||
zoomPercent: number;
|
||||
}
|
||||
@@ -92,12 +102,15 @@ function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
function PrecisePdfPage({
|
||||
document,
|
||||
descriptor,
|
||||
onExternalLink,
|
||||
onNavigateToPage,
|
||||
renderQueue,
|
||||
zoomPercent
|
||||
}: PrecisePdfPageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||
const annotationLayerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayWidth, setDisplayWidth] = useState(0);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [renderState, setRenderState] = useState<
|
||||
@@ -158,11 +171,13 @@ function PrecisePdfPage({
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const textContainer = textLayerRef.current;
|
||||
const annotationContainer = annotationLayerRef.current;
|
||||
if (
|
||||
!nearViewport ||
|
||||
displayWidth <= 0 ||
|
||||
!canvas ||
|
||||
!textContainer
|
||||
!textContainer ||
|
||||
!annotationContainer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -171,6 +186,7 @@ function PrecisePdfPage({
|
||||
let page: PDFPageProxy | undefined;
|
||||
let renderTask: RenderTask | undefined;
|
||||
let textLayer: TextLayer | undefined;
|
||||
let annotationLayer: AnnotationLayer | undefined;
|
||||
|
||||
setRenderError("");
|
||||
setRenderState("queued");
|
||||
@@ -212,6 +228,7 @@ function PrecisePdfPage({
|
||||
canvas.style.width = `${displayViewport.width}px`;
|
||||
canvas.style.height = `${displayViewport.height}px`;
|
||||
textContainer.replaceChildren();
|
||||
annotationContainer.replaceChildren();
|
||||
|
||||
renderTask = page.render({
|
||||
canvas,
|
||||
@@ -229,7 +246,43 @@ function PrecisePdfPage({
|
||||
textContentSource: page.streamTextContent(),
|
||||
viewport: displayViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
const annotations = await page.getAnnotations({
|
||||
intent: "display"
|
||||
});
|
||||
const linkService = createPdfAnnotationLinkService(
|
||||
document,
|
||||
descriptor.pageNumber,
|
||||
{
|
||||
onExternalLink,
|
||||
onNavigateToPage
|
||||
}
|
||||
);
|
||||
annotationLayer = new AnnotationLayer({
|
||||
div: annotationContainer,
|
||||
accessibilityManager: undefined,
|
||||
annotationCanvasMap: undefined,
|
||||
annotationEditorUIManager: undefined,
|
||||
page,
|
||||
viewport: displayViewport,
|
||||
structTreeLayer: undefined,
|
||||
commentManager: undefined,
|
||||
linkService,
|
||||
annotationStorage: document.annotationStorage
|
||||
});
|
||||
await Promise.all([
|
||||
textLayer.render(),
|
||||
annotationLayer.render({
|
||||
viewport: displayViewport,
|
||||
div: annotationContainer,
|
||||
annotations,
|
||||
page,
|
||||
linkService:
|
||||
linkService as unknown as PdfJsAnnotationLinkService,
|
||||
annotationStorage: document.annotationStorage,
|
||||
renderForms: false,
|
||||
enableScripting: false
|
||||
})
|
||||
]);
|
||||
} catch (reason: unknown) {
|
||||
if (
|
||||
disposed ||
|
||||
@@ -251,10 +304,12 @@ function PrecisePdfPage({
|
||||
cancelQueuedRender();
|
||||
renderTask?.cancel();
|
||||
textLayer?.cancel();
|
||||
annotationLayer?.destroy();
|
||||
page?.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
textContainer.replaceChildren();
|
||||
annotationContainer.replaceChildren();
|
||||
setRenderState("idle");
|
||||
};
|
||||
}, [
|
||||
@@ -264,6 +319,8 @@ function PrecisePdfPage({
|
||||
displayWidth,
|
||||
document,
|
||||
nearViewport,
|
||||
onExternalLink,
|
||||
onNavigateToPage,
|
||||
renderQueue
|
||||
]);
|
||||
|
||||
@@ -282,6 +339,10 @@ function PrecisePdfPage({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={textLayerRef} className="textLayer" />
|
||||
<div
|
||||
ref={annotationLayerRef}
|
||||
className="annotationLayer"
|
||||
/>
|
||||
{renderState !== "rendered" ? (
|
||||
<span className="precise-pdf-page-placeholder">
|
||||
{renderState === "rendering"
|
||||
@@ -299,6 +360,8 @@ function PrecisePdfPage({
|
||||
export function PrecisePdfPreview({
|
||||
blob,
|
||||
zoomPercent,
|
||||
onExternalLink,
|
||||
onNavigateToPage,
|
||||
onPageCount
|
||||
}: PrecisePdfPreviewProps) {
|
||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||
@@ -308,6 +371,19 @@ export function PrecisePdfPreview({
|
||||
() => new PdfPageRenderQueue(1),
|
||||
[document]
|
||||
);
|
||||
const externalLinkRef = useRef(onExternalLink);
|
||||
const navigateToPageRef = useRef(onNavigateToPage);
|
||||
externalLinkRef.current = onExternalLink;
|
||||
navigateToPageRef.current = onNavigateToPage;
|
||||
const handleExternalLink = useCallback((href: string) => {
|
||||
externalLinkRef.current?.(href);
|
||||
}, []);
|
||||
const handleNavigateToPage = useCallback(
|
||||
(pageNumber: number) => {
|
||||
navigateToPageRef.current?.(pageNumber);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||
|
||||
@@ -386,6 +462,8 @@ export function PrecisePdfPreview({
|
||||
key={page.pageNumber}
|
||||
document={document}
|
||||
descriptor={page}
|
||||
onExternalLink={handleExternalLink}
|
||||
onNavigateToPage={handleNavigateToPage}
|
||||
renderQueue={renderQueue}
|
||||
zoomPercent={zoomPercent}
|
||||
/>
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
lengthToMillimeters,
|
||||
millimetersToCssPixels,
|
||||
type ExportConfig
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
export interface DiagramDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DiagramPageFit extends DiagramDimensions {
|
||||
scaled: boolean;
|
||||
}
|
||||
|
||||
export interface SvgDiagramFitOptions {
|
||||
containerSelector: string;
|
||||
svgSelector?: string;
|
||||
resolvePageContent?: (
|
||||
container: HTMLElement,
|
||||
pageContent: DiagramDimensions
|
||||
) => DiagramDimensions;
|
||||
onFit?: (
|
||||
container: HTMLElement,
|
||||
svg: SVGSVGElement,
|
||||
fit: DiagramPageFit
|
||||
) => void;
|
||||
}
|
||||
|
||||
const PAGE_FIT_EPSILON_PX = 1;
|
||||
|
||||
function parsePixelValue(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function getPrecedingMediaTitleHeight(
|
||||
element: HTMLElement
|
||||
) {
|
||||
const title = element.previousElementSibling;
|
||||
if (
|
||||
!(title instanceof HTMLElement) ||
|
||||
!/^H[1-6]$/.test(title.tagName)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const style = getComputedStyle(title);
|
||||
title.style.setProperty("break-after", "avoid-page", "important");
|
||||
title.style.setProperty(
|
||||
"page-break-after",
|
||||
"avoid",
|
||||
"important"
|
||||
);
|
||||
return (
|
||||
title.getBoundingClientRect().height +
|
||||
parsePixelValue(style.marginTop) +
|
||||
parsePixelValue(style.marginBottom)
|
||||
);
|
||||
}
|
||||
|
||||
export function ensurePageBreakAfter(element: HTMLElement) {
|
||||
const existingMarker = element.nextElementSibling;
|
||||
if (
|
||||
existingMarker instanceof HTMLElement &&
|
||||
existingMarker.dataset.mediaPageBreak === "true"
|
||||
) {
|
||||
return existingMarker;
|
||||
}
|
||||
const marker = element.ownerDocument.createElement("div");
|
||||
marker.dataset.mediaPageBreak = "true";
|
||||
marker.setAttribute("aria-hidden", "true");
|
||||
marker.style.setProperty("height", "0", "important");
|
||||
marker.style.setProperty("break-before", "page", "important");
|
||||
marker.style.setProperty(
|
||||
"page-break-before",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
element.after(marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function parseSvgViewBox(
|
||||
value: string | null
|
||||
): DiagramDimensions | 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 };
|
||||
}
|
||||
|
||||
function parseSvgDimensions(
|
||||
svg: SVGSVGElement
|
||||
): DiagramDimensions | undefined {
|
||||
const width = Number.parseFloat(svg.getAttribute("width") ?? "");
|
||||
const height = Number.parseFloat(
|
||||
svg.getAttribute("height") ?? ""
|
||||
);
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getPageContentDimensions(
|
||||
config: ExportConfig
|
||||
): DiagramDimensions {
|
||||
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 calculateDiagramPageFit(
|
||||
intrinsic: DiagramDimensions,
|
||||
pageContent: DiagramDimensions
|
||||
): DiagramPageFit {
|
||||
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 fitSvgDiagramsToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig,
|
||||
options: SvgDiagramFitOptions
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
const containers = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(options.containerSelector)
|
||||
);
|
||||
|
||||
for (const container of containers) {
|
||||
const svg = container.querySelector<SVGSVGElement>(
|
||||
options.svgSelector ?? "svg"
|
||||
);
|
||||
const intrinsic = svg
|
||||
? parseSvgViewBox(svg.getAttribute("viewBox")) ??
|
||||
parseSvgDimensions(svg)
|
||||
: undefined;
|
||||
if (!svg || !intrinsic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedPageContent =
|
||||
options.resolvePageContent?.(container, pageContent) ??
|
||||
pageContent;
|
||||
const availablePageContent = {
|
||||
width: resolvedPageContent.width,
|
||||
height: Math.max(
|
||||
0,
|
||||
resolvedPageContent.height -
|
||||
getPrecedingMediaTitleHeight(container)
|
||||
)
|
||||
};
|
||||
const fit = calculateDiagramPageFit(
|
||||
intrinsic,
|
||||
availablePageContent
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
container.dataset.pageHeightFitted = "true";
|
||||
container.style.setProperty("margin-block", "0", "important");
|
||||
container.style.setProperty("break-after", "page", "important");
|
||||
container.style.setProperty(
|
||||
"page-break-after",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
ensurePageBreakAfter(container);
|
||||
if (!svg.getAttribute("viewBox")) {
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${intrinsic.width} ${intrinsic.height}`
|
||||
);
|
||||
}
|
||||
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"
|
||||
);
|
||||
options.onFit?.(container, svg, fit);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
calculateDiagramPageFit,
|
||||
ensurePageBreakAfter,
|
||||
getPageContentDimensions,
|
||||
getPrecedingMediaTitleHeight
|
||||
} from "./diagram-page-fit";
|
||||
|
||||
function getImageBlock(image: HTMLImageElement) {
|
||||
const parent = image.parentElement;
|
||||
if (parent?.classList.contains("md-document-image-block")) {
|
||||
return parent;
|
||||
}
|
||||
if (
|
||||
parent?.tagName === "P" &&
|
||||
Array.from(parent.childNodes).every(
|
||||
(node) =>
|
||||
node === image ||
|
||||
(node.nodeType === Node.TEXT_NODE && !node.textContent?.trim())
|
||||
)
|
||||
) {
|
||||
parent.classList.add("md-document-image-block");
|
||||
return parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function prepareDocumentImageBlocks(root: ParentNode) {
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
let prepared = 0;
|
||||
|
||||
for (const image of images) {
|
||||
if (getImageBlock(image)) {
|
||||
prepared += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function fitDocumentImagesToPage(
|
||||
root: ParentNode,
|
||||
config: ExportConfig,
|
||||
options: { prepareBlocks?: boolean } = {}
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(config);
|
||||
if (options.prepareBlocks !== false) {
|
||||
prepareDocumentImageBlocks(root);
|
||||
}
|
||||
const images = Array.from(
|
||||
root.querySelectorAll<HTMLImageElement>("img.md-document-image")
|
||||
);
|
||||
|
||||
for (const image of images) {
|
||||
const block = getImageBlock(image);
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
continue;
|
||||
}
|
||||
const imageHeight = image.getBoundingClientRect().height;
|
||||
const blockHeight = block?.getBoundingClientRect().height ?? imageHeight;
|
||||
const nonImageHeight = Math.max(0, blockHeight - imageHeight);
|
||||
const titleHeight = block
|
||||
? getPrecedingMediaTitleHeight(block)
|
||||
: 0;
|
||||
const fit = calculateDiagramPageFit(
|
||||
{ width, height },
|
||||
{
|
||||
width: pageContent.width,
|
||||
height: Math.max(
|
||||
0,
|
||||
pageContent.height - nonImageHeight - titleHeight
|
||||
)
|
||||
}
|
||||
);
|
||||
if (!fit.scaled) {
|
||||
continue;
|
||||
}
|
||||
if (block?.classList.contains("md-document-image-block")) {
|
||||
block.dataset.pageHeightFitted = "true";
|
||||
block.style.setProperty("margin-block", "0", "important");
|
||||
block.style.setProperty("break-after", "page", "important");
|
||||
block.style.setProperty(
|
||||
"page-break-after",
|
||||
"always",
|
||||
"important"
|
||||
);
|
||||
ensurePageBreakAfter(block);
|
||||
}
|
||||
image.dataset.pageHeightFitted = "true";
|
||||
image.style.setProperty("display", "block");
|
||||
image.style.setProperty("width", `${fit.width}px`, "important");
|
||||
image.style.setProperty("height", `${fit.height}px`, "important");
|
||||
image.style.setProperty("max-width", "100%", "important");
|
||||
image.style.setProperty(
|
||||
"max-height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
image.style.setProperty("object-fit", "contain");
|
||||
image.style.setProperty("margin-inline", "auto");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { classifyDocumentLink } from "@md-to-pdf/core";
|
||||
|
||||
export type WebDocumentLinkAction =
|
||||
| {
|
||||
type: "anchor";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "open";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
type: "ignore";
|
||||
};
|
||||
|
||||
export function resolveWebDocumentLinkAction(
|
||||
href: string
|
||||
): WebDocumentLinkAction {
|
||||
const link = classifyDocumentLink(href);
|
||||
if (link.kind === "anchor") {
|
||||
return {
|
||||
type: "anchor",
|
||||
href: link.normalizedHref
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
link.kind === "network" ||
|
||||
(link.kind === "protocol" &&
|
||||
(link.scheme === "mailto" || link.scheme === "tel"))
|
||||
) {
|
||||
return {
|
||||
type: "open",
|
||||
href: link.normalizedHref
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "ignore" };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import { fitSvgDiagramsToPage } from "./diagram-page-fit";
|
||||
|
||||
export function fitOversizedEChartsToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".md-echarts",
|
||||
svgSelector: ".md-echarts-host svg",
|
||||
resolvePageContent(figure, pageContent) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const figureHeight = figure.getBoundingClientRect().height;
|
||||
const hostHeight = host?.getBoundingClientRect().height ?? 0;
|
||||
const nonChartHeight = Math.max(
|
||||
0,
|
||||
figureHeight - hostHeight
|
||||
);
|
||||
return {
|
||||
width: pageContent.width,
|
||||
height: Math.max(0, pageContent.height - nonChartHeight)
|
||||
};
|
||||
},
|
||||
onFit(figure, _svg, fit) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.style.setProperty(
|
||||
"width",
|
||||
`${fit.width}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty(
|
||||
"height",
|
||||
`${fit.height}px`,
|
||||
"important"
|
||||
);
|
||||
host.style.setProperty("margin-inline", "auto", "important");
|
||||
host.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
export type MediaBackfillKind = "image" | "mermaid" | "echarts";
|
||||
|
||||
export interface MediaBackfillGeometry {
|
||||
remainingHeight: number;
|
||||
fixedHeight: number;
|
||||
baselineVisualHeight: number;
|
||||
}
|
||||
|
||||
export interface MediaBackfillCandidate {
|
||||
id: string;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface MediaBackfillOptions {
|
||||
minimumScale?: number;
|
||||
safetyGapPx?: number;
|
||||
}
|
||||
|
||||
const MEDIA_BLOCK_SELECTOR = [
|
||||
".md-document-image-block",
|
||||
".mermaid:not(.mermaid-error)",
|
||||
".md-echarts:not(.md-echarts-error)"
|
||||
].join(", ");
|
||||
const DEFAULT_MINIMUM_SCALE = 0.88;
|
||||
const DEFAULT_SAFETY_GAP_PX = 2;
|
||||
const SCALE_EPSILON = 0.001;
|
||||
|
||||
function finitePositive(value: string | undefined) {
|
||||
const parsed = Number.parseFloat(value ?? "");
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function getMediaKind(block: Element): MediaBackfillKind | undefined {
|
||||
if (block.classList.contains("md-document-image-block")) {
|
||||
return "image";
|
||||
}
|
||||
if (block.classList.contains("mermaid")) {
|
||||
return "mermaid";
|
||||
}
|
||||
if (block.classList.contains("md-echarts")) {
|
||||
return "echarts";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMediaVisual(
|
||||
block: Element,
|
||||
kind: MediaBackfillKind
|
||||
): HTMLElement | SVGSVGElement | undefined {
|
||||
if (kind === "image") {
|
||||
return (
|
||||
block.querySelector<HTMLImageElement>("img.md-document-image") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
if (kind === "echarts") {
|
||||
return (
|
||||
block.querySelector<HTMLElement>(".md-echarts-host") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
return (
|
||||
block.querySelector<SVGSVGElement>("svg") ??
|
||||
block.querySelector<HTMLImageElement>("img") ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function parsePixelValue(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getOuterBottom(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.bottom +
|
||||
parsePixelValue(getComputedStyle(element).marginBottom)
|
||||
);
|
||||
}
|
||||
|
||||
function getOuterTop(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
rect.top -
|
||||
parsePixelValue(getComputedStyle(element).marginTop)
|
||||
);
|
||||
}
|
||||
|
||||
function getMediaGroupTop(
|
||||
block: HTMLElement,
|
||||
pageContentTop: number
|
||||
) {
|
||||
const title = block.previousElementSibling;
|
||||
if (
|
||||
title instanceof HTMLElement &&
|
||||
/^H[1-6]$/.test(title.tagName)
|
||||
) {
|
||||
return Math.max(
|
||||
pageContentTop,
|
||||
getOuterTop(title)
|
||||
);
|
||||
}
|
||||
return Math.max(
|
||||
pageContentTop,
|
||||
getOuterTop(block)
|
||||
);
|
||||
}
|
||||
|
||||
function getLastContentBottom(pageContent: HTMLElement) {
|
||||
const contentRect = pageContent.getBoundingClientRect();
|
||||
const leafElements = Array.from(
|
||||
pageContent.querySelectorAll<HTMLElement>("[data-ref]")
|
||||
).filter(
|
||||
(element) => !element.querySelector("[data-ref]")
|
||||
);
|
||||
let bottom = contentRect.top;
|
||||
|
||||
for (const element of leafElements) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0 ||
|
||||
rect.bottom <= contentRect.top ||
|
||||
rect.top >= contentRect.bottom
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
bottom = Math.max(
|
||||
bottom,
|
||||
Math.min(getOuterBottom(element), contentRect.bottom)
|
||||
);
|
||||
}
|
||||
return bottom;
|
||||
}
|
||||
|
||||
function hasForcedBreakBefore(block: HTMLElement) {
|
||||
const title = block.previousElementSibling;
|
||||
const elements = [
|
||||
block,
|
||||
title instanceof HTMLElement && /^H[1-6]$/.test(title.tagName)
|
||||
? title
|
||||
: undefined
|
||||
].filter((element): element is HTMLElement => Boolean(element));
|
||||
return elements.some((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
style.breakBefore === "page" ||
|
||||
style.pageBreakBefore === "always"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateMediaBackfillScale(
|
||||
geometry: MediaBackfillGeometry,
|
||||
options: MediaBackfillOptions = {}
|
||||
) {
|
||||
const minimumScale =
|
||||
options.minimumScale ?? DEFAULT_MINIMUM_SCALE;
|
||||
const safetyGapPx =
|
||||
options.safetyGapPx ?? DEFAULT_SAFETY_GAP_PX;
|
||||
if (
|
||||
!Number.isFinite(geometry.remainingHeight) ||
|
||||
!Number.isFinite(geometry.fixedHeight) ||
|
||||
!Number.isFinite(geometry.baselineVisualHeight) ||
|
||||
geometry.remainingHeight <= 0 ||
|
||||
geometry.fixedHeight < 0 ||
|
||||
geometry.baselineVisualHeight <= 0 ||
|
||||
!Number.isFinite(minimumScale) ||
|
||||
minimumScale <= 0 ||
|
||||
minimumScale >= 1 ||
|
||||
!Number.isFinite(safetyGapPx) ||
|
||||
safetyGapPx < 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const availableVisualHeight =
|
||||
geometry.remainingHeight -
|
||||
geometry.fixedHeight -
|
||||
safetyGapPx;
|
||||
const scale =
|
||||
availableVisualHeight / geometry.baselineVisualHeight;
|
||||
if (
|
||||
scale < minimumScale ||
|
||||
scale >= 1 - SCALE_EPSILON
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
export function prepareMediaBackfillBlocks(root: ParentNode) {
|
||||
const blocks = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(MEDIA_BLOCK_SELECTOR)
|
||||
);
|
||||
let prepared = 0;
|
||||
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const kind = getMediaKind(block);
|
||||
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||
const rect = visual?.getBoundingClientRect();
|
||||
if (
|
||||
!kind ||
|
||||
!visual ||
|
||||
!rect ||
|
||||
rect.width <= 0 ||
|
||||
rect.height <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
block.dataset.mediaBackfillId = `media-${index + 1}`;
|
||||
block.dataset.mediaBackfillKind = kind;
|
||||
block.dataset.mediaBaselineWidth = String(rect.width);
|
||||
block.dataset.mediaBaselineHeight = String(rect.height);
|
||||
prepared += 1;
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function getMediaBackfillIdsInDocumentOrder(
|
||||
root: ParentNode
|
||||
) {
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).flatMap((block) =>
|
||||
block.dataset.mediaBackfillId
|
||||
? [block.dataset.mediaBackfillId]
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
export function findMediaBackfillCandidates(
|
||||
root: ParentNode,
|
||||
options: MediaBackfillOptions = {}
|
||||
) {
|
||||
const pages = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
const candidates: MediaBackfillCandidate[] = [];
|
||||
const candidateIds = new Set<string>();
|
||||
|
||||
for (let pageIndex = 1; pageIndex < pages.length; pageIndex += 1) {
|
||||
const previousPage = pages[pageIndex - 1];
|
||||
const currentPage = pages[pageIndex];
|
||||
const previousContent =
|
||||
previousPage?.querySelector<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const currentContent =
|
||||
currentPage?.querySelector<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
if (!previousContent || !currentContent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaBlocks = Array.from(
|
||||
currentContent.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).sort(
|
||||
(left, right) =>
|
||||
left.getBoundingClientRect().top -
|
||||
right.getBoundingClientRect().top
|
||||
);
|
||||
const block = mediaBlocks[0];
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = getMediaKind(block);
|
||||
const visual = kind ? getMediaVisual(block, kind) : undefined;
|
||||
const id = block.dataset.mediaBackfillId;
|
||||
const baselineVisualHeight = finitePositive(
|
||||
block.dataset.mediaBaselineHeight
|
||||
);
|
||||
if (!kind || !visual || !id || !baselineVisualHeight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasForcedBreakBefore(block)) {
|
||||
continue;
|
||||
}
|
||||
const currentContentRect = currentContent.getBoundingClientRect();
|
||||
const visualHeight = visual.getBoundingClientRect().height;
|
||||
const mediaGroupTop = getMediaGroupTop(
|
||||
block,
|
||||
currentContentRect.top
|
||||
);
|
||||
const fixedHeight = Math.max(
|
||||
0,
|
||||
getOuterBottom(block) -
|
||||
mediaGroupTop -
|
||||
visualHeight
|
||||
);
|
||||
|
||||
const previousContentRect =
|
||||
previousContent.getBoundingClientRect();
|
||||
const previousFlowBottom =
|
||||
getLastContentBottom(previousContent);
|
||||
const remainingHeight = Math.max(
|
||||
0,
|
||||
previousContentRect.bottom - previousFlowBottom
|
||||
);
|
||||
const scale = calculateMediaBackfillScale(
|
||||
{
|
||||
remainingHeight,
|
||||
fixedHeight,
|
||||
baselineVisualHeight
|
||||
},
|
||||
options
|
||||
);
|
||||
if (scale === undefined || candidateIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
candidateIds.add(id);
|
||||
candidates.push({ id, scale });
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function setVisualSize(
|
||||
visual: HTMLElement | SVGSVGElement,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
visual.style.setProperty("display", "block");
|
||||
visual.style.setProperty("width", `${width}px`, "important");
|
||||
visual.style.setProperty("height", `${height}px`, "important");
|
||||
visual.style.setProperty("max-width", "100%", "important");
|
||||
visual.style.setProperty("max-height", `${height}px`, "important");
|
||||
visual.style.setProperty("margin-inline", "auto", "important");
|
||||
}
|
||||
|
||||
export function applyMediaBackfillCandidates(
|
||||
source: ParentNode,
|
||||
candidates: MediaBackfillCandidate[]
|
||||
) {
|
||||
let applied = 0;
|
||||
for (const candidate of candidates) {
|
||||
const block = Array.from(
|
||||
source.querySelectorAll<HTMLElement>(
|
||||
"[data-media-backfill-id]"
|
||||
)
|
||||
).find(
|
||||
(element) =>
|
||||
element.dataset.mediaBackfillId === candidate.id
|
||||
);
|
||||
const kind = block ? getMediaKind(block) : undefined;
|
||||
const baselineWidth = finitePositive(
|
||||
block?.dataset.mediaBaselineWidth
|
||||
);
|
||||
const baselineHeight = finitePositive(
|
||||
block?.dataset.mediaBaselineHeight
|
||||
);
|
||||
const visual =
|
||||
block && kind ? getMediaVisual(block, kind) : undefined;
|
||||
if (
|
||||
!block ||
|
||||
!kind ||
|
||||
!visual ||
|
||||
!baselineWidth ||
|
||||
!baselineHeight ||
|
||||
!Number.isFinite(candidate.scale) ||
|
||||
candidate.scale <= 0 ||
|
||||
candidate.scale >= 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const width = baselineWidth * candidate.scale;
|
||||
const height = baselineHeight * candidate.scale;
|
||||
block.dataset.mediaBackfilled = "true";
|
||||
block.dataset.mediaBackfillScale = String(candidate.scale);
|
||||
setVisualSize(visual, width, height);
|
||||
|
||||
if (kind === "image") {
|
||||
visual.style.setProperty("object-fit", "contain");
|
||||
} else if (kind === "echarts") {
|
||||
const svg = block.querySelector<SVGSVGElement>(
|
||||
".md-echarts-host svg"
|
||||
);
|
||||
if (svg) {
|
||||
setVisualSize(svg, width, height);
|
||||
}
|
||||
visual.style.removeProperty("aspect-ratio");
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { MermaidExportConfig } from "@md-to-pdf/core";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
|
||||
export const MERMAID_SECURE_CONFIG_KEYS = [
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"maxEdges",
|
||||
"suppressErrorRendering",
|
||||
"themeCSS"
|
||||
];
|
||||
|
||||
export function createMermaidSiteConfig(
|
||||
config: MermaidExportConfig
|
||||
): MermaidConfig {
|
||||
return {
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
layout: config.layout,
|
||||
theme: config.theme,
|
||||
look: config.look,
|
||||
fontFamily: config.fontFamily,
|
||||
flowchart: {
|
||||
wrappingWidth: 320
|
||||
},
|
||||
maxTextSize: 50_000,
|
||||
maxEdges: 500,
|
||||
suppressErrorRendering: true,
|
||||
secure: [...MERMAID_SECURE_CONFIG_KEYS]
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
calculateDiagramPageFit,
|
||||
fitSvgDiagramsToPage,
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox,
|
||||
type DiagramDimensions,
|
||||
type DiagramPageFit
|
||||
} from "./diagram-page-fit";
|
||||
|
||||
export type MermaidDimensions = DiagramDimensions;
|
||||
export type MermaidPageFit = DiagramPageFit;
|
||||
export {
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox
|
||||
};
|
||||
export const calculateMermaidPageFit = calculateDiagramPageFit;
|
||||
|
||||
export function fitOversizedMermaidToPage(
|
||||
container: ParentNode,
|
||||
config: ExportConfig
|
||||
) {
|
||||
fitSvgDiagramsToPage(container, config, {
|
||||
containerSelector: ".mermaid"
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface MermaidSvgResult {
|
||||
svg: string;
|
||||
bindFunctions?: (element: Element) => void;
|
||||
}
|
||||
|
||||
export type MermaidRenderOutcome =
|
||||
| ({ success: true } & MermaidSvgResult)
|
||||
| {
|
||||
success: false;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export async function renderMermaidDefinitions(
|
||||
definitions: string[],
|
||||
renderDefinition: (
|
||||
definition: string,
|
||||
index: number
|
||||
) => Promise<MermaidSvgResult>
|
||||
): Promise<MermaidRenderOutcome[]> {
|
||||
const outcomes: MermaidRenderOutcome[] = [];
|
||||
|
||||
for (const [index, definition] of definitions.entries()) {
|
||||
try {
|
||||
outcomes.push({
|
||||
success: true,
|
||||
...(await renderDefinition(definition, index))
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
outcomes.push({
|
||||
success: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outcomes;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
const ELEMENT_NODE_TYPE = 1;
|
||||
|
||||
export function resolveBreakTokenElement(
|
||||
node: Node | undefined
|
||||
): Element | undefined {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.nodeType === ELEMENT_NODE_TYPE) {
|
||||
return node as Element;
|
||||
}
|
||||
|
||||
return node.parentElement ?? undefined;
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||
import type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
import { Previewer } from "pagedjs";
|
||||
import { fitOversizedEChartsToPage } from "./echarts-page-fit";
|
||||
import {
|
||||
fitDocumentImagesToPage,
|
||||
prepareDocumentImageBlocks
|
||||
} from "./document-image-fit";
|
||||
import {
|
||||
applyMediaBackfillCandidates,
|
||||
findMediaBackfillCandidates,
|
||||
getMediaBackfillIdsInDocumentOrder,
|
||||
prepareMediaBackfillBlocks
|
||||
} from "./media-page-backfill";
|
||||
import { createMermaidSiteConfig } from "./mermaid-config";
|
||||
import {
|
||||
fitOversizedMermaidToPage,
|
||||
getPageContentDimensions
|
||||
} from "./mermaid-page-fit";
|
||||
import { renderMermaidDefinitions } from "./mermaid-renderer";
|
||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||||
import { enablePrintMediaForPreview } from "./preview-styles";
|
||||
import "./paged-table-handler";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
documentBaseCss,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import type { PagedRenderTarget } from "./paged-render-target";
|
||||
|
||||
export type {
|
||||
PagedDocumentRenderResult,
|
||||
PagedDocumentTimings
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
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 mountMeasurementContainer(
|
||||
documentRef: Document,
|
||||
content: DocumentFragment,
|
||||
payload: PagedPreviewPayload,
|
||||
target: PagedRenderTarget
|
||||
) {
|
||||
const pageContent = getPageContentDimensions(payload.exportConfig);
|
||||
const style = documentRef.createElement("style");
|
||||
const themeCss =
|
||||
target === "preview"
|
||||
? enablePrintMediaForPreview(payload.themeCss)
|
||||
: payload.themeCss;
|
||||
style.dataset.pagedMeasurementStyle = "true";
|
||||
style.textContent = [
|
||||
documentBaseCss,
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss,
|
||||
themeCss,
|
||||
documentGeometryCss
|
||||
].join("\n");
|
||||
|
||||
const host = documentRef.createElement("div");
|
||||
host.dataset.pagedMeasurementHost = "true";
|
||||
host.style.cssText = [
|
||||
"position: fixed",
|
||||
"left: -100000px",
|
||||
"top: 0",
|
||||
`width: ${pageContent.width}px`,
|
||||
"height: auto",
|
||||
"opacity: 0",
|
||||
"pointer-events: none",
|
||||
"contain: layout style paint"
|
||||
].join(";");
|
||||
|
||||
documentRef.head.append(style);
|
||||
host.append(content);
|
||||
documentRef.body.append(host);
|
||||
|
||||
return {
|
||||
host,
|
||||
restore() {
|
||||
content.append(...Array.from(host.childNodes));
|
||||
host.remove();
|
||||
style.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
private echartsPromise:
|
||||
| Promise<
|
||||
typeof import("@md-to-pdf/markdown-echarts/browser")
|
||||
>
|
||||
| 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 = Promise.all([
|
||||
import("mermaid"),
|
||||
import("@mermaid-js/layout-elk")
|
||||
]).then(
|
||||
([{ default: mermaid }, { default: elkLayouts }]) => {
|
||||
mermaid.registerLayoutLoaders(elkLayouts);
|
||||
return mermaid;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.mermaidPromise;
|
||||
}
|
||||
|
||||
private loadECharts() {
|
||||
if (!this.echartsPromise) {
|
||||
this.echartsPromise = import(
|
||||
"@md-to-pdf/markdown-echarts/browser"
|
||||
);
|
||||
}
|
||||
return this.echartsPromise;
|
||||
}
|
||||
|
||||
private async renderECharts(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("echarts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { renderEChartsBlocks } = await this.loadECharts();
|
||||
const outcomes = await renderEChartsBlocks(container, {
|
||||
outputMode: "inline-svg",
|
||||
concurrency: 2,
|
||||
timeoutMs: 5_000
|
||||
});
|
||||
|
||||
return outcomes.flatMap((outcome, index) =>
|
||||
outcome.success
|
||||
? []
|
||||
: [
|
||||
`图表 ${index + 1}:${
|
||||
outcome.error?.message ?? "未知错误"
|
||||
}`
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceEChartsSvgWithImages(container: ParentNode) {
|
||||
const { freezeEChartsSvg } = await this.loadECharts();
|
||||
const figures = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
".md-echarts:not(.md-echarts-error)"
|
||||
)
|
||||
);
|
||||
for (const figure of figures) {
|
||||
const host = figure.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const svg = host?.querySelector<SVGSVGElement>("svg");
|
||||
if (!host || !svg) {
|
||||
continue;
|
||||
}
|
||||
const label =
|
||||
host.getAttribute("aria-label") || "ECharts 图表";
|
||||
host.replaceChildren(
|
||||
freezeEChartsSvg(host, svg, "svg-image", label)
|
||||
);
|
||||
figure.dataset.echartsRendered = "svg-image";
|
||||
}
|
||||
await waitForImages(container);
|
||||
}
|
||||
|
||||
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();
|
||||
mermaid.initialize(
|
||||
createMermaidSiteConfig(payload.exportConfig.mermaid)
|
||||
);
|
||||
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;
|
||||
|
||||
let echartsErrors: string[] = [];
|
||||
let echartsMs = 0;
|
||||
let echartsFitMs = 0;
|
||||
let mermaidFitMs = 0;
|
||||
let mermaidConversionMs = 0;
|
||||
if (
|
||||
payload.features.includes("echarts") ||
|
||||
content.querySelector(".mermaid svg") ||
|
||||
content.querySelector("img.md-document-image")
|
||||
) {
|
||||
const measurement = mountMeasurementContainer(
|
||||
documentRef,
|
||||
content,
|
||||
payload,
|
||||
options.target
|
||||
);
|
||||
try {
|
||||
await documentRef.fonts.ready;
|
||||
|
||||
const echartsStartedAt = performance.now();
|
||||
echartsErrors = await this.renderECharts(
|
||||
measurement.host,
|
||||
payload
|
||||
);
|
||||
echartsMs = performance.now() - echartsStartedAt;
|
||||
await waitForImages(measurement.host);
|
||||
prepareDocumentImageBlocks(measurement.host);
|
||||
prepareMediaBackfillBlocks(measurement.host);
|
||||
fitDocumentImagesToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig,
|
||||
{ prepareBlocks: false }
|
||||
);
|
||||
|
||||
const echartsFitStartedAt = performance.now();
|
||||
fitOversizedEChartsToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
await this.replaceEChartsSvgWithImages(measurement.host);
|
||||
echartsFitMs =
|
||||
performance.now() - echartsFitStartedAt;
|
||||
|
||||
const mermaidFitStartedAt = performance.now();
|
||||
fitOversizedMermaidToPage(
|
||||
measurement.host,
|
||||
payload.exportConfig
|
||||
);
|
||||
mermaidFitMs = performance.now() - mermaidFitStartedAt;
|
||||
|
||||
const mermaidConversionStartedAt = performance.now();
|
||||
if (options.mermaidOutput === "svg-image") {
|
||||
replaceMermaidSvgWithImages(measurement.host);
|
||||
await waitForImages(measurement.host);
|
||||
}
|
||||
mermaidConversionMs =
|
||||
performance.now() - mermaidConversionStartedAt;
|
||||
} finally {
|
||||
measurement.restore();
|
||||
}
|
||||
}
|
||||
|
||||
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 repaginationSource = content.cloneNode(
|
||||
true
|
||||
) as DocumentFragment;
|
||||
const stylesheets = [
|
||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||
stylesheet(documentRef, highlightCss, "highlight"),
|
||||
stylesheet(documentRef, katexCss, "katex"),
|
||||
stylesheet(documentRef, echartsCss, "echarts"),
|
||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
documentGeometryCss,
|
||||
"document-geometry"
|
||||
),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
buildPagedMediaCss(payload.exportConfig, payload),
|
||||
"paged-media"
|
||||
)
|
||||
];
|
||||
let previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
let flow = await previewer.preview(
|
||||
content,
|
||||
stylesheets,
|
||||
this.root
|
||||
);
|
||||
const mediaIds = getMediaBackfillIdsInDocumentOrder(
|
||||
repaginationSource
|
||||
);
|
||||
let mediaCandidates = new Map(
|
||||
findMediaBackfillCandidates(this.root).map((candidate) => [
|
||||
candidate.id,
|
||||
candidate
|
||||
])
|
||||
);
|
||||
for (const mediaId of mediaIds) {
|
||||
if (!shouldContinue()) {
|
||||
break;
|
||||
}
|
||||
const candidate = mediaCandidates.get(mediaId);
|
||||
if (
|
||||
!candidate ||
|
||||
applyMediaBackfillCandidates(repaginationSource, [
|
||||
candidate
|
||||
]) === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
previewer.chunker.destroy();
|
||||
previewer.polisher.destroy();
|
||||
previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
flow = await previewer.preview(
|
||||
repaginationSource.cloneNode(true) as DocumentFragment,
|
||||
stylesheets,
|
||||
this.root
|
||||
);
|
||||
mediaCandidates = new Map(
|
||||
findMediaBackfillCandidates(this.root).map(
|
||||
(nextCandidate) => [
|
||||
nextCandidate.id,
|
||||
nextCandidate
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
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,
|
||||
echartsErrors,
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
echartsMs,
|
||||
echartsFitMs,
|
||||
mermaidMs,
|
||||
mermaidFitMs,
|
||||
mermaidConversionMs,
|
||||
resourceWaitMs,
|
||||
paginationMs,
|
||||
finalizeMs,
|
||||
totalMs: performance.now() - totalStartedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mdToPdfRender?: (
|
||||
payload: PagedPreviewPayload,
|
||||
target?: PagedRenderTarget
|
||||
) => Promise<PagedDocumentRenderResult>;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { PagedDocumentRuntime } from "./paged-document-runtime";
|
||||
import echartsCss from "@md-to-pdf/markdown-echarts/styles.css?inline";
|
||||
import {
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
PagedDocumentRuntime,
|
||||
resolveMermaidOutputMode,
|
||||
resolvePagedRenderTarget,
|
||||
type PagedPreviewFrameMessage,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
||||
} from "@md-to-pdf/preview-engine";
|
||||
import highlightCss from "highlight.js/styles/github.css?inline";
|
||||
import katexCss from "katex/dist/katex.min.css?inline";
|
||||
import {
|
||||
beginPreviewFrameRender,
|
||||
completePreviewFrameRender
|
||||
@@ -23,7 +26,11 @@ function getPreviewRoot() {
|
||||
const previewRoot = getPreviewRoot();
|
||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot, {
|
||||
highlightCss,
|
||||
katexCss,
|
||||
echartsCss
|
||||
});
|
||||
document.documentElement.dataset.renderTarget = renderTarget;
|
||||
|
||||
let latestRequestId = 0;
|
||||
@@ -33,9 +40,47 @@ function post(message: PagedPreviewFrameMessage) {
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
}
|
||||
|
||||
function measurePreviewContentHeight() {
|
||||
const rootRect = previewRoot.getBoundingClientRect();
|
||||
const documentRect =
|
||||
document.documentElement.getBoundingClientRect();
|
||||
return Math.ceil(
|
||||
Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
previewRoot.scrollHeight,
|
||||
rootRect.bottom - documentRect.top
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLAnchorElement>("a[href]")
|
||||
: undefined;
|
||||
const href = target?.getAttribute("href")?.trim();
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "link",
|
||||
href
|
||||
});
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
async function renderPagedPreview(
|
||||
requestId: number,
|
||||
payload: PagedPreviewPayload
|
||||
payload: PagedPreviewPayload,
|
||||
layout: "paged" | "continuous"
|
||||
) {
|
||||
if (requestId !== latestRequestId) {
|
||||
return;
|
||||
@@ -45,15 +90,21 @@ async function renderPagedPreview(
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendering",
|
||||
requestId
|
||||
requestId,
|
||||
layout
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
const result =
|
||||
layout === "continuous"
|
||||
? await runtime.renderContinuous(payload, {
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
})
|
||||
: await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
@@ -62,7 +113,8 @@ async function renderPagedPreview(
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
contentHeight: Math.ceil(document.documentElement.scrollHeight),
|
||||
layout,
|
||||
contentHeight: measurePreviewContentHeight(),
|
||||
...result
|
||||
});
|
||||
} finally {
|
||||
@@ -121,12 +173,12 @@ window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
}
|
||||
|
||||
latestRequestId = Math.max(latestRequestId, event.data.requestId);
|
||||
const { requestId, payload } = event.data;
|
||||
const { requestId, layout, payload } = event.data;
|
||||
renderQueue = renderQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
try {
|
||||
await renderPagedPreview(requestId, payload);
|
||||
await renderPagedPreview(requestId, payload, layout);
|
||||
} catch (reason: unknown) {
|
||||
if (requestId !== latestRequestId) {
|
||||
return;
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import {
|
||||
getPaperDimensionsMm,
|
||||
type ExportConfig,
|
||||
type MarkdownDocumentMetadata,
|
||||
type PagedDocumentPayload
|
||||
} from "@md-to-pdf/core";
|
||||
export const PAGED_PREVIEW_MESSAGE_SCOPE = "md-to-pdf:paged-preview";
|
||||
|
||||
export type PreviewMetadata = MarkdownDocumentMetadata;
|
||||
export type PagedPreviewPayload = PagedDocumentPayload;
|
||||
|
||||
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;
|
||||
echartsErrors: string[];
|
||||
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%;
|
||||
}
|
||||
|
||||
.md-document-image-block {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.md-document-image-block > .md-document-image {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.md-document-image-caption {
|
||||
margin-top: 0.55em;
|
||||
color: #64748b;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#write table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
.md-echarts {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
`;
|
||||
|
||||
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,
|
||||
#write .md-echarts {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
#write table[data-empty-split-table="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#write img,
|
||||
#write svg {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
#write .md-document-image-block {
|
||||
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: 34px 0;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"] {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
html[data-render-target="preview"] body {
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.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 {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"] .pagedjs_pages {
|
||||
display: block;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"] .pagedjs_page {
|
||||
box-shadow: none;
|
||||
break-after: page;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
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>;
|
||||
if (
|
||||
candidate.scope !== PAGED_PREVIEW_MESSAGE_SCOPE ||
|
||||
!["ready", "rendering", "rendered", "error"].includes(
|
||||
candidate.type ?? ""
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.type !== "rendered") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Number.isInteger(candidate.requestId) &&
|
||||
Number.isInteger(candidate.pageCount) &&
|
||||
typeof candidate.contentHeight === "number" &&
|
||||
Number.isFinite(candidate.contentHeight) &&
|
||||
candidate.contentHeight > 0 &&
|
||||
Array.isArray(candidate.echartsErrors) &&
|
||||
Array.isArray(candidate.mermaidErrors)
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type PagedRenderTarget = "preview" | "pdf";
|
||||
|
||||
export function resolvePagedRenderTarget(
|
||||
search: string
|
||||
): PagedRenderTarget {
|
||||
const target = new URLSearchParams(search).get("target");
|
||||
return target === "pdf" ? "pdf" : "preview";
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { Handler, registerHandlers } from "pagedjs";
|
||||
import { resolveBreakTokenElement } from "./paged-break-token";
|
||||
|
||||
interface PagedBreakToken {
|
||||
node?: Node;
|
||||
}
|
||||
|
||||
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 element = resolveBreakTokenElement(breakToken?.node);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tables = elementAncestors(element, "table");
|
||||
if (element.matches("table")) {
|
||||
tables.push(element);
|
||||
}
|
||||
|
||||
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
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { decodeLocalDocumentLinkFromPdf } from "@md-to-pdf/core";
|
||||
|
||||
export type PdfDestination = string | unknown[];
|
||||
|
||||
export interface PdfAnnotationLinkCallbacks {
|
||||
onExternalLink: (href: string) => void;
|
||||
onNavigateToPage: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
export interface PdfAnnotationLinkService {
|
||||
externalLinkEnabled: boolean;
|
||||
addLinkAttributes(
|
||||
link: HTMLAnchorElement,
|
||||
url: string,
|
||||
newWindow?: boolean
|
||||
): void;
|
||||
getDestinationHash(destination: PdfDestination): string;
|
||||
getAnchorUrl(anchor: string): string;
|
||||
goToDestination(destination: PdfDestination): Promise<void>;
|
||||
executeNamedAction(action: string): void;
|
||||
executeSetOCGState(action: object): Promise<void>;
|
||||
getAttachmentContent(id: string): Promise<undefined>;
|
||||
}
|
||||
|
||||
export async function resolvePdfDestinationPage(
|
||||
document: Pick<
|
||||
PDFDocumentProxy,
|
||||
"getDestination" | "getPageIndex" | "numPages"
|
||||
>,
|
||||
destination: PdfDestination
|
||||
) {
|
||||
const explicitDestination =
|
||||
typeof destination === "string"
|
||||
? await document.getDestination(destination)
|
||||
: destination;
|
||||
if (
|
||||
!Array.isArray(explicitDestination) ||
|
||||
explicitDestination.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pageReference = explicitDestination[0];
|
||||
let pageNumber: number;
|
||||
if (
|
||||
typeof pageReference === "number" &&
|
||||
Number.isInteger(pageReference)
|
||||
) {
|
||||
pageNumber = pageReference + 1;
|
||||
} else if (
|
||||
pageReference &&
|
||||
typeof pageReference === "object"
|
||||
) {
|
||||
pageNumber =
|
||||
(await document.getPageIndex(
|
||||
pageReference as Parameters<
|
||||
PDFDocumentProxy["getPageIndex"]
|
||||
>[0]
|
||||
)) + 1;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pageNumber >= 1 && pageNumber <= document.numPages
|
||||
? pageNumber
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolvePdfNamedActionPage(
|
||||
action: string,
|
||||
currentPage: number,
|
||||
pageCount: number
|
||||
) {
|
||||
switch (action) {
|
||||
case "FirstPage":
|
||||
return 1;
|
||||
case "LastPage":
|
||||
return pageCount;
|
||||
case "NextPage":
|
||||
case "PageDown":
|
||||
return Math.min(pageCount, currentPage + 1);
|
||||
case "PrevPage":
|
||||
case "PageUp":
|
||||
return Math.max(1, currentPage - 1);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPdfAnnotationLinkService(
|
||||
document: Pick<
|
||||
PDFDocumentProxy,
|
||||
"getDestination" | "getPageIndex" | "numPages"
|
||||
>,
|
||||
currentPage: number,
|
||||
callbacks: PdfAnnotationLinkCallbacks
|
||||
): PdfAnnotationLinkService {
|
||||
return {
|
||||
externalLinkEnabled: true,
|
||||
addLinkAttributes(link, url) {
|
||||
const resolvedUrl =
|
||||
decodeLocalDocumentLinkFromPdf(url) ?? url;
|
||||
link.href = "#";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.title ||= resolvedUrl;
|
||||
link.dataset.pdfExternalHref = resolvedUrl;
|
||||
link.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callbacks.onExternalLink(resolvedUrl);
|
||||
return false;
|
||||
};
|
||||
},
|
||||
getDestinationHash() {
|
||||
return "#pdf-destination";
|
||||
},
|
||||
getAnchorUrl() {
|
||||
return "#pdf-action";
|
||||
},
|
||||
async goToDestination(destination) {
|
||||
try {
|
||||
const pageNumber = await resolvePdfDestinationPage(
|
||||
document,
|
||||
destination
|
||||
);
|
||||
if (pageNumber !== undefined) {
|
||||
callbacks.onNavigateToPage(pageNumber);
|
||||
}
|
||||
} catch {
|
||||
// 损坏或无法解析的 PDF 目标保持无行为。
|
||||
}
|
||||
},
|
||||
executeNamedAction(action) {
|
||||
const pageNumber = resolvePdfNamedActionPage(
|
||||
action,
|
||||
currentPage,
|
||||
document.numPages
|
||||
);
|
||||
if (pageNumber !== undefined) {
|
||||
callbacks.onNavigateToPage(pageNumber);
|
||||
}
|
||||
},
|
||||
async executeSetOCGState() {
|
||||
// 精确预览不改变 PDF 可选内容组状态。
|
||||
},
|
||||
async getAttachmentContent() {
|
||||
// 精确预览不下载或执行 PDF 内嵌附件。
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
const printMediaPattern =
|
||||
/@media(\s+)(only\s+)?print(?=\s*(?:\{|and\b|,))/gi;
|
||||
|
||||
export function enablePrintMediaForPreview(css: string) {
|
||||
return css.replace(
|
||||
printMediaPattern,
|
||||
(_match, whitespace: string, qualifier: string | undefined) =>
|
||||
`@media${whitespace}${qualifier ?? ""}screen`
|
||||
);
|
||||
}
|
||||
+41
-1
@@ -583,7 +583,8 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas,
|
||||
.precise-pdf-page .textLayer {
|
||||
.precise-pdf-page .textLayer,
|
||||
.precise-pdf-page .annotationLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
@@ -617,6 +618,45 @@ textarea:focus {
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
.precise-pdf-page .annotationLayer {
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
color-scheme: only light;
|
||||
pointer-events: none;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page .annotationLayer section {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
text-align: initial;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page
|
||||
.annotationLayer
|
||||
.linkAnnotation
|
||||
> a {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.precise-pdf-page
|
||||
.annotationLayer
|
||||
.linkAnnotation
|
||||
> a:hover,
|
||||
.precise-pdf-page
|
||||
.annotationLayer
|
||||
.linkAnnotation
|
||||
> a:focus-visible {
|
||||
background: rgb(31 111 83 / 14%);
|
||||
outline: 1px solid rgb(31 111 83 / 45%);
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer :is(span, br) {
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
|
||||
Vendored
+5
-1
@@ -32,8 +32,12 @@ declare global {
|
||||
| undefined
|
||||
>;
|
||||
discardPendingMarkdown(): Promise<void>;
|
||||
onMarkdownOpened(listener: () => void): () => void;
|
||||
onMarkdownOpened(
|
||||
listener: (reason: "open" | "reload" | "replace") => void
|
||||
): () => void;
|
||||
startNewMarkdown(): Promise<void>;
|
||||
setDocumentDirty(dirty: boolean): Promise<void>;
|
||||
openDocumentLink(href: string): Promise<void>;
|
||||
saveMarkdown(
|
||||
fileName: string,
|
||||
markdown: string
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.4.5");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.5");
|
||||
expect(APP_VERSION).toBe("0.5.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitDocumentImagesToPage } from "../src/document-image-fit";
|
||||
|
||||
describe("Markdown 图片单页适配", () => {
|
||||
it("标记独立图片段落并将超高图片缩放到单页", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML =
|
||||
'<p> <img class="md-document-image" src="data:image/png;base64,AA=="> </p>';
|
||||
const image = root.querySelector("img")!;
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1000 },
|
||||
naturalHeight: { value: 3000 }
|
||||
});
|
||||
|
||||
fitDocumentImagesToPage(root, defaultExportConfig);
|
||||
|
||||
expect(image.parentElement?.classList).toContain(
|
||||
"md-document-image-block"
|
||||
);
|
||||
expect(image.dataset.pageHeightFitted).toBe("true");
|
||||
expect(image.parentElement?.dataset.pageHeightFitted).toBe("true");
|
||||
expect(
|
||||
image.parentElement?.style.getPropertyValue("margin-block")
|
||||
).toBe("0");
|
||||
expect(Number.parseFloat(image.style.height)).toBeCloseTo(
|
||||
1000.5748,
|
||||
3
|
||||
);
|
||||
expect(Number.parseFloat(image.style.width)).toBeCloseTo(
|
||||
333.5249,
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it("普通横图保持主题控制的尺寸", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML =
|
||||
'<p><img class="md-document-image" src="data:image/png;base64,AA=="></p>';
|
||||
const image = root.querySelector("img")!;
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1600 },
|
||||
naturalHeight: { value: 900 }
|
||||
});
|
||||
|
||||
fitDocumentImagesToPage(root, defaultExportConfig);
|
||||
|
||||
expect(image.dataset.pageHeightFitted).toBeUndefined();
|
||||
expect(image.style.height).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWebDocumentLinkAction } from "../src/document-link";
|
||||
|
||||
describe("Web 文档链接行为", () => {
|
||||
it("保留文档锚点", () => {
|
||||
expect(resolveWebDocumentLinkAction("#section")).toEqual({
|
||||
type: "anchor",
|
||||
href: "#section"
|
||||
});
|
||||
});
|
||||
|
||||
it("在外部窗口打开 HTTP 链接和浏览器协议", () => {
|
||||
expect(
|
||||
resolveWebDocumentLinkAction("//example.com/path")
|
||||
).toEqual({
|
||||
type: "open",
|
||||
href: "https://example.com/path"
|
||||
});
|
||||
expect(
|
||||
resolveWebDocumentLinkAction("mailto:a@example.com")
|
||||
).toEqual({
|
||||
type: "open",
|
||||
href: "mailto:a@example.com"
|
||||
});
|
||||
expect(resolveWebDocumentLinkAction("tel:+8612345")).toEqual({
|
||||
type: "open",
|
||||
href: "tel:+8612345"
|
||||
});
|
||||
});
|
||||
|
||||
it("忽略本地、未知和危险地址", () => {
|
||||
for (const href of [
|
||||
"../docs/a.md",
|
||||
"C:/docs/a.md",
|
||||
"file:///C:/docs/a.md",
|
||||
"obsidian://open?vault=x",
|
||||
"javascript:alert(1)"
|
||||
]) {
|
||||
expect(resolveWebDocumentLinkAction(href)).toEqual({
|
||||
type: "ignore"
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
|
||||
|
||||
describe("ECharts 单页高度适配", () => {
|
||||
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<h2 style="margin: 10px 0">大型图表</h2>
|
||||
<figure class="md-echarts">
|
||||
<div class="md-echarts-host" style="height: 2000px">
|
||||
<svg width="1000" height="2000"></svg>
|
||||
</div>
|
||||
</figure>
|
||||
`;
|
||||
|
||||
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||
const title = root.querySelector<HTMLElement>("h2");
|
||||
const host = root.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const svg = root.querySelector<SVGSVGElement>("svg");
|
||||
if (title) {
|
||||
title.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 700,
|
||||
bottom: 40,
|
||||
width: 700,
|
||||
height: 40,
|
||||
toJSON: () => ({})
|
||||
});
|
||||
}
|
||||
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||
|
||||
expect(figure?.dataset.pageHeightFitted).toBe("true");
|
||||
expect(title?.style.breakAfter).toBe("avoid-page");
|
||||
expect(figure?.style.breakAfter).toBe("page");
|
||||
expect(figure?.style.pageBreakAfter).toBe("always");
|
||||
expect(figure?.nextElementSibling).toHaveProperty(
|
||||
"dataset.mediaPageBreak",
|
||||
"true"
|
||||
);
|
||||
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
|
||||
960.5748,
|
||||
3
|
||||
);
|
||||
expect(host?.style.aspectRatio).toBe("");
|
||||
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
|
||||
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
|
||||
960.5748,
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it("普通高度图表不改变作者设置的宿主高度", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<figure class="md-echarts">
|
||||
<div class="md-echarts-host" style="height: 300px">
|
||||
<svg viewBox="0 0 1000 400"></svg>
|
||||
</div>
|
||||
</figure>
|
||||
`;
|
||||
|
||||
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||
|
||||
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||
const host = root.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
|
||||
expect(host?.style.height).toBe("300px");
|
||||
});
|
||||
});
|
||||
@@ -1,329 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMediaBackfillCandidates,
|
||||
calculateMediaBackfillScale,
|
||||
findMediaBackfillCandidates,
|
||||
getMediaBackfillIdsInDocumentOrder,
|
||||
prepareMediaBackfillBlocks
|
||||
} from "../src/media-page-backfill";
|
||||
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit";
|
||||
|
||||
function rect(width: number, height: number) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({})
|
||||
};
|
||||
}
|
||||
|
||||
function positionedRect(
|
||||
top: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
return {
|
||||
...rect(width, height),
|
||||
y: top,
|
||||
top,
|
||||
bottom: top + height
|
||||
};
|
||||
}
|
||||
|
||||
describe("媒体分页空白回填", () => {
|
||||
it("全页适配时保留前置标题高度并避免标题孤行", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<h2 style="margin: 10px 0 12px">媒体标题</h2>
|
||||
<div class="mermaid"></div>
|
||||
`;
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>(".mermaid")!;
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
|
||||
expect(getPrecedingMediaTitleHeight(block)).toBe(40);
|
||||
expect(title.style.getPropertyValue("break-after")).toBe(
|
||||
"avoid-page"
|
||||
);
|
||||
expect(title.style.getPropertyPriority("break-after")).toBe(
|
||||
"important"
|
||||
);
|
||||
});
|
||||
|
||||
it("按限宽后的基准高度计算额外缩放率", () => {
|
||||
expect(
|
||||
calculateMediaBackfillScale({
|
||||
remainingHeight: 420,
|
||||
fixedHeight: 30,
|
||||
baselineVisualHeight: 430
|
||||
})
|
||||
).toBeCloseTo(388 / 430, 6);
|
||||
});
|
||||
|
||||
it("缩小超过阈值或无需缩小时不回填", () => {
|
||||
expect(
|
||||
calculateMediaBackfillScale({
|
||||
remainingHeight: 300,
|
||||
fixedHeight: 30,
|
||||
baselineVisualHeight: 430
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
calculateMediaBackfillScale({
|
||||
remainingHeight: 500,
|
||||
fixedHeight: 30,
|
||||
baselineVisualHeight: 430
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("记录图片限宽后的实际基准尺寸", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<figure class="md-document-image-block">
|
||||
<img class="md-document-image">
|
||||
<figcaption>标题</figcaption>
|
||||
</figure>
|
||||
`;
|
||||
const image = root.querySelector("img")!;
|
||||
image.getBoundingClientRect = () => rect(640, 480);
|
||||
|
||||
expect(prepareMediaBackfillBlocks(root)).toBe(1);
|
||||
const figure = root.querySelector<HTMLElement>("figure")!;
|
||||
expect(figure.dataset.mediaBackfillKind).toBe("image");
|
||||
expect(figure.dataset.mediaBaselineWidth).toBe("640");
|
||||
expect(figure.dataset.mediaBaselineHeight).toBe("480");
|
||||
});
|
||||
|
||||
it("保持媒体元素在源文档中的串行处理顺序", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<figure data-media-backfill-id="media-2"></figure>
|
||||
<div><figure data-media-backfill-id="media-1"></figure></div>
|
||||
<figure></figure>
|
||||
`;
|
||||
|
||||
expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([
|
||||
"media-2",
|
||||
"media-1"
|
||||
]);
|
||||
});
|
||||
|
||||
it("根据上一页剩余区域生成一次回填候选", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<div
|
||||
class="pagedjs_page_content_flow"
|
||||
data-ref="previous-content"
|
||||
>上一页内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<h2 data-ref="media-title">图片标题</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const previousFlow =
|
||||
pageContents[0]!.firstElementChild as HTMLElement;
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>("figure")!;
|
||||
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||
pageContents[0]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
previousFlow.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 550);
|
||||
pageContents[1]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
block.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 330);
|
||||
image.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 300);
|
||||
|
||||
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||
{ id: "media-1", scale: 0.92 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("显式分页的媒体块不会回填到上一页", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content"></div>
|
||||
</div>
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<h2 data-ref="media-title" style="break-before: page">
|
||||
图片标题
|
||||
</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-ref="media-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>("figure")!;
|
||||
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||
pageContents[0]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
pageContents[1]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
block.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 330);
|
||||
image.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 300);
|
||||
|
||||
expect(findMediaBackfillCandidates(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it("忽略媒体之前其他内容的显式分页规则", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<div data-ref="previous-content">上一页内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<h1 data-ref="earlier-heading" style="break-before: page">
|
||||
本页章节
|
||||
</h1>
|
||||
<h2 data-ref="media-title">图片标题</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-ref="media-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const previousFlow = root.querySelector<HTMLElement>(
|
||||
"[data-ref='previous-content']"
|
||||
)!;
|
||||
const earlierHeading = root.querySelector<HTMLElement>("h1")!;
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>("figure")!;
|
||||
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||
pageContents[0]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
previousFlow.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 550);
|
||||
pageContents[1]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
earlierHeading.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 40);
|
||||
block.getBoundingClientRect = () =>
|
||||
positionedRect(200, 600, 330);
|
||||
image.getBoundingClientRect = () =>
|
||||
positionedRect(200, 600, 300);
|
||||
|
||||
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||
{ id: "media-1", scale: 0.92 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("将候选缩放应用到图片和 ECharts 主体", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<h2>图片标题保持原尺寸</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-backfill-kind="image"
|
||||
data-media-baseline-width="800"
|
||||
data-media-baseline-height="600"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
<figure
|
||||
class="md-echarts"
|
||||
data-media-backfill-id="media-2"
|
||||
data-media-backfill-kind="echarts"
|
||||
data-media-baseline-width="700"
|
||||
data-media-baseline-height="500"
|
||||
>
|
||||
<div class="md-echarts-host"><svg></svg></div>
|
||||
</figure>
|
||||
<div
|
||||
class="mermaid"
|
||||
data-media-backfill-id="media-3"
|
||||
data-media-backfill-kind="mermaid"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<svg></svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
expect(
|
||||
applyMediaBackfillCandidates(root, [
|
||||
{ id: "media-1", scale: 0.9 },
|
||||
{ id: "media-2", scale: 0.88 },
|
||||
{ id: "media-3", scale: 0.9 }
|
||||
])
|
||||
).toBe(3);
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const image = root.querySelector<HTMLImageElement>(
|
||||
"img.md-document-image"
|
||||
)!;
|
||||
const host = root.querySelector<HTMLElement>(".md-echarts-host")!;
|
||||
const svg = root.querySelector<SVGSVGElement>("svg")!;
|
||||
const mermaidSvg = root.querySelector<SVGSVGElement>(
|
||||
".mermaid svg"
|
||||
)!;
|
||||
expect(title.getAttribute("style")).toBeNull();
|
||||
expect(image.style.width).toBe("720px");
|
||||
expect(image.style.height).toBe("540px");
|
||||
expect(host.style.width).toBe("616px");
|
||||
expect(host.style.height).toBe("440px");
|
||||
expect(svg.style.width).toBe("616px");
|
||||
expect(svg.style.height).toBe("440px");
|
||||
expect(mermaidSvg.style.width).toBe("540px");
|
||||
expect(mermaidSvg.style.height).toBe("360px");
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
createMermaidSiteConfig,
|
||||
MERMAID_SECURE_CONFIG_KEYS
|
||||
} from "../src/mermaid-config";
|
||||
|
||||
describe("Mermaid 站点配置", () => {
|
||||
it("默认使用 default 主题和 classic 外观", () => {
|
||||
const config = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
expect(config.theme).toBe("default");
|
||||
expect(config.look).toBe("classic");
|
||||
expect(config.securityLevel).toBe("strict");
|
||||
expect(config.startOnLoad).toBe(false);
|
||||
expect(config.flowchart?.wrappingWidth).toBe(320);
|
||||
});
|
||||
|
||||
it("锁定安全限制和原始主题 CSS", () => {
|
||||
expect(MERMAID_SECURE_CONFIG_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"maxEdges",
|
||||
"suppressErrorRendering",
|
||||
"themeCSS"
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("每次创建独立的 secure 配置数组", () => {
|
||||
const first = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
const second = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
expect(first.secure).toEqual(second.secure);
|
||||
expect(first.secure).not.toBe(second.secure);
|
||||
});
|
||||
|
||||
it("应用导出配置中的布局、主题、外观和字体", () => {
|
||||
const config = createMermaidSiteConfig({
|
||||
layout: "elk",
|
||||
theme: "forest",
|
||||
look: "handDrawn",
|
||||
fontFamily: "Microsoft YaHei, sans-serif"
|
||||
});
|
||||
|
||||
expect(config).toMatchObject({
|
||||
layout: "elk",
|
||||
theme: "forest",
|
||||
look: "handDrawn",
|
||||
fontFamily: "Microsoft YaHei, sans-serif"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderMermaidDefinitions } from "../src/mermaid-renderer";
|
||||
|
||||
describe("Mermaid 独立图表渲染", () => {
|
||||
it("单个图表失败后继续渲染后续图表", async () => {
|
||||
const renderDefinition = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("语法错误"))
|
||||
.mockResolvedValueOnce({ svg: "<svg>第二张图</svg>" });
|
||||
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
["invalid", "flowchart LR\nA --> B"],
|
||||
renderDefinition
|
||||
);
|
||||
|
||||
expect(renderDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(outcomes[0]).toMatchObject({
|
||||
success: false,
|
||||
error: expect.any(Error)
|
||||
});
|
||||
expect(outcomes[1]).toEqual({
|
||||
success: true,
|
||||
svg: "<svg>第二张图</svg>"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBreakTokenElement } from "../src/paged-break-token";
|
||||
|
||||
describe("Paged.js 分页断点", () => {
|
||||
it("直接返回元素断点", () => {
|
||||
const element = {
|
||||
nodeType: 1,
|
||||
parentElement: null
|
||||
} as unknown as Element;
|
||||
|
||||
expect(resolveBreakTokenElement(element)).toBe(element);
|
||||
});
|
||||
|
||||
it("文本断点使用父元素继续查找", () => {
|
||||
const parentElement = {} as Element;
|
||||
const textNode = {
|
||||
nodeType: 3,
|
||||
parentElement
|
||||
} as unknown as Node;
|
||||
|
||||
expect(resolveBreakTokenElement(textNode)).toBe(parentElement);
|
||||
});
|
||||
|
||||
it("安全跳过空断点和无父元素节点", () => {
|
||||
const detachedTextNode = {
|
||||
nodeType: 3,
|
||||
parentElement: null
|
||||
} as unknown as Node;
|
||||
|
||||
expect(resolveBreakTokenElement(undefined)).toBeUndefined();
|
||||
expect(
|
||||
resolveBreakTokenElement(detachedTextNode)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
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("padding: 34px 0");
|
||||
expect(css).toContain('html[data-render-target="preview"]');
|
||||
expect(css).toContain("overflow-x: hidden");
|
||||
expect(css).toContain("overflow-y: hidden");
|
||||
expect(css).not.toContain("scrollbar-color");
|
||||
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");
|
||||
});
|
||||
|
||||
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: 2400,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPagedPreviewFrameMessage({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId: 3,
|
||||
pageCount: 2,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPagedPreviewFrameMessage({
|
||||
scope: "other",
|
||||
type: "ready"
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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,118 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
|
||||
import {
|
||||
createPdfAnnotationLinkService,
|
||||
resolvePdfDestinationPage,
|
||||
resolvePdfNamedActionPage
|
||||
} from "../src/pdf-annotation-links";
|
||||
|
||||
function createDocument() {
|
||||
return {
|
||||
numPages: 5,
|
||||
getDestination: vi.fn(async (name: string) =>
|
||||
name === "chapter" ? [{ num: 7, gen: 0 }] : null
|
||||
),
|
||||
getPageIndex: vi.fn(async () => 2)
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 精确预览链接服务", () => {
|
||||
it("解析命名目标、显式引用和页索引目标", async () => {
|
||||
const pdfDocument = createDocument();
|
||||
|
||||
await expect(
|
||||
resolvePdfDestinationPage(pdfDocument, "chapter")
|
||||
).resolves.toBe(3);
|
||||
await expect(
|
||||
resolvePdfDestinationPage(pdfDocument, [1, { name: "Fit" }])
|
||||
).resolves.toBe(2);
|
||||
await expect(
|
||||
resolvePdfDestinationPage(pdfDocument, "missing")
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("将外部 URI 交给平台链接策略且阻止默认导航", () => {
|
||||
const onExternalLink = vi.fn();
|
||||
const service = createPdfAnnotationLinkService(
|
||||
createDocument(),
|
||||
2,
|
||||
{
|
||||
onExternalLink,
|
||||
onNavigateToPage: vi.fn()
|
||||
}
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
service.addLinkAttributes(
|
||||
link,
|
||||
"https://example.com/document"
|
||||
);
|
||||
document.body.append(link);
|
||||
|
||||
expect(link.dataset.pdfExternalHref).toBe(
|
||||
"https://example.com/document"
|
||||
);
|
||||
expect(link.rel).toBe("noopener noreferrer");
|
||||
expect(
|
||||
link.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
expect(onExternalLink).toHaveBeenCalledWith(
|
||||
"https://example.com/document"
|
||||
);
|
||||
});
|
||||
|
||||
it("在交给平台策略前还原 PDF 中编码的本地链接", () => {
|
||||
const onExternalLink = vi.fn();
|
||||
const service = createPdfAnnotationLinkService(
|
||||
createDocument(),
|
||||
2,
|
||||
{
|
||||
onExternalLink,
|
||||
onNavigateToPage: vi.fn()
|
||||
}
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
service.addLinkAttributes(
|
||||
link,
|
||||
encodeLocalDocumentLinkForPdf("../README.md") ?? ""
|
||||
);
|
||||
|
||||
expect(link.dataset.pdfExternalHref).toBe("../README.md");
|
||||
link.click();
|
||||
expect(onExternalLink).toHaveBeenCalledWith("../README.md");
|
||||
});
|
||||
|
||||
it("导航到 PDF 内部目标并忽略损坏目标", async () => {
|
||||
const onNavigateToPage = vi.fn();
|
||||
const service = createPdfAnnotationLinkService(
|
||||
createDocument(),
|
||||
2,
|
||||
{
|
||||
onExternalLink: vi.fn(),
|
||||
onNavigateToPage
|
||||
}
|
||||
);
|
||||
|
||||
await service.goToDestination("chapter");
|
||||
await service.goToDestination("missing");
|
||||
|
||||
expect(onNavigateToPage).toHaveBeenCalledTimes(1);
|
||||
expect(onNavigateToPage).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("只处理受支持的 PDF 命名翻页动作", () => {
|
||||
expect(resolvePdfNamedActionPage("FirstPage", 3, 5)).toBe(1);
|
||||
expect(resolvePdfNamedActionPage("LastPage", 3, 5)).toBe(5);
|
||||
expect(resolvePdfNamedActionPage("NextPage", 5, 5)).toBe(5);
|
||||
expect(resolvePdfNamedActionPage("PrevPage", 1, 5)).toBe(1);
|
||||
expect(
|
||||
resolvePdfNamedActionPage("Print", 3, 5)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enablePrintMediaForPreview } from "../src/preview-styles";
|
||||
|
||||
describe("打印媒体预览", () => {
|
||||
it("将打印媒体规则转换为屏幕预览规则", () => {
|
||||
const css = `
|
||||
html { font-size: 16px; }
|
||||
@media print {
|
||||
html { font-size: 13px; }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(enablePrintMediaForPreview(css)).toContain("@media screen {");
|
||||
expect(enablePrintMediaForPreview(css)).toContain(
|
||||
"html { font-size: 13px; }"
|
||||
);
|
||||
});
|
||||
|
||||
it("支持 only print 和带条件的打印媒体规则", () => {
|
||||
const css = [
|
||||
"@media only print { body { color: black; } }",
|
||||
"@media print and (color) { body { background: white; } }"
|
||||
].join("\n");
|
||||
|
||||
expect(enablePrintMediaForPreview(css)).toBe(
|
||||
[
|
||||
"@media only screen { body { color: black; } }",
|
||||
"@media screen and (color) { body { background: white; } }"
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
it("不改变普通屏幕媒体规则", () => {
|
||||
const css = "@media screen and (min-width: 800px) { body { margin: 0; } }";
|
||||
expect(enablePrintMediaForPreview(css)).toBe(css);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user