建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
418 lines
11 KiB
TypeScript
418 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
DOCX_STYLE_SLOT_NAMES,
|
|
millimetersToPoints,
|
|
parseCssBorder,
|
|
parseCssColor,
|
|
parseCssFontFamilies,
|
|
parseCssLengthToPt,
|
|
resolveDocxThemeTokens,
|
|
type DocxComputedStyle,
|
|
type DocxStyleSlotName,
|
|
type DocxThemeStyleSnapshot
|
|
} from "../src/index.js";
|
|
|
|
const computed: DocxComputedStyle = {
|
|
fontFamily:
|
|
'"Noto Serif CJK SC", "Microsoft YaHei", serif',
|
|
fontSize: "16px",
|
|
fontWeight: "400",
|
|
fontStyle: "normal",
|
|
color: "rgb(17, 34, 51)",
|
|
backgroundColor: "rgba(0, 0, 0, 0)",
|
|
lineHeight: "24px",
|
|
letterSpacing: "normal",
|
|
textAlign: "start",
|
|
direction: "ltr",
|
|
textIndent: "32px",
|
|
textDecorationLine: "none",
|
|
marginTop: "8px",
|
|
marginRight: "0px",
|
|
marginBottom: "12px",
|
|
marginLeft: "0px",
|
|
paddingTop: "4px",
|
|
paddingRight: "8px",
|
|
paddingBottom: "4px",
|
|
paddingLeft: "8px",
|
|
borderTop: "0px none rgb(17, 34, 51)",
|
|
borderRight: "0px none rgb(17, 34, 51)",
|
|
borderBottom: "1px solid rgb(221, 221, 221)",
|
|
borderLeft: "0px none rgb(17, 34, 51)",
|
|
width: "640px",
|
|
maxWidth: "none",
|
|
minWidth: "0px",
|
|
minHeight: "0px",
|
|
height: "24px",
|
|
breakBefore: "auto",
|
|
breakAfter: "avoid-page",
|
|
breakInside: "avoid",
|
|
display: "block",
|
|
flexDirection: "row",
|
|
alignItems: "normal",
|
|
justifyContent: "normal",
|
|
outline: "0px none rgb(17, 34, 51)",
|
|
outlineOffset: "0px"
|
|
};
|
|
|
|
function createSnapshot(
|
|
changes: Partial<
|
|
Record<DocxStyleSlotName, Partial<DocxComputedStyle> | null>
|
|
> = {}
|
|
): DocxThemeStyleSnapshot {
|
|
return {
|
|
schemaVersion: 1,
|
|
themeId: "external-clean",
|
|
themeFingerprint: "e".repeat(64),
|
|
viewport: {
|
|
widthPx: 794,
|
|
heightPx: 1123,
|
|
deviceScaleFactor: 1
|
|
},
|
|
rootFontSizePx: 16,
|
|
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => {
|
|
const change = changes[slot];
|
|
if (change === null) {
|
|
return { slot, matched: false };
|
|
}
|
|
return {
|
|
slot,
|
|
matched: true,
|
|
computed: {
|
|
...computed,
|
|
...change
|
|
}
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
function findSlot(
|
|
tokens: ReturnType<typeof resolveDocxThemeTokens>,
|
|
name: DocxStyleSlotName
|
|
) {
|
|
const slot = tokens.slots.find((entry) => entry.slot === name);
|
|
if (!slot) {
|
|
throw new Error(`缺少令牌槽位 ${name}`);
|
|
}
|
|
return slot;
|
|
}
|
|
|
|
describe("CSS 到 Word 基础值归一化", () => {
|
|
it("换算浏览器长度、颜色、字体列表和边框", () => {
|
|
expect(parseCssLengthToPt("16px")).toBe(12);
|
|
expect(parseCssLengthToPt("25.4mm")).toBe(72);
|
|
expect(millimetersToPoints(2)).toBeCloseTo(5.669, 3);
|
|
expect(parseCssColor("rgb(17, 34, 51)")).toBe("#112233");
|
|
expect(parseCssColor("rgba(0, 0, 0, 0)")).toBeUndefined();
|
|
expect(parseCssColor("rgba(255, 0, 0, 0.5)")).toBe("#ff8080");
|
|
expect(parseCssColor("#00000080")).toBe("#7f7f7f");
|
|
expect(
|
|
parseCssFontFamilies(
|
|
'"Source Han Serif SC", "Microsoft YaHei", serif'
|
|
)
|
|
).toEqual([
|
|
"Source Han Serif SC",
|
|
"Microsoft YaHei",
|
|
"serif"
|
|
]);
|
|
expect(parseCssBorder("1px dashed rgb(17, 34, 51)"))
|
|
.toEqual({
|
|
widthPt: 0.75,
|
|
color: "#112233",
|
|
style: "dashed",
|
|
approximated: false
|
|
});
|
|
expect(parseCssBorder("rgb(119, 119, 119) solid 1px"))
|
|
.toMatchObject({
|
|
widthPt: 0.75,
|
|
color: "#777777",
|
|
style: "single"
|
|
});
|
|
expect(parseCssBorder("0px none rgba(0, 0, 0, 0)"))
|
|
.toMatchObject({
|
|
widthPt: 0,
|
|
style: "none"
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("DOCX 主题令牌归一化", () => {
|
|
it("按槽位语义映射文本、段落、边框和分页", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot(),
|
|
config: {
|
|
mode: "auto",
|
|
basePreset: "general",
|
|
overrides: {},
|
|
legacyPreset: false
|
|
}
|
|
});
|
|
const paragraph = findSlot(tokens, "paragraph");
|
|
expect(paragraph.source).toBe("computed-css");
|
|
expect(paragraph.confidence).toBe("exact");
|
|
expect(paragraph.style).toMatchObject({
|
|
fontCandidates: [
|
|
"Noto Serif CJK SC",
|
|
"Microsoft YaHei",
|
|
"serif"
|
|
],
|
|
fontSizePt: 12,
|
|
color: "#112233",
|
|
lineSpacing: 1.5,
|
|
alignment: "left",
|
|
firstLineIndentPt: 24,
|
|
spacingBeforePt: 6,
|
|
spacingAfterPt: 9,
|
|
keepLines: true,
|
|
keepWithNext: true,
|
|
borders: {
|
|
bottom: {
|
|
widthPt: 0.75,
|
|
color: "#dddddd",
|
|
style: "single"
|
|
}
|
|
}
|
|
});
|
|
expect(findSlot(tokens, "strong").style)
|
|
.not.toHaveProperty("firstLineIndentPt");
|
|
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
|
|
expect(tokens.slots).toHaveLength(DOCX_STYLE_SLOT_NAMES.length);
|
|
});
|
|
|
|
it("按 border-box 文档的内容区宽度归一化全宽表格", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot({
|
|
document: {
|
|
width: "790px",
|
|
paddingLeft: "30px",
|
|
paddingRight: "30px",
|
|
borderLeft: "0px none rgb(17, 34, 51)",
|
|
borderRight: "0px none rgb(17, 34, 51)"
|
|
},
|
|
table: {
|
|
width: "730px"
|
|
}
|
|
}),
|
|
config: {
|
|
mode: "auto",
|
|
basePreset: "general",
|
|
overrides: {},
|
|
legacyPreset: false
|
|
}
|
|
});
|
|
|
|
expect(findSlot(tokens, "table").style.widthPercent).toBe(100);
|
|
});
|
|
|
|
it("按父级内容区归一化右置百分比结构容器", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot({
|
|
"official-signature": {
|
|
width: "332.8px",
|
|
containingBlockWidth: "640px",
|
|
marginLeft: "307.2px",
|
|
marginRight: "0px",
|
|
textAlign: "center"
|
|
}
|
|
}),
|
|
config: {
|
|
mode: "auto",
|
|
basePreset: "official",
|
|
overrides: {},
|
|
legacyPreset: false
|
|
}
|
|
});
|
|
|
|
expect(findSlot(tokens, "official-signature").style).toMatchObject({
|
|
widthPercent: 52,
|
|
leftIndentPt: 230.4,
|
|
rightIndentPt: 0,
|
|
alignment: "center"
|
|
});
|
|
});
|
|
|
|
it("保留封面高度、垂直对齐、轮廓线和隐藏字段语义", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot({
|
|
"tender-cover": {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minHeight: "220mm",
|
|
borderBottom: "0px none rgb(17, 34, 51)",
|
|
outline: "1px solid rgb(119, 119, 119)",
|
|
outlineOffset: "-16px"
|
|
},
|
|
"tender-bidder": {
|
|
display: "none",
|
|
alignSelf: "flex-end",
|
|
contentPaddingLeft: "8px"
|
|
}
|
|
}),
|
|
config: {
|
|
mode: "auto",
|
|
basePreset: "tender",
|
|
overrides: {},
|
|
legacyPreset: false
|
|
}
|
|
});
|
|
const cover = findSlot(tokens, "tender-cover");
|
|
expect(cover.confidence).toBe("approximate");
|
|
expect(cover.style).toMatchObject({
|
|
minimumHeightPt: 623.622,
|
|
verticalAlignment: "center",
|
|
childAlignment: "center",
|
|
borders: {
|
|
top: {
|
|
widthPt: 0.75,
|
|
color: "#777777",
|
|
style: "single"
|
|
}
|
|
}
|
|
});
|
|
expect(findSlot(tokens, "tender-bidder").style).toMatchObject({
|
|
hidden: true,
|
|
selfAlignment: "right",
|
|
contentPaddingLeftPt: 6
|
|
});
|
|
expect(
|
|
tokens.diagnostics.some(
|
|
(entry) =>
|
|
entry.slot === "tender-cover" &&
|
|
entry.code === "css-property-unsupported" &&
|
|
entry.property === "outline-offset"
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("保留结构槽位的实际宽度以映射 Flex 子项位置", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot({
|
|
document: {
|
|
width: "640px",
|
|
paddingLeft: "8px",
|
|
paddingRight: "8px"
|
|
},
|
|
"project-report-owner": {
|
|
minWidth: "70%"
|
|
}
|
|
}),
|
|
config: {
|
|
mode: "auto",
|
|
basePreset: "formal",
|
|
overrides: {},
|
|
legacyPreset: false
|
|
}
|
|
});
|
|
|
|
expect(
|
|
findSlot(tokens, "project-report-owner").style.minimumWidthPercent
|
|
).toBe(70);
|
|
});
|
|
|
|
it("将超出 Word 能力的装饰边收敛并生成诊断", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot({
|
|
"tender-cover": {
|
|
borderTop: "40px solid rgb(18, 85, 138)",
|
|
outline: "1px solid rgb(119, 119, 119)",
|
|
outlineOffset: "-16px"
|
|
}
|
|
}),
|
|
config: {
|
|
mode: "auto",
|
|
basePreset: "tender",
|
|
overrides: {},
|
|
legacyPreset: false
|
|
}
|
|
});
|
|
expect(
|
|
findSlot(tokens, "tender-cover").style.borders?.top?.widthPt
|
|
).toBe(20);
|
|
expect(
|
|
tokens.diagnostics.some(
|
|
(entry) =>
|
|
entry.slot === "tender-cover" &&
|
|
entry.property === "border-top" &&
|
|
entry.code === "layout-approximated"
|
|
)
|
|
).toBe(true);
|
|
expect(
|
|
tokens.diagnostics.some(
|
|
(entry) =>
|
|
entry.slot === "tender-cover" &&
|
|
entry.property === "outline" &&
|
|
entry.code === "css-property-unsupported"
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("以清单覆盖自动值并在未命中时显式降级", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot({ caption: null }),
|
|
config: {
|
|
mode: "auto-with-overrides",
|
|
basePreset: "formal",
|
|
legacyPreset: true,
|
|
overrides: {
|
|
body: {
|
|
sizePt: 11,
|
|
firstLineIndentChars: 2
|
|
},
|
|
hyperlink: {
|
|
color: "#aa0000",
|
|
underline: false
|
|
}
|
|
}
|
|
}
|
|
});
|
|
expect(findSlot(tokens, "paragraph")).toMatchObject({
|
|
source: "manifest-override",
|
|
confidence: "exact",
|
|
style: {
|
|
fontSizePt: 11,
|
|
firstLineIndentPt: 22
|
|
}
|
|
});
|
|
expect(findSlot(tokens, "hyperlink").style).toMatchObject({
|
|
color: "#aa0000",
|
|
underline: false
|
|
});
|
|
expect(findSlot(tokens, "caption")).toMatchObject({
|
|
source: "preset-fallback",
|
|
confidence: "fallback",
|
|
style: { fontCandidates: [] }
|
|
});
|
|
expect(
|
|
tokens.diagnostics.some(
|
|
(entry) =>
|
|
entry.slot === "caption" &&
|
|
entry.code === "slot-not-found"
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("显式模式不读取 CSS,仅使用覆盖与预设降级", () => {
|
|
const tokens = resolveDocxThemeTokens({
|
|
snapshot: createSnapshot(),
|
|
config: {
|
|
mode: "explicit",
|
|
basePreset: "technical",
|
|
legacyPreset: false,
|
|
overrides: {
|
|
headings: {
|
|
sizesPt: [24, 20, 18, 16, 14, 12]
|
|
}
|
|
}
|
|
}
|
|
});
|
|
expect(findSlot(tokens, "paragraph").source)
|
|
.toBe("preset-fallback");
|
|
expect(findSlot(tokens, "heading-3")).toMatchObject({
|
|
source: "manifest-override",
|
|
style: { fontSizePt: 18 }
|
|
});
|
|
});
|
|
});
|