release: 发布 v0.6.1 DOCX 视觉一致性修复

新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

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

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

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

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
This commit is contained in:
SkyJourney
2026-08-04 10:30:44 +08:00
parent b275c671fc
commit 2c5c1bd317
84 changed files with 4404 additions and 344 deletions
@@ -33,7 +33,7 @@ function generatedFixture() {
encoder.encode(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}"><w:body>` +
`<w:p><w:r><w:t>CONTAINER_START</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>发文字号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialIssueRow"/></w:pPr><w:r><w:t>号</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>发布单位</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>签发人</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>日期</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>CONTAINER_END</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>SECTION_BREAK</w:t></w:r></w:p>` +
@@ -42,7 +42,7 @@ function generatedFixture() {
`<wp:inline xmlns:wp="${wordprocessingDrawing}"><wp:extent cx="100" cy="200"/><wp:docPr id="1" name="Picture" descr="Mermaid 图表 1" title="mdtp-media:docx-media-1"/>` +
`<a:graphic xmlns:a="${drawing}"><a:graphicData uri="${picture}"><pic:pic xmlns:pic="${picture}"><pic:nvPicPr><pic:cNvPr id="0" name="image.png"/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdImage"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm rot="60000" flipH="1"><a:off x="0" y="0"/><a:ext cx="100" cy="200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic>` +
`</wp:inline></w:drawing></w:r></w:p>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>D</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:trPr><w:tblHeader/></w:trPr><w:tc><w:tcPr/><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>C</w:t></w:r></w:p></w:tc><w:tc><w:tcPr/><w:p><w:r><w:t>单元格前</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>mdtp_ic_c</w:t></w:r><w:r><w:t>单元格后</w:t></w:r></w:p></w:tc></w:tr></w:tbl>` +
`<w:p><w:pPr><w:pStyle w:val="MdOfficialSignatureDate"/></w:pPr><w:r><w:t>2026年7月29日</w:t></w:r></w:p>` +
`<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1000" w:right="1000" w:bottom="1000" w:left="1000"/><w:pgNumType w:start="5"/><w:titlePg/></w:sectPr>` +
`</w:body></w:document>`
@@ -78,7 +78,7 @@ const plan: PandocStructurePlan = {
kind: "paragraph",
styleId: "MdOfficialIssueRow",
layout: "space-between",
segments: ["发文字号", "签发人"],
segments: ["号", "发布单位", "签发人", "日期"],
separator: "tab"
}
]
@@ -161,11 +161,28 @@ const tokens: DocxThemeTokenSet = {
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
fontCandidates: ["Body Face"],
fontSizePt: 10,
lineSpacing: 1.8
}
},
{
slot: "inline-code",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: ["Code Face"],
fontSizePt: 9,
color: "#112233",
backgroundColor: "#f5f5f5",
paddingPt: {
top: 3,
right: 6,
bottom: 3,
left: 6
}
}
},
{
slot: "table-header",
source: "computed-css",
@@ -213,7 +230,18 @@ describe("生成 DOCX 结构收口", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens
tokens,
[],
{
tables: [],
textBlocks: [{
ordinal: 1,
text: "期号发布单位签发人日期",
letterSpacingPt: 0,
linePitchPt: 17.25,
lineBreakOffsets: [6]
}]
}
);
const entries = readGeneratedDocxPackage(
result.content
@@ -237,6 +265,9 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain(
'<w:vAlign w:val="center"/>'
);
expect(documentXml).toContain(
'w:line="345" w:lineRule="exact"'
);
expect(documentXml).toContain(
'<w:pgNumType w:start="1"/>'
);
@@ -276,9 +307,11 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml).toContain(
'<w:tab w:val="right" w:pos="9806"/>'
);
expect(documentXml).not.toContain('<w:tab w:val="center"');
expect(documentXml).toContain("<w:br/>");
expect(documentXml).toContain('w:fill="F5F5F5"');
expect(documentXml).toContain(
'<w:spacing w:before="140"/>'
'w:spacing w:before="140"'
);
expect(documentXml).toContain(
'<w:spacing w:after="1020"/>'
@@ -318,6 +351,180 @@ describe("生成 DOCX 结构收口", () => {
});
});
it("将 Chromium 避头尾规则留下的单个汉字尾行翻译为显式换行", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>',
'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></w:r></w:p>' +
'<w:p><w:pPr><w:pStyle w:val="FirstParagraph"/><w:jc w:val="both"/></w:pPr><w:r><w:t>浏览器末行保留项目。</w:t></w:r></w:p>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
tokens,
[],
{
tables: [],
textBlocks: [{
ordinal: 1,
text: "浏览器末行保留项目。",
letterSpacingPt: 0,
alignment: "justify",
lineBreakOffsets: [8]
}]
}
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toContain(
'<w:t>浏览器末行保留项</w:t><w:br/><w:t>目。</w:t>'
);
});
it("为右对齐盒模型保留 CSS 右侧内容内缩", () => {
const fixture = readGeneratedDocxPackage(generatedFixture());
const documentXml = decoder.decode(
fixture.entries.get("word/document.xml")!
).replace(
'<w:p><w:pPr><w:pStyle w:val="MdTenderTitle"/></w:pPr><w:r><w:t>可编辑封面</w:t></w:r></w:p>',
'<w:p><w:pPr><w:pStyle w:val="MdTenderCopyMark"/></w:pPr><w:r><w:t>正本</w:t></w:r></w:p>'
);
fixture.entries.set("word/document.xml", encoder.encode(documentXml));
const copyMarkTokens: DocxThemeTokenSet = {
...tokens,
slots: [
...tokens.slots.map((entry) =>
entry.slot === "tender-cover"
? {
...entry,
style: {
...entry.style,
minimumHeightPt: 500,
verticalAlignment: "center" as const
}
}
: entry
),
{
slot: "tender-copy-mark",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
fontSizePt: 13,
selfAlignment: "right",
paddingPt: { top: 3, right: 11.34, bottom: 3, left: 11.34 },
borders: {
right: { widthPt: 1.13, style: "single", color: "#222222" }
}
}
}
]
};
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(fixture.entries),
plan,
copyMarkTokens
);
const outputXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(outputXml).toMatch(
/<w:pStyle w:val="MdTenderCopyMark"\/>[\s\S]*?<w:t><\/w:t>[\s\S]*?<w:sz w:val="25"\/>[\s\S]*?<w:t xml:space="preserve">\u00a0\u00a0<\/w:t>/u
);
});
it("按 Chromium 实测比例写入固定 DXA 表格网格和单元格宽度", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens,
[],
{
tables: [
{
ordinal: 1,
widthPercent: 90,
leftOffsetPercent: 5,
columnWidthPercents: [30, 70],
rows: []
}
],
textBlocks: [
{
ordinal: 1,
text: "单元格前mdtp_ic_c单元格后",
letterSpacingPt: 0,
alignment: "distribute",
lineBreakOffsets: []
}
],
inlineCodes: [
{
ordinal: 1,
text: "mdtp_ic_c",
fontSizePt: 8,
letterSpacingPt: 0.2,
color: "#334455",
backgroundColor: "#abcdef",
paddingPt: {
top: 1,
right: 2,
bottom: 1,
left: 2
},
borderPt: {
top: 0.5,
right: 0.5,
bottom: 0.5,
left: 0.5
}
}
]
}
);
const documentXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(documentXml).toContain(
'<w:tblW w:w="8915" w:type="dxa"/>'
);
expect(documentXml).toContain(
'<w:tblInd w:w="495" w:type="dxa"/>'
);
expect(documentXml).toContain('<w:tblLayout w:type="fixed"/>');
expect(documentXml).toContain('<w:gridCol w:w="2675"/>');
expect(documentXml).toContain('<w:gridCol w:w="6240"/>');
expect(
documentXml.match(/<w:tcW w:w="2675" w:type="dxa"\/>/gu)
).toHaveLength(2);
expect(
documentXml.match(/<w:tcW w:w="6240" w:type="dxa"\/>/gu)
).toHaveLength(2);
expect(documentXml).toMatch(
/<w:r><w:rPr><w:rStyle w:val="VerbatimChar"\/><w:rFonts[^>]*w:ascii="Code Face"[^>]*\/>[\s\S]*?<w:color w:val="334455"\/>[\s\S]*?<w:sz w:val="16"\/>[\s\S]*?<w:bdr[^>]*w:sz="20"[^>]*w:space="0"[^>]*w:color="ABCDEF"[^>]*\/>[\s\S]*?<w:shd[^>]*w:fill="ABCDEF"[^>]*\/>[\s\S]*?<w:t>mdtp_ic_c<\/w:t><\/w:r>/u
);
expect(documentXml).toMatch(
/<w:r><w:rPr><w:rFonts[^>]*w:ascii="Body Face"[^>]*\/>[\s\S]*?<w:t><\/w:t><\/w:r>/u
);
expect(documentXml).toContain('<w:jc w:val="distribute"/>');
});
it("按稳定媒体计划规范化内联尺寸、比例锁和对齐", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
@@ -574,7 +781,7 @@ describe("生成 DOCX 结构收口", () => {
expect(documentXml.match(/<w:trHeight\b/gu)).toHaveLength(1);
});
it("将主题项目符号位置与 Word 悬挂缩进合并", () => {
it("将主题列表文字起点映射为 Word 悬挂缩进", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
entries.set(
@@ -612,10 +819,66 @@ describe("生成 DOCX 结构收口", () => {
);
expect(numberingXml).toContain(
'<w:ind w:left="960" w:hanging="360"/>'
'<w:ind w:left="600" w:hanging="360"/>'
);
expect(numberingXml).toContain(
'<w:ind w:left="1680" w:hanging="360"/>'
'<w:ind w:left="1320" w:hanging="360"/>'
);
});
it("将 Chromium 实测列表项文字起点映射为段落直接缩进", () => {
const source = readGeneratedDocxPackage(generatedFixture());
const entries = new Map(source.entries);
const documentXml = decoder.decode(entries.get("word/document.xml")!);
entries.set(
"word/document.xml",
encoder.encode(documentXml.replace(
"<w:sectPr>",
`<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>一级列表</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>二级</w:t></w:r><w:r><w:rPr><w:rStyle w:val="VerbatimChar"/></w:rPr><w:t>代码</w:t></w:r></w:p>` +
`<w:sectPr>`
))
);
entries.set(
"word/numbering.xml",
encoder.encode(
`<w:numbering xmlns:w="${word}"><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="1"/></w:num></w:numbering>`
)
);
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
tokens,
[],
{
tables: [],
listItems: [
{
ordinal: 1,
text: "一级列表",
depth: 0,
textStartPt: 10
},
{
ordinal: 2,
text: "二级代码",
depth: 1,
textStartPt: 25
}
]
}
);
const finalizedXml = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/document.xml"
)!
);
expect(finalizedXml).toMatch(
/<w:numPr>[\s\S]*?<w:ind w:left="200" w:hanging="200"\/>[\s\S]*?<w:t><\/w:t>/u
);
expect(finalizedXml).toMatch(
/<w:numPr>[\s\S]*?<w:ind w:left="500" w:hanging="360"\/>[\s\S]*?<w:t><\/w:t>[\s\S]*?<w:t><\/w:t>/u
);
});