新增通用 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 产物仍为未签名内部发行。
467 lines
12 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|