feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -30,17 +30,32 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
"img, svg"
|
||||
)
|
||||
);
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
article.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 700,
|
||||
bottom: 900,
|
||||
width: 700,
|
||||
height: 900,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect;
|
||||
const horizontalPositions = [0, 200, 400];
|
||||
media.forEach((element, index) => {
|
||||
const left = horizontalPositions[index]!;
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 10,
|
||||
x: left,
|
||||
y: 20 + index * 100,
|
||||
left: 10,
|
||||
left,
|
||||
top: 20 + index * 100,
|
||||
right: 650,
|
||||
bottom: 380 + index * 100,
|
||||
width: 640,
|
||||
height: 360,
|
||||
right: left + 300,
|
||||
bottom: 170 + index * 100,
|
||||
width: 300,
|
||||
height: 150,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect;
|
||||
});
|
||||
@@ -52,12 +67,13 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
|
||||
expect(
|
||||
targets.map(
|
||||
({ id, kind, kindOrdinal, altText, caption }) => ({
|
||||
({ id, kind, kindOrdinal, altText, caption, alignment }) => ({
|
||||
id,
|
||||
kind,
|
||||
kindOrdinal,
|
||||
altText,
|
||||
caption
|
||||
caption,
|
||||
alignment
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
@@ -66,21 +82,24 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
kind: "image",
|
||||
kindOrdinal: 1,
|
||||
altText: "架构截图",
|
||||
caption: "系统架构"
|
||||
caption: "系统架构",
|
||||
alignment: "left"
|
||||
},
|
||||
{
|
||||
id: "docx-media-2",
|
||||
kind: "mermaid",
|
||||
kindOrdinal: 1,
|
||||
altText: "处理流程",
|
||||
caption: undefined
|
||||
caption: undefined,
|
||||
alignment: "center"
|
||||
},
|
||||
{
|
||||
id: "docx-media-3",
|
||||
kind: "echarts",
|
||||
kindOrdinal: 1,
|
||||
altText: "年度收入",
|
||||
caption: "收入趋势"
|
||||
caption: "收入趋势",
|
||||
alignment: "right"
|
||||
}
|
||||
]);
|
||||
expect(
|
||||
@@ -96,6 +115,19 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
</article>
|
||||
`;
|
||||
const image = document.querySelector("img")!;
|
||||
const article = document.querySelector<HTMLElement>("#write")!;
|
||||
article.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 900,
|
||||
width: 800,
|
||||
height: 900,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect;
|
||||
image.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
@@ -115,6 +147,8 @@ describe("DOCX 媒体捕获计划", () => {
|
||||
});
|
||||
|
||||
expect(target?.displayWidthPx).toBeLessThanOrEqual(800);
|
||||
expect(target?.displayHeightPx).toBeLessThanOrEqual(900);
|
||||
expect(target?.alignment).toBe("center");
|
||||
expect(
|
||||
(target?.captureWidthPx ?? 0) *
|
||||
(target?.rasterScale ?? 0)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPagedPageDecorations } from "../src/paged-page-decorations.js";
|
||||
import { createPagedPageSequence } from "../src/paged-page-sequence.js";
|
||||
import type { PagedPreviewPayload } from "../src/paged-preview.js";
|
||||
|
||||
function createPage(content: string) {
|
||||
return `
|
||||
<div class="pagedjs_page">
|
||||
${content}
|
||||
<div class="pagedjs_margin-top-left"><div class="pagedjs_margin-content">左页眉</div></div>
|
||||
<div class="pagedjs_margin-top-center"><div class="pagedjs_margin-content">中页眉</div></div>
|
||||
<div class="pagedjs_margin-top-right"><div class="pagedjs_margin-content">右页眉</div></div>
|
||||
<div class="pagedjs_margin-bottom-left"><div class="pagedjs_margin-content"></div></div>
|
||||
<div class="pagedjs_margin-bottom-center"><div class="pagedjs_margin-content"></div></div>
|
||||
<div class="pagedjs_margin-bottom-right"><div class="pagedjs_margin-content"></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function createPayload(): PagedPreviewPayload {
|
||||
return {
|
||||
articleHtml: '<article id="write"></article>',
|
||||
fileName: "封面测试.md",
|
||||
metadata: {
|
||||
title: "封面测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
},
|
||||
features: [],
|
||||
themeCss: "",
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
enabled: true,
|
||||
showOnFirstPage: true
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "outer",
|
||||
showOnFirstPage: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("分页页面装饰", () => {
|
||||
it("隐藏封面装饰并按正文页序计算首页和外侧页码", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = [
|
||||
createPage("封面"),
|
||||
createPage("正文首页"),
|
||||
createPage("正文第二页")
|
||||
].join("");
|
||||
const payload = createPayload();
|
||||
const sequence = createPagedPageSequence(3, new Set([0]), 1);
|
||||
|
||||
applyPagedPageDecorations(root, payload, sequence);
|
||||
|
||||
const pages = root.querySelectorAll<HTMLElement>(".pagedjs_page");
|
||||
expect(pages[0]?.dataset.headerVisible).toBe("false");
|
||||
expect(pages[0]?.dataset.footerVisible).toBe("false");
|
||||
expect(
|
||||
pages[0]?.querySelector<HTMLElement>(".pagedjs_margin-top-left")
|
||||
?.style.visibility
|
||||
).toBe("hidden");
|
||||
expect(pages[1]?.dataset.headerVisible).toBe("true");
|
||||
expect(pages[1]?.dataset.footerVisible).toBe("false");
|
||||
expect(pages[2]?.dataset.footerVisible).toBe("true");
|
||||
const pageNumber = pages[2]?.querySelector<HTMLElement>(
|
||||
".pagedjs_margin-bottom-left .pagedjs_margin-content"
|
||||
);
|
||||
expect(pageNumber?.textContent).toBe("2 / 2");
|
||||
expect(pageNumber?.dataset.pageNumberAlignment).toBe("left");
|
||||
});
|
||||
|
||||
it("允许正文首页独立隐藏页眉但保留页码", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = createPage("正文首页");
|
||||
const payload = createPayload();
|
||||
payload.exportConfig = {
|
||||
...payload.exportConfig,
|
||||
header: {
|
||||
...payload.exportConfig.header,
|
||||
showOnFirstPage: false
|
||||
},
|
||||
footer: {
|
||||
...payload.exportConfig.footer,
|
||||
alignment: "center",
|
||||
showOnFirstPage: true,
|
||||
startFrom: 5
|
||||
}
|
||||
};
|
||||
|
||||
applyPagedPageDecorations(
|
||||
root,
|
||||
payload,
|
||||
createPagedPageSequence(1, new Set(), 5)
|
||||
);
|
||||
|
||||
const page = root.querySelector<HTMLElement>(".pagedjs_page");
|
||||
expect(page?.dataset.headerVisible).toBe("false");
|
||||
expect(page?.dataset.footerVisible).toBe("true");
|
||||
expect(
|
||||
page?.querySelector<HTMLElement>(
|
||||
".pagedjs_margin-bottom-center .pagedjs_margin-content"
|
||||
)?.textContent
|
||||
).toBe("5 / 1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyPagedPages,
|
||||
createPagedPageSequence
|
||||
} from "../src/paged-page-sequence.js";
|
||||
|
||||
function semanticDocument(withCover: boolean) {
|
||||
return {
|
||||
schemaVersion: 1 as const,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress" as const,
|
||||
firstBodyHeading: "keep" as const
|
||||
},
|
||||
regions: withCover
|
||||
? [
|
||||
{
|
||||
kind: "cover" as const,
|
||||
nodes: [
|
||||
{
|
||||
kind: "text" as const,
|
||||
role: "project-report-title" as const,
|
||||
text: "封面"
|
||||
}
|
||||
],
|
||||
section: {
|
||||
headerFooter: "none" as const,
|
||||
pageNumber: "hidden" as const,
|
||||
breakAfter: "next-page" as const,
|
||||
followingPageNumberStart: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("分页页面状态", () => {
|
||||
it("将封面排除在正文页序之外", () => {
|
||||
expect(createPagedPageSequence(4, new Set([0]), 1)).toEqual({
|
||||
physicalPageCount: 4,
|
||||
bodyPageCount: 3,
|
||||
bodyPageNumberStart: 1,
|
||||
pages: [
|
||||
{ physicalPageIndex: 0, kind: "cover" },
|
||||
{
|
||||
physicalPageIndex: 1,
|
||||
kind: "body-first",
|
||||
bodyPageIndex: 0,
|
||||
pageNumber: 1
|
||||
},
|
||||
{
|
||||
physicalPageIndex: 2,
|
||||
kind: "body-rest",
|
||||
bodyPageIndex: 1,
|
||||
pageNumber: 2
|
||||
},
|
||||
{
|
||||
physicalPageIndex: 3,
|
||||
kind: "body-rest",
|
||||
bodyPageIndex: 2,
|
||||
pageNumber: 3
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("无封面时将物理首页识别为正文首页", () => {
|
||||
const sequence = createPagedPageSequence(2, new Set(), 5);
|
||||
|
||||
expect(sequence.bodyPageCount).toBe(2);
|
||||
expect(sequence.pages[0]).toMatchObject({
|
||||
kind: "body-first",
|
||||
bodyPageIndex: 0,
|
||||
pageNumber: 5
|
||||
});
|
||||
});
|
||||
|
||||
it("根据语义封面和分页 DOM 标记页面", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page"><div data-semantic-region="cover">封面</div></div>
|
||||
<div class="pagedjs_page"><p>正文一</p></div>
|
||||
<div class="pagedjs_page"><p>正文二</p></div>
|
||||
`;
|
||||
|
||||
const sequence = classifyPagedPages(root, {
|
||||
semanticDocument: semanticDocument(true),
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
footer: { ...defaultExportConfig.footer, startFrom: 8 }
|
||||
}
|
||||
});
|
||||
|
||||
const pages = root.querySelectorAll<HTMLElement>(".pagedjs_page");
|
||||
expect(sequence.bodyPageCount).toBe(2);
|
||||
expect(pages[0]?.dataset.pageKind).toBe("cover");
|
||||
expect(pages[0]?.dataset.pageNumber).toBeUndefined();
|
||||
expect(pages[1]?.dataset.pageKind).toBe("body-first");
|
||||
expect(pages[1]?.dataset.pageNumber).toBe("1");
|
||||
expect(pages[2]?.dataset.pageNumber).toBe("2");
|
||||
});
|
||||
|
||||
it("没有封面语义时忽略孤立的封面 DOM 标记", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<div class="pagedjs_page"><div data-semantic-region="cover">普通内容</div></div>
|
||||
`;
|
||||
|
||||
const sequence = classifyPagedPages(root, {
|
||||
semanticDocument: semanticDocument(false),
|
||||
exportConfig: defaultExportConfig
|
||||
});
|
||||
|
||||
expect(sequence.pages[0]).toMatchObject({
|
||||
kind: "body-first",
|
||||
pageNumber: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,14 @@ const payload: PagedPreviewPayload = {
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
semanticDocument: {
|
||||
schemaVersion: 1,
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: []
|
||||
},
|
||||
features: [],
|
||||
themeCss: "#write { color: #333; }",
|
||||
exportConfig: defaultExportConfig
|
||||
@@ -94,6 +102,9 @@ describe("分页预览协议", () => {
|
||||
);
|
||||
expect(css).toContain('content: "内网团队"');
|
||||
expect(css).toContain("border-bottom: 0.2mm solid currentColor");
|
||||
expect(css).toContain(
|
||||
"transform: translateY(calc(16mm - 8mm - 3mm))"
|
||||
);
|
||||
});
|
||||
|
||||
it("支持自定义页码模板和起始页码", () => {
|
||||
@@ -192,6 +203,40 @@ describe("分页预览协议", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("由语义文档强制封面结束后换页", () => {
|
||||
expect(documentGeometryCss).toContain(
|
||||
'#write [data-semantic-break-after="next-page"]'
|
||||
);
|
||||
expect(documentGeometryCss).toContain(
|
||||
"break-after: page !important"
|
||||
);
|
||||
expect(documentGeometryCss).toContain(
|
||||
"page-break-after: always !important"
|
||||
);
|
||||
expect(documentGeometryCss).toContain(
|
||||
'[data-semantic-region="cover"]'
|
||||
);
|
||||
expect(documentGeometryCss).toContain(
|
||||
"break-inside: avoid !important"
|
||||
);
|
||||
});
|
||||
|
||||
it("使用标准 Letter 横向尺寸生成分页规则", () => {
|
||||
const css = buildPagedMediaCss(
|
||||
{
|
||||
...defaultExportConfig,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
format: "Letter",
|
||||
orientation: "landscape"
|
||||
}
|
||||
},
|
||||
payload
|
||||
);
|
||||
|
||||
expect(css).toContain("size: 279.4mm 215.9mm;");
|
||||
});
|
||||
|
||||
it("避免 Typora 围栏容器重复应用行内代码盒模型", () => {
|
||||
expect(documentBaseCss).toContain(
|
||||
"#write pre.md-fences > code"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { constrainSemanticCoversToPage } from "../src/semantic-cover-fit.js";
|
||||
|
||||
function createCover(height: number) {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `
|
||||
<header
|
||||
data-semantic-region="cover"
|
||||
data-semantic-break-after="next-page"
|
||||
>封面</header>
|
||||
`;
|
||||
const cover = root.querySelector<HTMLElement>("header")!;
|
||||
cover.getBoundingClientRect = () =>
|
||||
({ height } as DOMRect);
|
||||
return { root, cover };
|
||||
}
|
||||
|
||||
describe("语义封面页面适配", () => {
|
||||
it("只将超过横向页面内容区的封面收束为单页高度", () => {
|
||||
const { root, cover } = createCover(900);
|
||||
|
||||
expect(constrainSemanticCoversToPage(root, 680)).toBe(1);
|
||||
expect(cover.style.boxSizing).toBe("border-box");
|
||||
expect(cover.style.height).toBe("680px");
|
||||
expect(cover.style.minHeight).toBe("680px");
|
||||
expect(cover.style.maxHeight).toBe("680px");
|
||||
expect(cover.dataset.semanticCoverFit).toBe("constrained");
|
||||
});
|
||||
|
||||
it("纵向页面可以容纳主题封面时保持主题原始高度", () => {
|
||||
const { root, cover } = createCover(680);
|
||||
|
||||
expect(constrainSemanticCoversToPage(root, 900)).toBe(0);
|
||||
expect(cover.getAttribute("style")).toBeNull();
|
||||
expect(cover.dataset.semanticCoverFit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user