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
@@ -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);
});
});