Files
MorphDoc/packages/preview-engine/tests/docx-media-runtime.test.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

467 lines
12 KiB
TypeScript

// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
collectDocxDocumentLayoutPlan,
collectDocxInlineCodeLayouts,
collectDocxListItemLayouts,
collectDocxTextBlockLayouts,
collectDocxMediaCaptureTargets,
removeInheritedPagedSplitJustification,
renderDocxMediaCapturePlan
} from "../src/index.js";
describe("DOCX 媒体捕获计划", () => {
beforeEach(() => {
document.body.innerHTML = "";
window.scrollTo(0, 0);
});
it("连续布局使用原始打印媒体主题 CSS", async () => {
const renderContinuous = vi.fn(async () => ({
echartsErrors: [],
mermaidErrors: []
}));
const root = document.createElement("main");
root.innerHTML = '<article id="write"></article>';
await renderDocxMediaCapturePlan(
{ renderContinuous } as never,
root,
{
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: "@media print { html { font-size: 13px; } }",
exportConfig: {} as never
},
{ contentWidthPx: 640, contentHeightPx: 900 }
);
expect(renderContinuous).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ themeMedia: "print" })
);
});
it("只移除会把 Paged.js 末行两端对齐继承给子块的容器标记", () => {
document.body.innerHTML = `
<main id="pages">
<article id="write" data-align-last-split-element="justify">
<h1>标题</h1>
<p data-align-last-split-element="justify">跨页正文</p>
</article>
</main>
`;
expect(
removeInheritedPagedSplitJustification(
document.querySelector("#pages")!
)
).toBe(1);
expect(
document.querySelector("#write")?.hasAttribute(
"data-align-last-split-element"
)
).toBe(false);
expect(
document.querySelector("p")?.getAttribute(
"data-align-last-split-element"
)
).toBe("justify");
});
it("按文档顺序标记图片、Mermaid 和 ECharts", () => {
document.body.innerHTML = `
<article id="write">
<figure class="md-document-image-block">
<img class="md-document-image" alt="架构截图">
<figcaption>系统架构</figcaption>
</figure>
<div class="mermaid"><svg aria-label="处理流程"></svg></div>
<figure class="md-echarts">
<div class="md-echarts-host" aria-label="年度收入">
<svg></svg>
</div>
<figcaption>收入趋势</figcaption>
</figure>
</article>
`;
const media = Array.from(
document.querySelectorAll<HTMLElement | SVGSVGElement>(
"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: left,
y: 20 + index * 100,
left,
top: 20 + index * 100,
right: left + 300,
bottom: 170 + index * 100,
width: 300,
height: 150,
toJSON: () => ({})
}) as DOMRect;
});
const targets = collectDocxMediaCaptureTargets(document, {
contentWidthPx: 700,
contentHeightPx: 900
});
expect(
targets.map(
({ id, kind, kindOrdinal, altText, caption, alignment }) => ({
id,
kind,
kindOrdinal,
altText,
caption,
alignment
})
)
).toEqual([
{
id: "docx-media-1",
kind: "image",
kindOrdinal: 1,
altText: "架构截图",
caption: "系统架构",
alignment: "left"
},
{
id: "docx-media-2",
kind: "mermaid",
kindOrdinal: 1,
altText: "处理流程",
caption: undefined,
alignment: "center"
},
{
id: "docx-media-3",
kind: "echarts",
kindOrdinal: 1,
altText: "年度收入",
caption: "收入趋势",
alignment: "right"
}
]);
expect(
media.map((element) => element.dataset.docxMediaId)
).toEqual(["docx-media-1", "docx-media-2", "docx-media-3"]);
expect(targets[0]?.rasterScale).toBe(3.125);
});
it("限制超大媒体的显示尺寸和 PNG 像素规模", () => {
document.body.innerHTML = `
<article id="write">
<img class="md-document-image" alt="">
</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,
y: 0,
left: 0,
top: 0,
right: image.style.width ? 800 : 4000,
bottom: image.style.width ? 600 : 3000,
width: image.style.width ? 800 : 4000,
height: image.style.width ? 600 : 3000,
toJSON: () => ({})
}) as DOMRect;
const [target] = collectDocxMediaCaptureTargets(document, {
contentWidthPx: 800,
contentHeightPx: 900
});
expect(target?.displayWidthPx).toBeLessThanOrEqual(800);
expect(target?.displayHeightPx).toBeLessThanOrEqual(900);
expect(target?.alignment).toBe("center");
expect(
(target?.captureWidthPx ?? 0) *
(target?.rasterScale ?? 0)
).toBeLessThanOrEqual(4096);
expect(target?.altText).toBe("图片 1");
});
it("从真实 DOM 几何采集主题无关的表格列宽比例", () => {
document.body.innerHTML = `
<article id="write">
<table><tbody>
<tr><td>短列</td><td>较长内容列</td></tr>
<tr><td>1</td><td>2</td></tr>
</tbody></table>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
const table = document.querySelector<HTMLTableElement>("table")!;
const cells = Array.from(table.querySelectorAll<HTMLTableCellElement>("td"));
article.getBoundingClientRect = () => ({
left: 100,
right: 900,
top: 0,
bottom: 900,
width: 800,
height: 900
}) as DOMRect;
table.getBoundingClientRect = () => ({
left: 140,
right: 860,
top: 20,
bottom: 220,
width: 720,
height: 200
}) as DOMRect;
cells.forEach((cell, index) => {
const firstColumn = index % 2 === 0;
cell.getBoundingClientRect = () => ({
left: firstColumn ? 140 : 356,
right: firstColumn ? 356 : 860,
top: index < 2 ? 20 : 120,
bottom: index < 2 ? 120 : 220,
width: firstColumn ? 216 : 504,
height: 100
}) as DOMRect;
});
expect(
collectDocxDocumentLayoutPlan(document, {
contentWidthPx: 800,
contentHeightPx: 900
})
).toEqual({
inlineCodes: [],
listItems: [],
textBlocks: [],
tables: [
{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70],
rows: [
{
cells: [
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
},
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
}
]
},
{
cells: [
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
},
{
columnSpan: 1,
backgroundColor: "#ffffff",
color: "#000000",
bold: false,
italic: false,
alignment: "left"
}
]
}
]
}
]
});
});
it("按真实上下文采集行内代码字号、颜色和盒模型", () => {
document.body.innerHTML = `
<article id="write" style="font-size: 20px">
<p><code style="font-size: .8em; letter-spacing: 1px; color: rgb(17, 34, 51); background: rgb(245, 245, 245); padding: 2px 4px">inline</code></p>
<pre><code>block</code></pre>
</article>
`;
expect(
collectDocxInlineCodeLayouts(
document.querySelector<HTMLElement>("#write")!
)
).toEqual([
{
ordinal: 1,
text: "inline",
fontSizePt: 12,
letterSpacingPt: 0.75,
color: "#112233",
backgroundColor: "#f5f5f5",
paddingPt: {
top: 1.5,
right: 3,
bottom: 1.5,
left: 3
},
borderPt: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
}
]);
});
it("按列表项实际文字起点采集嵌套缩进且排除子列表文字", () => {
document.body.innerHTML = `
<article id="write">
<ul><li>一级 <code>代码</code><ul><li>二级项目</li></ul></li></ul>
</article>
`;
const article = document.querySelector<HTMLElement>("#write")!;
article.getBoundingClientRect = () => ({
left: 100,
right: 900,
top: 0,
bottom: 900,
width: 800,
height: 900
}) as DOMRect;
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
getBoundingClientRect?: () => DOMRect;
};
const original = rangePrototype.getBoundingClientRect;
rangePrototype.getBoundingClientRect = function (this: Range) {
const left = this.startContainer.textContent === "二级项目" ? 340 : 220;
return {
left,
right: left + 10,
top: 20,
bottom: 32,
width: 10,
height: 12
} as DOMRect;
};
try {
expect(collectDocxListItemLayouts(article)).toEqual([
{
ordinal: 1,
text: "一级 代码",
depth: 0,
textStartPt: 90
},
{
ordinal: 2,
text: "二级项目",
depth: 1,
textStartPt: 180
}
]);
} finally {
rangePrototype.getBoundingClientRect = original;
}
});
it("采集正文文本块在 Chromium 中的实际行断点", () => {
document.body.innerHTML = `
<article id="write">
<section data-semantic-region="cover"><p>独立封面</p></section>
<p>甲乙丙丁</p>
</article>
`;
const rangePrototype = Object.getPrototypeOf(document.createRange()) as {
getBoundingClientRect?: () => DOMRect;
};
const original = rangePrototype.getBoundingClientRect;
rangePrototype.getBoundingClientRect = function (this: Range) {
const top = this.startOffset < 2 ? 100 : 120;
return {
x: this.startOffset * 10,
y: top,
left: this.startOffset * 10,
right: this.startOffset * 10 + 10,
top,
bottom: top + 12,
width: 10,
height: 12,
toJSON: () => ({})
} as DOMRect;
};
try {
expect(
collectDocxTextBlockLayouts(
document.querySelector<HTMLElement>("#write")!
)
).toEqual([
{
ordinal: 1,
text: "甲乙丙丁",
letterSpacingPt: 0,
alignment: "left",
linePitchPt: 15,
lineBreakOffsets: [2]
}
]);
} finally {
rangePrototype.getBoundingClientRect = original;
}
});
});