From b275c671fcc661fe56932b552e6b30c33bdcd038 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Mon, 3 Aug 2026 18:15:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DDOCX=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=B0=81=E9=9D=A2=E8=A7=86=E8=A7=89=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。 按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。 --- apps/server/scripts/verify-docx-r4-matrix.mjs | 61 ++++ docs/PROGRESS.md | 25 +- packages/document-visual-diff/src/compare.ts | 7 +- packages/document-visual-diff/src/raster.ts | 99 +++++- packages/document-visual-diff/src/report.ts | 1 + packages/document-visual-diff/src/types.ts | 3 + .../document-visual-diff/tests/raster.test.ts | 29 +- .../src/document-structure-transform.ts | 293 ++++++++++++++++-- .../document-structure-transform.test.ts | 65 +++- packages/docx-theme-engine/src/normalizer.ts | 20 +- packages/docx-theme-engine/src/tokens.ts | 2 + .../tests/normalizer.test.ts | 10 +- .../src/paged-document-runtime.ts | 1 + packages/preview-engine/src/paged-preview.ts | 5 - .../preview-engine/src/semantic-cover-fit.ts | 115 ++++++- .../tests/paged-preview.test.ts | 8 +- .../tests/semantic-cover-fit.test.ts | 56 +++- scripts/verify-docx-layout-visual-matrix.mjs | 55 +++- themes/tender-business-blue/theme.css | 12 +- 19 files changed, 785 insertions(+), 82 deletions(-) diff --git a/apps/server/scripts/verify-docx-r4-matrix.mjs b/apps/server/scripts/verify-docx-r4-matrix.mjs index 0a45832..6491835 100644 --- a/apps/server/scripts/verify-docx-r4-matrix.mjs +++ b/apps/server/scripts/verify-docx-r4-matrix.mjs @@ -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}` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 796e909..11dde58 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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` 版本、发行说明、发布产物、发布提交和标签。 diff --git a/packages/document-visual-diff/src/compare.ts b/packages/document-visual-diff/src/compare.ts index 4af0780..fe008b4 100644 --- a/packages/document-visual-diff/src/compare.ts +++ b/packages/document-visual-diff/src/compare.ts @@ -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, }; diff --git a/packages/document-visual-diff/src/raster.ts b/packages/document-visual-diff/src/raster.ts index 8844a7f..932a634 100644 --- a/packages/document-visual-diff/src/raster.ts +++ b/packages/document-visual-diff/src/raster.ts @@ -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, diff --git a/packages/document-visual-diff/src/report.ts b/packages/document-visual-diff/src/report.ts index e68bab7..d95852c 100644 --- a/packages/document-visual-diff/src/report.ts +++ b/packages/document-visual-diff/src/report.ts @@ -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) <= diff --git a/packages/document-visual-diff/src/types.ts b/packages/document-visual-diff/src/types.ts index 56bc45e..46a6c9e 100644 --- a/packages/document-visual-diff/src/types.ts +++ b/packages/document-visual-diff/src/types.ts @@ -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; diff --git a/packages/document-visual-diff/tests/raster.test.ts b/packages/document-visual-diff/tests/raster.test.ts index b203b1e..78b6158 100644 --- a/packages/document-visual-diff/tests/raster.test.ts +++ b/packages/document-visual-diff/tests/raster.test.ts @@ -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), diff --git a/packages/docx-engine/src/document-structure-transform.ts b/packages/docx-engine/src/document-structure-transform.ts index c55053f..40edf0d 100644 --- a/packages/docx-engine/src/document-structure-transform.ts +++ b/packages/docx-engine/src/document-structure-transform.ts @@ -39,6 +39,12 @@ type DocxBorderToken = NonNullable< const WORDPROCESSING_DRAWING_NAMESPACE = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"; +// Chromium 预留 6pt;Word/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, 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( diff --git a/packages/docx-engine/tests/document-structure-transform.test.ts b/packages/docx-engine/tests/document-structure-transform.test.ts index 32a3246..2eaab79 100644 --- a/packages/docx-engine/tests/document-structure-transform.test.ts +++ b/packages/docx-engine/tests/document-structure-transform.test.ts @@ -438,14 +438,14 @@ describe("生成 DOCX 结构收口", () => { expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toMatch( - //u + //u ); expect(documentXml).toMatch( /[\s\S]*?<\/w:rPr>可编辑封面<\/w:t>/u ); expect(documentXml).toContain('/u + /<\/w:tbl>[\s\S]*?[\s\S]*?/u ); expect( documentXml.match(/)/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( + '' + ); + expect(documentXml).toMatch( + / { 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( - '' + '' + ); + expect(documentXml).toContain( + '' + ); + expect(documentXml).toContain( + '' ); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml).toContain(''); expect(documentXml.match(//gu)).toHaveLength(3); - expect(documentXml.match(/\.<\/w:t>/gu)).toHaveLength(2); + expect(documentXml.match(/\.<\/w:t>/gu)).toHaveLength(3); expect(documentXml.match(/)/gu)).toHaveLength(2); expect(documentXml.match(/ { 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); }); diff --git a/packages/preview-engine/src/paged-document-runtime.ts b/packages/preview-engine/src/paged-document-runtime.ts index 5aeb077..8d26316 100644 --- a/packages/preview-engine/src/paged-document-runtime.ts +++ b/packages/preview-engine/src/paged-document-runtime.ts @@ -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); diff --git a/packages/preview-engine/src/paged-preview.ts b/packages/preview-engine/src/paged-preview.ts index d022802..38460a5 100644 --- a/packages/preview-engine/src/paged-preview.ts +++ b/packages/preview-engine/src/paged-preview.ts @@ -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; diff --git a/packages/preview-engine/src/semantic-cover-fit.ts b/packages/preview-engine/src/semantic-cover-fit.ts index 8a93e4d..f9e6776 100644 --- a/packages/preview-engine/src/semantic-cover-fit.ts +++ b/packages/preview-engine/src/semantic-cover-fit.ts @@ -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; diff --git a/packages/preview-engine/tests/paged-preview.test.ts b/packages/preview-engine/tests/paged-preview.test.ts index 095503d..a43c2c7 100644 --- a/packages/preview-engine/tests/paged-preview.test.ts +++ b/packages/preview-engine/tests/paged-preview.test.ts @@ -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" ); }); diff --git a/packages/preview-engine/tests/semantic-cover-fit.test.ts b/packages/preview-engine/tests/semantic-cover-fit.test.ts index 794e6b1..7055b72 100644 --- a/packages/preview-engine/tests/semantic-cover-fit.test.ts +++ b/packages/preview-engine/tests/semantic-cover-fit.test.ts @@ -14,19 +14,44 @@ function createCover(height: number) { const cover = root.querySelector("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( + '[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 = ` +

标题

+

日期

+ `; + 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( + '[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"); + } + }); }); diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs index c502d09..e0ea24d 100644 --- a/scripts/verify-docx-layout-visual-matrix.mjs +++ b/scripts/verify-docx-layout-visual-matrix.mjs @@ -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"); diff --git a/themes/tender-business-blue/theme.css b/themes/tender-business-blue/theme.css index 9b8d117..90386e7 100644 --- a/themes/tender-business-blue/theme.css +++ b/themes/tender-business-blue/theme.css @@ -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);