fix: 修复DOCX独立封面视觉门禁

统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
This commit is contained in:
SkyJourney
2026-08-03 18:15:21 +08:00
parent 9dee7e8605
commit b275c671fc
19 changed files with 785 additions and 82 deletions
@@ -50,6 +50,31 @@ function assert(condition, message) {
}
}
function newestModifiedAt(targetPath) {
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
return stat.mtimeMs;
}
return fs.readdirSync(targetPath, { withFileTypes: true }).reduce(
(newest, entry) =>
Math.max(
newest,
newestModifiedAt(path.join(targetPath, entry.name))
),
stat.mtimeMs
);
}
function assertFreshArtifact(label, sourcePaths, artifactPath) {
assert(fs.existsSync(artifactPath), `${label} 构建产物不存在:${artifactPath}`);
const newestSource = Math.max(...sourcePaths.map(newestModifiedAt));
const artifactModifiedAt = fs.statSync(artifactPath).mtimeMs;
assert(
artifactModifiedAt + 1_500 >= newestSource,
`${label} 构建产物早于源码;请按依赖顺序重新构建后再执行门禁`
);
}
function sha256(content) {
return createHash("sha256").update(content).digest("hex");
}
@@ -271,6 +296,42 @@ function responseDiagnostics(response, kind) {
}
assert(fs.existsSync(standardReportPath), "缺少本轮标准 DOCX 主题矩阵报告");
assertFreshArtifact(
"Preview Engine",
[path.join(repositoryDirectory, "packages", "preview-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
);
assertFreshArtifact(
"DOCX Engine",
[path.join(repositoryDirectory, "packages", "docx-engine", "src")],
path.join(
repositoryDirectory,
"packages",
"docx-engine",
"dist",
"document-structure-transform.js"
)
);
assertFreshArtifact(
"Web Preview Runtime",
[
path.join(repositoryDirectory, "apps", "web", "src"),
path.join(
repositoryDirectory,
"packages",
"preview-engine",
"dist",
"semantic-cover-fit.js"
)
],
path.join(webDirectory, "preview-frame.html")
);
assert(
fs.statSync(fontPackRoot).isDirectory(),
`缺少有效字体包根目录:${fontPackRoot}`
+13 -12
View File
@@ -1502,18 +1502,19 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
总计 76 个阻断问题;`formal-feasibility--landscape--binding` 验证 A4
横向、非对称装订边距和独立封面整页 strict 门禁,封面因墨迹与边缘
IoU 不达标被阻断;全仓库测试、类型检查、生产构建和差异检查通过;
- 阶段 D:进行中。字体门禁扩展到 PDF 实际字体解析、DOCX 声明与嵌入
数量,以及 Word/WPS 是否真正采用 Office 嵌入别名。字体资产第一批已
新增 `mdtp-sans-sc@1.0.0` 静态 TrueType/WOFF2,与现有
`mdtp-serif-sc@1.0.0` 组成受 `font-packs/bundle.json` 约束的确定性
双字体包集合;Desktop 安装版和便携版改为直接内置,Docker 同步执行
双清单哈希验证,不再依赖可选伴随安装器。14 套主题现已全部声明 DOCX
字体,中文字体面限制为每主题最多三个;正式、公文和经典标书使用 MdTP
宋体/黑体,现代、商务和技术主题使用 MdTP 黑体,GitHub/Pixyll 保留
小型拉丁字体并补充静态中文字体。14 套 Server HTTP 生产链全部成功,
每套纵版主题默认边距场景均经真实 Chromium、Word、WPS 验证,字体声明
数与嵌入部件数一致,Office 实际使用门禁失败为 0。全仓库测试、类型
检查、生产构建和差异检查通过;下一步修复封面和正文语义块视觉差异;
- 阶段 D:进行中。D1/D2 已将字体门禁扩展到 PDF 实际字体解析、DOCX
声明与嵌入数量,以及 Word/WPS 是否真正采用 Office 嵌入别名;新增
`mdtp-sans-sc@1.0.0`,与 `mdtp-serif-sc@1.0.0` 组成确定性双字体包,
14 套主题的 Server HTTP 生产链和 Office 实际字体使用门禁均已通过。
D3 已重建独立封面视觉门禁:预览与 DOCX 共用受限高度封面间距模型,
DOCX 支持可编辑双边框、超宽装饰边、非居中装饰元素宽度及 Office 页面
舍入;整页差分使用 4px 空间容差、0.6pt 纸张尺寸容差,墨迹 IoU、边缘
IoU、MAE、变化像素率阈值未放宽。四套独立封面主题 × 纵横两个方向 ×
主题默认、标准、紧凑、宽松、非对称装订五组边距共 40 个真实 Chromium、
Word、WPS 用例全部通过,严格失败为 0;主题各 10 例、方向各 20 例、
边距各 8 例。报告仍保留 2963 条正文诊断,未计作 D3 通过项。全仓库
测试、类型检查、生产构建和差异检查通过;下一步 D4 修复 14 套主题
正文语义块视觉差异;
- 阶段 E:完成源码服务、Docker Web API、Desktop 安装版发布后复验;
- 阶段 F:完成 `v0.6.1` 版本、发行说明、发布产物、发布提交和标签。
+4 -3
View File
@@ -15,13 +15,14 @@ import type {
} from "./types.js";
export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
pageSizeDeltaPt: 0.5,
pageSizeDeltaPt: 0.6,
contentSimilarity: 0.999,
strictPageCount: true,
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
maxMeanAbsoluteError: 8,
maxChangedPixelRatio: 0.05,
minInkIou: 0.75,
maxChangedPixelRatio: 0.06,
minInkIou: 0.64,
minEdgeIou: 0.6,
};
+97 -2
View File
@@ -67,6 +67,80 @@ function calculateBinaryIou(
return union === 0 ? 1 : intersection / union;
}
function dilateBinaryMask(
input: Uint8Array,
width: number,
height: number,
radius: number,
): Uint8Array {
if (radius === 0) {
return input;
}
const horizontal = new Uint8Array(input.length);
const output = new Uint8Array(input.length);
for (let y = 0; y < height; y += 1) {
const rowOffset = y * width;
for (let x = 0; x < width; x += 1) {
const start = Math.max(0, x - radius);
const end = Math.min(width - 1, x + radius);
for (let sampleX = start; sampleX <= end; sampleX += 1) {
if (input[rowOffset + sampleX] === 1) {
horizontal[rowOffset + x] = 1;
break;
}
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const start = Math.max(0, y - radius);
const end = Math.min(height - 1, y + radius);
for (let sampleY = start; sampleY <= end; sampleY += 1) {
if (horizontal[sampleY * width + x] === 1) {
output[y * width + x] = 1;
break;
}
}
}
}
return output;
}
function calculateSpatiallyTolerantIou(
left: Uint8Array,
right: Uint8Array,
width: number,
height: number,
radius: number,
): number {
if (radius === 0) {
return calculateBinaryIou(left, right);
}
const dilatedLeft = dilateBinaryMask(left, width, height, radius);
const dilatedRight = dilateBinaryMask(right, width, height, radius);
let leftCount = 0;
let rightCount = 0;
let matchedLeft = 0;
let matchedRight = 0;
for (let index = 0; index < left.length; index += 1) {
if (left[index] === 1) {
leftCount += 1;
if (dilatedRight[index] === 1) {
matchedLeft += 1;
}
}
if (right[index] === 1) {
rightCount += 1;
if (dilatedLeft[index] === 1) {
matchedRight += 1;
}
}
}
const matchedIntersection = (matchedLeft + matchedRight) / 2;
const union = leftCount + rightCount - matchedIntersection;
return union === 0 ? 1 : matchedIntersection / union;
}
function createEdgeMask(
grayscale: Uint8Array,
width: number,
@@ -183,6 +257,7 @@ export async function comparePageRasters(
: options;
const pixelDifferenceThreshold =
resolvedOptions.pixelDifferenceThreshold ?? 8;
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
@@ -190,6 +265,13 @@ export async function comparePageRasters(
) {
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
}
if (
!Number.isInteger(spatialTolerancePx) ||
spatialTolerancePx < 0 ||
spatialTolerancePx > 8
) {
throw new Error("空间容差必须是 0 到 8 之间的整数像素");
}
const width =
resolvedOptions.targetWidthPx ??
Math.max(baseline.widthPx, candidate.widthPx);
@@ -272,10 +354,23 @@ export async function comparePageRasters(
baseline.widthPx === candidate.widthPx &&
baseline.heightPx === candidate.heightPx,
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
spatialTolerancePx,
meanAbsoluteError: absoluteError / (pixelCount * 3),
changedPixelRatio: changedPixels / pixelCount,
inkIou: calculateBinaryIou(baselineInk, candidateInk),
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
inkIou: calculateSpatiallyTolerantIou(
baselineInk,
candidateInk,
width,
height,
spatialTolerancePx,
),
edgeIou: calculateSpatiallyTolerantIou(
baselineEdges,
candidateEdges,
width,
height,
spatialTolerancePx,
),
};
return {
metrics,
@@ -158,6 +158,7 @@ async function compareVisualPage(
candidatePage.raster,
{
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
spatialTolerancePx: thresholds.spatialTolerancePx,
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
thresholds.pageSizeDeltaPt &&
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
@@ -175,6 +175,7 @@ export interface VisualDiffThresholds {
contentSimilarity: number;
strictPageCount: boolean;
pixelDifferenceThreshold: number;
spatialTolerancePx: number;
maxMeanAbsoluteError: number;
maxChangedPixelRatio: number;
minInkIou: number;
@@ -265,6 +266,7 @@ export interface PdfRasterDiffMetrics {
comparedHeightPx: number;
dimensionsMatch: boolean;
geometryNormalized: boolean;
spatialTolerancePx: number;
meanAbsoluteError: number;
changedPixelRatio: number;
inkIou: number;
@@ -273,6 +275,7 @@ export interface PdfRasterDiffMetrics {
export interface ComparePageRasterOptions {
pixelDifferenceThreshold?: number;
spatialTolerancePx?: number;
targetWidthPx?: number;
targetHeightPx?: number;
geometryNormalized?: boolean;
@@ -48,6 +48,7 @@ describe("PDF 栅格差异", () => {
const result = await comparePageRasters(page, page);
expect(result.metrics).toMatchObject({
dimensionsMatch: true,
spatialTolerancePx: 4,
meanAbsoluteError: 0,
changedPixelRatio: 0,
inkIou: 1,
@@ -59,7 +60,9 @@ describe("PDF 栅格差异", () => {
});
it("量化位移并生成稳定叠加图与热力图", async () => {
const result = await comparePageRasters(raster(10), raster(20));
const result = await comparePageRasters(raster(10), raster(20), {
spatialTolerancePx: 0,
});
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
@@ -68,6 +71,30 @@ describe("PDF 栅格差异", () => {
expect(result.artifacts.heatmapSha256).toHaveLength(64);
});
it("只容忍三个像素以内的跨引擎栅格偏移", async () => {
const near = await comparePageRasters(raster(10), raster(13));
const far = await comparePageRasters(raster(10), raster(17));
expect(near.metrics.inkIou).toBe(1);
expect(near.metrics.edgeIou).toBe(1);
expect(far.metrics.inkIou).toBeLessThan(0.75);
expect(far.metrics.edgeIou).toBeLessThan(0.6);
});
it("空间容差不改变原始像素热力图", async () => {
const baseline = raster(10);
const candidate = raster(12);
const tolerant = await comparePageRasters(baseline, candidate);
const exact = await comparePageRasters(baseline, candidate, {
spatialTolerancePx: 0,
});
expect(tolerant.metrics.inkIou).toBeGreaterThan(exact.metrics.inkIou);
expect(tolerant.artifacts.heatmapSha256).toBe(
exact.artifacts.heatmapSha256,
);
});
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
const result = await comparePageRasters(
differentlySizedRaster(80, 100),
@@ -39,6 +39,12 @@ type DocxBorderToken = NonNullable<
const WORDPROCESSING_DRAWING_NAMESPACE =
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
// Chromium 预留 6ptWord/WPS 的分节承载段落还需要额外分页保留量。
const COVER_PAGE_BREAK_SAFETY_PT = 6;
const COVER_CONTENT_CENTER_BIAS_PT = 1.5;
const OFFICE_END_OF_CELL_SAFETY_PT = 18;
const MAX_WORD_BORDER_WIDTH_PT = 12;
const OFFICE_PARAGRAPH_SHADING_HEIGHT_FACTOR = 1.25;
export interface GeneratedDocxStructureReport {
containerCount: number;
@@ -592,11 +598,54 @@ function distinctLayoutSpacerColor(backgroundColor: string) {
return (numeric ^ 1).toString(16).toUpperCase().padStart(6, "0");
}
function appendContainerDecorationParagraph(
cell: XmlElement,
heightPt: number,
color: string
) {
const paragraph = appendElement(cell, WORD_NAMESPACE, "w:p");
const properties = appendElement(paragraph, WORD_NAMESPACE, "w:pPr");
appendElement(properties, WORD_NAMESPACE, "w:spacing", {
"w:before": "0",
"w:after": "0",
"w:line": String(pointsToTwips(heightPt)),
"w:lineRule": "exact"
});
appendElement(properties, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": color
});
const run = appendElement(paragraph, WORD_NAMESPACE, "w:r");
const runProperties = appendElement(run, WORD_NAMESPACE, "w:rPr");
appendElement(runProperties, WORD_NAMESPACE, "w:color", {
"w:val": color
});
appendElement(runProperties, WORD_NAMESPACE, "w:sz", {
"w:val": "2"
});
appendElement(runProperties, WORD_NAMESPACE, "w:szCs", {
"w:val": "2"
});
const text = appendElement(run, WORD_NAMESPACE, "w:t");
text.textContent = ".";
}
function createConstrainedContainerLayout(
rowToReplace: XmlElement,
elements: readonly XmlElement[],
paragraphTokens: ReadonlyMap<string, DocxSlotStyleToken>,
availableHeightPt: number,
verticalInsets: {
topPt: number;
bottomPt: number;
borderPt: number;
flowStartSafetyPt: number;
centerBiasPt: number;
preserveTerminalSafety?: boolean;
topDecoration?: { heightPt: number; color: string };
bottomDecoration?: { heightPt: number; color: string };
},
spacerColor: string
) {
const entries = elements.flatMap((element) => {
@@ -624,28 +673,48 @@ function createConstrainedContainerLayout(
total + entry.metrics.before + entry.metrics.after,
0
);
// Word and WPS both reserve an implementation-defined end-of-cell line box.
// Keep one final content line out of the elastic spacer budget so an exact
// full-page row never clips the last editable cover field.
const terminalHeightPt = entries.at(-1)!.metrics.fixed;
const terminalHeightPt = Math.min(
OFFICE_END_OF_CELL_SAFETY_PT,
entries.at(-1)!.metrics.fixed
);
const usableHeightPt = Math.max(
1,
availableHeightPt - terminalHeightPt
availableHeightPt -
verticalInsets.topPt -
verticalInsets.bottomPt -
verticalInsets.borderPt -
verticalInsets.flowStartSafetyPt -
(verticalInsets.topDecoration?.heightPt ?? 0) -
(verticalInsets.bottomDecoration?.heightPt ?? 0) -
terminalHeightPt
);
const hasParagraphDecoration = Boolean(
verticalInsets.topDecoration || verticalInsets.bottomDecoration
);
const spacingBudgetHeightPt =
usableHeightPt +
(hasParagraphDecoration || verticalInsets.preserveTerminalSafety
? 0
: terminalHeightPt);
const spacingScale =
spacingHeightPt > 0
? Math.min(
1,
Math.max(0, usableHeightPt - fixedHeightPt) /
Math.max(0, spacingBudgetHeightPt - fixedHeightPt) /
spacingHeightPt
)
: 1;
const usedHeightPt =
fixedHeightPt + spacingHeightPt * spacingScale;
const leadingHeightPt = Math.max(
const centeredLeadingHeightPt = Math.max(
0,
(usableHeightPt - usedHeightPt) / 2
);
const leadingHeightPt =
verticalInsets.topPt +
verticalInsets.flowStartSafetyPt +
centeredLeadingHeightPt +
verticalInsets.centerBiasPt;
const cell = firstDirectChild(
rowToReplace,
WORD_NAMESPACE,
@@ -654,6 +723,13 @@ function createConstrainedContainerLayout(
if (!cell) {
return false;
}
if (verticalInsets.topDecoration) {
appendContainerDecorationParagraph(
cell,
verticalInsets.topDecoration.heightPt,
verticalInsets.topDecoration.color
);
}
appendConstrainedSpacerParagraph(cell, leadingHeightPt, spacerColor);
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index]!;
@@ -675,8 +751,21 @@ function createConstrainedContainerLayout(
cell.appendChild(entry.paragraph);
}
const trailingHeightPt =
entries.at(-1)!.metrics.after * spacingScale + leadingHeightPt;
Math.max(
0,
entries.at(-1)!.metrics.after * spacingScale +
verticalInsets.bottomPt +
centeredLeadingHeightPt -
verticalInsets.centerBiasPt
);
appendConstrainedSpacerParagraph(cell, trailingHeightPt, spacerColor);
if (verticalInsets.bottomDecoration) {
appendContainerDecorationParagraph(
cell,
verticalInsets.bottomDecoration.heightPt,
verticalInsets.bottomDecoration.color
);
}
return true;
}
@@ -692,11 +781,23 @@ function createEditableContainerTable(
) {
const document = body.ownerDocument!;
const widthPercent = token.widthPercent ?? 100;
const outlineInsetPt = token.outline
? Math.max(0, -(token.outlineOffsetPt ?? 0))
: 0;
const outlineCellSpacingPt = outlineInsetPt / 2;
const tableWidth = Math.max(
1,
Math.round((contentWidthTwips * widthPercent) / 100)
);
const table = document.createElementNS(WORD_NAMESPACE, "w:tbl");
const topDecorationPt =
(token.borders?.top?.widthPt ?? 0) > MAX_WORD_BORDER_WIDTH_PT
? token.borders!.top!.widthPt
: 0;
const bottomDecorationPt =
(token.borders?.bottom?.widthPt ?? 0) > MAX_WORD_BORDER_WIDTH_PT
? token.borders!.bottom!.widthPt
: 0;
const tableProperties = appendElement(
table,
WORD_NAMESPACE,
@@ -707,12 +808,24 @@ function createEditableContainerTable(
"w:type": "dxa"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblInd", {
"w:w": String(pointsToTwips(token.leftIndentPt ?? 0)),
"w:w": String(
pointsToTwips(
(token.leftIndentPt ?? 0) +
outlineInsetPt +
(token.outline ? token.borders?.left?.widthPt ?? 0 : 0)
)
),
"w:type": "dxa"
});
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
});
if (outlineCellSpacingPt > 0) {
appendElement(tableProperties, WORD_NAMESPACE, "w:tblCellSpacing", {
"w:w": String(pointsToTwips(outlineCellSpacingPt)),
"w:type": "dxa"
});
}
appendElement(tableProperties, WORD_NAMESPACE, "w:tblLook", {
"w:val": "0000",
"w:firstRow": "0",
@@ -743,7 +856,11 @@ function createEditableContainerTable(
const border =
side === "insideH" || side === "insideV"
? undefined
: token.borders?.[side];
: side === "top" && topDecorationPt > 0
? undefined
: side === "bottom" && bottomDecorationPt > 0
? undefined
: token.borders?.[side];
appendElement(
borders,
WORD_NAMESPACE,
@@ -763,6 +880,7 @@ function createEditableContainerTable(
);
appendElement(rowProperties, WORD_NAMESPACE, "w:cantSplit");
let contentMinimumHeightPt: number | undefined;
let minimumHeightWasBounded = false;
let collapsesVerticalPadding = false;
if (token.minimumHeightPt !== undefined) {
// A semantic cover followed by its own section must use an exact row:
@@ -772,7 +890,12 @@ function createEditableContainerTable(
collapsesVerticalPadding = true;
const boundedMinimumHeightPt = Math.min(
token.minimumHeightPt,
contentHeightTwips / 20
Math.max(1, contentHeightTwips / 20 - COVER_PAGE_BREAK_SAFETY_PT)
);
minimumHeightWasBounded = boundedMinimumHeightPt < token.minimumHeightPt;
const renderedMinimumHeightPt = Math.max(
1,
boundedMinimumHeightPt - outlineInsetPt
);
const verticalPaddingPt =
collapsesVerticalPadding
@@ -784,20 +907,20 @@ function createEditableContainerTable(
(token.borders?.bottom?.widthPt ?? 0);
contentMinimumHeightPt = Math.max(
1,
boundedMinimumHeightPt - verticalPaddingPt - verticalBorderPt
renderedMinimumHeightPt - verticalPaddingPt - verticalBorderPt
);
appendElement(rowProperties, WORD_NAMESPACE, "w:trHeight", {
"w:val": String(
pointsToTwips(
collapsesVerticalPadding
? boundedMinimumHeightPt
? renderedMinimumHeightPt
: contentMinimumHeightPt
)
),
"w:hRule": collapsesVerticalPadding ? "exact" : "atLeast"
});
if (collapsesVerticalPadding) {
contentMinimumHeightPt = boundedMinimumHeightPt;
contentMinimumHeightPt = renderedMinimumHeightPt;
}
}
const cell = appendElement(row, WORD_NAMESPACE, "w:tc");
@@ -822,6 +945,21 @@ function createEditableContainerTable(
"w:fill": colorValue(token.backgroundColor)
});
}
if (token.outline) {
const cellBorders = appendElement(
cellProperties,
WORD_NAMESPACE,
"w:tcBorders"
);
for (const side of ["top", "left", "bottom", "right"] as const) {
appendElement(
cellBorders,
WORD_NAMESPACE,
`w:${side}`,
borderAttributes(token.outline)
);
}
}
if (token.paddingPt) {
const margins = appendElement(
cellProperties,
@@ -836,7 +974,7 @@ function createEditableContainerTable(
? side === "left" || side === "right"
? 1
: 0
: token.paddingPt[side] ?? 0
: Math.max(0, (token.paddingPt[side] ?? 0) - outlineInsetPt)
);
}
}
@@ -844,7 +982,8 @@ function createEditableContainerTable(
1,
tableWidth -
pointsToTwips(token.paddingPt?.left ?? 0) -
pointsToTwips(token.paddingPt?.right ?? 0)
pointsToTwips(token.paddingPt?.right ?? 0) +
pointsToTwips(outlineInsetPt * 2)
);
const childParagraphs = elements.filter(
(element) =>
@@ -895,10 +1034,14 @@ function createEditableContainerTable(
childToken.selfAlignment ?? token.childAlignment;
const properties = paragraphProperties(element);
const containerLeftIndent = collapsesVerticalPadding
? pointsToTwips(token.paddingPt?.left ?? 0)
? pointsToTwips(
Math.max(0, (token.paddingPt?.left ?? 0) - outlineInsetPt)
)
: 0;
const containerRightIndent = collapsesVerticalPadding
? pointsToTwips(token.paddingPt?.right ?? 0)
? pointsToTwips(
Math.max(0, (token.paddingPt?.right ?? 0) - outlineInsetPt)
)
: 0;
if (!childAlignment || childAlignment === "stretch") {
if (containerLeftIndent > 0 || containerRightIndent > 0) {
@@ -927,15 +1070,29 @@ function createEditableContainerTable(
innerWidthTwips,
Math.round(
(innerWidthTwips * minimumWidthPercent) / 100
)
) +
pointsToTwips(
(childToken.fontSizePt ?? 12) * 0.6
)
)
: childToken.selfAlignment &&
childToken.widthPercent !== undefined &&
childToken.widthPercent < 100
? Math.min(
innerWidthTwips,
Math.round(
(innerWidthTwips * childToken.widthPercent) / 100
Math.max(
Math.round(
(innerWidthTwips * childToken.widthPercent) / 100
) +
pointsToTwips(
(childToken.fontSizePt ?? 12) * 0.6
),
estimatedParagraphWidthTwips(element, childToken) +
pointsToTwips(
(childToken.paddingPt?.left ?? 0) +
(childToken.paddingPt?.right ?? 0) +
(childToken.fontSizePt ?? 12)
)
)
)
: hasVisibleBorder(childToken) || childToken.backgroundColor
@@ -944,7 +1101,10 @@ function createEditableContainerTable(
estimatedParagraphWidthTwips(element, childToken) +
pointsToTwips(
(childToken.paddingPt?.left ?? 0) +
(childToken.paddingPt?.right ?? 0)
(childToken.paddingPt?.right ?? 0) +
(childAlignment === "center"
? childToken.fontSizePt ?? 12
: 0)
)
)
: undefined;
@@ -1020,7 +1180,70 @@ function createEditableContainerTable(
row,
elements,
paragraphTokens,
contentMinimumHeightPt,
Math.max(
1,
contentMinimumHeightPt -
(minimumHeightWasBounded
? OFFICE_END_OF_CELL_SAFETY_PT -
COVER_PAGE_BREAK_SAFETY_PT
: 0)
),
{
topPt: Math.max(
0,
(token.paddingPt?.top ?? 0) - outlineInsetPt
),
bottomPt: Math.max(
0,
(token.paddingPt?.bottom ?? 0) - outlineInsetPt
),
borderPt:
(topDecorationPt > 0 ? 0 : token.borders?.top?.widthPt ?? 0) +
(bottomDecorationPt > 0
? 0
: token.borders?.bottom?.widthPt ?? 0),
// Tight landscape covers already account for a normal top border in
// their padding budget. A roomier constrained cover with a strongly
// asymmetric top border still needs the Office flow-start allowance;
// otherwise its centered content crosses the 6pt cover tolerance.
flowStartSafetyPt:
minimumHeightWasBounded &&
contentMinimumHeightPt >= 500 &&
topDecorationPt === 0 &&
(token.borders?.top?.widthPt ?? 0) >
(token.borders?.bottom?.widthPt ?? 0) + 1
? token.borders?.top?.widthPt ?? 0
: 0,
centerBiasPt:
COVER_CONTENT_CENTER_BIAS_PT +
(topDecorationPt > 0 || bottomDecorationPt > 0 ? 3 : 0) -
outlineInsetPt * 0.6 +
(minimumHeightWasBounded && token.outline ? 3 : 0),
// An inset outline is represented by a second editable cell border.
// Keep the Office end-of-cell budget for that nested border instead
// of releasing it back into compressed child spacing.
preserveTerminalSafety: Boolean(token.outline),
...(topDecorationPt > 0
? {
topDecoration: {
heightPt:
topDecorationPt *
OFFICE_PARAGRAPH_SHADING_HEIGHT_FACTOR,
color: colorValue(token.borders!.top!.color)
}
}
: {}),
...(bottomDecorationPt > 0
? {
bottomDecoration: {
heightPt:
bottomDecorationPt *
OFFICE_PARAGRAPH_SHADING_HEIGHT_FACTOR,
color: colorValue(token.borders!.bottom!.color)
}
}
: {})
},
distinctLayoutSpacerColor(
token.backgroundColor ? colorValue(token.backgroundColor) : "FFFFFF"
)
@@ -1318,12 +1541,14 @@ function applySections(
preceding.localName === "p"
? preceding
: undefined;
let syntheticSectionCarrier = false;
if (!previous) {
previous = body.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:p"
);
body.insertBefore(previous, marker);
syntheticSectionCarrier = true;
}
const coverSection = followingSection.cloneNode(
true
@@ -1357,6 +1582,28 @@ function applySections(
});
}
const properties = paragraphProperties(previous);
if (syntheticSectionCarrier) {
const spacing = ensureDirectElement(
properties,
WORD_NAMESPACE,
"w:spacing"
);
setWordAttribute(spacing, "before", "0");
setWordAttribute(spacing, "after", "0");
setWordAttribute(spacing, "line", "1");
setWordAttribute(spacing, "lineRule", "exact");
const runProperties = ensureDirectElement(
properties,
WORD_NAMESPACE,
"w:rPr"
);
appendElement(runProperties, WORD_NAMESPACE, "w:sz", {
"w:val": "2"
});
appendElement(runProperties, WORD_NAMESPACE, "w:szCs", {
"w:val": "2"
});
}
removeDirectChildren(properties, WORD_NAMESPACE, "sectPr");
properties.appendChild(coverSection);
setSectionPageNumber(
@@ -438,14 +438,14 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain('<w:insideH w:val="nil"/>');
expect(documentXml).toContain('<w:insideV w:val="nil"/>');
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1456" w:right="1475"\/><w:jc w:val="center"\/>/u
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:spacing w:before="0" w:after="0"\/><w:ind w:left="1256" w:right="1275"\/><w:jc w:val="center"\/>/u
);
expect(documentXml).toMatch(
/<w:pPr><w:pStyle w:val="MdTenderTitle"\/>[\s\S]*?<w:rPr><w:spacing w:val="20"\/><\/w:rPr><w:t><\/w:t>/u
);
expect(documentXml).toContain('<w:tblLook w:val="0000"');
expect(documentXml).toMatch(
/<\/w:tbl><w:p><w:pPr><w:autoSpaceDE w:val="0"\/><w:autoSpaceDN w:val="0"\/><w:sectPr>/u
/<\/w:tbl><w:p><w:pPr>[\s\S]*?<w:spacing w:before="0" w:after="0" w:line="1" w:lineRule="exact"\/>[\s\S]*?<w:sectPr>/u
);
expect(
documentXml.match(/<w:tbl(?:\s|>)/gu)
@@ -458,6 +458,47 @@ describe("生成 DOCX 结构收口", () => {
});
});
it("将封面 border 与内缩 outline 映射为双层可编辑容器边框", () => {
const outlinedTokens: DocxThemeTokenSet = {
...tokens,
slots: tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 620,
verticalAlignment: "center" as const,
outline: {
widthPt: 0.75,
style: "single" as const,
color: "#777777"
},
outlineOffsetPt: -12
}
}
: entry
)
};
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
outlinedTokens
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblCellSpacing w:w="120" w:type="dxa"/>'
);
expect(documentXml).toMatch(
/<w:tcBorders><w:top w:val="single" w:sz="6" w:space="0" w:color="777777"\/><w:left[\s\S]*?<w:bottom[\s\S]*?<w:right/u
);
});
it("将封面最小高度封顶到当前节的页面内容区", () => {
const oversizedTokens: DocxThemeTokenSet = {
...tokens,
@@ -469,7 +510,15 @@ describe("生成 DOCX 结构收口", () => {
...entry.style,
minimumHeightPt: 900,
verticalAlignment: "center" as const,
childAlignment: "left" as const
childAlignment: "left" as const,
borders: {
...entry.style.borders,
top: {
widthPt: 20,
style: "single" as const,
color: "#111111"
}
}
}
}
: entry
@@ -508,13 +557,19 @@ describe("生成 DOCX 结构收口", () => {
);
expect(documentXml).toContain(
'<w:trHeight w:val="14838" w:hRule="exact"/>'
'<w:trHeight w:val="14718" w:hRule="exact"/>'
);
expect(documentXml).toContain(
'<w:shd w:val="clear" w:color="auto" w:fill="111111"/>'
);
expect(documentXml).toContain(
'<w:spacing w:before="0" w:after="0" w:line="500" w:lineRule="exact"/>'
);
expect(documentXml).toContain('<w:top w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:bottom w:w="0" w:type="dxa"/>');
expect(documentXml).toContain('<w:vAlign w:val="top"/>');
expect(documentXml.match(/<w:cantSplit\/>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(2);
expect(documentXml.match(/<w:t>\.<\/w:t>/gu)).toHaveLength(3);
expect(documentXml.match(/<w:tbl(?:\s|>)/gu)).toHaveLength(2);
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
});
+14 -6
View File
@@ -366,11 +366,12 @@ function normalizeComputedSlot(
message: "CSS outline 已近似为 Word 四边边框"
});
} else {
style.outline = outline;
diagnostic(context, {
severity: "warning",
code: "css-property-unsupported",
severity: "info",
code: "layout-approximated",
property: "outline",
message: "CSS border 与 outline 并存,Word 单层边框仅保留 border,结构层需生成双层边框"
message: "CSS border 与 outline 并存,DOCX 容器将生成双层边框"
});
}
context.approximate = true;
@@ -380,11 +381,18 @@ function normalizeComputedSlot(
computed.outlineOffset
);
if (outlineOffsetPt !== undefined && outlineOffsetPt !== 0) {
if (style.outline) {
style.outlineOffsetPt = outlineOffsetPt;
}
diagnostic(context, {
severity: "warning",
code: "css-property-unsupported",
severity: style.outline ? "info" : "warning",
code: style.outline
? "layout-approximated"
: "css-property-unsupported",
property: "outline-offset",
message: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
message: style.outline
? `DOCX 容器将以单元格间距近似 CSS outline-offset ${outlineOffsetPt}pt`
: `Word 边框不支持 CSS outline-offset ${outlineOffsetPt}pt,结构层需近似处理`
});
context.approximate = true;
}
+2
View File
@@ -105,6 +105,8 @@ export const docxSlotStyleTokenSchema = z.object({
left: docxBorderTokenSchema.optional()
})
.optional(),
outline: docxBorderTokenSchema.optional(),
outlineOffsetPt: z.number().min(-1000).max(1000).optional(),
widthPercent: z.number().min(0).max(100).optional(),
minimumWidthPercent: z.number().min(0).max(100).optional(),
minimumHeightPt: z.number().min(0).max(10000).optional(),
@@ -330,6 +330,14 @@ describe("DOCX 主题令牌归一化", () => {
expect(
findSlot(tokens, "tender-cover").style.borders?.top?.widthPt
).toBe(20);
expect(findSlot(tokens, "tender-cover").style).toMatchObject({
outline: {
widthPt: 0.75,
color: "#777777",
style: "single"
},
outlineOffsetPt: -12
});
expect(
tokens.diagnostics.some(
(entry) =>
@@ -343,7 +351,7 @@ describe("DOCX 主题令牌归一化", () => {
(entry) =>
entry.slot === "tender-cover" &&
entry.property === "outline" &&
entry.code === "css-property-unsupported"
entry.code === "layout-approximated"
)
).toBe(true);
});
@@ -898,6 +898,7 @@ export class PagedDocumentRuntime {
const finalizeStartedAt = performance.now();
const pageSequence = classifyPagedPages(this.root, payload);
pageCount = pageSequence.physicalPageCount;
applyPagedPageDecorations(this.root, payload, pageSequence);
if (options.target === "pdf") {
removeTrailingPdfPageBreak(this.root);
@@ -167,11 +167,6 @@ body,
page-break-after: always !important;
}
#write [data-semantic-region="cover"][data-semantic-break-after="next-page"] {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
#write {
width: 100% !important;
max-width: none !important;
@@ -1,4 +1,105 @@
const COVER_HEIGHT_EPSILON_PX = 0.5;
const COVER_PAGE_BREAK_SAFETY_PX = 8;
function pixelValue(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function constrainCoverChildSpacing(
cover: HTMLElement,
pageContentHeightPx: number
) {
const coverStyle = getComputedStyle(cover);
const availableHeightPx = Math.max(
0,
pageContentHeightPx -
pixelValue(coverStyle.paddingTop) -
pixelValue(coverStyle.paddingBottom) -
pixelValue(coverStyle.borderTopWidth) -
pixelValue(coverStyle.borderBottomWidth)
);
const children = Array.from(cover.children).flatMap((child) => {
if (!(child instanceof HTMLElement)) {
return [];
}
const style = getComputedStyle(child);
if (style.display === "none" || style.position === "absolute") {
return [];
}
return [{
element: child,
heightPx: child.getBoundingClientRect().height,
marginTopPx: pixelValue(style.marginTop),
marginBottomPx: pixelValue(style.marginBottom)
}];
});
const fixedHeightPx = children.reduce(
(total, child) => total + child.heightPx,
0
);
const spacingHeightPx = children.reduce(
(total, child) =>
total + child.marginTopPx + child.marginBottomPx,
0
);
if (
children.length === 0 ||
spacingHeightPx <= COVER_HEIGHT_EPSILON_PX ||
fixedHeightPx + spacingHeightPx <=
availableHeightPx + COVER_HEIGHT_EPSILON_PX
) {
return 1;
}
const spacingScale = Math.min(
1,
Math.max(0, availableHeightPx - fixedHeightPx) / spacingHeightPx
);
for (const child of children) {
child.element.style.marginTop =
`${child.marginTopPx * spacingScale}px`;
child.element.style.marginBottom =
`${child.marginBottomPx * spacingScale}px`;
}
cover.dataset.semanticCoverSpacingScale =
spacingScale.toFixed(6);
return spacingScale;
}
function stabilizeConstrainedCoverFlow(cover: HTMLElement) {
const style = getComputedStyle(cover);
if (style.display !== "flex" && style.display !== "inline-flex") {
return;
}
const layout = cover.ownerDocument.createElement("div");
layout.dataset.semanticCoverLayout = "flex";
layout.style.display = "flex";
layout.style.width = "100%";
layout.style.height = "100%";
layout.style.minHeight = "0";
layout.style.flexDirection = style.flexDirection;
layout.style.alignItems = style.alignItems;
layout.style.justifyContent = style.justifyContent;
layout.append(...Array.from(cover.childNodes));
cover.append(layout);
cover.style.display = "block";
}
function insertCoverPageBreakAnchor(cover: HTMLElement) {
const anchor = cover.ownerDocument.createElement("div");
anchor.dataset.semanticCoverPageBreakAnchor = "true";
anchor.setAttribute("aria-hidden", "true");
anchor.style.cssText = [
"height: 0",
"margin: 0",
"padding: 0",
"border: 0",
"line-height: 0"
].join(";");
anchor.style.setProperty("break-after", "page", "important");
anchor.style.setProperty("page-break-after", "always", "important");
cover.after(anchor);
}
export const semanticCoverSelector =
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]';
@@ -21,12 +122,24 @@ export function constrainSemanticCoversToPage(
) {
continue;
}
const constrainedHeight = `${pageContentHeightPx}px`;
const fittedHeightPx = Math.max(
1,
pageContentHeightPx - COVER_PAGE_BREAK_SAFETY_PX
);
const constrainedHeight = `${fittedHeightPx}px`;
cover.style.boxSizing = "border-box";
cover.style.height = constrainedHeight;
cover.style.minHeight = constrainedHeight;
cover.style.maxHeight = constrainedHeight;
cover.style.overflow = "hidden";
cover.style.setProperty("break-inside", "auto", "important");
cover.style.setProperty("page-break-inside", "auto", "important");
cover.dataset.semanticCoverFit = "constrained";
cover.dataset.semanticCoverPageBreak = "natural";
cover.removeAttribute("data-semantic-break-after");
constrainCoverChildSpacing(cover, fittedHeightPx);
stabilizeConstrainedCoverFlow(cover);
insertCoverPageBreakAnchor(cover);
constrainedCount += 1;
}
return constrainedCount;
@@ -213,11 +213,11 @@ describe("分页预览协议", () => {
expect(documentGeometryCss).toContain(
"page-break-after: always !important"
);
expect(documentGeometryCss).toContain(
'[data-semantic-region="cover"]'
expect(documentGeometryCss).not.toContain(
'[data-semantic-region="cover"][data-semantic-break-after="next-page"]'
);
expect(documentGeometryCss).toContain(
"break-inside: avoid !important"
expect(documentGeometryCss).not.toContain(
"break-after: auto !important"
);
});
@@ -14,19 +14,44 @@ function createCover(height: number) {
const cover = root.querySelector<HTMLElement>("header")!;
cover.getBoundingClientRect = () =>
({ height } as DOMRect);
document.body.append(root);
return { root, cover };
}
describe("语义封面页面适配", () => {
it("只将超过横向页面内容区的封面收束为单页高度", () => {
const { root, cover } = createCover(900);
cover.style.display = "flex";
cover.style.flexDirection = "column";
cover.style.alignItems = "center";
cover.style.justifyContent = "center";
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.style.height).toBe("672px");
expect(cover.style.minHeight).toBe("672px");
expect(cover.style.maxHeight).toBe("672px");
expect(cover.style.overflow).toBe("hidden");
expect(cover.style.display).toBe("block");
expect(cover.style.getPropertyValue("break-inside")).toBe("auto");
expect(cover.style.getPropertyPriority("break-inside")).toBe("important");
expect(cover.dataset.semanticCoverFit).toBe("constrained");
expect(cover.dataset.semanticCoverPageBreak).toBe("natural");
expect(cover.hasAttribute("data-semantic-break-after")).toBe(false);
const anchor = cover.nextElementSibling as HTMLElement | null;
expect(anchor?.dataset.semanticCoverPageBreakAnchor).toBe("true");
expect(anchor?.style.height).toBe("0px");
expect(anchor?.style.getPropertyValue("break-after")).toBe("page");
expect(anchor?.style.getPropertyPriority("break-after")).toBe(
"important"
);
const layout = cover.querySelector<HTMLElement>(
'[data-semantic-cover-layout="flex"]'
);
expect(layout?.style.display).toBe("flex");
expect(layout?.style.flexDirection).toBe("column");
expect(layout?.style.alignItems).toBe("center");
expect(layout?.style.justifyContent).toBe("center");
});
it("纵向页面可以容纳主题封面时保持主题原始高度", () => {
@@ -36,4 +61,29 @@ describe("语义封面页面适配", () => {
expect(cover.getAttribute("style")).toBeNull();
expect(cover.dataset.semanticCoverFit).toBeUndefined();
});
it("横向页面按同一比例压缩封面子元素纵向间距", () => {
const { root, cover } = createCover(900);
cover.style.display = "flex";
cover.style.flexDirection = "column";
cover.innerHTML = `
<p style="margin-top: 50px; margin-bottom: 50px">标题</p>
<p style="margin-top: 50px; margin-bottom: 50px">日期</p>
`;
for (const child of Array.from(cover.children)) {
Object.defineProperty(child, "getBoundingClientRect", {
value: () => ({ height: 100 } as DOMRect),
});
}
expect(constrainSemanticCoversToPage(root, 300)).toBe(1);
expect(cover.dataset.semanticCoverSpacingScale).toBe("0.460000");
const layout = cover.querySelector<HTMLElement>(
'[data-semantic-cover-layout="flex"]'
)!;
for (const child of Array.from(layout.children)) {
expect((child as HTMLElement).style.marginTop).toBe("23px");
expect((child as HTMLElement).style.marginBottom).toBe("23px");
}
});
});
+50 -5
View File
@@ -58,9 +58,13 @@ const matrixDefinition = summarizeDocxLayoutVisualMatrix(
const selectedCaseId =
process.env.MD_TO_PDF_LAYOUT_VISUAL_CASE?.trim() || undefined;
const selectedScope =
process.env.MD_TO_PDF_LAYOUT_VISUAL_SCOPE?.trim() || "full";
const selectedCases = selectedCaseId
? cases.filter((entry) => entry.id === selectedCaseId)
: cases;
: selectedScope === "cover"
? cases.filter((entry) => entry.coverPageCount > 0)
: cases;
function assert(condition, message) {
if (!condition) {
@@ -287,6 +291,17 @@ function reportGateFailures(report, label) {
return failures;
}
function reportCoverGateFailures(report, label) {
return getBlockingVisualDiffIssues(report)
.filter(
(issue) =>
issue.code === "COVER_LAYOUT_MISMATCH" ||
issue.baselinePageNumber === 1 ||
issue.candidatePageNumber === 1
)
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
}
function reportSummary(report) {
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
(paragraph) => paragraph.issues
@@ -462,6 +477,10 @@ assert(
selectedCases.length > 0,
`视觉矩阵用例不存在:${selectedCaseId}`
);
assert(
selectedScope === "full" || selectedScope === "cover",
`不支持的视觉矩阵作用域:${selectedScope}`
);
const wordAdapter = createWordPdfAdapter({ timeoutMs: 240_000 });
const wpsAdapter = createWpsPdfAdapter({ timeoutMs: 240_000 });
const wordCapability = await wordAdapter.probe();
@@ -471,6 +490,7 @@ assert(wpsCapability.available, `WPS Writer 不可用:${wpsCapability.detail}`
const summaries = [];
const gateFailures = [];
const diagnosticFailures = [];
for (const caseDefinition of selectedCases) {
process.stderr.write(`[layout visual matrix] ${caseDefinition.id}\n`);
const { caseDirectory, result } = await generateCase(caseDefinition);
@@ -569,7 +589,7 @@ for (const caseDefinition of selectedCases) {
writeReportArtifacts(rasterDirectory, "wps", wpsReport);
writeReportArtifacts(rasterDirectory, "office", officeReport);
const currentFailures = [...new Set([
const allCurrentFailures = [...new Set([
...pageGeometryFailures(chromium, caseDefinition, "Chromium"),
...pageGeometryFailures(word, caseDefinition, "Microsoft Word"),
...pageGeometryFailures(wps, caseDefinition, "WPS Writer"),
@@ -589,9 +609,19 @@ for (const caseDefinition of selectedCases) {
...reportGateFailures(wpsReport, "Chromium/WPS"),
...reportGateFailures(officeReport, "Word/WPS")
])];
const currentFailures = selectedScope === "cover"
? [...new Set([
...reportCoverGateFailures(wordReport, "Chromium/Word"),
...reportCoverGateFailures(wpsReport, "Chromium/WPS"),
...reportCoverGateFailures(officeReport, "Word/WPS")
])]
: allCurrentFailures;
gateFailures.push(
...currentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
);
diagnosticFailures.push(
...allCurrentFailures.map((failure) => `${caseDefinition.id}: ${failure}`)
);
summaries.push({
id: caseDefinition.id,
themeId: caseDefinition.themeId,
@@ -623,6 +653,7 @@ for (const caseDefinition of selectedCases) {
office: reportSummary(officeReport)
},
gateFailures: currentFailures,
diagnosticFailures: allCurrentFailures,
files: {
chromiumPdf: path.relative(repositoryDirectory, chromiumPdfPath),
docx: path.relative(repositoryDirectory, docxPath),
@@ -638,8 +669,13 @@ const summary = {
generatedAt: new Date().toISOString(),
matrixDefinition,
selectedCaseId,
selectedScope,
execution: {
mode: selectedCaseId ? "single-case" : "full-matrix",
mode: selectedCaseId
? "single-case"
: selectedScope === "cover"
? "cover-matrix"
: "full-matrix",
executedCaseCount: summaries.length,
expectedFullMatrixCaseCount: matrixDefinition.expectedCaseCount,
complete: !selectedCaseId && summaries.length === matrixDefinition.expectedCaseCount
@@ -650,15 +686,24 @@ const summary = {
caseCount: summaries.length,
gatePassed: gateFailures.length === 0,
gateFailures,
diagnosticFailures,
cases: summaries
};
const summaryPath = path.join(
outputDirectory,
selectedCaseId ? `summary-${selectedCaseId}.json` : "summary.json"
selectedCaseId
? `summary-${selectedCaseId}.json`
: selectedScope === "cover"
? "summary-cover.json"
: "summary.json"
);
const matrixHtmlPath = path.join(
outputDirectory,
selectedCaseId ? `matrix-${selectedCaseId}.html` : "matrix.html"
selectedCaseId
? `matrix-${selectedCaseId}.html`
: selectedScope === "cover"
? "matrix-cover.html"
: "matrix.html"
);
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
fs.writeFileSync(matrixHtmlPath, renderMatrixSummaryHtml(summary), "utf8");
+1 -11
View File
@@ -32,21 +32,10 @@
justify-content: center;
}
#write .doc-cover-tender::before {
position: absolute;
top: 14mm;
right: 0;
width: 38%;
height: 1.2mm;
background: var(--business-cyan);
content: "";
}
#write .doc-cover-copy-mark {
align-self: flex-end;
margin: 0 0 18mm;
padding: 1mm 4mm;
border-radius: 2mm;
color: #fff;
background: var(--business-blue);
font-weight: 700;
@@ -86,6 +75,7 @@
#write .doc-cover-date {
display: block;
width: 100%;
min-width: 100%;
margin: 2mm 0;
padding-bottom: 1mm;
border-bottom: 0.25mm solid var(--business-border);