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:
SkyJourney
2026-07-28 18:01:22 +08:00
parent 925b0d1485
commit 58087d0c7e
86 changed files with 4564 additions and 1204 deletions
+92
View File
@@ -0,0 +1,92 @@
# @md-to-pdf/preview-engine
Markdown PDF 导出器的共享预览与分页引擎。Web 连续/快速预览、服务端
Playwright PDF 和 Electron PDF 均通过同一套运行时完成媒体渲染、尺寸
适配与 Paged.js 分页。
## 目录结构
```text
src/
├── index.ts 公共导出入口
├── paged-document-runtime.ts 连续与分页渲染主流程
├── paged-preview.ts 分页载荷、消息协议与页面 CSS
├── continuous-preview.ts 无分页 DOM 更新与稳定节点复用
├── incremental-pagination.ts 修改边界、稳定前缀与后缀分页缓存
├── paged-table-handler.ts 跨页表格表头处理
├── media-page-backfill.ts 图片与图表按文档顺序回填
├── document-image-fit.ts Markdown 图片单页适配
├── mermaid-*.ts Mermaid 配置、渲染与尺寸适配
├── echarts-page-fit.ts ECharts 单页适配
├── diagram-page-fit.ts 图像类元素的共享几何计算
├── pdf-document-links.ts PDF 本地链接暂存编码
├── preview-styles.ts 预览/打印媒体样式转换
└── paged-render-target.ts Preview 与 PDF 运行目标
tests/ 引擎单元测试
```
## 使用方式
宿主负责提供渲染根节点和三份第三方样式,公共引擎不依赖 Vite 的
`?inline` 导入语法:
```ts
import { PagedDocumentRuntime } from "@md-to-pdf/preview-engine";
const runtime = new PagedDocumentRuntime(root, {
highlightCss,
katexCss,
echartsCss
});
const result = await runtime.render(payload, {
target: "preview",
mermaidOutput: "svg-image"
});
const continuous = await runtime.renderContinuous(payload);
```
`payload` 使用 `@md-to-pdf/core` 中的 `PagedDocumentPayload`。主题 CSS、
纸张尺寸、页边距、页眉页脚及 Markdown 功能标识都随载荷传入。
## 连续预览与增量分页
- `renderContinuous()` 不创建纸张和分页节点,只更新统一 `#write` DOM
- 连续模式仍等待并渲染 Mermaid、ECharts、图片和字体;
- 快速分页为源块生成稳定身份,编辑后保留修改点之前的分页结果;
- 重新计算从最早受影响位置开始,后续页面串行生成;
- 缓存不参与 Playwright/Electron 的最终 PDF 权威输出。
围栏代码块遵循 Typora DOM 约定:外层 `pre.md-fences` 负责主题样式,
内部语义 `<code>` 会清除重复的行内代码背景、边框、内边距和字号缩放,
不使用 `!important`,自定义主题仍可用更具体规则覆盖。
## 媒体分页
图片、Mermaid 和 ECharts 必须与紧邻标题作为同一媒体块。引擎按 DOM
文档顺序串行处理,每个媒体元素至多回填或缩放一次。图片先按内容限宽
得到基准高度;上一页空白与媒体需求接近时,才尝试只缩放媒体主体回填,
标题始终保持自然尺寸。
## PDF 链接
`preparePdfDocumentLinks()` 仅在 PDF 目标中将本地链接编码为
`https://mdpdf.local.invalid/...`。PDF.js 注释层通过 core 解码后交给
Web/Desktop 平台处理,HTTP/HTTPS 和文档内锚点保持原始 PDF 行为。
## 设计边界
- 本包不解析 Markdown;语义 HTML 由 `@md-to-pdf/renderer` 生成。
- 本包不读取文件、不发 HTTP 请求,也不决定 Web 或 Electron 平台行为。
- 第三方展示样式由宿主注入,便于浏览器、Playwright 和 Electron 复用。
- 媒体块按文档顺序串行判断回填,每个媒体元素至多触发一次重排。
- 连续与分页模式共享媒体渲染、链接和样式契约,不维护两套引擎。
## 验证
```powershell
npm run test -w @md-to-pdf/preview-engine
npm run typecheck -w @md-to-pdf/preview-engine
npm run build -w @md-to-pdf/preview-engine
```
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@md-to-pdf/preview-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0",
"@md-to-pdf/markdown-echarts": "0.1.0",
"@mermaid-js/layout-elk": "0.2.2",
"echarts": "6.1.0",
"mermaid": "11.16.0",
"pagedjs": "^0.4.3"
},
"devDependencies": {
"happy-dom": "^20.11.1",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,41 @@
export function findCommonPrefixLength(
previous: readonly string[],
next: readonly string[]
) {
const maximum = Math.min(previous.length, next.length);
let index = 0;
while (index < maximum && previous[index] === next[index]) {
index += 1;
}
return index;
}
export function createNodeSignature(node: Node) {
if (node instanceof Element) {
return `element:${node.outerHTML}`;
}
return `node:${node.nodeType}:${node.textContent ?? ""}`;
}
export function mountContinuousRenderStage(
root: HTMLElement,
articleTemplate: HTMLElement,
content: DocumentFragment
) {
const article = articleTemplate.cloneNode(false) as HTMLElement;
article.dataset.continuousRenderStage = "true";
article.append(content);
root.append(article);
return {
article,
takeContent() {
const rendered = article.ownerDocument.createDocumentFragment();
rendered.append(...Array.from(article.childNodes));
return rendered;
},
dispose() {
article.remove();
}
};
}
@@ -0,0 +1,245 @@
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);
}
}
@@ -0,0 +1,106 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
calculateDiagramPageFit,
ensurePageBreakAfter,
getPageContentDimensions,
getPrecedingMediaTitleHeight
} from "./diagram-page-fit.js";
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,47 @@
import type { ExportConfig } from "@md-to-pdf/core";
import { fitSvgDiagramsToPage } from "./diagram-page-fit.js";
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");
}
});
}
@@ -0,0 +1,109 @@
export interface PagedBreakTokenLike {
node?: Node;
offset?: number;
}
export interface PagedPageLike {
element: HTMLElement;
startToken?: PagedBreakTokenLike;
}
export interface IncrementalPaginationPlan {
prefixLength: number;
invalidationPageIndex: number;
startToken: PagedBreakTokenLike | undefined;
}
function getNodeReference(node: Node | undefined) {
if (!node) {
return undefined;
}
if (node instanceof HTMLElement && node.dataset.ref) {
return node.dataset.ref;
}
return node.parentElement?.dataset.ref;
}
function pageContainsReference(
page: PagedPageLike,
reference: string
) {
return Array.from(
page.element.querySelectorAll<HTMLElement>("[data-ref]")
).some((element) => element.dataset.ref === reference);
}
export function findSourceNodePageIndex(
pages: readonly PagedPageLike[],
sourceNode: Node | undefined
) {
const reference = getNodeReference(sourceNode);
if (!reference) {
return undefined;
}
const pageIndex = pages.findIndex((page) =>
pageContainsReference(page, reference)
);
return pageIndex >= 0 ? pageIndex : undefined;
}
export function createIncrementalPaginationPlan(
prefixLength: number,
previousNodeCount: number,
sourceNodes: readonly Node[],
pages: readonly PagedPageLike[]
): IncrementalPaginationPlan | undefined {
if (
prefixLength <= 0 ||
pages.length === 0 ||
prefixLength > sourceNodes.length ||
prefixLength > previousNodeCount
) {
return undefined;
}
const boundaryNode =
sourceNodes[prefixLength] ??
sourceNodes[prefixLength - 1];
const boundaryPageIndex =
findSourceNodePageIndex(pages, boundaryNode);
if (boundaryPageIndex === undefined) {
return undefined;
}
// 媒体块允许回填到上一页,因此变化边界必须额外向前回退一页。
const invalidationPageIndex = Math.max(
0,
boundaryPageIndex - 1
);
return {
prefixLength,
invalidationPageIndex,
startToken: pages[invalidationPageIndex]?.startToken
};
}
export function assignPagedSourceReferences(
root: Node,
createReference: () => string
) {
const elements: Element[] = [];
if (root instanceof Element) {
elements.push(root);
}
const queryRoot = root as Node & {
querySelectorAll?: ParentNode["querySelectorAll"];
};
if (typeof queryRoot.querySelectorAll === "function") {
elements.push(...Array.from(queryRoot.querySelectorAll("*")));
}
for (const element of elements) {
if (!element.hasAttribute("data-ref")) {
element.setAttribute("data-ref", createReference());
}
if (element.id) {
element.setAttribute("data-id", element.id);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
export * from "./diagram-page-fit.js";
export * from "./continuous-preview.js";
export * from "./document-image-fit.js";
export * from "./echarts-page-fit.js";
export * from "./incremental-pagination.js";
export * from "./media-page-backfill.js";
export * from "./mermaid-config.js";
export * from "./mermaid-page-fit.js";
export * from "./mermaid-renderer.js";
export * from "./mermaid-static-image.js";
export * from "./paged-break-token.js";
export * from "./paged-document-runtime.js";
export * from "./paged-preview.js";
export * from "./paged-render-target.js";
export * from "./pdf-document-links.js";
export * from "./preview-styles.js";
@@ -0,0 +1,397 @@
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;
}
@@ -0,0 +1,32 @@
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]
};
}
@@ -0,0 +1,26 @@
import type { ExportConfig } from "@md-to-pdf/core";
import {
calculateDiagramPageFit,
fitSvgDiagramsToPage,
getPageContentDimensions,
parseSvgViewBox,
type DiagramDimensions,
type DiagramPageFit
} from "./diagram-page-fit.js";
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"
});
}
@@ -0,0 +1,37 @@
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;
}
@@ -0,0 +1,100 @@
export function createSvgDataUrl(svgMarkup: string) {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
}
export interface MermaidBoxSize {
width: number;
height: number;
}
export type MermaidOutputMode = "inline-svg" | "svg-image";
export function resolveMermaidOutputMode(
search: string
): MermaidOutputMode {
return new URLSearchParams(search).get("mermaid-output") ===
"inline-svg"
? "inline-svg"
: "svg-image";
}
export function getLockedMermaidBoxSize(
size: MermaidBoxSize
): MermaidBoxSize | undefined {
if (
!Number.isFinite(size.width) ||
!Number.isFinite(size.height) ||
size.width <= 0 ||
size.height <= 0
) {
return undefined;
}
return size;
}
export function getMermaidImageAlt(
ariaLabel: string | null,
title: string | null
) {
return ariaLabel?.trim() || title?.trim() || "Mermaid 图表";
}
export function replaceMermaidSvgWithImages(container: ParentNode) {
const diagrams = Array.from(
container.querySelectorAll<HTMLElement>(".mermaid")
);
for (const diagram of diagrams) {
const svg = diagram.querySelector<SVGSVGElement>("svg");
if (!svg) {
continue;
}
const documentWindow = svg.ownerDocument.defaultView;
const svgRect = svg.getBoundingClientRect();
const diagramRect = diagram.getBoundingClientRect();
const computedStyle = documentWindow?.getComputedStyle(svg);
const lockedSvgSize = getLockedMermaidBoxSize(svgRect);
const lockedDiagramSize = getLockedMermaidBoxSize(diagramRect);
const image = svg.ownerDocument.createElement("img");
image.className = "mermaid-svg-image";
image.alt = getMermaidImageAlt(
svg.getAttribute("aria-label"),
svg.querySelector("title")?.textContent ?? null
);
image.src = createSvgDataUrl(
new XMLSerializer().serializeToString(svg)
);
image.style.cssText = svg.style.cssText;
if (computedStyle?.display) {
image.style.setProperty("display", computedStyle.display, "important");
}
if (computedStyle?.verticalAlign) {
image.style.setProperty(
"vertical-align",
computedStyle.verticalAlign,
"important"
);
}
if (lockedSvgSize) {
const width = `${lockedSvgSize.width}px`;
const height = `${lockedSvgSize.height}px`;
image.style.setProperty("width", width, "important");
image.style.setProperty("height", height, "important");
image.style.setProperty("max-width", width, "important");
image.style.setProperty("max-height", height, "important");
image.setAttribute("width", String(lockedSvgSize.width));
image.setAttribute("height", String(lockedSvgSize.height));
}
if (lockedDiagramSize) {
diagram.style.setProperty("box-sizing", "border-box", "important");
diagram.style.setProperty(
"height",
`${lockedDiagramSize.height}px`,
"important"
);
}
svg.replaceWith(image);
}
}
@@ -0,0 +1,15 @@
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;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
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;
layout: "paged" | "continuous";
payload: PagedPreviewPayload;
}
export type PagedPreviewFrameMessage =
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "ready";
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "link";
href: string;
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "rendering";
requestId: number;
layout: "paged" | "continuous";
}
| {
scope: typeof PAGED_PREVIEW_MESSAGE_SCOPE;
type: "rendered";
requestId: number;
layout: "paged" | "continuous";
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%;
}
#write pre.md-fences > code {
display: block;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.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;
}
`;
export const documentInteractionCss = `
:where(#write a[href]) {
color: var(--md-link-color, var(--md-accent, #0969da));
text-decoration-line: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.16em;
cursor: pointer;
}
:where(#write a[href]:hover) {
color: var(--md-link-hover-color, #0550ae);
}
:where(#write a[href]:focus-visible) {
border-radius: 2px;
outline: 2px solid currentColor;
outline-offset: 2px;
}
`;
export const continuousDocumentGeometryCss = `
html,
body,
#preview-root {
height: auto !important;
min-height: 0 !important;
overflow: hidden !important;
background: transparent !important;
}
#preview-root {
padding: 34px 0;
}
#write {
width: 100% !important;
max-width: none !important;
height: auto !important;
min-height: 0 !important;
margin: 0 !important;
padding: 16mm !important;
background: #fff;
box-shadow: 0 3px 16px rgb(34 43 38 / 16%);
}
#write[data-continuous-render-stage="true"] {
position: fixed !important;
inset: 0 auto auto 0 !important;
z-index: -1 !important;
visibility: hidden !important;
pointer-events: none !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,
layout: "paged" | "continuous" = "paged"
): PagedPreviewRenderRequest {
return {
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "render",
requestId,
layout,
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) &&
(candidate.layout === "paged" ||
candidate.layout === "continuous") &&
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", "link", "rendering", "rendered", "error"].includes(
candidate.type ?? ""
)
) {
return false;
}
if (candidate.type === "ready") {
return true;
}
if (candidate.type === "link") {
return (
typeof candidate.href === "string" &&
candidate.href.trim().length > 0
);
}
if (candidate.type === "rendering") {
return (
Number.isInteger(candidate.requestId) &&
(candidate.layout === "paged" ||
candidate.layout === "continuous")
);
}
if (candidate.type === "error") {
return (
Number.isInteger(candidate.requestId) &&
typeof candidate.message === "string"
);
}
if (candidate.type !== "rendered") {
return false;
}
const rendered = candidate as Partial<
Extract<PagedPreviewFrameMessage, { type: "rendered" }>
>;
return (
Number.isInteger(rendered.requestId) &&
(rendered.layout === "paged" ||
rendered.layout === "continuous") &&
Number.isInteger(rendered.pageCount) &&
typeof rendered.contentHeight === "number" &&
Number.isFinite(rendered.contentHeight) &&
rendered.contentHeight > 0 &&
Array.isArray(rendered.echartsErrors) &&
Array.isArray(rendered.mermaidErrors)
);
}
@@ -0,0 +1,8 @@
export type PagedRenderTarget = "preview" | "pdf";
export function resolvePagedRenderTarget(
search: string
): PagedRenderTarget {
const target = new URLSearchParams(search).get("target");
return target === "pdf" ? "pdf" : "preview";
}
@@ -0,0 +1,136 @@
import { Handler, registerHandlers } from "pagedjs";
import { resolveBreakTokenElement } from "./paged-break-token.js";
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 };
+78
View File
@@ -0,0 +1,78 @@
declare module "pagedjs" {
export interface PagedBreakToken {
node?: Node;
offset?: number;
}
export interface PagedPage {
element: HTMLElement;
startToken?: PagedBreakToken;
endToken?: PagedBreakToken;
destroy(): void;
}
export interface PagedFlow {
total: number;
pages: PagedPage[];
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: {
source: DocumentFragment;
pages: PagedPage[];
total: number;
rendered: boolean;
pagesArea: HTMLElement;
hooks: {
afterParsed: {
trigger(
parsed: DocumentFragment,
chunker: unknown
): Promise<void>;
};
afterRendered: {
trigger(
pages: PagedPage[],
chunker: unknown
): Promise<void>;
};
};
loadFonts(): Promise<void>;
removePages(fromIndex?: number): void;
render(
parsed: DocumentFragment,
startAt?: PagedBreakToken
): Promise<{ done: boolean; canceled?: boolean }>;
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,20 @@
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
export function preparePdfDocumentLinks(root: ParentNode) {
let rewritten = 0;
for (const link of root.querySelectorAll<HTMLAnchorElement>(
"a[href]"
)) {
const href = link.getAttribute("href");
if (!href) {
continue;
}
const encoded = encodeLocalDocumentLinkForPdf(href);
if (!encoded) {
continue;
}
link.setAttribute("href", encoded);
rewritten += 1;
}
return rewritten;
}
@@ -0,0 +1,10 @@
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`
);
}
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
findCommonPrefixLength,
mountContinuousRenderStage
} from "../src/continuous-preview.js";
describe("连续预览增量边界", () => {
it("从首个变化块开始替换后缀", () => {
expect(
findCommonPrefixLength(
["标题", "段落 A", "图表", "结尾"],
["标题", "段落 A", "新图表", "新结尾"]
)
).toBe(2);
});
it("处理新增、删除和完全相同的文档", () => {
expect(findCommonPrefixLength(["A"], ["A", "B"])).toBe(1);
expect(findCommonPrefixLength(["A", "B"], ["A"])).toBe(1);
expect(findCommonPrefixLength(["A", "B"], ["A", "B"])).toBe(2);
expect(findCommonPrefixLength(["A"], ["B"])).toBe(0);
});
it("将变化后缀挂载到真实 DOM 测量且提交时不保留包装层", () => {
const root = document.createElement("div");
const article = document.createElement("article");
article.id = "write";
article.className = "theme-document";
const suffix = document.createDocumentFragment();
const heading = document.createElement("h2");
heading.textContent = "图表";
const chart = document.createElement("figure");
chart.className = "md-echarts";
suffix.append(heading, chart);
document.body.append(root);
const stage = mountContinuousRenderStage(
root,
article,
suffix
);
expect(stage.article.isConnected).toBe(true);
expect(stage.article.parentElement).toBe(root);
expect(stage.article.id).toBe("write");
expect(stage.article.className).toBe("theme-document");
expect(stage.article.dataset.continuousRenderStage).toBe("true");
expect(Array.from(stage.article.children)).toEqual([
heading,
chart
]);
const rendered = stage.takeContent();
expect(Array.from(rendered.children)).toEqual([heading, chart]);
expect(stage.article.childNodes).toHaveLength(0);
stage.dispose();
expect(stage.article.isConnected).toBe(false);
expect(root.childNodes).toHaveLength(0);
});
});
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitDocumentImagesToPage } from "../src/document-image-fit.js";
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,79 @@
// @vitest-environment happy-dom
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit.js";
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");
});
});
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
assignPagedSourceReferences,
createIncrementalPaginationPlan,
findSourceNodePageIndex
} from "../src/incremental-pagination.js";
function createSourceNode(reference: string) {
const node = document.createElement("section");
node.dataset.ref = reference;
return node;
}
function createPage(
references: string[],
startReference?: string
) {
const element = document.createElement("article");
for (const reference of references) {
element.append(createSourceNode(reference));
}
return {
element,
startToken: startReference
? {
node: createSourceNode(startReference),
offset: 0
}
: undefined
};
}
describe("快速预览增量分页计划", () => {
it("从变化块所在页的前一页开始续排", () => {
const sourceNodes = [
createSourceNode("a"),
createSourceNode("b"),
createSourceNode("c"),
createSourceNode("d")
];
const pages = [
createPage(["a"]),
createPage(["b"], "b"),
createPage(["c"], "c"),
createPage(["d"], "d")
];
expect(
createIncrementalPaginationPlan(
2,
sourceNodes.length,
sourceNodes,
pages
)
).toEqual({
prefixLength: 2,
invalidationPageIndex: 1,
startToken: pages[1]?.startToken
});
});
it("尾部新增时以旧文档最后一个块定位并回退一页", () => {
const sourceNodes = [
createSourceNode("a"),
createSourceNode("b")
];
const pages = [
createPage(["a"]),
createPage(["b"], "b")
];
expect(
createIncrementalPaginationPlan(
2,
sourceNodes.length,
sourceNodes,
pages
)?.invalidationPageIndex
).toBe(0);
});
it("无法验证边界时拒绝使用旧分页缓存", () => {
const sourceNodes = [createSourceNode("a")];
const pages = [createPage(["other"])];
expect(
createIncrementalPaginationPlan(
1,
sourceNodes.length,
sourceNodes,
pages
)
).toBeUndefined();
expect(
createIncrementalPaginationPlan(
0,
sourceNodes.length,
sourceNodes,
pages
)
).toBeUndefined();
});
it("通过 data-ref 在分页结果中反查源节点页码", () => {
const pages = [
createPage(["a"]),
createPage(["b", "c"])
];
expect(
findSourceNodePageIndex(pages, createSourceNode("c"))
).toBe(1);
});
it("只为新节点补充分页引用并保留既有引用", () => {
const root = document.createElement("article");
root.dataset.ref = "existing-root";
root.id = "write";
const child = document.createElement("p");
child.id = "paragraph";
root.append(child);
let sequence = 0;
assignPagedSourceReferences(root, () => {
sequence += 1;
return `generated-${sequence}`;
});
expect(root.dataset.ref).toBe("existing-root");
expect(root.dataset.id).toBe("write");
expect(child.dataset.ref).toBe("generated-1");
expect(child.dataset.id).toBe("paragraph");
});
});
@@ -0,0 +1,329 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import {
applyMediaBackfillCandidates,
calculateMediaBackfillScale,
findMediaBackfillCandidates,
getMediaBackfillIdsInDocumentOrder,
prepareMediaBackfillBlocks
} from "../src/media-page-backfill.js";
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit.js";
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");
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { defaultExportConfig } from "@md-to-pdf/core";
import {
createMermaidSiteConfig,
MERMAID_SECURE_CONFIG_KEYS
} from "../src/mermaid-config.js";
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"
});
});
});
@@ -0,0 +1,53 @@
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import {
calculateMermaidPageFit,
getPageContentDimensions,
parseSvgViewBox
} from "../src/mermaid-page-fit.js";
describe("Mermaid 单页高度适配", () => {
it("解析 SVG viewBox 尺寸", () => {
expect(parseSvgViewBox("0 0 1200 2400")).toEqual({
width: 1200,
height: 2400
});
expect(parseSvgViewBox("0,0,800,600")).toEqual({
width: 800,
height: 600
});
expect(parseSvgViewBox("0 0 0 600")).toBeUndefined();
expect(parseSvgViewBox(null)).toBeUndefined();
});
it("使用纸张和页边距计算正文区域", () => {
const dimensions = getPageContentDimensions(defaultExportConfig);
expect(dimensions.width).toBeCloseTo(672.7559, 3);
expect(dimensions.height).toBeCloseTo(1001.5748, 3);
});
it("宽度适配后未超高时保持普通 Mermaid 布局", () => {
const fit = calculateMermaidPageFit(
{ width: 1600, height: 900 },
{ width: 672, height: 1000 }
);
expect(fit).toEqual({
width: 672,
height: 378,
scaled: false
});
});
it("宽度适配后超高时按页面高度等比例缩小", () => {
const fit = calculateMermaidPageFit(
{ width: 1000, height: 2000 },
{ width: 672, height: 1000 }
);
expect(fit.scaled).toBe(true);
expect(fit.height).toBe(999);
expect(fit.width).toBe(499.5);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { renderMermaidDefinitions } from "../src/mermaid-renderer.js";
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>"
});
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
createSvgDataUrl,
getLockedMermaidBoxSize,
getMermaidImageAlt,
resolveMermaidOutputMode
} from "../src/mermaid-static-image.js";
describe("Mermaid 静态 SVG 图片", () => {
it("生成无需外部请求的 UTF-8 Data URL", () => {
const url = createSvgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg"><text>中文 &amp; A</text></svg>'
);
expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
expect(decodeURIComponent(url.split(",")[1] ?? "")).toContain(
"<text>中文 &amp; 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");
});
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { resolveBreakTokenElement } from "../src/paged-break-token.js";
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();
});
});
@@ -0,0 +1,268 @@
import { defaultExportConfig } from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import {
buildPagedMediaCss,
continuousDocumentGeometryCss,
createPagedPreviewRenderRequest,
documentBaseCss,
documentGeometryCss,
documentInteractionCss,
formatPageNumber,
isPagedPreviewFrameMessage,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
type PagedPreviewPayload
} from "../src/paged-preview.js";
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("避免 Typora 围栏容器重复应用行内代码盒模型", () => {
expect(documentBaseCss).toContain(
"#write pre.md-fences > code"
);
expect(documentBaseCss).toContain("background: transparent;");
expect(documentBaseCss).toContain("font-size: inherit;");
expect(documentBaseCss).not.toMatch(
/#write pre\.md-fences > code\s*\{[^}]*!important/su
);
});
it("以低优先级兜底样式保留链接识别和焦点反馈", () => {
expect(documentInteractionCss).toContain(":where(#write a[href])");
expect(documentInteractionCss).toContain(
"text-decoration-line: underline;"
);
expect(documentInteractionCss).toContain(":focus-visible");
expect(documentInteractionCss).toContain("--md-accent");
expect(documentInteractionCss).not.toContain("!important");
});
it("连续预览由内容高度驱动且不创建内部滚动容器", () => {
expect(continuousDocumentGeometryCss).toContain(
"overflow: hidden !important"
);
expect(continuousDocumentGeometryCss).toContain(
"min-height: 0 !important"
);
expect(continuousDocumentGeometryCss).toContain(
'[data-continuous-render-stage="true"]'
);
expect(continuousDocumentGeometryCss).toContain(
"position: fixed !important"
);
expect(continuousDocumentGeometryCss).not.toContain("100vh");
});
it("生成并识别分页渲染请求", () => {
const request = createPagedPreviewRenderRequest(7, payload);
expect(request).toEqual({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "render",
requestId: 7,
layout: "paged",
payload
});
expect(isPagedPreviewRenderRequest(request)).toBe(true);
expect(
createPagedPreviewRenderRequest(
8,
payload,
"continuous"
).layout
).toBe("continuous");
expect(
isPagedPreviewRenderRequest({
...request,
requestId: "7"
})
).toBe(false);
});
it("只接受分页 iframe 协议消息", () => {
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "link",
href: "https://example.com"
})
).toBe(true);
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "link",
href: ""
})
).toBe(false);
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
layout: "paged",
pageCount: 2,
contentHeight: 2400,
echartsErrors: [],
mermaidErrors: []
})
).toBe(true);
expect(
isPagedPreviewFrameMessage({
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
type: "rendered",
requestId: 3,
layout: "paged",
pageCount: 2,
echartsErrors: [],
mermaidErrors: []
})
).toBe(false);
expect(
isPagedPreviewFrameMessage({
scope: "other",
type: "ready"
})
).toBe(false);
});
});
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { resolvePagedRenderTarget } from "../src/paged-render-target.js";
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,33 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { preparePdfDocumentLinks } from "../src/pdf-document-links.js";
describe("PDF 本地链接准备", () => {
it("只改写本地链接并保留网络、协议和锚点", () => {
const root = document.createElement("article");
root.innerHTML = [
'<a href="../README.md">本地</a>',
'<a href="file:///C:/docs/a.md">文件</a>',
'<a href="https://example.com">网络</a>',
'<a href="mailto:a@example.com">邮件</a>',
'<a href="#chapter">锚点</a>'
].join("");
expect(preparePdfDocumentLinks(root)).toBe(2);
const hrefs = Array.from(root.querySelectorAll("a")).map(
(link) => link.getAttribute("href")
);
expect(hrefs[0]).toContain(
"https://mdpdf.local.invalid/document-link?"
);
expect(hrefs[1]).toContain(
"https://mdpdf.local.invalid/document-link?"
);
expect(hrefs.slice(2)).toEqual([
"https://example.com",
"mailto:a@example.com",
"#chapter"
]);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { enablePrintMediaForPreview } from "../src/preview-styles.js";
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);
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
]
},
"include": [
"src"
]
}