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:
@@ -6,7 +6,7 @@ import {
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.4.5");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.4.5");
|
||||
expect(APP_VERSION).toBe("0.5.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitDocumentImagesToPage } from "../src/document-image-fit";
|
||||
|
||||
describe("Markdown 图片单页适配", () => {
|
||||
it("标记独立图片段落并将超高图片缩放到单页", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML =
|
||||
'<p> <img class="md-document-image" src="data:image/png;base64,AA=="> </p>';
|
||||
const image = root.querySelector("img")!;
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1000 },
|
||||
naturalHeight: { value: 3000 }
|
||||
});
|
||||
|
||||
fitDocumentImagesToPage(root, defaultExportConfig);
|
||||
|
||||
expect(image.parentElement?.classList).toContain(
|
||||
"md-document-image-block"
|
||||
);
|
||||
expect(image.dataset.pageHeightFitted).toBe("true");
|
||||
expect(image.parentElement?.dataset.pageHeightFitted).toBe("true");
|
||||
expect(
|
||||
image.parentElement?.style.getPropertyValue("margin-block")
|
||||
).toBe("0");
|
||||
expect(Number.parseFloat(image.style.height)).toBeCloseTo(
|
||||
1000.5748,
|
||||
3
|
||||
);
|
||||
expect(Number.parseFloat(image.style.width)).toBeCloseTo(
|
||||
333.5249,
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it("普通横图保持主题控制的尺寸", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML =
|
||||
'<p><img class="md-document-image" src="data:image/png;base64,AA=="></p>';
|
||||
const image = root.querySelector("img")!;
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1600 },
|
||||
naturalHeight: { value: 900 }
|
||||
});
|
||||
|
||||
fitDocumentImagesToPage(root, defaultExportConfig);
|
||||
|
||||
expect(image.dataset.pageHeightFitted).toBeUndefined();
|
||||
expect(image.style.height).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWebDocumentLinkAction } from "../src/document-link";
|
||||
|
||||
describe("Web 文档链接行为", () => {
|
||||
it("保留文档锚点", () => {
|
||||
expect(resolveWebDocumentLinkAction("#section")).toEqual({
|
||||
type: "anchor",
|
||||
href: "#section"
|
||||
});
|
||||
});
|
||||
|
||||
it("在外部窗口打开 HTTP 链接和浏览器协议", () => {
|
||||
expect(
|
||||
resolveWebDocumentLinkAction("//example.com/path")
|
||||
).toEqual({
|
||||
type: "open",
|
||||
href: "https://example.com/path"
|
||||
});
|
||||
expect(
|
||||
resolveWebDocumentLinkAction("mailto:a@example.com")
|
||||
).toEqual({
|
||||
type: "open",
|
||||
href: "mailto:a@example.com"
|
||||
});
|
||||
expect(resolveWebDocumentLinkAction("tel:+8612345")).toEqual({
|
||||
type: "open",
|
||||
href: "tel:+8612345"
|
||||
});
|
||||
});
|
||||
|
||||
it("忽略本地、未知和危险地址", () => {
|
||||
for (const href of [
|
||||
"../docs/a.md",
|
||||
"C:/docs/a.md",
|
||||
"file:///C:/docs/a.md",
|
||||
"obsidian://open?vault=x",
|
||||
"javascript:alert(1)"
|
||||
]) {
|
||||
expect(resolveWebDocumentLinkAction(href)).toEqual({
|
||||
type: "ignore"
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitOversizedEChartsToPage } from "../src/echarts-page-fit";
|
||||
|
||||
describe("ECharts 单页高度适配", () => {
|
||||
it("将超高 SVG 和宿主同步缩放到单页内容区", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<h2 style="margin: 10px 0">大型图表</h2>
|
||||
<figure class="md-echarts">
|
||||
<div class="md-echarts-host" style="height: 2000px">
|
||||
<svg width="1000" height="2000"></svg>
|
||||
</div>
|
||||
</figure>
|
||||
`;
|
||||
|
||||
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||
const title = root.querySelector<HTMLElement>("h2");
|
||||
const host = root.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
const svg = root.querySelector<SVGSVGElement>("svg");
|
||||
if (title) {
|
||||
title.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 700,
|
||||
bottom: 40,
|
||||
width: 700,
|
||||
height: 40,
|
||||
toJSON: () => ({})
|
||||
});
|
||||
}
|
||||
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||
|
||||
expect(figure?.dataset.pageHeightFitted).toBe("true");
|
||||
expect(title?.style.breakAfter).toBe("avoid-page");
|
||||
expect(figure?.style.breakAfter).toBe("page");
|
||||
expect(figure?.style.pageBreakAfter).toBe("always");
|
||||
expect(figure?.nextElementSibling).toHaveProperty(
|
||||
"dataset.mediaPageBreak",
|
||||
"true"
|
||||
);
|
||||
expect(Number.parseFloat(host?.style.height ?? "0")).toBeCloseTo(
|
||||
960.5748,
|
||||
3
|
||||
);
|
||||
expect(host?.style.aspectRatio).toBe("");
|
||||
expect(svg?.getAttribute("viewBox")).toBe("0 0 1000 2000");
|
||||
expect(Number.parseFloat(svg?.style.height ?? "0")).toBeCloseTo(
|
||||
960.5748,
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it("普通高度图表不改变作者设置的宿主高度", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<figure class="md-echarts">
|
||||
<div class="md-echarts-host" style="height: 300px">
|
||||
<svg viewBox="0 0 1000 400"></svg>
|
||||
</div>
|
||||
</figure>
|
||||
`;
|
||||
|
||||
fitOversizedEChartsToPage(root, defaultExportConfig);
|
||||
|
||||
const figure = root.querySelector<HTMLElement>(".md-echarts");
|
||||
const host = root.querySelector<HTMLElement>(
|
||||
".md-echarts-host"
|
||||
);
|
||||
expect(figure?.dataset.pageHeightFitted).toBeUndefined();
|
||||
expect(host?.style.height).toBe("300px");
|
||||
});
|
||||
});
|
||||
@@ -1,329 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMediaBackfillCandidates,
|
||||
calculateMediaBackfillScale,
|
||||
findMediaBackfillCandidates,
|
||||
getMediaBackfillIdsInDocumentOrder,
|
||||
prepareMediaBackfillBlocks
|
||||
} from "../src/media-page-backfill";
|
||||
import { getPrecedingMediaTitleHeight } from "../src/diagram-page-fit";
|
||||
|
||||
function rect(width: number, height: number) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
width,
|
||||
height,
|
||||
toJSON: () => ({})
|
||||
};
|
||||
}
|
||||
|
||||
function positionedRect(
|
||||
top: number,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
return {
|
||||
...rect(width, height),
|
||||
y: top,
|
||||
top,
|
||||
bottom: top + height
|
||||
};
|
||||
}
|
||||
|
||||
describe("媒体分页空白回填", () => {
|
||||
it("全页适配时保留前置标题高度并避免标题孤行", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<h2 style="margin: 10px 0 12px">媒体标题</h2>
|
||||
<div class="mermaid"></div>
|
||||
`;
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>(".mermaid")!;
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
|
||||
expect(getPrecedingMediaTitleHeight(block)).toBe(40);
|
||||
expect(title.style.getPropertyValue("break-after")).toBe(
|
||||
"avoid-page"
|
||||
);
|
||||
expect(title.style.getPropertyPriority("break-after")).toBe(
|
||||
"important"
|
||||
);
|
||||
});
|
||||
|
||||
it("按限宽后的基准高度计算额外缩放率", () => {
|
||||
expect(
|
||||
calculateMediaBackfillScale({
|
||||
remainingHeight: 420,
|
||||
fixedHeight: 30,
|
||||
baselineVisualHeight: 430
|
||||
})
|
||||
).toBeCloseTo(388 / 430, 6);
|
||||
});
|
||||
|
||||
it("缩小超过阈值或无需缩小时不回填", () => {
|
||||
expect(
|
||||
calculateMediaBackfillScale({
|
||||
remainingHeight: 300,
|
||||
fixedHeight: 30,
|
||||
baselineVisualHeight: 430
|
||||
})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
calculateMediaBackfillScale({
|
||||
remainingHeight: 500,
|
||||
fixedHeight: 30,
|
||||
baselineVisualHeight: 430
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("记录图片限宽后的实际基准尺寸", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<figure class="md-document-image-block">
|
||||
<img class="md-document-image">
|
||||
<figcaption>标题</figcaption>
|
||||
</figure>
|
||||
`;
|
||||
const image = root.querySelector("img")!;
|
||||
image.getBoundingClientRect = () => rect(640, 480);
|
||||
|
||||
expect(prepareMediaBackfillBlocks(root)).toBe(1);
|
||||
const figure = root.querySelector<HTMLElement>("figure")!;
|
||||
expect(figure.dataset.mediaBackfillKind).toBe("image");
|
||||
expect(figure.dataset.mediaBaselineWidth).toBe("640");
|
||||
expect(figure.dataset.mediaBaselineHeight).toBe("480");
|
||||
});
|
||||
|
||||
it("保持媒体元素在源文档中的串行处理顺序", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<figure data-media-backfill-id="media-2"></figure>
|
||||
<div><figure data-media-backfill-id="media-1"></figure></div>
|
||||
<figure></figure>
|
||||
`;
|
||||
|
||||
expect(getMediaBackfillIdsInDocumentOrder(root)).toEqual([
|
||||
"media-2",
|
||||
"media-1"
|
||||
]);
|
||||
});
|
||||
|
||||
it("根据上一页剩余区域生成一次回填候选", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<div
|
||||
class="pagedjs_page_content_flow"
|
||||
data-ref="previous-content"
|
||||
>上一页内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<h2 data-ref="media-title">图片标题</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const previousFlow =
|
||||
pageContents[0]!.firstElementChild as HTMLElement;
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>("figure")!;
|
||||
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||
pageContents[0]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
previousFlow.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 550);
|
||||
pageContents[1]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
block.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 330);
|
||||
image.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 300);
|
||||
|
||||
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||
{ id: "media-1", scale: 0.92 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("显式分页的媒体块不会回填到上一页", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content"></div>
|
||||
</div>
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<h2 data-ref="media-title" style="break-before: page">
|
||||
图片标题
|
||||
</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-ref="media-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>("figure")!;
|
||||
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||
pageContents[0]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
pageContents[1]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
block.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 330);
|
||||
image.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 300);
|
||||
|
||||
expect(findMediaBackfillCandidates(root)).toEqual([]);
|
||||
});
|
||||
|
||||
it("忽略媒体之前其他内容的显式分页规则", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<div data-ref="previous-content">上一页内容</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pagedjs_page">
|
||||
<div class="pagedjs_page_content">
|
||||
<h1 data-ref="earlier-heading" style="break-before: page">
|
||||
本页章节
|
||||
</h1>
|
||||
<h2 data-ref="media-title">图片标题</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-ref="media-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const pageContents = root.querySelectorAll<HTMLElement>(
|
||||
".pagedjs_page_content"
|
||||
);
|
||||
const previousFlow = root.querySelector<HTMLElement>(
|
||||
"[data-ref='previous-content']"
|
||||
)!;
|
||||
const earlierHeading = root.querySelector<HTMLElement>("h1")!;
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const block = root.querySelector<HTMLElement>("figure")!;
|
||||
const image = root.querySelector<HTMLImageElement>("img")!;
|
||||
pageContents[0]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
previousFlow.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 550);
|
||||
pageContents[1]!.getBoundingClientRect = () =>
|
||||
positionedRect(100, 700, 1_000);
|
||||
earlierHeading.getBoundingClientRect = () =>
|
||||
positionedRect(100, 600, 40);
|
||||
title.getBoundingClientRect = () =>
|
||||
positionedRect(150, 600, 40);
|
||||
block.getBoundingClientRect = () =>
|
||||
positionedRect(200, 600, 330);
|
||||
image.getBoundingClientRect = () =>
|
||||
positionedRect(200, 600, 300);
|
||||
|
||||
expect(findMediaBackfillCandidates(root)).toEqual([
|
||||
{ id: "media-1", scale: 0.92 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("将候选缩放应用到图片和 ECharts 主体", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<h2>图片标题保持原尺寸</h2>
|
||||
<figure
|
||||
class="md-document-image-block"
|
||||
data-media-backfill-id="media-1"
|
||||
data-media-backfill-kind="image"
|
||||
data-media-baseline-width="800"
|
||||
data-media-baseline-height="600"
|
||||
>
|
||||
<img class="md-document-image">
|
||||
</figure>
|
||||
<figure
|
||||
class="md-echarts"
|
||||
data-media-backfill-id="media-2"
|
||||
data-media-backfill-kind="echarts"
|
||||
data-media-baseline-width="700"
|
||||
data-media-baseline-height="500"
|
||||
>
|
||||
<div class="md-echarts-host"><svg></svg></div>
|
||||
</figure>
|
||||
<div
|
||||
class="mermaid"
|
||||
data-media-backfill-id="media-3"
|
||||
data-media-backfill-kind="mermaid"
|
||||
data-media-baseline-width="600"
|
||||
data-media-baseline-height="400"
|
||||
>
|
||||
<svg></svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
expect(
|
||||
applyMediaBackfillCandidates(root, [
|
||||
{ id: "media-1", scale: 0.9 },
|
||||
{ id: "media-2", scale: 0.88 },
|
||||
{ id: "media-3", scale: 0.9 }
|
||||
])
|
||||
).toBe(3);
|
||||
const title = root.querySelector<HTMLElement>("h2")!;
|
||||
const image = root.querySelector<HTMLImageElement>(
|
||||
"img.md-document-image"
|
||||
)!;
|
||||
const host = root.querySelector<HTMLElement>(".md-echarts-host")!;
|
||||
const svg = root.querySelector<SVGSVGElement>("svg")!;
|
||||
const mermaidSvg = root.querySelector<SVGSVGElement>(
|
||||
".mermaid svg"
|
||||
)!;
|
||||
expect(title.getAttribute("style")).toBeNull();
|
||||
expect(image.style.width).toBe("720px");
|
||||
expect(image.style.height).toBe("540px");
|
||||
expect(host.style.width).toBe("616px");
|
||||
expect(host.style.height).toBe("440px");
|
||||
expect(svg.style.width).toBe("616px");
|
||||
expect(svg.style.height).toBe("440px");
|
||||
expect(mermaidSvg.style.width).toBe("540px");
|
||||
expect(mermaidSvg.style.height).toBe("360px");
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
createMermaidSiteConfig,
|
||||
MERMAID_SECURE_CONFIG_KEYS
|
||||
} from "../src/mermaid-config";
|
||||
|
||||
describe("Mermaid 站点配置", () => {
|
||||
it("默认使用 default 主题和 classic 外观", () => {
|
||||
const config = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
expect(config.theme).toBe("default");
|
||||
expect(config.look).toBe("classic");
|
||||
expect(config.securityLevel).toBe("strict");
|
||||
expect(config.startOnLoad).toBe(false);
|
||||
expect(config.flowchart?.wrappingWidth).toBe(320);
|
||||
});
|
||||
|
||||
it("锁定安全限制和原始主题 CSS", () => {
|
||||
expect(MERMAID_SECURE_CONFIG_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
"secure",
|
||||
"securityLevel",
|
||||
"startOnLoad",
|
||||
"maxTextSize",
|
||||
"maxEdges",
|
||||
"suppressErrorRendering",
|
||||
"themeCSS"
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("每次创建独立的 secure 配置数组", () => {
|
||||
const first = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
const second = createMermaidSiteConfig(defaultExportConfig.mermaid);
|
||||
expect(first.secure).toEqual(second.secure);
|
||||
expect(first.secure).not.toBe(second.secure);
|
||||
});
|
||||
|
||||
it("应用导出配置中的布局、主题、外观和字体", () => {
|
||||
const config = createMermaidSiteConfig({
|
||||
layout: "elk",
|
||||
theme: "forest",
|
||||
look: "handDrawn",
|
||||
fontFamily: "Microsoft YaHei, sans-serif"
|
||||
});
|
||||
|
||||
expect(config).toMatchObject({
|
||||
layout: "elk",
|
||||
theme: "forest",
|
||||
look: "handDrawn",
|
||||
fontFamily: "Microsoft YaHei, sans-serif"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateMermaidPageFit,
|
||||
getPageContentDimensions,
|
||||
parseSvgViewBox
|
||||
} from "../src/mermaid-page-fit";
|
||||
|
||||
describe("Mermaid 单页高度适配", () => {
|
||||
it("解析 SVG viewBox 尺寸", () => {
|
||||
expect(parseSvgViewBox("0 0 1200 2400")).toEqual({
|
||||
width: 1200,
|
||||
height: 2400
|
||||
});
|
||||
expect(parseSvgViewBox("0,0,800,600")).toEqual({
|
||||
width: 800,
|
||||
height: 600
|
||||
});
|
||||
expect(parseSvgViewBox("0 0 0 600")).toBeUndefined();
|
||||
expect(parseSvgViewBox(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("使用纸张和页边距计算正文区域", () => {
|
||||
const dimensions = getPageContentDimensions(defaultExportConfig);
|
||||
|
||||
expect(dimensions.width).toBeCloseTo(672.7559, 3);
|
||||
expect(dimensions.height).toBeCloseTo(1001.5748, 3);
|
||||
});
|
||||
|
||||
it("宽度适配后未超高时保持普通 Mermaid 布局", () => {
|
||||
const fit = calculateMermaidPageFit(
|
||||
{ width: 1600, height: 900 },
|
||||
{ width: 672, height: 1000 }
|
||||
);
|
||||
|
||||
expect(fit).toEqual({
|
||||
width: 672,
|
||||
height: 378,
|
||||
scaled: false
|
||||
});
|
||||
});
|
||||
|
||||
it("宽度适配后超高时按页面高度等比例缩小", () => {
|
||||
const fit = calculateMermaidPageFit(
|
||||
{ width: 1000, height: 2000 },
|
||||
{ width: 672, height: 1000 }
|
||||
);
|
||||
|
||||
expect(fit.scaled).toBe(true);
|
||||
expect(fit.height).toBe(999);
|
||||
expect(fit.width).toBe(499.5);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderMermaidDefinitions } from "../src/mermaid-renderer";
|
||||
|
||||
describe("Mermaid 独立图表渲染", () => {
|
||||
it("单个图表失败后继续渲染后续图表", async () => {
|
||||
const renderDefinition = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("语法错误"))
|
||||
.mockResolvedValueOnce({ svg: "<svg>第二张图</svg>" });
|
||||
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
["invalid", "flowchart LR\nA --> B"],
|
||||
renderDefinition
|
||||
);
|
||||
|
||||
expect(renderDefinition).toHaveBeenCalledTimes(2);
|
||||
expect(outcomes[0]).toMatchObject({
|
||||
success: false,
|
||||
error: expect.any(Error)
|
||||
});
|
||||
expect(outcomes[1]).toEqual({
|
||||
success: true,
|
||||
svg: "<svg>第二张图</svg>"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSvgDataUrl,
|
||||
getLockedMermaidBoxSize,
|
||||
getMermaidImageAlt,
|
||||
resolveMermaidOutputMode
|
||||
} from "../src/mermaid-static-image";
|
||||
|
||||
describe("Mermaid 静态 SVG 图片", () => {
|
||||
it("生成无需外部请求的 UTF-8 Data URL", () => {
|
||||
const url = createSvgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>中文 & A</text></svg>'
|
||||
);
|
||||
|
||||
expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
expect(decodeURIComponent(url.split(",")[1] ?? "")).toContain(
|
||||
"<text>中文 & A</text>"
|
||||
);
|
||||
});
|
||||
|
||||
it("保留渲染后的精确小数尺寸", () => {
|
||||
expect(
|
||||
getLockedMermaidBoxSize({
|
||||
width: 742.375,
|
||||
height: 386.625
|
||||
})
|
||||
).toEqual({
|
||||
width: 742.375,
|
||||
height: 386.625
|
||||
});
|
||||
expect(
|
||||
getLockedMermaidBoxSize({
|
||||
width: 0,
|
||||
height: 386.625
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("优先使用无障碍标签并回退到 SVG 标题", () => {
|
||||
expect(getMermaidImageAlt(" 数据链路 ", "备用标题")).toBe(
|
||||
"数据链路"
|
||||
);
|
||||
expect(getMermaidImageAlt(null, " 数据关系图 ")).toBe(
|
||||
"数据关系图"
|
||||
);
|
||||
expect(getMermaidImageAlt(" ", null)).toBe("Mermaid 图表");
|
||||
});
|
||||
|
||||
it("默认使用静态 SVG 并允许内部切回内联模式", () => {
|
||||
expect(resolveMermaidOutputMode("")).toBe("svg-image");
|
||||
expect(resolveMermaidOutputMode("?target=pdf")).toBe("svg-image");
|
||||
expect(
|
||||
resolveMermaidOutputMode(
|
||||
"?target=pdf&mermaid-output=inline-svg"
|
||||
)
|
||||
).toBe("inline-svg");
|
||||
expect(
|
||||
resolveMermaidOutputMode("?mermaid-output=unknown")
|
||||
).toBe("svg-image");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBreakTokenElement } from "../src/paged-break-token";
|
||||
|
||||
describe("Paged.js 分页断点", () => {
|
||||
it("直接返回元素断点", () => {
|
||||
const element = {
|
||||
nodeType: 1,
|
||||
parentElement: null
|
||||
} as unknown as Element;
|
||||
|
||||
expect(resolveBreakTokenElement(element)).toBe(element);
|
||||
});
|
||||
|
||||
it("文本断点使用父元素继续查找", () => {
|
||||
const parentElement = {} as Element;
|
||||
const textNode = {
|
||||
nodeType: 3,
|
||||
parentElement
|
||||
} as unknown as Node;
|
||||
|
||||
expect(resolveBreakTokenElement(textNode)).toBe(parentElement);
|
||||
});
|
||||
|
||||
it("安全跳过空断点和无父元素节点", () => {
|
||||
const detachedTextNode = {
|
||||
nodeType: 3,
|
||||
parentElement: null
|
||||
} as unknown as Node;
|
||||
|
||||
expect(resolveBreakTokenElement(undefined)).toBeUndefined();
|
||||
expect(
|
||||
resolveBreakTokenElement(detachedTextNode)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
createPagedPreviewRenderRequest,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
isPagedPreviewFrameMessage,
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type PagedPreviewPayload
|
||||
} from "../src/paged-preview";
|
||||
|
||||
const payload: PagedPreviewPayload = {
|
||||
articleHtml: '<article id="write"><h1>分页测试</h1></article>',
|
||||
fileName: "分页测试.md",
|
||||
metadata: {
|
||||
title: "分页测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
themeCss: "#write { color: #333; }",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
|
||||
describe("分页预览协议", () => {
|
||||
it("生成真实纸张尺寸和页边距 CSS", () => {
|
||||
const css = buildPagedMediaCss(defaultExportConfig, payload);
|
||||
|
||||
expect(css).toContain("size: 210mm 297mm");
|
||||
expect(css).toContain("margin: 16mm 16mm");
|
||||
expect(css).toContain("@bottom-center");
|
||||
expect(css).not.toContain("counter-reset: page");
|
||||
expect(css).toContain("#write thead");
|
||||
expect(css).toContain("display: table-header-group");
|
||||
expect(css).toContain("break-inside: avoid");
|
||||
expect(css).toContain(".pagedjs_pages");
|
||||
expect(css).toContain("padding: 34px 0");
|
||||
expect(css).toContain('html[data-render-target="preview"]');
|
||||
expect(css).toContain("overflow-x: hidden");
|
||||
expect(css).toContain("overflow-y: hidden");
|
||||
expect(css).not.toContain("scrollbar-color");
|
||||
expect(css).toContain('html[data-render-target="pdf"]');
|
||||
expect(css).toContain("height: auto !important");
|
||||
expect(css).toContain("overflow: visible !important");
|
||||
expect(css).toContain("padding: 0");
|
||||
expect(css).toContain("break-after: page");
|
||||
expect(css).not.toContain("@media print");
|
||||
});
|
||||
|
||||
it("安全替换页眉变量并生成三栏页边距盒", () => {
|
||||
const css = buildPagedMediaCss(
|
||||
{
|
||||
...defaultExportConfig,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
enabled: true,
|
||||
showDivider: true,
|
||||
left: {
|
||||
enabled: true,
|
||||
content: '${title} — ${filename} "测试"'
|
||||
},
|
||||
center: {
|
||||
enabled: true,
|
||||
content: "${author}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
...payload,
|
||||
metadata: {
|
||||
...payload.metadata,
|
||||
author: "内网团队"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(css).toContain("@top-left");
|
||||
expect(css).toContain("@top-center");
|
||||
expect(css).toContain("@top-right");
|
||||
expect(css).toContain(
|
||||
'content: "分页测试 — 分页测试.md \\"测试\\""'
|
||||
);
|
||||
expect(css).toContain('content: "内网团队"');
|
||||
expect(css).toContain("border-bottom: 0.2mm solid currentColor");
|
||||
});
|
||||
|
||||
it("支持自定义页码模板和起始页码", () => {
|
||||
const css = buildPagedMediaCss(
|
||||
{
|
||||
...defaultExportConfig,
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
format: "custom",
|
||||
template: "第 ${page} / ${pages} 页",
|
||||
alignment: "right",
|
||||
startFrom: 5
|
||||
}
|
||||
},
|
||||
payload
|
||||
);
|
||||
|
||||
expect(css).toContain("@bottom-right");
|
||||
expect(
|
||||
formatPageNumber(
|
||||
{
|
||||
...defaultExportConfig.footer,
|
||||
format: "custom",
|
||||
template: "第 ${page} / ${pages} 页",
|
||||
startFrom: 5
|
||||
},
|
||||
2,
|
||||
8
|
||||
)
|
||||
).toBe("第 7 / 8 页");
|
||||
});
|
||||
|
||||
it("逐页递增内置页码格式但保持真实总页数", () => {
|
||||
expect(
|
||||
formatPageNumber(defaultExportConfig.footer, 0, 5)
|
||||
).toBe("1 / 5");
|
||||
expect(
|
||||
formatPageNumber(defaultExportConfig.footer, 1, 5)
|
||||
).toBe("2 / 5");
|
||||
expect(
|
||||
formatPageNumber(
|
||||
{
|
||||
...defaultExportConfig.footer,
|
||||
format: "chinese-page-total",
|
||||
startFrom: 5
|
||||
},
|
||||
3,
|
||||
5
|
||||
)
|
||||
).toBe("第 8 页 / 共 5 页");
|
||||
expect(
|
||||
formatPageNumber(
|
||||
{
|
||||
...defaultExportConfig.footer,
|
||||
format: "dash-page"
|
||||
},
|
||||
4,
|
||||
5
|
||||
)
|
||||
).toBe("- 5 -");
|
||||
});
|
||||
|
||||
it("在主题 CSS 之后强制分页画布保持透明", () => {
|
||||
expect(documentGeometryCss).toContain(
|
||||
"background: transparent !important"
|
||||
);
|
||||
});
|
||||
|
||||
it("生成并识别分页渲染请求", () => {
|
||||
const request = createPagedPreviewRenderRequest(7, payload);
|
||||
|
||||
expect(request).toEqual({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "render",
|
||||
requestId: 7,
|
||||
payload
|
||||
});
|
||||
expect(isPagedPreviewRenderRequest(request)).toBe(true);
|
||||
expect(
|
||||
isPagedPreviewRenderRequest({
|
||||
...request,
|
||||
requestId: "7"
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("只接受分页 iframe 协议消息", () => {
|
||||
expect(
|
||||
isPagedPreviewFrameMessage({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId: 3,
|
||||
pageCount: 2,
|
||||
contentHeight: 2400,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPagedPreviewFrameMessage({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId: 3,
|
||||
pageCount: 2,
|
||||
echartsErrors: [],
|
||||
mermaidErrors: []
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPagedPreviewFrameMessage({
|
||||
scope: "other",
|
||||
type: "ready"
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePagedRenderTarget } from "../src/paged-render-target";
|
||||
|
||||
describe("分页文档渲染目标", () => {
|
||||
it("默认使用网页预览目标", () => {
|
||||
expect(resolvePagedRenderTarget("")).toBe("preview");
|
||||
expect(resolvePagedRenderTarget("?target=unknown")).toBe("preview");
|
||||
});
|
||||
|
||||
it("识别 PDF 渲染目标", () => {
|
||||
expect(resolvePagedRenderTarget("?target=pdf")).toBe("pdf");
|
||||
expect(resolvePagedRenderTarget("?foo=1&target=pdf")).toBe("pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { encodeLocalDocumentLinkForPdf } from "@md-to-pdf/core";
|
||||
import {
|
||||
createPdfAnnotationLinkService,
|
||||
resolvePdfDestinationPage,
|
||||
resolvePdfNamedActionPage
|
||||
} from "../src/pdf-annotation-links";
|
||||
|
||||
function createDocument() {
|
||||
return {
|
||||
numPages: 5,
|
||||
getDestination: vi.fn(async (name: string) =>
|
||||
name === "chapter" ? [{ num: 7, gen: 0 }] : null
|
||||
),
|
||||
getPageIndex: vi.fn(async () => 2)
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 精确预览链接服务", () => {
|
||||
it("解析命名目标、显式引用和页索引目标", async () => {
|
||||
const pdfDocument = createDocument();
|
||||
|
||||
await expect(
|
||||
resolvePdfDestinationPage(pdfDocument, "chapter")
|
||||
).resolves.toBe(3);
|
||||
await expect(
|
||||
resolvePdfDestinationPage(pdfDocument, [1, { name: "Fit" }])
|
||||
).resolves.toBe(2);
|
||||
await expect(
|
||||
resolvePdfDestinationPage(pdfDocument, "missing")
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("将外部 URI 交给平台链接策略且阻止默认导航", () => {
|
||||
const onExternalLink = vi.fn();
|
||||
const service = createPdfAnnotationLinkService(
|
||||
createDocument(),
|
||||
2,
|
||||
{
|
||||
onExternalLink,
|
||||
onNavigateToPage: vi.fn()
|
||||
}
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
service.addLinkAttributes(
|
||||
link,
|
||||
"https://example.com/document"
|
||||
);
|
||||
document.body.append(link);
|
||||
|
||||
expect(link.dataset.pdfExternalHref).toBe(
|
||||
"https://example.com/document"
|
||||
);
|
||||
expect(link.rel).toBe("noopener noreferrer");
|
||||
expect(
|
||||
link.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
expect(onExternalLink).toHaveBeenCalledWith(
|
||||
"https://example.com/document"
|
||||
);
|
||||
});
|
||||
|
||||
it("在交给平台策略前还原 PDF 中编码的本地链接", () => {
|
||||
const onExternalLink = vi.fn();
|
||||
const service = createPdfAnnotationLinkService(
|
||||
createDocument(),
|
||||
2,
|
||||
{
|
||||
onExternalLink,
|
||||
onNavigateToPage: vi.fn()
|
||||
}
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
service.addLinkAttributes(
|
||||
link,
|
||||
encodeLocalDocumentLinkForPdf("../README.md") ?? ""
|
||||
);
|
||||
|
||||
expect(link.dataset.pdfExternalHref).toBe("../README.md");
|
||||
link.click();
|
||||
expect(onExternalLink).toHaveBeenCalledWith("../README.md");
|
||||
});
|
||||
|
||||
it("导航到 PDF 内部目标并忽略损坏目标", async () => {
|
||||
const onNavigateToPage = vi.fn();
|
||||
const service = createPdfAnnotationLinkService(
|
||||
createDocument(),
|
||||
2,
|
||||
{
|
||||
onExternalLink: vi.fn(),
|
||||
onNavigateToPage
|
||||
}
|
||||
);
|
||||
|
||||
await service.goToDestination("chapter");
|
||||
await service.goToDestination("missing");
|
||||
|
||||
expect(onNavigateToPage).toHaveBeenCalledTimes(1);
|
||||
expect(onNavigateToPage).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it("只处理受支持的 PDF 命名翻页动作", () => {
|
||||
expect(resolvePdfNamedActionPage("FirstPage", 3, 5)).toBe(1);
|
||||
expect(resolvePdfNamedActionPage("LastPage", 3, 5)).toBe(5);
|
||||
expect(resolvePdfNamedActionPage("NextPage", 5, 5)).toBe(5);
|
||||
expect(resolvePdfNamedActionPage("PrevPage", 1, 5)).toBe(1);
|
||||
expect(
|
||||
resolvePdfNamedActionPage("Print", 3, 5)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enablePrintMediaForPreview } from "../src/preview-styles";
|
||||
|
||||
describe("打印媒体预览", () => {
|
||||
it("将打印媒体规则转换为屏幕预览规则", () => {
|
||||
const css = `
|
||||
html { font-size: 16px; }
|
||||
@media print {
|
||||
html { font-size: 13px; }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(enablePrintMediaForPreview(css)).toContain("@media screen {");
|
||||
expect(enablePrintMediaForPreview(css)).toContain(
|
||||
"html { font-size: 13px; }"
|
||||
);
|
||||
});
|
||||
|
||||
it("支持 only print 和带条件的打印媒体规则", () => {
|
||||
const css = [
|
||||
"@media only print { body { color: black; } }",
|
||||
"@media print and (color) { body { background: white; } }"
|
||||
].join("\n");
|
||||
|
||||
expect(enablePrintMediaForPreview(css)).toBe(
|
||||
[
|
||||
"@media only screen { body { color: black; } }",
|
||||
"@media screen and (color) { body { background: white; } }"
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
it("不改变普通屏幕媒体规则", () => {
|
||||
const css = "@media screen and (min-width: 800px) { body { margin: 0; } }";
|
||||
expect(enablePrintMediaForPreview(css)).toBe(css);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user