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:
File diff suppressed because it is too large
Load Diff
@@ -172,7 +172,7 @@ function pandocArguments(paths: {
|
||||
return [
|
||||
paths.markdown,
|
||||
"--from",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars",
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html",
|
||||
"--to",
|
||||
"docx",
|
||||
"--standalone",
|
||||
@@ -375,7 +375,8 @@ export class PandocDocxConverter {
|
||||
pandocDocx,
|
||||
structurePlan,
|
||||
input.themeTokens,
|
||||
preparedMedia.layout
|
||||
preparedMedia.layout,
|
||||
preparedMedia.documentLayout
|
||||
);
|
||||
docx = embedFontsInGeneratedDocx(
|
||||
finalized.content,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MAXIMUM_DOCX_RESOURCE_COUNT,
|
||||
MAXIMUM_DOCX_TOTAL_MEDIA_BYTES,
|
||||
type DocxMediaKind,
|
||||
type DocxDocumentLayoutPlan,
|
||||
type PreparedDocxMedia
|
||||
} from "@md-to-pdf/core";
|
||||
|
||||
@@ -47,6 +48,7 @@ export interface PreparedPandocMedia {
|
||||
map: PandocMediaMap;
|
||||
files: PandocMediaFile[];
|
||||
layout: PandocMediaLayoutItem[];
|
||||
documentLayout: DocxDocumentLayoutPlan;
|
||||
}
|
||||
|
||||
export const DOCX_MEDIA_BINDING_PREFIX = "mdtp-media:";
|
||||
@@ -184,5 +186,10 @@ export function preparePandocMedia(
|
||||
if (media.totalBytes !== totalBytes) {
|
||||
throw new Error("DOCX 媒体总大小声明不一致");
|
||||
}
|
||||
return { map, files, layout };
|
||||
return {
|
||||
map,
|
||||
files,
|
||||
layout,
|
||||
documentLayout: media.documentLayout ?? { tables: [] }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,9 +69,17 @@ const containerRoles = new Set<SemanticDocumentRole>([
|
||||
"tender-cover"
|
||||
]);
|
||||
|
||||
const semanticRoleSlotAliases: Readonly<
|
||||
Partial<Record<SemanticDocumentRole, DocxStyleSlotName>>
|
||||
> = {};
|
||||
|
||||
function slotName(
|
||||
role: SemanticDocumentRole
|
||||
): DocxStyleSlotName | undefined {
|
||||
const alias = semanticRoleSlotAliases[role];
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
role
|
||||
|
||||
@@ -7,8 +7,10 @@ import type {
|
||||
import type { ResolvedDocxThemeStyle } from "./style-presets.js";
|
||||
import {
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
PANDOC_SYNTAX_STYLE_IDS,
|
||||
collectDocxTokenFonts,
|
||||
createDocxTokenSlotMap,
|
||||
resolvePandocSyntaxStyleSlot,
|
||||
resolveTokenFonts
|
||||
} from "./token-style-map.js";
|
||||
import {
|
||||
@@ -64,7 +66,8 @@ function setRunStyle(
|
||||
"iCs",
|
||||
"u",
|
||||
"shd",
|
||||
"spacing"
|
||||
"spacing",
|
||||
"kern"
|
||||
);
|
||||
if (options.fonts) {
|
||||
setFonts(parent, options.fonts);
|
||||
@@ -106,6 +109,9 @@ function setRunStyle(
|
||||
"w:val": wordCharacterSpacingTwips(options.sizePt)
|
||||
});
|
||||
}
|
||||
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||
"w:val": "2"
|
||||
});
|
||||
}
|
||||
|
||||
function setParagraphSpacing(
|
||||
@@ -200,8 +206,16 @@ function fallbackFontsForSlot(
|
||||
if (slot.startsWith("heading-") || slot.endsWith("-title")) {
|
||||
return fallback.headings.fonts;
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
return fallback.code.fonts;
|
||||
if (
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
) {
|
||||
return {
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
};
|
||||
}
|
||||
if (
|
||||
slot === "table" ||
|
||||
@@ -232,7 +246,12 @@ function fallbackFontSizeForSlot(
|
||||
if (slot.endsWith("-title")) {
|
||||
return fallback.headings.sizesPt[0];
|
||||
}
|
||||
if (slot === "inline-code" || slot === "code-block") {
|
||||
if (
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
) {
|
||||
return fallback.code.sizePt;
|
||||
}
|
||||
if (
|
||||
@@ -251,10 +270,17 @@ function fallbackFontSizeForSlot(
|
||||
function applyTokenRunStyle(
|
||||
parent: XmlElement,
|
||||
token: DocxSlotStyleToken,
|
||||
fallbackFonts: DocxFontFamily
|
||||
fallbackFonts: DocxFontFamily,
|
||||
preserveFallbackEastAsia = false
|
||||
) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "kern");
|
||||
appendElement(parent, WORD_NAMESPACE, "w:kern", {
|
||||
"w:val": "2"
|
||||
});
|
||||
if (token.fontCandidates.length) {
|
||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts));
|
||||
setFonts(parent, resolveTokenFonts(token, fallbackFonts, {
|
||||
preserveFallbackEastAsia
|
||||
}));
|
||||
}
|
||||
if (token.fontSizePt !== undefined) {
|
||||
removeDirectChildren(parent, WORD_NAMESPACE, "sz", "szCs");
|
||||
@@ -1176,17 +1202,77 @@ function applyTokenStyles(
|
||||
binding.styleId === "SourceCode"
|
||||
);
|
||||
}
|
||||
const runToken = binding.styleId === "SourceCode"
|
||||
? slots.get("code-block-text")?.style ?? entry.style
|
||||
: entry.style;
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"w:rPr"
|
||||
),
|
||||
entry.style,
|
||||
fallbackFontsForSlot(slot, fallback)
|
||||
runToken,
|
||||
fallbackFontsForSlot(slot, fallback),
|
||||
binding.styleId === "SourceCode" ||
|
||||
slot === "inline-code" ||
|
||||
slot === "code-block" ||
|
||||
slot === "code-block-text" ||
|
||||
slot.startsWith("code-token-")
|
||||
);
|
||||
}
|
||||
}
|
||||
const codeTextToken =
|
||||
slots.get("code-block-text")?.style ??
|
||||
slots.get("code-block")?.style;
|
||||
if (codeTextToken) {
|
||||
const baseStyleId = "MdSourceCodeChar";
|
||||
const baseStyle = ensureStyle(
|
||||
styles,
|
||||
baseStyleId,
|
||||
"character",
|
||||
"BodyTextChar"
|
||||
);
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(baseStyle, WORD_NAMESPACE, "w:rPr"),
|
||||
codeTextToken,
|
||||
{
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
},
|
||||
true
|
||||
);
|
||||
for (const styleId of PANDOC_SYNTAX_STYLE_IDS) {
|
||||
const target = ensureStyle(
|
||||
styles,
|
||||
styleId,
|
||||
"character",
|
||||
baseStyleId
|
||||
);
|
||||
const basedOn = firstDirectChild(
|
||||
target,
|
||||
WORD_NAMESPACE,
|
||||
"basedOn"
|
||||
);
|
||||
if (basedOn) {
|
||||
setWordAttribute(basedOn, "val", baseStyleId);
|
||||
}
|
||||
const syntaxSlot = resolvePandocSyntaxStyleSlot(styleId);
|
||||
const syntaxToken = syntaxSlot
|
||||
? slots.get(syntaxSlot)?.style
|
||||
: undefined;
|
||||
if (syntaxToken) {
|
||||
applyTokenRunStyle(
|
||||
ensureDirectElement(target, WORD_NAMESPACE, "w:rPr"),
|
||||
syntaxToken,
|
||||
{
|
||||
...fallback.code.fonts,
|
||||
eastAsia: fallback.body.fonts.eastAsia
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyTableTokenStyles(styles, slots, fallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||
paragraph("MdOfficialPrintingRow")
|
||||
],
|
||||
"briefing-masthead": [paragraph("MdBriefingMasthead")],
|
||||
"briefing-masthead-text": [paragraph("MdBriefingMastheadText")],
|
||||
"briefing-meta": [paragraph("MdBriefingMeta")],
|
||||
"briefing-title": [paragraph("MdBriefingTitle")],
|
||||
"briefing-contact": [paragraph("MdBriefingContact")],
|
||||
@@ -141,6 +142,93 @@ export const DOCX_SLOT_WORD_STYLE_BINDINGS: Readonly<
|
||||
"tender-date": [paragraph("MdTenderDate")]
|
||||
};
|
||||
|
||||
export const PANDOC_SYNTAX_STYLE_IDS = [
|
||||
"AlertTok",
|
||||
"AnnotationTok",
|
||||
"AttributeTok",
|
||||
"BaseNTok",
|
||||
"BuiltInTok",
|
||||
"CharTok",
|
||||
"CommentTok",
|
||||
"CommentVarTok",
|
||||
"ConstantTok",
|
||||
"ControlFlowTok",
|
||||
"DataTypeTok",
|
||||
"DecValTok",
|
||||
"DocumentationTok",
|
||||
"ErrorTok",
|
||||
"ExtensionTok",
|
||||
"FloatTok",
|
||||
"FunctionTok",
|
||||
"ImportTok",
|
||||
"InformationTok",
|
||||
"KeywordTok",
|
||||
"NormalTok",
|
||||
"OperatorTok",
|
||||
"OtherTok",
|
||||
"PreprocessorTok",
|
||||
"SpecialCharTok",
|
||||
"SpecialStringTok",
|
||||
"StringTok",
|
||||
"VariableTok",
|
||||
"VerbatimStringTok",
|
||||
"WarningTok"
|
||||
] as const;
|
||||
|
||||
const pandocSyntaxSlots: Readonly<
|
||||
Partial<Record<(typeof PANDOC_SYNTAX_STYLE_IDS)[number], DocxStyleSlotName>>
|
||||
> = {
|
||||
AlertTok: "code-token-keyword",
|
||||
AnnotationTok: "code-token-comment",
|
||||
AttributeTok: "code-token-attribute",
|
||||
BaseNTok: "code-token-number",
|
||||
BuiltInTok: "code-token-built-in",
|
||||
CharTok: "code-token-string",
|
||||
CommentTok: "code-token-comment",
|
||||
CommentVarTok: "code-token-comment",
|
||||
ConstantTok: "code-token-literal",
|
||||
ControlFlowTok: "code-token-keyword",
|
||||
DataTypeTok: "code-token-attribute",
|
||||
DecValTok: "code-token-number",
|
||||
DocumentationTok: "code-token-comment",
|
||||
ErrorTok: "code-token-keyword",
|
||||
ExtensionTok: "code-token-built-in",
|
||||
FloatTok: "code-token-number",
|
||||
FunctionTok: "code-token-title",
|
||||
ImportTok: "code-token-keyword",
|
||||
InformationTok: "code-token-comment",
|
||||
KeywordTok: "code-token-keyword",
|
||||
OperatorTok: "code-token-operator",
|
||||
PreprocessorTok: "code-token-meta",
|
||||
SpecialCharTok: "code-token-string",
|
||||
SpecialStringTok: "code-token-string",
|
||||
StringTok: "code-token-string",
|
||||
VariableTok: "code-token-variable",
|
||||
VerbatimStringTok: "code-token-string",
|
||||
WarningTok: "code-token-keyword"
|
||||
};
|
||||
|
||||
export function resolvePandocSyntaxStyleSlot(
|
||||
styleId: string,
|
||||
text = ""
|
||||
): DocxStyleSlotName | "code-block-text" | undefined {
|
||||
if (!(PANDOC_SYNTAX_STYLE_IDS as readonly string[]).includes(styleId)) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
styleId === "NormalTok" ||
|
||||
styleId === "OtherTok" ||
|
||||
(styleId === "FunctionTok" && /^[\p{P}\p{S}\s]+$/u.test(text))
|
||||
) {
|
||||
return styleId === "FunctionTok"
|
||||
? "code-token-punctuation"
|
||||
: "code-block-text";
|
||||
}
|
||||
return pandocSyntaxSlots[
|
||||
styleId as (typeof PANDOC_SYNTAX_STYLE_IDS)[number]
|
||||
] ?? "code-block-text";
|
||||
}
|
||||
|
||||
const eastAsiaFontPattern =
|
||||
/(?:fandol|yahei|simsun|simhei|fangsong|kaiti|dengxian|cjk|source\s+han|pingfang|heiti|songti|宋|黑|仿宋|楷|等线)/iu;
|
||||
const genericFontNames = new Set([
|
||||
@@ -154,7 +242,8 @@ const genericFontNames = new Set([
|
||||
|
||||
export function resolveTokenFonts(
|
||||
token: DocxSlotStyleToken | undefined,
|
||||
fallback: DocxFontFamily
|
||||
fallback: DocxFontFamily,
|
||||
options: { preserveFallbackEastAsia?: boolean } = {}
|
||||
): DocxFontFamily {
|
||||
const candidates = (token?.fontCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
@@ -166,7 +255,9 @@ export function resolveTokenFonts(
|
||||
const eastAsia =
|
||||
candidates.find((candidate) =>
|
||||
eastAsiaFontPattern.test(candidate)
|
||||
) ?? candidates[0]!;
|
||||
) ?? (options.preserveFallbackEastAsia
|
||||
? fallback.eastAsia
|
||||
: candidates[0]!);
|
||||
const latin =
|
||||
candidates.find(
|
||||
(candidate) => !eastAsiaFontPattern.test(candidate)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("DOCX 字体包写入", () => {
|
||||
writeGeneratedDocxPackage(invalidEntries)
|
||||
)
|
||||
).toThrow("字体元数据与嵌入字形不匹配");
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("没有字体时保持原始 DOCX 字节不变", () => {
|
||||
const input = createTestBaselineReference();
|
||||
|
||||
@@ -126,6 +126,9 @@ describe("Pandoc DOCX 转换器", () => {
|
||||
expect(arguments_).toContain("--lua-filter");
|
||||
expect(arguments_).toContain("--data-dir");
|
||||
expect(arguments_).toContain("--resource-path");
|
||||
expect(argumentAfter(arguments_, "--from")).toBe(
|
||||
"markdown+yaml_metadata_block+pipe_tables+footnotes+task_lists+tex_math_dollars-raw_html"
|
||||
);
|
||||
expect(
|
||||
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
|
||||
).toContain("media-map.json");
|
||||
|
||||
@@ -66,6 +66,36 @@ function paragraphSegments(block: PandocStructureBlock): string[] {
|
||||
}
|
||||
|
||||
describe("Pandoc 语义结构投影", () => {
|
||||
it("将简报抬头文字映射到父级抬头样式", () => {
|
||||
const model: SemanticDocumentModel = {
|
||||
schemaVersion: 1,
|
||||
profile: "briefing",
|
||||
titlePolicy: {
|
||||
metadataTitle: "suppress",
|
||||
firstBodyHeading: "keep"
|
||||
},
|
||||
regions: [{
|
||||
kind: "prefix",
|
||||
nodes: [{
|
||||
kind: "group",
|
||||
role: "briefing-masthead",
|
||||
children: [text("briefing-masthead-text", "工作简报")]
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const plan = createPandocStructurePlan(model, tokens(), {
|
||||
markerSeed: "briefing"
|
||||
});
|
||||
expect(plan.prefix[0]).toMatchObject({
|
||||
kind: "container",
|
||||
blocks: [{
|
||||
kind: "paragraph",
|
||||
styleId: "MdBriefingMastheadText",
|
||||
segments: ["工作简报"]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
|
||||
const model: SemanticDocumentModel = {
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -272,6 +272,26 @@ describe("动态 reference.docx", () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-block-text",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Text Face"],
|
||||
fontSizePt: 10,
|
||||
color: "#223344"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "code-token-attribute",
|
||||
source: "computed-css",
|
||||
confidence: "exact",
|
||||
style: {
|
||||
fontCandidates: ["Code Text Face"],
|
||||
fontSizePt: 10,
|
||||
color: "#166534"
|
||||
}
|
||||
},
|
||||
{
|
||||
slot: "table",
|
||||
source: "computed-css",
|
||||
@@ -390,6 +410,13 @@ describe("动态 reference.docx", () => {
|
||||
expect(sourceCodeStyle).toMatch(
|
||||
/<w:ind\b[^>]*w:left="195"[^>]*w:right="75"[^>]*\/>/u
|
||||
);
|
||||
expect(sourceCodeStyle).toContain('w:ascii="Code Text Face"');
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="MdSourceCodeChar"[\s\S]*?<w:sz w:val="20"\/>/u
|
||||
);
|
||||
expect(stylesXml).toMatch(
|
||||
/w:style[^>]*w:styleId="DataTypeTok"[\s\S]*?<w:basedOn w:val="MdSourceCodeChar"\/>[\s\S]*?<w:color w:val="166534"\/>/u
|
||||
);
|
||||
expect(stylesXml).not.toContain('w:val="justify"');
|
||||
expect(stylesXml).toContain(
|
||||
'w:line="459" w:lineRule="exact"'
|
||||
@@ -463,6 +490,7 @@ describe("动态 reference.docx", () => {
|
||||
const entries = unzipSync(result.content);
|
||||
const documentXml = decoder.decode(entries["word/document.xml"]);
|
||||
const settingsXml = decoder.decode(entries["word/settings.xml"]);
|
||||
const stylesXml = decoder.decode(entries["word/styles.xml"]);
|
||||
const headerXml = decoder.decode(entries["word/header1.xml"]);
|
||||
const relationships = decoder.decode(
|
||||
entries["word/_rels/document.xml.rels"]
|
||||
@@ -478,6 +506,7 @@ describe("动态 reference.docx", () => {
|
||||
expect(documentXml.match(/w:headerReference/gu)).toHaveLength(3);
|
||||
expect(documentXml.match(/w:footerReference/gu)).toHaveLength(3);
|
||||
expect(settingsXml).toContain("<w:evenAndOddHeaders");
|
||||
expect(stylesXml).toContain('<w:kern w:val="2"');
|
||||
expect(headerXml).toContain("年度 <报告>");
|
||||
expect(headerXml).toContain("报告 & 计划.md");
|
||||
expect(headerXml).toContain('w:tab w:val="center"');
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DOCX_SLOT_WORD_STYLE_BINDINGS,
|
||||
collectDocxTokenFonts,
|
||||
createDocxTokenSlotMap,
|
||||
resolvePandocSyntaxStyleSlot,
|
||||
resolveTokenFonts
|
||||
} from "../src/index.js";
|
||||
|
||||
@@ -72,5 +73,31 @@ describe("DOCX 令牌样式映射", () => {
|
||||
"Source Han Serif SC",
|
||||
"Times New Roman"
|
||||
]);
|
||||
expect(
|
||||
resolveTokenFonts(
|
||||
{ fontCandidates: ["MdTP Mono", "monospace"] },
|
||||
{ latin: "Arial", eastAsia: "MdTP Sans SC" },
|
||||
{ preserveFallbackEastAsia: true }
|
||||
)
|
||||
).toEqual({
|
||||
latin: "MdTP Mono",
|
||||
eastAsia: "MdTP Sans SC",
|
||||
complexScript: "MdTP Mono"
|
||||
});
|
||||
});
|
||||
|
||||
it("将 Pandoc 语法样式映射到引擎级代码语义槽位", () => {
|
||||
expect(resolvePandocSyntaxStyleSlot("DataTypeTok", '"key"'))
|
||||
.toBe("code-token-attribute");
|
||||
expect(resolvePandocSyntaxStyleSlot("StringTok", '"value"'))
|
||||
.toBe("code-token-string");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", ":"))
|
||||
.toBe("code-token-punctuation");
|
||||
expect(resolvePandocSyntaxStyleSlot("FunctionTok", "render"))
|
||||
.toBe("code-token-title");
|
||||
expect(resolvePandocSyntaxStyleSlot("NormalTok", " "))
|
||||
.toBe("code-block-text");
|
||||
expect(resolvePandocSyntaxStyleSlot("UnknownTok", "x"))
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user