feat: 完成 DOCX 通用结构映射与严格收口

This commit is contained in:
SkyJourney
2026-07-31 15:19:34 +08:00
parent 6e40374200
commit f3847f3e4b
31 changed files with 3175 additions and 64 deletions
+15 -1
View File
@@ -5,7 +5,10 @@
兼容样式预设映射为 Word 样式,并生成纸张、页边距、字体、段落、代码、表格、
页眉、页脚和页码均已映射的动态模板。Pandoc 转换器通过静态 Lua Filter
将普通图片、Mermaid 和 ECharts 节点替换为受控 PNG,同时保留 Markdown
正文、标题、列表、表格、代码和公式的可编辑文档结构。
正文、标题、列表、表格、代码和公式的可编辑文档结构。统一语义文档模型
会先转换为主题无关的 Pandoc 结构计划,Lua Filter 再绑定标准 Markdown
样式与 `Md*` 结构样式;生成后的 OOXML 收口层负责真实分节、封面页眉页脚
隔离、正文页码重启、表格内容区全宽和分页控制。
## 设计边界
@@ -15,7 +18,11 @@
- 不解析任意主题 CSS,只消费 `@md-to-pdf/docx-theme-engine` 的受限
语义令牌;`docxStyle` 预设在槽位缺失时提供兼容降级;
- 槽位只映射到稳定的标准或 `Md*` Word 样式,不包含主题 ID 分支;
- Front Matter 结构、标题策略和分节意图只消费统一语义文档模型,不按
主题 ID 编写转换分支;
- 生成结果会复验全部 XML、包内关系、内容类型、最终节和关键样式;
- 最终 DOCX 必须清除全部内部结构标记,并校验实际样式使用、分节、
`SECTIONPAGES`、全宽表格和标题去重;
- 每次转换使用独立临时目录、隔离 Pandoc data 目录并在 `finally` 清理;
- 媒体映射检查顺序、PNG、像素、单图/总大小和物理显示尺寸;
- 并发排队、跨端用例编排和错误协议映射由
@@ -40,6 +47,7 @@ npm run verify:docx-reference
npm run verify:docx-conversion
npm run verify:docx-acceptance
npm run verify:docx-theme-styles
npm run verify:docx-themes
```
`verify:docx-reference` 要求本机 `PATH` 中存在 Pandoc 3.9.0.2,也可以通过
@@ -59,3 +67,9 @@ npm run verify:docx-theme-styles
内置主题的 56 个槽位,检查跨引擎令牌一致性,并使用固定 Pandoc 默认
模板生成 14 份动态 `reference.docx`。模板矩阵检查标准 Markdown 样式、
结构化 `Md*` 样式、正文字体、字号、字体表和缓存指纹。
根级 `verify:docx-themes` 会先重新构建运行时并使用 Playwright Chromium
生成本轮真实主题令牌,再以固定 Pandoc 生成 14 份最终 DOCX。矩阵硬门禁
覆盖标准样式、原生页眉页脚、结构字段完整率、封面真实分节、正文页码
重启、`SECTIONPAGES`、内容区全宽表格、标题去重、内部标记清理和
`altChunk` 禁用;不会使用陈旧快照或合成令牌降级。
@@ -1,18 +1,29 @@
local map_path = os.getenv("MD_TO_PDF_DOCX_MEDIA_MAP")
local structure_plan_path =
os.getenv("MD_TO_PDF_DOCX_STRUCTURE_PLAN")
if map_path == nil or map_path == "" then
error("DOCX media map path is missing")
end
local map_file, open_error = io.open(map_path, "rb")
if map_file == nil then
error("DOCX media map cannot be opened: " .. tostring(open_error))
if structure_plan_path == nil or structure_plan_path == "" then
error("DOCX structure plan path is missing")
end
local map_content = map_file:read("*a")
map_file:close()
local function read_json(path, description)
local file, open_error = io.open(path, "rb")
if file == nil then
error(
description .. " cannot be opened: " .. tostring(open_error)
)
end
local content = file:read("*a")
file:close()
return pandoc.json.decode(content, false)
end
local media_map = pandoc.json.decode(map_content, false)
local media_map = read_json(map_path, "DOCX media map")
local structure_plan =
read_json(structure_plan_path, "DOCX structure plan")
local counters = {
image = 0,
mermaid = 0,
@@ -86,6 +97,69 @@ local function replace_code_block(block)
return pandoc.Para { image }
end
local function append_inlines(target, value)
target:extend(pandoc.Inlines(value))
end
local function structure_paragraph(block)
local inlines = pandoc.Inlines {}
for index, segment in ipairs(block.segments) do
if index > 1 then
if block.separator == "tab" then
inlines:insert(
pandoc.RawInline(
"openxml",
"<w:r><w:tab/></w:r>"
)
)
else
inlines:insert(pandoc.Space())
end
end
append_inlines(inlines, segment)
end
local paragraph = pandoc.Para(inlines)
if block.styleId == nil or block.styleId == "" then
return paragraph
end
return pandoc.Div(
{ paragraph },
pandoc.Attr("", {}, { ["custom-style"] = block.styleId })
)
end
local function marker_paragraph(marker)
return pandoc.Para { pandoc.Str(marker) }
end
local function structure_blocks(blocks)
local result = pandoc.Blocks {}
for _, block in ipairs(blocks) do
if block.kind == "paragraph" then
result:insert(structure_paragraph(block))
elseif block.kind == "container" then
local children = structure_blocks(block.blocks)
result:insert(marker_paragraph(block.startMarker))
result:extend(children)
result:insert(marker_paragraph(block.endMarker))
elseif block.kind == "section-break" then
result:insert(marker_paragraph(block.marker))
else
error("Unsupported DOCX structure block: " .. tostring(block.kind))
end
end
return result
end
local function suppress_first_body_heading(blocks)
for index, block in ipairs(blocks) do
if block.t == "Header" and block.level == 1 then
blocks:remove(index)
return
end
end
end
local function validate_counts()
for _, kind in ipairs({ "image", "mermaid", "echarts" }) do
if counters[kind] ~= #media_map[kind] then
@@ -104,6 +178,16 @@ return {
Image = replace_image,
CodeBlock = replace_code_block
}
if structure_plan.titlePolicy.metadataTitle == "suppress" then
transformed.meta.title = nil
end
if structure_plan.titlePolicy.firstBodyHeading == "suppress" then
suppress_first_body_heading(transformed.blocks)
end
local blocks = structure_blocks(structure_plan.prefix)
blocks:extend(transformed.blocks)
blocks:extend(structure_blocks(structure_plan.suffix))
transformed.blocks = blocks
validate_counts()
return transformed
end
@@ -25,7 +25,7 @@ try {
$word.DisplayAlerts = 0
$word.AutomationSecurity = 3
$word.Options.SaveNormalPrompt = $false
$document = $word.Documents.Open(
$document = $word.Documents.OpenNoRepairDialog(
$resolvedInput,
$false,
$true,
@@ -38,7 +38,7 @@ try {
0,
0,
$false,
$true,
$false,
0,
$true
)
@@ -11,6 +11,7 @@ import {
PandocRuntime,
inspectDocxAcceptance
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
const directory = path.dirname(fileURLToPath(import.meta.url));
const packageDirectory = path.resolve(directory, "..");
@@ -95,6 +96,23 @@ function createMedia() {
};
}
function createThemeTokens(theme) {
return {
schemaVersion: 1,
themeId: theme.id,
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: theme.docxStyle?.preset ?? "general",
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
source: "preset-fallback",
confidence: "fallback",
style: { fontCandidates: [] }
})),
diagnostics: []
};
}
const technicalTheme = loadTheme("typora-like");
const officialTheme = loadTheme("gov-red-standard");
const commonExpectation = {
@@ -287,6 +305,7 @@ for (const variant of variants) {
language: "zh-CN",
exportConfig: variant.exportConfig,
theme: variant.theme,
themeTokens: createThemeTokens(variant.theme),
metadata: {
title: "v0.6.0 DOCX 自动验收",
author: "Markdown PDF 导出器研发组",
@@ -294,6 +313,14 @@ for (const variant of variants) {
keywords: ["DOCX", "Pandoc", "OOXML"],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
media: createMedia()
});
const report = inspectDocxAcceptance(
@@ -9,6 +9,7 @@ import {
PandocDocxConverter,
PandocRuntime
} from "@md-to-pdf/docx-engine";
import { DOCX_STYLE_SLOT_NAMES } from "@md-to-pdf/docx-theme-engine";
import { unzipSync } from "fflate";
const directory = path.dirname(fileURLToPath(import.meta.url));
@@ -76,6 +77,20 @@ const runtime = new PandocRuntime(
: {}
);
const converter = new PandocDocxConverter({ runtime });
const themeTokens = {
schemaVersion: 1,
themeId: theme.id,
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: theme.docxStyle?.preset ?? "general",
slots: DOCX_STYLE_SLOT_NAMES.map((slot) => ({
slot,
source: "preset-fallback",
confidence: "fallback",
style: { fontCandidates: [] }
})),
diagnostics: []
};
const conversionInput = {
markdown,
fileName: "Pandoc 媒体转换验收.md",
@@ -85,6 +100,7 @@ const conversionInput = {
themeId: theme.id
},
theme,
themeTokens,
metadata: {
title: "Pandoc 媒体转换验收",
author: "Markdown PDF 导出器研发组",
@@ -92,6 +108,14 @@ const conversionInput = {
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
media: {
resources: [
resource("image", 1, { width: 384, height: 192 }),
@@ -24,6 +24,12 @@ const outputDirectory = path.join(
"output",
"docx-theme-matrix"
);
const themeTokenReportPath = path.join(
repositoryDirectory,
"output",
"docx-theme-styles",
"snapshots.json"
);
const decoder = new TextDecoder();
const placeholderPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
@@ -49,6 +55,26 @@ function countMatches(value, pattern) {
return value.match(pattern)?.length ?? 0;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function countOccurrences(value, search) {
let count = 0;
let offset = 0;
while (offset <= value.length - search.length) {
const index = value.indexOf(search, offset);
if (index < 0) {
break;
}
count += 1;
offset = index + search.length;
}
return count;
}
function readThemeManifests() {
return fs
.readdirSync(themesDirectory, { withFileTypes: true })
@@ -90,7 +116,13 @@ function structuralValues(document) {
.map(String);
}
function inspectDocument(content, theme, metadata, exportConfig) {
function inspectDocument(
content,
theme,
metadata,
semanticDocument,
exportConfig
) {
const entries = unzipSync(content);
const documentXml = decoder.decode(entries["word/document.xml"]);
const stylesXml = decoder.decode(entries["word/styles.xml"]);
@@ -120,6 +152,37 @@ function inspectDocument(content, theme, metadata, exportConfig) {
documentXml,
/<w:br[^>]*w:type="page"/gu
);
const sections = [
...documentXml.matchAll(
/<w:sectPr(?:\s[^>]*)?>[\s\S]*?<\/w:sectPr>/gu
)
].map((match) => match[0]);
const fullWidthTableCount = countMatches(
documentXml,
/<w:tblW\s+w:w="5000"\s+w:type="pct"\s*\/>/gu
);
const internalMarkerCount = countMatches(
documentXml,
/MD_TO_PDF_(?:CONTAINER|SECTION)_/gu
);
const pandocTitleParagraphCount = countMatches(
documentXml,
/<w:pStyle\s+w:val="Title"\s*\/>/gu
);
const visibleMetadataTitleCount = countOccurrences(
bodyText,
metadata.title
);
const titlePolicyPassed =
semanticDocument.titlePolicy.metadataTitle === "suppress"
? pandocTitleParagraphCount === 0
: pandocTitleParagraphCount === 1;
const titleDeduplicationPassed =
semanticDocument.titlePolicy.metadataTitle === "suppress" &&
semanticDocument.titlePolicy.firstBodyHeading === "keep" &&
!metadata.document?.profile
? visibleMetadataTitleCount === 1
: titlePolicyPassed;
return {
bytes: content.byteLength,
@@ -128,6 +191,14 @@ function inspectDocument(content, theme, metadata, exportConfig) {
tableCount: countMatches(documentXml, /<w:tbl(?:\s|>)/gu),
drawingCount: countMatches(documentXml, /<w:drawing(?:\s|>)/gu),
explicitPageBreaks,
sectionCount: sections.length,
fullWidthTableCount,
internalMarkerCount,
pandocTitleParagraphCount,
visibleMetadataTitleCount,
titlePolicy: semanticDocument.titlePolicy,
titlePolicyPassed,
titleDeduplicationPassed,
headerPartCount: Object.keys(entries).filter((name) =>
/^word\/header\d+\.xml$/u.test(name)
).length,
@@ -138,6 +209,7 @@ function inspectDocument(content, theme, metadata, exportConfig) {
!headerXml.includes("<w:tbl") && !footerXml.includes("<w:tbl"),
pageField: footerXml.includes(" PAGE "),
totalPagesField: footerXml.includes(" NUMPAGES "),
sectionPagesField: footerXml.includes(" SECTIONPAGES "),
altChunkCount: countMatches(documentXml, /<w:altChunk(?:\s|>)/gu),
styles: {
normal: stylesXml.includes('w:styleId="Normal"'),
@@ -157,7 +229,11 @@ function inspectDocument(content, theme, metadata, exportConfig) {
expectsStandaloneCover,
standaloneCoverDetected:
expectsStandaloneCover &&
explicitPageBreaks > 0 &&
sections.length >= 2 &&
!sections[0]?.includes("headerReference") &&
!sections[0]?.includes("footerReference") &&
sections.at(-1)?.includes('w:pgNumType w:start="1"') ===
true &&
missingStructureValues.length === 0
};
}
@@ -220,6 +296,49 @@ function createMedia(markdown) {
totalBytes: placeholderPng.byteLength * resources.length
};
}
function readThemeTokenReport(themes) {
if (!fs.existsSync(themeTokenReportPath)) {
throw new Error(
`缺少本轮 Playwright 主题令牌报告:${path.relative(repositoryDirectory, themeTokenReportPath)}`
);
}
const report = JSON.parse(
fs.readFileSync(themeTokenReportPath, "utf8")
);
assert(
Array.isArray(report.tokenSets),
"Playwright 主题令牌报告缺少 tokenSets"
);
assert(
report.tokenSets.length === themes.length,
`真实主题令牌数量应为 ${themes.length},实际为 ${report.tokenSets.length}`
);
const tokensByTheme = new Map(
report.tokenSets.map((tokens) => {
assert(
tokens.slots?.length === 56,
`主题 ${tokens.themeId} 的真实令牌槽位不是 56`
);
assert(
/^[a-f0-9]{64}$/u.test(tokens.themeFingerprint ?? ""),
`主题 ${tokens.themeId} 的 CSS 指纹无效`
);
return [tokens.themeId, tokens];
})
);
for (const theme of themes) {
assert(
tokensByTheme.has(theme.id),
`真实主题令牌缺少 ${theme.id}`
);
}
return {
generatedAt: report.generatedAt,
chromiumVersion: report.chromiumVersion,
tokensByTheme
};
}
const runtime = new PandocRuntime(
process.env.DOCX_PANDOC_PATH?.trim()
? { configuredPath: process.env.DOCX_PANDOC_PATH.trim() }
@@ -238,6 +357,8 @@ if (capability.capability.detectedVersion !== DOCX_PANDOC_VERSION) {
fs.mkdirSync(outputDirectory, { recursive: true });
const converter = new PandocDocxConverter({ runtime });
const themes = readThemeManifests();
assert(themes.length === 14, `内置主题数量应为 14,实际为 ${themes.length}`);
const themeTokenReport = readThemeTokenReport(themes);
const results = [];
for (const theme of themes) {
@@ -248,6 +369,7 @@ for (const theme of themes) {
}
const markdown = fs.readFileSync(samplePath, "utf8");
const rendered = renderMarkdown(markdown, { language: "zh-CN" });
const themeTokens = themeTokenReport.tokensByTheme.get(theme.id);
const exportConfig = {
...defaultExportConfig,
name: `${theme.name} DOCX 主题验收`,
@@ -268,7 +390,9 @@ for (const theme of themes) {
language: rendered.metadata.language,
exportConfig,
theme,
themeTokens,
metadata: rendered.metadata,
semanticDocument: rendered.semanticDocument,
media: createMedia(markdown)
});
} catch (error) {
@@ -297,14 +421,72 @@ for (const theme of themes) {
conversion.docx,
theme,
rendered.metadata,
rendered.semanticDocument,
exportConfig
)
});
}
for (const result of results) {
const { inspection } = result;
assert(
Object.values(inspection.styles).every(Boolean),
`主题 ${result.id} 缺少标准 Word 样式`
);
assert(
inspection.nativeHeaderFooter,
`主题 ${result.id} 的页眉页脚包含表格模拟`
);
assert(
inspection.altChunkCount === 0,
`主题 ${result.id} 包含 altChunk`
);
assert(
inspection.internalMarkerCount === 0,
`主题 ${result.id} 残留内部结构标记`
);
assert(
inspection.titlePolicyPassed,
`主题 ${result.id} 的标题策略未生效`
);
assert(
inspection.titleDeduplicationPassed,
`主题 ${result.id} 存在重复标题`
);
assert(
inspection.fullWidthTableCount === inspection.tableCount,
`主题 ${result.id} 存在非内容区全宽表格`
);
if (inspection.profile) {
assert(
inspection.structureCoverage === 1,
`主题 ${result.id} 的结构字段覆盖率不是 100%`
);
}
if (inspection.expectsStandaloneCover) {
assert(
inspection.standaloneCoverDetected,
`主题 ${result.id} 未生成合格的独立封面分节`
);
assert(
inspection.sectionPagesField,
`主题 ${result.id} 未使用 SECTIONPAGES`
);
}
}
const report = {
generatedAt: new Date().toISOString(),
pandocVersion: capability.capability.detectedVersion,
themeTokens: {
source: path.relative(
repositoryDirectory,
themeTokenReportPath
),
generatedAt: themeTokenReport.generatedAt,
chromiumVersion: themeTokenReport.chromiumVersion,
tokenSetCount: themeTokenReport.tokensByTheme.size
},
themeCount: themes.length,
outputDirectory: path.relative(
repositoryDirectory,
@@ -4,6 +4,7 @@ import {
OFFICE_RELATIONSHIP_NAMESPACE,
PACKAGE_RELATIONSHIP_NAMESPACE,
WORD_NAMESPACE,
firstDirectChild,
parseXmlPart,
type XmlElement
} from "./ooxml.js";
@@ -64,7 +65,15 @@ export interface DocxAcceptanceExpectation {
minimumPngImages?: number;
requiredImageAltText?: readonly string[];
requiredStyleIds?: readonly string[];
requiredParagraphStyleIds?: readonly string[];
requirePageField?: boolean;
minimumSections?: number;
requireFirstSectionWithoutHeaderFooter?: boolean;
finalPageNumberStart?: number;
requireSectionPagesField?: boolean;
minimumFullWidthTables?: number;
maximumTextOccurrences?: Readonly<Record<string, number>>;
forbidInternalMarkers?: boolean;
}
export interface DocxAcceptanceReport {
@@ -93,10 +102,15 @@ export interface DocxAcceptanceReport {
drawingCount: number;
pngImageCount: number;
pageFieldCount: number;
sectionPageFieldCount: number;
sectionCount: number;
fullWidthTableCount: number;
internalMarkerCount: number;
altChunkCount: number;
};
imageAltText: string[];
styleIds: string[];
appliedParagraphStyleIds: string[];
checks: Record<string, true>;
}
@@ -213,6 +227,48 @@ function collectPageFieldCount(
return count;
}
function collectFieldCount(
entries: ReadonlyMap<string, Uint8Array>,
field: "SECTIONPAGES"
) {
let count = 0;
for (const [partName, content] of entries) {
if (!/^word\/(?:document|header\d+|footer\d+)\.xml$/u.test(partName)) {
continue;
}
const document = parseXmlPart(content, partName);
for (const element of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "instrText")
)) {
if (
new RegExp(`\\b${field}\\b`, "u").test(
element.textContent ?? ""
)
) {
count += 1;
}
}
}
return count;
}
function countTextOccurrences(value: string, search: string) {
if (!search) {
return 0;
}
let count = 0;
let offset = 0;
while (offset <= value.length - search.length) {
const index = value.indexOf(search, offset);
if (index < 0) {
break;
}
count += 1;
offset = index + search.length;
}
return count;
}
export function inspectDocxAcceptance(
content: Uint8Array,
expectation: DocxAcceptanceExpectation
@@ -313,6 +369,62 @@ export function inspectDocxAcceptance(
expectation.page.marginsTwips[side]
);
}
assertMinimum(
expectation.id,
"sections",
sections.length,
expectation.minimumSections
);
if (expectation.requireFirstSectionWithoutHeaderFooter) {
const firstSection = sections[0]!;
const headerReferences =
firstSection.getElementsByTagNameNS(
WORD_NAMESPACE,
"headerReference"
).length;
const footerReferences =
firstSection.getElementsByTagNameNS(
WORD_NAMESPACE,
"footerReference"
).length;
assertEqual(
expectation.id,
"first-section-header-references",
headerReferences,
0
);
assertEqual(
expectation.id,
"first-section-footer-references",
footerReferences,
0
);
}
if (expectation.finalPageNumberStart !== undefined) {
const pageNumber = firstDirectChild(
section,
WORD_NAMESPACE,
"pgNumType"
);
if (!pageNumber) {
fail(
expectation.id,
"final-page-number-start",
"最终节缺少 w:pgNumType"
);
}
assertEqual(
expectation.id,
"final-page-number-start",
numericAttribute(
pageNumber,
"start",
expectation.id,
"final-page-number-start"
),
expectation.finalPageNumberStart
);
}
const paragraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
@@ -326,6 +438,22 @@ export function inspectDocxAcceptance(
WORD_NAMESPACE,
"tbl"
).length;
const fullWidthTableCount = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "tbl")
).filter((table) => {
const properties = firstDirectChild(
table,
WORD_NAMESPACE,
"tblPr"
);
const width = properties
? firstDirectChild(properties, WORD_NAMESPACE, "tblW")
: undefined;
return (
width?.getAttributeNS(WORD_NAMESPACE, "type") === "pct" &&
width.getAttributeNS(WORD_NAMESPACE, "w") === "5000"
);
}).length;
const numberedParagraphCount = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"numPr"
@@ -368,6 +496,12 @@ export function inspectDocxAcceptance(
tableCount,
expectation.minimumTables
);
assertMinimum(
expectation.id,
"full-width-tables",
fullWidthTableCount,
expectation.minimumFullWidthTables
);
assertMinimum(
expectation.id,
"numbering",
@@ -410,6 +544,30 @@ export function inspectDocxAcceptance(
);
}
}
for (const [text, maximum] of Object.entries(
expectation.maximumTextOccurrences ?? {}
)) {
const actual = countTextOccurrences(bodyText, text);
if (actual > maximum) {
fail(
expectation.id,
"text-occurrences",
`${JSON.stringify(text)} 最多允许 ${maximum} 次,实际 ${actual}`
);
}
}
const internalMarkerCount = countTextOccurrences(
bodyText,
"MD_TO_PDF_"
);
if (expectation.forbidInternalMarkers) {
assertEqual(
expectation.id,
"internal-markers",
internalMarkerCount,
0
);
}
if ((expectation.requiredFootnoteText?.length ?? 0) > 0) {
const footnotesPart = entries.get("word/footnotes.xml");
if (!footnotesPart) {
@@ -517,11 +675,40 @@ export function inspectDocxAcceptance(
);
}
}
const appliedParagraphStyleIds = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "pStyle")
)
.map((element) =>
element.getAttributeNS(WORD_NAMESPACE, "val")
)
.filter((value): value is string => Boolean(value));
for (const requiredStyleId of
expectation.requiredParagraphStyleIds ?? []) {
if (!appliedParagraphStyleIds.includes(requiredStyleId)) {
fail(
expectation.id,
"applied-paragraph-styles",
`正文未使用样式 ${requiredStyleId}`
);
}
}
const pageFieldCount = collectPageFieldCount(entries);
const sectionPageFieldCount = collectFieldCount(
entries,
"SECTIONPAGES"
);
if (expectation.requirePageField) {
assertMinimum(expectation.id, "page-field", pageFieldCount, 1);
}
if (expectation.requireSectionPagesField) {
assertMinimum(
expectation.id,
"section-pages-field",
sectionPageFieldCount,
1
);
}
return {
id: expectation.id,
package: packageValidation,
@@ -538,10 +725,15 @@ export function inspectDocxAcceptance(
drawingCount,
pngImageCount: pngParts.size,
pageFieldCount,
sectionPageFieldCount,
sectionCount: sections.length,
fullWidthTableCount,
internalMarkerCount,
altChunkCount
},
imageAltText,
styleIds,
appliedParagraphStyleIds,
checks: {
package: true,
page: true,
@@ -549,6 +741,9 @@ export function inspectDocxAcceptance(
relationships: true,
pngMedia: true,
styles: true,
sections: true,
tables: true,
textOccurrences: true,
noAltChunk: true
}
};
@@ -0,0 +1,864 @@
import type {
DocxSlotStyleToken,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
WORD_NAMESPACE,
appendElement,
colorValue,
directChildren,
firstDirectChild,
parseXmlPart,
removeDirectChildren,
serializeXmlPart,
type XmlElement
} from "./ooxml.js";
import type {
PandocStructureBlock,
PandocStructureContainer,
PandocStructurePlan,
PandocStructureSectionBreak
} from "./pandoc-structure.js";
import {
readGeneratedDocxPackage,
writeGeneratedDocxPackage
} from "./reference-package.js";
import {
createDocxTokenSlotMap,
DOCX_SLOT_WORD_STYLE_BINDINGS
} from "./token-style-map.js";
type DocxBorderToken = NonNullable<
NonNullable<DocxSlotStyleToken["borders"]>["top"]
>;
export interface GeneratedDocxStructureReport {
containerCount: number;
sectionCount: number;
tableCount: number;
pageBreakAfterCount: number;
sectionPageFieldCount: number;
}
export interface FinalizedGeneratedDocx {
content: Uint8Array;
report: GeneratedDocxStructureReport;
}
interface ContainerDescriptor {
container: PandocStructureContainer;
followedBySection: boolean;
}
function setWordAttribute(
element: XmlElement,
localName: string,
value: string
) {
element.setAttributeNS(
WORD_NAMESPACE,
`w:${localName}`,
value
);
}
function wordAttribute(
element: XmlElement,
localName: string
) {
return element.getAttributeNS(WORD_NAMESPACE, localName);
}
function ensureFirstElement(
parent: XmlElement,
localName: string,
qualifiedName: string
) {
const existing = firstDirectChild(
parent,
WORD_NAMESPACE,
localName
);
if (existing) {
return existing;
}
const element = parent.ownerDocument!.createElementNS(
WORD_NAMESPACE,
qualifiedName
);
parent.insertBefore(element, parent.firstChild);
return element;
}
function paragraphProperties(paragraph: XmlElement) {
return ensureFirstElement(paragraph, "pPr", "w:pPr");
}
function paragraphText(paragraph: XmlElement) {
return Array.from(
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "t")
)
.map((element) => element.textContent ?? "")
.join("");
}
function bodyElements(body: XmlElement) {
return Array.from(body.childNodes).filter(
(node): node is XmlElement => node.nodeType === 1
);
}
function markerParagraph(
body: XmlElement,
marker: string
): XmlElement {
const matches = bodyElements(body).filter(
(element) =>
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p" &&
paragraphText(element) === marker
);
if (matches.length !== 1) {
throw new Error(
`DOCX 结构标记数量无效:${marker}${matches.length}`
);
}
return matches[0]!;
}
function flattenContainers(
blocks: readonly PandocStructureBlock[]
): PandocStructureContainer[] {
return blocks.flatMap((block) =>
block.kind === "container"
? [block, ...flattenContainers(block.blocks)]
: []
);
}
function containerDescriptors(
plan: PandocStructurePlan
): ContainerDescriptor[] {
const descriptors: ContainerDescriptor[] = [];
for (const blocks of [plan.prefix, plan.suffix]) {
for (const [index, block] of blocks.entries()) {
if (block.kind !== "container") {
continue;
}
descriptors.push({
container: block,
followedBySection:
blocks[index + 1]?.kind === "section-break"
});
for (const nested of flattenContainers(block.blocks)) {
descriptors.push({
container: nested,
followedBySection: false
});
}
}
}
return descriptors;
}
function sectionBreaks(
blocks: readonly PandocStructureBlock[]
): PandocStructureSectionBreak[] {
return blocks.flatMap((block) => {
if (block.kind === "section-break") {
return [block];
}
return block.kind === "container"
? sectionBreaks(block.blocks)
: [];
});
}
function toggleParagraphProperty(
paragraph: XmlElement,
localName: "keepLines" | "keepNext" | "pageBreakBefore",
enabled: boolean
) {
const properties = paragraphProperties(paragraph);
removeDirectChildren(properties, WORD_NAMESPACE, localName);
if (enabled) {
appendElement(
properties,
WORD_NAMESPACE,
`w:${localName}`
);
}
}
function appendPageBreak(paragraph: XmlElement): boolean {
const existing = Array.from(
paragraph.getElementsByTagNameNS(WORD_NAMESPACE, "br")
).some(
(element) => wordAttribute(element, "type") === "page"
);
if (existing) {
return false;
}
const run = appendElement(
paragraph,
WORD_NAMESPACE,
"w:r"
);
appendElement(run, WORD_NAMESPACE, "w:br", {
"w:type": "page"
});
return true;
}
function borderAttributes(border: DocxBorderToken) {
return {
"w:val": border.style,
"w:sz": String(
Math.max(2, Math.min(96, Math.round(border.widthPt * 8)))
),
"w:space": "0",
"w:color": colorValue(border.color)
};
}
function applyContainerToken(
paragraphs: readonly XmlElement[],
token: DocxSlotStyleToken | undefined,
followedBySection: boolean
) {
if (!token || paragraphs.length === 0) {
return 0;
}
for (const [index, paragraph] of paragraphs.entries()) {
const properties = paragraphProperties(paragraph);
if (token.backgroundColor) {
removeDirectChildren(properties, WORD_NAMESPACE, "shd");
appendElement(properties, WORD_NAMESPACE, "w:shd", {
"w:val": "clear",
"w:color": "auto",
"w:fill": colorValue(token.backgroundColor)
});
}
if (token.borders) {
const borders =
firstDirectChild(properties, WORD_NAMESPACE, "pBdr") ??
appendElement(properties, WORD_NAMESPACE, "w:pBdr");
for (const side of ["left", "right"] as const) {
removeDirectChildren(borders, WORD_NAMESPACE, side);
const border = token.borders[side];
if (border) {
appendElement(
borders,
WORD_NAMESPACE,
`w:${side}`,
borderAttributes(border)
);
}
}
if (index === 0) {
removeDirectChildren(borders, WORD_NAMESPACE, "top");
if (token.borders.top) {
appendElement(
borders,
WORD_NAMESPACE,
"w:top",
borderAttributes(token.borders.top)
);
}
}
if (index === paragraphs.length - 1) {
removeDirectChildren(borders, WORD_NAMESPACE, "bottom");
if (token.borders.bottom) {
appendElement(
borders,
WORD_NAMESPACE,
"w:bottom",
borderAttributes(token.borders.bottom)
);
}
}
}
if (token.keepLines) {
toggleParagraphProperty(paragraph, "keepLines", true);
if (index < paragraphs.length - 1) {
toggleParagraphProperty(paragraph, "keepNext", true);
}
}
if (token.keepWithNext) {
toggleParagraphProperty(paragraph, "keepNext", true);
}
if (index === 0 && token.pageBreakBefore) {
toggleParagraphProperty(
paragraph,
"pageBreakBefore",
true
);
}
}
return token.pageBreakAfter && !followedBySection
? Number(appendPageBreak(paragraphs.at(-1)!))
: 0;
}
function applyContainerRanges(
body: XmlElement,
plan: PandocStructurePlan,
tokens: DocxThemeTokenSet
) {
const slots = createDocxTokenSlotMap(tokens);
let pageBreakAfterCount = 0;
let containerCount = 0;
for (const descriptor of containerDescriptors(plan)) {
const { container } = descriptor;
const start = markerParagraph(body, container.startMarker);
const end = markerParagraph(body, container.endMarker);
const elements = bodyElements(body);
const startIndex = elements.indexOf(start);
const endIndex = elements.indexOf(end);
if (startIndex < 0 || endIndex <= startIndex) {
throw new Error(
`DOCX 容器标记顺序无效:${container.startMarker}`
);
}
const paragraphs = elements
.slice(startIndex + 1, endIndex)
.filter(
(element) =>
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p"
);
const token = container.slot
? slots.get(container.slot)?.style
: undefined;
pageBreakAfterCount += applyContainerToken(
paragraphs,
token,
descriptor.followedBySection
);
body.removeChild(start);
body.removeChild(end);
containerCount += 1;
}
return { containerCount, pageBreakAfterCount };
}
function finalSection(body: XmlElement) {
const section = firstDirectChild(
body,
WORD_NAMESPACE,
"sectPr"
);
if (!section) {
throw new Error("DOCX 正文缺少最终节");
}
return section;
}
function setSectionPageNumber(
section: XmlElement,
start: number
) {
removeDirectChildren(section, WORD_NAMESPACE, "pgNumType");
const pageNumber = section.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:pgNumType"
);
setWordAttribute(pageNumber, "start", String(start));
const insertionPoint =
firstDirectChild(section, WORD_NAMESPACE, "cols") ??
firstDirectChild(section, WORD_NAMESPACE, "formProt") ??
firstDirectChild(section, WORD_NAMESPACE, "vAlign") ??
firstDirectChild(section, WORD_NAMESPACE, "titlePg");
section.insertBefore(pageNumber, insertionPoint ?? null);
}
function applySections(
body: XmlElement,
plan: PandocStructurePlan
) {
const breaks = [
...sectionBreaks(plan.prefix),
...sectionBreaks(plan.suffix)
];
if (breaks.length === 0) {
return 0;
}
const followingSection = finalSection(body);
for (const sectionBreak of breaks) {
const marker = markerParagraph(body, sectionBreak.marker);
const elements = bodyElements(body);
const markerIndex = elements.indexOf(marker);
const previous = [...elements.slice(0, markerIndex)]
.reverse()
.find(
(element) =>
element.namespaceURI === WORD_NAMESPACE &&
element.localName === "p"
);
if (!previous) {
throw new Error(
`DOCX 分节标记之前缺少段落:${sectionBreak.marker}`
);
}
const coverSection = followingSection.cloneNode(
true
) as XmlElement;
removeDirectChildren(
coverSection,
WORD_NAMESPACE,
"headerReference",
"footerReference",
"pgNumType",
"titlePg",
"type",
"vAlign"
);
const type = coverSection.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:type"
);
setWordAttribute(type, "val", "nextPage");
coverSection.insertBefore(
type,
firstDirectChild(coverSection, WORD_NAMESPACE, "pgSz") ??
coverSection.firstChild
);
if (sectionBreak.verticalAlignment) {
appendElement(coverSection, WORD_NAMESPACE, "w:vAlign", {
"w:val": sectionBreak.verticalAlignment
});
}
const properties = paragraphProperties(previous);
removeDirectChildren(properties, WORD_NAMESPACE, "sectPr");
properties.appendChild(coverSection);
setSectionPageNumber(
followingSection,
sectionBreak.followingPageNumberStart
);
body.removeChild(marker);
}
return breaks.length;
}
function replaceSectionPageFields(
entries: Map<string, Uint8Array>,
enabled: boolean
) {
if (!enabled) {
return 0;
}
let count = 0;
for (const [partName, content] of entries) {
if (!/^word\/footer\d+\.xml$/u.test(partName)) {
continue;
}
const document = parseXmlPart(content, partName);
for (const instruction of Array.from(
document.getElementsByTagNameNS(
WORD_NAMESPACE,
"instrText"
)
)) {
const value = instruction.textContent ?? "";
const replaced = value.replace(
/\bNUMPAGES\b/gu,
"SECTIONPAGES"
);
if (replaced === value) {
continue;
}
while (instruction.firstChild) {
instruction.removeChild(instruction.firstChild);
}
instruction.appendChild(
instruction.ownerDocument!.createTextNode(replaced)
);
count += 1;
}
entries.set(partName, serializeXmlPart(document));
}
return count;
}
function normalizeGeneratedStyles(
entries: Map<string, Uint8Array>
) {
const partName = "word/styles.xml";
const document = parseXmlPart(entries.get(partName)!, partName);
const styles = document.documentElement;
if (
!styles ||
styles.namespaceURI !== WORD_NAMESPACE ||
styles.localName !== "styles"
) {
throw new Error("word/styles.xml 的根元素无效");
}
const seenStyleIds = new Set<string>();
for (const style of directChildren(
styles,
WORD_NAMESPACE,
"style"
)) {
const styleId = wordAttribute(style, "styleId");
if (!styleId || !seenStyleIds.has(styleId)) {
if (styleId) {
seenStyleIds.add(styleId);
}
continue;
}
styles.removeChild(style);
}
for (const alignment of Array.from(
styles.getElementsByTagNameNS(WORD_NAMESPACE, "jc")
)) {
if (wordAttribute(alignment, "val") === "justify") {
setWordAttribute(alignment, "val", "both");
}
}
for (const style of directChildren(
styles,
WORD_NAMESPACE,
"style"
)) {
if (wordAttribute(style, "type") !== "table") {
continue;
}
const properties = firstDirectChild(
style,
WORD_NAMESPACE,
"tblPr"
);
if (properties) {
removeDirectChildren(properties, WORD_NAMESPACE, "tblW");
}
}
entries.set(partName, serializeXmlPart(document));
}
function sectionContentWidth(section: XmlElement) {
const pageSize = firstDirectChild(
section,
WORD_NAMESPACE,
"pgSz"
);
const margins = firstDirectChild(
section,
WORD_NAMESPACE,
"pgMar"
);
const width = Number(pageSize && wordAttribute(pageSize, "w"));
const left = Number(margins && wordAttribute(margins, "left"));
const right = Number(margins && wordAttribute(margins, "right"));
const contentWidth = width - left - right;
if (
!Number.isFinite(contentWidth) ||
contentWidth <= 0
) {
throw new Error("DOCX 内容区宽度无效");
}
return Math.round(contentWidth);
}
function gridSpan(cell: XmlElement) {
const properties = firstDirectChild(
cell,
WORD_NAMESPACE,
"tcPr"
);
const span = properties
? firstDirectChild(properties, WORD_NAMESPACE, "gridSpan")
: undefined;
const value = Number(span && wordAttribute(span, "val"));
return Number.isInteger(value) && value > 0 ? value : 1;
}
function tableColumnCount(table: XmlElement) {
return Math.max(
1,
...directChildren(table, WORD_NAMESPACE, "tr").map((row) =>
directChildren(row, WORD_NAMESPACE, "tc").reduce(
(total, cell) => total + gridSpan(cell),
0
)
)
);
}
function scaledColumnWidths(
table: XmlElement,
count: number,
targetWidth: number
) {
const grid = firstDirectChild(
table,
WORD_NAMESPACE,
"tblGrid"
);
const declared = grid
? directChildren(grid, WORD_NAMESPACE, "gridCol").map(
(column) => Number(wordAttribute(column, "w"))
)
: [];
const weights =
declared.length === count &&
declared.every((value) => Number.isFinite(value) && value > 0)
? declared
: Array.from({ length: count }, () => 1);
const total = weights.reduce((sum, value) => sum + value, 0);
const widths = weights.map((value) =>
Math.max(1, Math.round((targetWidth * value) / total))
);
const lastIndex = widths.length - 1;
widths[lastIndex] =
widths[lastIndex]! +
targetWidth - widths.reduce((sum, value) => sum + value, 0);
return widths;
}
function ensureTableGrid(table: XmlElement) {
const existing = firstDirectChild(
table,
WORD_NAMESPACE,
"tblGrid"
);
if (existing) {
return existing;
}
const grid = table.ownerDocument!.createElementNS(
WORD_NAMESPACE,
"w:tblGrid"
);
const firstRow = firstDirectChild(
table,
WORD_NAMESPACE,
"tr"
);
table.insertBefore(grid, firstRow ?? null);
return grid;
}
function applyTables(
document: ReturnType<typeof parseXmlPart>,
section: XmlElement,
tokens: DocxThemeTokenSet
) {
const tableToken =
createDocxTokenSlotMap(tokens).get("table")?.style;
const widthPercent = tableToken?.widthPercent ?? 100;
const targetWidth = Math.max(
1,
Math.round(
(sectionContentWidth(section) * widthPercent) / 100
)
);
const tables = Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "tbl")
);
for (const table of tables) {
const properties = ensureFirstElement(
table,
"tblPr",
"w:tblPr"
);
removeDirectChildren(
properties,
WORD_NAMESPACE,
"tblW",
"tblLayout"
);
appendElement(properties, WORD_NAMESPACE, "w:tblW", {
"w:w": String(Math.round(widthPercent * 50)),
"w:type": "pct"
});
appendElement(properties, WORD_NAMESPACE, "w:tblLayout", {
"w:type": "fixed"
});
const columnCount = tableColumnCount(table);
const widths = scaledColumnWidths(
table,
columnCount,
targetWidth
);
const grid = ensureTableGrid(table);
for (const child of Array.from(grid.childNodes)) {
grid.removeChild(child);
}
for (const width of widths) {
appendElement(grid, WORD_NAMESPACE, "w:gridCol", {
"w:w": String(width)
});
}
for (const row of directChildren(
table,
WORD_NAMESPACE,
"tr"
)) {
if (tableToken?.keepLines) {
const rowProperties = ensureFirstElement(
row,
"trPr",
"w:trPr"
);
removeDirectChildren(
rowProperties,
WORD_NAMESPACE,
"cantSplit"
);
appendElement(
rowProperties,
WORD_NAMESPACE,
"w:cantSplit"
);
}
let columnIndex = 0;
for (const cell of directChildren(
row,
WORD_NAMESPACE,
"tc"
)) {
const span = gridSpan(cell);
const cellWidth = widths
.slice(columnIndex, columnIndex + span)
.reduce((sum, value) => sum + value, 0);
columnIndex += span;
const cellProperties = ensureFirstElement(
cell,
"tcPr",
"w:tcPr"
);
removeDirectChildren(
cellProperties,
WORD_NAMESPACE,
"tcW"
);
appendElement(
cellProperties,
WORD_NAMESPACE,
"w:tcW",
{
"w:w": String(cellWidth),
"w:type": "dxa"
}
);
}
}
}
return tables.length;
}
function pageBreakAfterStyleIds(tokens: DocxThemeTokenSet) {
const ids = new Set<string>();
for (const entry of tokens.slots) {
if (!entry.style.pageBreakAfter) {
continue;
}
for (const binding of
DOCX_SLOT_WORD_STYLE_BINDINGS[entry.slot] ?? []) {
if (binding.type === "paragraph") {
ids.add(binding.styleId);
}
}
}
return ids;
}
function applyParagraphPageBreaks(
document: ReturnType<typeof parseXmlPart>,
tokens: DocxThemeTokenSet
) {
const styleIds = pageBreakAfterStyleIds(tokens);
let count = 0;
for (const paragraph of Array.from(
document.getElementsByTagNameNS(WORD_NAMESPACE, "p")
)) {
const properties = firstDirectChild(
paragraph,
WORD_NAMESPACE,
"pPr"
);
const style = properties
? firstDirectChild(properties, WORD_NAMESPACE, "pStyle")
: undefined;
const styleId = style && wordAttribute(style, "val");
if (styleId && styleIds.has(styleId)) {
count += Number(appendPageBreak(paragraph));
}
}
return count;
}
function ensureNoMarkers(
body: XmlElement,
plan: PandocStructurePlan
) {
const markers = new Set<string>();
for (const descriptor of containerDescriptors(plan)) {
markers.add(descriptor.container.startMarker);
markers.add(descriptor.container.endMarker);
}
for (const section of [
...sectionBreaks(plan.prefix),
...sectionBreaks(plan.suffix)
]) {
markers.add(section.marker);
}
const residual = bodyElements(body).find(
(element) =>
element.localName === "p" &&
markers.has(paragraphText(element))
);
if (residual) {
throw new Error(
`DOCX 结构标记未被消费:${paragraphText(residual)}`
);
}
}
export function finalizeGeneratedDocxStructure(
content: Uint8Array,
plan: PandocStructurePlan,
tokens: DocxThemeTokenSet
): FinalizedGeneratedDocx {
const source = readGeneratedDocxPackage(content);
const entries = new Map(source.entries);
const document = parseXmlPart(
entries.get("word/document.xml")!,
"word/document.xml"
);
const body = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"body"
)[0];
if (!body) {
throw new Error("DOCX 正文结构无效");
}
const containers = applyContainerRanges(body, plan, tokens);
const sectionCount = applySections(body, plan);
const final = finalSection(body);
const tableCount = applyTables(document, final, tokens);
const pageBreakAfterCount =
containers.pageBreakAfterCount +
applyParagraphPageBreaks(document, tokens);
ensureNoMarkers(body, plan);
entries.set(
"word/document.xml",
serializeXmlPart(document)
);
const sectionPageFieldCount = replaceSectionPageFields(
entries,
sectionCount > 0
);
normalizeGeneratedStyles(entries);
return {
content: writeGeneratedDocxPackage(entries),
report: {
containerCount: containers.containerCount,
sectionCount,
tableCount,
pageBreakAfterCount,
sectionPageFieldCount
}
};
}
+2
View File
@@ -1,8 +1,10 @@
export * from "./acceptance-validator.js";
export * from "./document-structure-transform.js";
export * from "./header-footer-transform.js";
export * from "./ooxml.js";
export * from "./pandoc-process.js";
export * from "./pandoc-media.js";
export * from "./pandoc-structure.js";
export * from "./pandoc-converter.js";
export * from "./pandoc-runtime-manifest.js";
export * from "./pandoc-runtime.js";
+388 -3
View File
@@ -1,10 +1,11 @@
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
import type {
Document as XmlDocument,
Element as XmlElement
Element as XmlElement,
Node as XmlNode
} from "@xmldom/xmldom";
export type { XmlDocument, XmlElement };
export type { XmlDocument, XmlElement, XmlNode };
export const WORD_NAMESPACE =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
@@ -20,6 +21,385 @@ export const DRAWING_NAMESPACE =
const decoder = new TextDecoder("utf-8", { fatal: true });
const encoder = new TextEncoder();
const wordprocessingChildOrders = new Map<
string,
readonly string[]
>([
[
"style",
[
"name",
"aliases",
"basedOn",
"next",
"link",
"autoRedefine",
"hidden",
"uiPriority",
"semiHidden",
"unhideWhenUsed",
"qFormat",
"locked",
"personal",
"personalCompose",
"personalReply",
"rsid",
"pPr",
"rPr",
"tblPr",
"trPr",
"tcPr"
]
],
[
"pPr",
[
"pStyle",
"keepNext",
"keepLines",
"pageBreakBefore",
"framePr",
"widowControl",
"numPr",
"suppressLineNumbers",
"pBdr",
"shd",
"tabs",
"suppressAutoHyphens",
"kinsoku",
"wordWrap",
"overflowPunct",
"topLinePunct",
"autoSpaceDE",
"autoSpaceDN",
"bidi",
"adjustRightInd",
"snapToGrid",
"spacing",
"ind",
"contextualSpacing",
"mirrorIndents",
"suppressOverlap",
"jc",
"textDirection",
"textAlignment",
"textboxTightWrap",
"outlineLvl",
"divId",
"cnfStyle",
"rPr",
"sectPr",
"pPrChange"
]
],
[
"rPr",
[
"rStyle",
"rFonts",
"b",
"bCs",
"i",
"iCs",
"caps",
"smallCaps",
"strike",
"dstrike",
"outline",
"shadow",
"emboss",
"imprint",
"noProof",
"snapToGrid",
"vanish",
"webHidden",
"color",
"spacing",
"w",
"kern",
"position",
"sz",
"szCs",
"highlight",
"u",
"effect",
"bdr",
"shd",
"fitText",
"vertAlign",
"rtl",
"cs",
"em",
"lang",
"eastAsianLayout",
"specVanish",
"oMath",
"rPrChange"
]
],
[
"numPr",
["ilvl", "numId", "numberingChange", "ins"]
],
[
"pBdr",
["top", "left", "bottom", "right", "between", "bar"]
],
[
"tblPr",
[
"tblStyle",
"tblpPr",
"tblOverlap",
"bidiVisual",
"tblStyleRowBandSize",
"tblStyleColBandSize",
"tblW",
"jc",
"tblCellSpacing",
"tblInd",
"tblBorders",
"shd",
"tblLayout",
"tblCellMar",
"tblLook",
"tblCaption",
"tblDescription",
"tblPrChange"
]
],
[
"trPr",
[
"cnfStyle",
"divId",
"gridBefore",
"gridAfter",
"wBefore",
"wAfter",
"cantSplit",
"trHeight",
"tblHeader",
"tblCellSpacing",
"jc",
"hidden",
"ins",
"del",
"trPrChange",
"conflictIns",
"conflictDel"
]
],
[
"tcPr",
[
"cnfStyle",
"tcW",
"gridSpan",
"hMerge",
"vMerge",
"tcBorders",
"shd",
"noWrap",
"tcMar",
"textDirection",
"tcFitText",
"vAlign",
"hideMark",
"headers",
"cellIns",
"cellDel",
"cellMerge",
"tcPrChange"
]
],
[
"tblStylePr",
["pPr", "rPr", "tblPr", "trPr", "tcPr"]
],
[
"sectPr",
[
"headerReference",
"footerReference",
"footnotePr",
"endnotePr",
"type",
"pgSz",
"pgMar",
"paperSrc",
"pgBorders",
"lnNumType",
"pgNumType",
"cols",
"formProt",
"vAlign",
"noEndnote",
"titlePg",
"textDirection",
"bidi",
"rtlGutter",
"docGrid",
"printerSettings",
"sectPrChange"
]
],
[
"tblBorders",
["top", "left", "bottom", "right", "insideH", "insideV"]
],
[
"tcBorders",
[
"top",
"left",
"bottom",
"right",
"insideH",
"insideV",
"tl2br",
"tr2bl"
]
],
[
"tblCellMar",
["top", "left", "start", "bottom", "right", "end"]
],
[
"tcMar",
["top", "left", "start", "bottom", "right", "end"]
],
[
"font",
[
"altName",
"panose1",
"charset",
"family",
"notTrueType",
"pitch",
"sig",
"embedRegular",
"embedBold",
"embedItalic",
"embedBoldItalic"
]
]
]);
function directWordChildren(parent: XmlElement) {
return Array.from(parent.childNodes).filter(
(node): node is XmlElement =>
node.nodeType === 1 &&
(node as XmlElement).namespaceURI === WORD_NAMESPACE
);
}
function wordChildOrder(parent: XmlElement) {
if (parent.namespaceURI !== WORD_NAMESPACE) {
return undefined;
}
return wordprocessingChildOrders.get(parent.localName ?? "");
}
function orderedWordChildren(parent: XmlElement) {
const order = wordChildOrder(parent);
if (!order) {
return undefined;
}
const ranks = new Map(order.map((name, index) => [name, index]));
return {
order,
ranks,
children: directWordChildren(parent).filter((child) =>
ranks.has(child.localName ?? "")
)
};
}
function insertWordElementInOrder(
parent: XmlElement,
element: XmlElement
) {
const ordered = orderedWordChildren(parent);
const rank = ordered?.ranks.get(element.localName ?? "");
if (!ordered || rank === undefined) {
parent.appendChild(element);
return;
}
const insertionPoint = ordered.children.find((child) => {
const childRank = ordered.ranks.get(child.localName ?? "");
return childRank !== undefined && childRank > rank;
});
parent.insertBefore(element, insertionPoint ?? null);
}
export function normalizeWordprocessingElementOrder(
document: XmlDocument
) {
const elements = Array.from(document.getElementsByTagName("*"));
for (const parent of elements) {
const ordered = orderedWordChildren(parent);
if (!ordered || ordered.children.length < 2) {
continue;
}
const sorted = ordered.children
.map((child, index) => ({ child, index }))
.sort((left, right) => {
const leftRank = ordered.ranks.get(
left.child.localName ?? ""
)!;
const rightRank = ordered.ranks.get(
right.child.localName ?? ""
)!;
return leftRank - rightRank || left.index - right.index;
})
.map(({ child }) => child);
if (
sorted.every(
(child, index) => child === ordered.children[index]
)
) {
continue;
}
const anchor: XmlNode | null =
ordered.children.at(-1)?.nextSibling ?? null;
for (const child of ordered.children) {
parent.removeChild(child);
}
for (const child of sorted) {
parent.insertBefore(child, anchor);
}
}
}
export function validateWordprocessingElementOrder(
document: XmlDocument,
partName: string
) {
const elements = Array.from(document.getElementsByTagName("*"));
for (const parent of elements) {
const ordered = orderedWordChildren(parent);
if (!ordered) {
continue;
}
let previousRank = -1;
let previousName = "";
for (const child of ordered.children) {
const name = child.localName ?? "";
const rank = ordered.ranks.get(name)!;
if (rank < previousRank) {
throw new Error(
`${partName} 的 w:${parent.localName} 子节点顺序无效:` +
`w:${name} 不得位于 w:${previousName} 之后`
);
}
previousRank = rank;
previousName = name;
}
}
}
export function parseXmlPart(
content: Uint8Array,
partName: string
@@ -42,6 +422,7 @@ export function parseXmlPart(
}
export function serializeXmlPart(document: XmlDocument) {
normalizeWordprocessingElementOrder(document);
return encoder.encode(
`<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(
document.documentElement!
@@ -113,7 +494,11 @@ export function appendElement(
: null;
element.setAttributeNS(attributeNamespace, name, value);
}
parent.appendChild(element);
if (namespace === WORD_NAMESPACE) {
insertWordElementInOrder(parent, element);
} else {
parent.appendChild(element);
}
return element;
}
+38 -3
View File
@@ -1,3 +1,6 @@
import {
randomUUID
} from "node:crypto";
import {
mkdir,
mkdtemp,
@@ -15,6 +18,7 @@ import {
type ExportConfig,
type MarkdownDocumentMetadata,
type PreparedDocxMedia,
type SemanticDocumentModel,
type ThemeManifest
} from "@md-to-pdf/core";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
@@ -23,8 +27,10 @@ import {
createDynamicReferenceDocx,
type DynamicReferenceDocxResult
} from "./reference-builder.js";
import { finalizeGeneratedDocxStructure } from "./document-structure-transform.js";
import type { DynamicReferenceDocxOptions } from "./reference-options.js";
import { preparePandocMedia } from "./pandoc-media.js";
import { createPandocStructurePlan } from "./pandoc-structure.js";
import {
runPandocProcess,
type PandocProcessRunner
@@ -42,6 +48,8 @@ const MAXIMUM_PANDOC_STDERR_BYTES = 64 * 1024;
const DEFAULT_REFERENCE_CACHE_SIZE = 32;
const temporaryDirectoryPrefix = "md-to-pdf-docx-";
const mediaMapEnvironmentName = "MD_TO_PDF_DOCX_MEDIA_MAP";
const structurePlanEnvironmentName =
"MD_TO_PDF_DOCX_STRUCTURE_PLAN";
const luaFilterUrl = new URL(
"../assets/docx-media-filter.lua",
import.meta.url
@@ -60,6 +68,7 @@ export interface PandocDocxConversionInput {
theme: ThemeManifest;
themeTokens: DocxThemeTokenSet;
metadata: MarkdownDocumentMetadata;
semanticDocument: SemanticDocumentModel;
media: PreparedDocxMedia;
}
@@ -215,12 +224,18 @@ export class PandocDocxConverter {
signal?: AbortSignal
): Promise<PandocDocxConversionResult> {
let preparedMedia;
let structurePlan;
try {
preparedMedia = preparePandocMedia(input.media);
structurePlan = createPandocStructurePlan(
input.semanticDocument,
input.themeTokens,
{ markerSeed: randomUUID() }
);
} catch (error) {
throw new PandocDocxConversionError(
"DOCX_GENERATION_FAILED",
"DOCX 媒体映射无效",
"DOCX 媒体或语义结构映射无效",
{ cause: error }
);
}
@@ -251,6 +266,10 @@ export class PandocDocxConverter {
"docx-media-filter.lua"
),
mediaMap: path.join(temporaryDirectory, "media-map.json"),
structurePlan: path.join(
temporaryDirectory,
"structure-plan.json"
),
mediaDirectory: path.join(temporaryDirectory, "media"),
dataDirectory: path.join(temporaryDirectory, "pandoc-data")
};
@@ -267,6 +286,11 @@ export class PandocDocxConverter {
JSON.stringify(preparedMedia.map),
"utf8"
),
writeFile(
paths.structurePlan,
JSON.stringify(structurePlan),
"utf8"
),
...preparedMedia.files.map((file) =>
writeFile(
path.join(temporaryDirectory, file.relativePath),
@@ -291,7 +315,8 @@ export class PandocDocxConverter {
cwd: temporaryDirectory,
env: {
...(this.options.environment ?? process.env),
[mediaMapEnvironmentName]: paths.mediaMap
[mediaMapEnvironmentName]: paths.mediaMap,
[structurePlanEnvironmentName]: paths.structurePlan
},
timeoutMs: this.timeoutMs,
maxStdoutBytes: MAXIMUM_PANDOC_STDOUT_BYTES,
@@ -333,9 +358,19 @@ export class PandocDocxConverter {
"DOCX 输出大小无效"
);
}
const docx = await readFile(paths.output);
let docx: Uint8Array;
let validation: DynamicReferenceValidation;
try {
const pandocDocx = await readFile(paths.output);
const finalized = finalizeGeneratedDocxStructure(
pandocDocx,
structurePlan,
input.themeTokens
);
docx = finalized.content;
if (docx.byteLength > this.maximumOutputBytes) {
throw new Error("DOCX 结构收口后的输出大小无效");
}
validation = validateGeneratedDocx(docx);
} catch (error) {
throw new PandocDocxConversionError(
@@ -0,0 +1,240 @@
import {
semanticDocumentModelSchema,
type SemanticDocumentGroupNode,
type SemanticDocumentModel,
type SemanticDocumentNode,
type SemanticDocumentRole,
type SemanticDocumentTextNode
} from "@md-to-pdf/core";
import {
type DocxStyleSlotName,
type DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
createDocxTokenSlotMap,
DOCX_SLOT_WORD_STYLE_BINDINGS
} from "./token-style-map.js";
export interface PandocStructureParagraph {
kind: "paragraph";
styleId?: string | undefined;
segments: string[];
separator: "space" | "tab";
}
export interface PandocStructureContainer {
kind: "container";
styleId?: string | undefined;
slot?: DocxStyleSlotName | undefined;
startMarker: string;
endMarker: string;
blocks: PandocStructureBlock[];
}
export interface PandocStructureSectionBreak {
kind: "section-break";
marker: string;
headerFooter: "none";
pageNumber: "hidden";
followingPageNumberStart: number;
verticalAlignment?: "top" | "center" | "bottom" | undefined;
}
export type PandocStructureBlock =
| PandocStructureParagraph
| PandocStructureContainer
| PandocStructureSectionBreak;
export interface PandocStructurePlan {
schemaVersion: 1;
titlePolicy: SemanticDocumentModel["titlePolicy"];
prefix: PandocStructureBlock[];
suffix: PandocStructureBlock[];
}
export interface CreatePandocStructurePlanOptions {
markerSeed?: string | undefined;
}
const containerRoles = new Set<SemanticDocumentRole>([
"official-masthead",
"official-signature",
"official-edition",
"briefing-masthead",
"project-report-cover",
"tender-cover"
]);
function slotName(
role: SemanticDocumentRole
): DocxStyleSlotName | undefined {
return Object.prototype.hasOwnProperty.call(
DOCX_SLOT_WORD_STYLE_BINDINGS,
role
)
? (role as DocxStyleSlotName)
: undefined;
}
function paragraphStyleId(
role: SemanticDocumentRole
): string | undefined {
const slot = slotName(role);
return slot
? DOCX_SLOT_WORD_STYLE_BINDINGS[slot]?.find(
(binding) => binding.type === "paragraph"
)?.styleId
: undefined;
}
function textValue(node: SemanticDocumentTextNode): string {
return `${node.label ?? ""}${node.text}`;
}
function isHidden(
role: SemanticDocumentRole,
slots: ReturnType<typeof createDocxTokenSlotMap>
): boolean {
const slot = slotName(role);
return slot ? slots.get(slot)?.style.hidden === true : false;
}
function inlineSegments(
node: SemanticDocumentNode,
slots: ReturnType<typeof createDocxTokenSlotMap>
): string[] {
if (isHidden(node.role, slots)) {
return [];
}
return node.kind === "text"
? [textValue(node)]
: node.children.flatMap((child) =>
inlineSegments(child, slots)
);
}
function projectTextNode(
node: SemanticDocumentTextNode
): PandocStructureParagraph {
const styleId = paragraphStyleId(node.role);
return {
kind: "paragraph",
...(styleId ? { styleId } : {}),
segments: [textValue(node)],
separator: "space"
};
}
function projectGroupNode(
node: SemanticDocumentGroupNode,
slots: ReturnType<typeof createDocxTokenSlotMap>,
nextMarker: (kind: string) => string
): PandocStructureBlock | undefined {
const styleId = paragraphStyleId(node.role);
if (containerRoles.has(node.role)) {
const blocks = node.children
.map((child) => projectNode(child, slots, nextMarker))
.filter(
(block): block is PandocStructureBlock =>
block !== undefined
);
return blocks.length
? {
kind: "container",
...(styleId ? { styleId } : {}),
...(slotName(node.role)
? { slot: slotName(node.role) }
: {}),
startMarker: nextMarker("container-start"),
endMarker: nextMarker("container-end"),
blocks
}
: undefined;
}
const segments = node.children.flatMap((child) =>
inlineSegments(child, slots)
);
return segments.length
? {
kind: "paragraph",
...(styleId ? { styleId } : {}),
segments,
separator: segments.length > 1 ? "tab" : "space"
}
: undefined;
}
function projectNode(
node: SemanticDocumentNode,
slots: ReturnType<typeof createDocxTokenSlotMap>,
nextMarker: (kind: string) => string
): PandocStructureBlock | undefined {
if (isHidden(node.role, slots)) {
return undefined;
}
return node.kind === "text"
? projectTextNode(node)
: projectGroupNode(node, slots, nextMarker);
}
export function createPandocStructurePlan(
modelInput: SemanticDocumentModel,
tokensInput: DocxThemeTokenSet,
options: CreatePandocStructurePlanOptions = {}
): PandocStructurePlan {
const model = semanticDocumentModelSchema.parse(modelInput);
const slots = createDocxTokenSlotMap(tokensInput);
const markerSeed = (options.markerSeed ?? "structure")
.replace(/[^a-z0-9]/giu, "")
.slice(0, 64);
if (!markerSeed) {
throw new Error("DOCX 结构标记种子无效");
}
let markerIndex = 0;
const nextMarker = (kind: string) =>
`MD_TO_PDF_${kind.toUpperCase().replace(/-/gu, "_")}_${markerSeed}_${++markerIndex}`;
const prefix: PandocStructureBlock[] = [];
const suffix: PandocStructureBlock[] = [];
for (const region of model.regions) {
const blocks = region.nodes
.map((node) => projectNode(node, slots, nextMarker))
.filter(
(block): block is PandocStructureBlock =>
block !== undefined
);
if (region.kind === "suffix") {
suffix.push(...blocks);
continue;
}
prefix.push(...blocks);
if (
blocks.length > 0 &&
region.section?.breakAfter === "next-page"
) {
const firstContainer = blocks.find(
(
block
): block is PandocStructureContainer =>
block.kind === "container"
);
const verticalAlignment = firstContainer?.slot
? slots.get(firstContainer.slot)?.style.verticalAlignment
: undefined;
prefix.push({
kind: "section-break",
marker: nextMarker("section-break"),
headerFooter: region.section.headerFooter,
pageNumber: region.section.pageNumber,
followingPageNumberStart:
region.section.followingPageNumberStart,
...(verticalAlignment ? { verticalAlignment } : {})
});
}
}
return {
schemaVersion: 1,
titlePolicy: model.titlePolicy,
prefix,
suffix
};
}
+30 -3
View File
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { MAXIMUM_DOCX_OUTPUT_BYTES } from "@md-to-pdf/core";
import { unzipSync, zipSync, type Zippable } from "fflate";
export const MAXIMUM_REFERENCE_DOCX_BYTES = 2 * 1024 * 1024;
@@ -21,6 +22,13 @@ export const referenceDocxPackageLimits: DocxPackageLimits = {
maximumUncompressedBytes: MAXIMUM_REFERENCE_UNCOMPRESSED_BYTES
};
export const generatedDocxPackageLimits: DocxPackageLimits = {
maximumBytes: MAXIMUM_DOCX_OUTPUT_BYTES,
maximumEntryCount: MAXIMUM_GENERATED_DOCX_ENTRY_COUNT,
maximumUncompressedBytes:
MAXIMUM_GENERATED_DOCX_UNCOMPRESSED_BYTES
};
export const requiredReferenceDocxParts = [
"[Content_Types].xml",
"_rels/.rels",
@@ -216,8 +224,15 @@ export function readReferenceDocxPackage(
return readDocxPackage(content, referenceDocxPackageLimits);
}
export function writeReferenceDocxPackage(
entries: ReadonlyMap<string, Uint8Array>
export function readGeneratedDocxPackage(
content: Uint8Array
): ReferenceDocxPackage {
return readDocxPackage(content, generatedDocxPackageLimits);
}
function writeDocxPackage(
entries: ReadonlyMap<string, Uint8Array>,
limits: DocxPackageLimits
) {
const zippable: Zippable = {};
const mtime = new Date("1980-01-01T00:00:00.000Z");
@@ -231,6 +246,18 @@ export function writeReferenceDocxPackage(
level: 6,
mtime
});
readReferenceDocxPackage(result);
readDocxPackage(result, limits);
return result;
}
export function writeReferenceDocxPackage(
entries: ReadonlyMap<string, Uint8Array>
) {
return writeDocxPackage(entries, referenceDocxPackageLimits);
}
export function writeGeneratedDocxPackage(
entries: ReadonlyMap<string, Uint8Array>
) {
return writeDocxPackage(entries, generatedDocxPackageLimits);
}
+12 -11
View File
@@ -378,7 +378,10 @@ function applyTokenParagraphStyle(
if (token.alignment !== undefined) {
removeDirectChildren(parent, WORD_NAMESPACE, "jc");
appendElement(parent, WORD_NAMESPACE, "w:jc", {
"w:val": token.alignment
"w:val":
token.alignment === "justify"
? "both"
: token.alignment
});
}
if (token.backgroundColor !== undefined) {
@@ -709,7 +712,13 @@ function applyTableAndCaption(
WORD_NAMESPACE,
"w:tblPr"
);
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar", "tblBorders");
removeDirectChildren(
tblPr,
WORD_NAMESPACE,
"tblW",
"tblCellMar",
"tblBorders"
);
const margins = appendElement(
tblPr,
WORD_NAMESPACE,
@@ -863,15 +872,7 @@ function applyTableTokenStyles(
WORD_NAMESPACE,
"w:tblPr"
);
if (tableToken?.widthPercent !== undefined) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblW");
appendElement(tblPr, WORD_NAMESPACE, "w:tblW", {
"w:w": String(
Math.round(tableToken.widthPercent * 50)
),
"w:type": "pct"
});
}
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblW");
const padding = cellToken?.paddingPt ?? tableToken?.paddingPt;
if (padding) {
removeDirectChildren(tblPr, WORD_NAMESPACE, "tblCellMar");
+51 -8
View File
@@ -7,6 +7,7 @@ import {
WORD_NAMESPACE,
directChildren,
parseXmlPart,
validateWordprocessingElementOrder,
type XmlElement
} from "./ooxml.js";
import {
@@ -262,13 +263,54 @@ function validateStyles(entries: ReadonlyMap<string, Uint8Array>) {
) {
throw new Error("word/styles.xml 的根元素无效");
}
const styleIds = new Set(
Array.from(
root.getElementsByTagNameNS(WORD_NAMESPACE, "style")
).map((element) =>
element.getAttributeNS(WORD_NAMESPACE, "styleId")
)
);
const styleIds = new Set<string>();
for (const style of directChildren(
root,
WORD_NAMESPACE,
"style"
)) {
const styleId =
style.getAttributeNS(WORD_NAMESPACE, "styleId") ?? "";
if (styleIds.has(styleId)) {
throw new Error(
`word/styles.xml 包含重复样式 ID${styleId}`
);
}
styleIds.add(styleId);
if (
style.getAttributeNS(WORD_NAMESPACE, "type") === "table"
) {
for (const properties of directChildren(
style,
WORD_NAMESPACE,
"tblPr"
)) {
if (
directChildren(
properties,
WORD_NAMESPACE,
"tblW"
).length > 0
) {
throw new Error(
`word/styles.xml 的表格样式不得声明 w:tblW:${styleId}`
);
}
}
}
}
for (const alignment of Array.from(
root.getElementsByTagNameNS(WORD_NAMESPACE, "jc")
)) {
if (
alignment.getAttributeNS(WORD_NAMESPACE, "val") ===
"justify"
) {
throw new Error(
"word/styles.xml 的两端对齐必须使用 w:jc=\"both\""
);
}
}
for (const styleId of requiredStyleIds) {
if (!styleIds.has(styleId)) {
throw new Error(`word/styles.xml 缺少关键样式:${styleId}`);
@@ -297,7 +339,8 @@ function validateDocx(
for (const [partName, part] of reference.entries) {
if (partName.endsWith(".xml") || partName.endsWith(".rels")) {
parseXmlPart(part, partName);
const document = parseXmlPart(part, partName);
validateWordprocessingElementOrder(document, partName);
xmlPartCount += 1;
}
if (partName.endsWith(".rels")) {
@@ -32,7 +32,14 @@ function xml(value: string) {
return encoder.encode(value);
}
function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
function createAcceptanceDocx(
overrides: {
altChunk?: boolean;
duplicateText?: boolean;
internalMarker?: boolean;
invalidPropertyOrder?: boolean;
} = {}
) {
return zipSync({
"[Content_Types].xml": xml(
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>'
@@ -41,7 +48,7 @@ function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
`<Relationships xmlns="${packageRelationships}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
),
"word/document.xml": xml(
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:body></w:document>`
`<w:document xmlns:w="${word}" xmlns:r="${relationships}" xmlns:m="${math}" xmlns:wp="${drawing}"><w:body><w:p><w:pPr><w:pStyle w:val="Heading1"/><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:hyperlink r:id="rIdLink"><w:r><w:t>可编辑正文</w:t></w:r></w:hyperlink><w:r><w:footnoteReference w:id="1"/></w:r><m:oMath><m:r><m:t>x</m:t></m:r></m:oMath></w:p>${overrides.invalidPropertyOrder ? '<w:p><w:pPr><w:jc w:val="left"/><w:spacing w:after="0"/></w:pPr></w:p>' : ""}${overrides.duplicateText ? "<w:p><w:r><w:t>可编辑正文</w:t></w:r></w:p>" : ""}${overrides.internalMarker ? "<w:p><w:r><w:t>MD_TO_PDF_INTERNAL</w:t></w:r></w:p>" : ""}<w:p><w:pPr><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/></w:sectPr></w:pPr></w:p><w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr><w:tr><w:tc><w:p><w:r><w:t>原生表格</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="图片 1" descr="普通图片"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/></wp:inline></w:drawing></w:r></w:p>${overrides.altChunk ? '<w:altChunk r:id="rIdChunk"/>' : ""}<w:sectPr><w:footerReference w:type="default" r:id="rIdFooter"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="907" w:right="907" w:bottom="907" w:left="907"/><w:pgNumType w:start="1"/></w:sectPr></w:body></w:document>`
),
"word/styles.xml": xml(
`<w:styles xmlns:w="${word}">${["Normal", "Heading1", "SourceCode", "Table", "Caption"].map((id) => `<w:style w:type="paragraph" w:styleId="${id}"/>`).join("")}</w:styles>`
@@ -53,7 +60,7 @@ function createAcceptanceDocx(overrides: { altChunk?: boolean } = {}) {
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>'
),
"word/footer1.xml": xml(
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText> PAGE \\* MERGEFORMAT </w:instrText></w:r><w:r><w:instrText> SECTIONPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
),
"word/footnotes.xml": xml(
`<w:footnotes xmlns:w="${word}"><w:footnote w:id="1"><w:p><w:r><w:t>脚注</w:t></w:r></w:p></w:footnote></w:footnotes>`
@@ -93,7 +100,17 @@ const expectation = {
minimumPngImages: 1,
requiredImageAltText: ["普通图片"],
requiredStyleIds: ["Normal", "Heading1", "SourceCode", "Table"],
requirePageField: true
requiredParagraphStyleIds: ["Heading1"],
requirePageField: true,
minimumSections: 2,
requireFirstSectionWithoutHeaderFooter: true,
finalPageNumberStart: 1,
requireSectionPagesField: true,
minimumFullWidthTables: 1,
maximumTextOccurrences: {
可编辑正文: 1
},
forbidInternalMarkers: true
};
describe("DOCX 自动验收器", () => {
@@ -112,6 +129,10 @@ describe("DOCX 自动验收器", () => {
mathObjectCount: 1,
pngImageCount: 1,
pageFieldCount: 1,
sectionPageFieldCount: 1,
sectionCount: 2,
fullWidthTableCount: 1,
internalMarkerCount: 0,
altChunkCount: 0
});
expect(report.checks).toEqual(
@@ -131,4 +152,28 @@ describe("DOCX 自动验收器", () => {
)
).toThrow("altChunk");
});
it("拒绝重复文本和未清理的内部标记", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ duplicateText: true }),
expectation
)
).toThrow("最多允许 1 次");
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ internalMarker: true }),
expectation
)
).toThrow("internal-markers");
});
it("拒绝 WordprocessingML 属性子节点乱序", () => {
expect(() =>
inspectDocxAcceptance(
createAcceptanceDocx({ invalidPropertyOrder: true }),
expectation
)
).toThrow("w:pPr 子节点顺序无效");
});
});
@@ -0,0 +1,251 @@
import { describe, expect, it } from "vitest";
import type { DocxThemeTokenSet } from "@md-to-pdf/docx-theme-engine";
import {
finalizeGeneratedDocxStructure,
readGeneratedDocxPackage,
readReferenceDocxPackage,
writeGeneratedDocxPackage,
type PandocStructurePlan
} from "../src/index.js";
import { createTestBaselineReference } from "./reference-test-fixture.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const word =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const relationships =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships";
function generatedFixture() {
const baseline = readReferenceDocxPackage(
createTestBaselineReference()
);
const entries = new Map(baseline.entries);
entries.set(
"word/document.xml",
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="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>` +
`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>正文标题</w:t></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: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:tbl>` +
`<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>`
)
);
entries.set(
"word/footer1.xml",
encoder.encode(
`<w:ftr xmlns:w="${word}"><w:p><w:r><w:instrText xml:space="preserve"> NUMPAGES \\* MERGEFORMAT </w:instrText></w:r></w:p></w:ftr>`
)
);
return writeGeneratedDocxPackage(entries);
}
const plan: PandocStructurePlan = {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
prefix: [
{
kind: "container",
styleId: "MdTenderCover",
slot: "tender-cover",
startMarker: "CONTAINER_START",
endMarker: "CONTAINER_END",
blocks: []
},
{
kind: "section-break",
marker: "SECTION_BREAK",
headerFooter: "none",
pageNumber: "hidden",
followingPageNumberStart: 1,
verticalAlignment: "center"
}
],
suffix: []
};
const tokens: DocxThemeTokenSet = {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: "tender",
slots: [
{
slot: "tender-cover",
source: "computed-css",
confidence: "approximate",
style: {
fontCandidates: [],
backgroundColor: "#f5f5f5",
pageBreakAfter: true,
keepLines: true,
borders: {
top: {
widthPt: 1,
style: "single",
color: "#111111"
},
right: {
widthPt: 1,
style: "single",
color: "#111111"
},
bottom: {
widthPt: 1,
style: "single",
color: "#111111"
},
left: {
widthPt: 1,
style: "single",
color: "#111111"
}
}
}
},
{
slot: "table",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
widthPercent: 100,
keepLines: true
}
},
{
slot: "heading-1",
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
pageBreakAfter: true
}
}
],
diagnostics: []
};
describe("生成 DOCX 结构收口", () => {
it("生成真实分节、正文页码、节总页数和固定宽度表格", () => {
const result = finalizeGeneratedDocxStructure(
generatedFixture(),
plan,
tokens
);
const entries = readGeneratedDocxPackage(
result.content
).entries;
const documentXml = decoder.decode(
entries.get("word/document.xml")!
);
const footerXml = decoder.decode(
entries.get("word/footer1.xml")!
);
expect(documentXml).not.toContain("CONTAINER_START");
expect(documentXml).not.toContain("CONTAINER_END");
expect(documentXml).not.toContain("SECTION_BREAK");
expect(
documentXml.match(/<w:sectPr(?:\s|>)/gu)
).toHaveLength(2);
expect(documentXml).toContain(
'<w:type w:val="nextPage"/>'
);
expect(documentXml).toContain(
'<w:vAlign w:val="center"/>'
);
expect(documentXml).toContain(
'<w:pgNumType w:start="1"/>'
);
expect(documentXml).toContain(
'<w:tblW w:w="5000" w:type="pct"/>'
);
expect(documentXml).toContain(
'<w:tblLayout w:type="fixed"/>'
);
expect(documentXml).toContain('<w:gridCol w:w="3302"/>');
expect(documentXml).toContain('<w:gridCol w:w="6604"/>');
expect(documentXml).toContain("<w:cantSplit/>");
expect(documentXml).toContain('w:fill="F5F5F5"');
expect(
documentXml.match(/<w:br w:type="page"\/>/gu)
).toHaveLength(1);
expect(footerXml).toContain("SECTIONPAGES");
expect(footerXml).not.toContain(" NUMPAGES ");
expect(result.report).toEqual({
containerCount: 1,
sectionCount: 1,
tableCount: 1,
pageBreakAfterCount: 1,
sectionPageFieldCount: 1
});
});
it("拒绝缺失或重复的内部结构标记", () => {
expect(() =>
finalizeGeneratedDocxStructure(
generatedFixture(),
{
...plan,
prefix: [
{
...plan.prefix[0]!,
startMarker: "MISSING"
},
plan.prefix[1]!
]
},
tokens
)
).toThrow("结构标记数量无效");
});
it("清理 Pandoc 追加的重复和降级样式", () => {
const source = readGeneratedDocxPackage(
generatedFixture()
);
const entries = new Map(source.entries);
const styles = decoder
.decode(entries.get("word/styles.xml")!)
.replace(
"</w:styles>",
'<w:style w:type="paragraph" w:styleId="MdTenderTitle">' +
'<w:name w:val="重复降级样式"/></w:style>' +
'<w:style w:type="paragraph" w:styleId="PandocFallback">' +
'<w:pPr><w:jc w:val="justify"/></w:pPr></w:style>' +
'<w:style w:type="table" w:styleId="PandocTableFallback">' +
'<w:tblPr><w:tblW w:w="5000" w:type="pct"/></w:tblPr>' +
"</w:style></w:styles>"
);
entries.set("word/styles.xml", encoder.encode(styles));
const result = finalizeGeneratedDocxStructure(
writeGeneratedDocxPackage(entries),
plan,
tokens
);
const outputStyles = decoder.decode(
readGeneratedDocxPackage(result.content).entries.get(
"word/styles.xml"
)!
);
expect(
outputStyles.match(/w:styleId="MdTenderTitle"/gu)
).toHaveLength(1);
expect(outputStyles).toContain(
'<w:jc w:val="both"/>'
);
expect(outputStyles).not.toContain('w:val="justify"');
expect(outputStyles).not.toContain("<w:tblW");
});
});
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
WORD_NAMESPACE,
appendElement,
parseXmlPart,
serializeXmlPart,
validateWordprocessingElementOrder
} from "../src/ooxml.js";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function paragraphPropertiesDocument(children = "") {
return parseXmlPart(
encoder.encode(
`<w:document xmlns:w="${WORD_NAMESPACE}">` +
`<w:body><w:p><w:pPr>${children}</w:pPr></w:p></w:body>` +
"</w:document>"
),
"word/document.xml"
);
}
describe("OOXML 子节点顺序", () => {
it("按 WordprocessingML 顺序插入新增属性", () => {
const document = paragraphPropertiesDocument(
"<w:pStyle w:val=\"Normal\"/><w:jc w:val=\"left\"/>"
);
const properties = document.getElementsByTagNameNS(
WORD_NAMESPACE,
"pPr"
)[0]!;
appendElement(properties, WORD_NAMESPACE, "w:pBdr");
appendElement(properties, WORD_NAMESPACE, "w:keepNext");
appendElement(properties, WORD_NAMESPACE, "w:spacing");
const xml = decoder.decode(serializeXmlPart(document));
expect(xml.indexOf("<w:keepNext")).toBeLessThan(
xml.indexOf("<w:pBdr")
);
expect(xml.indexOf("<w:pBdr")).toBeLessThan(
xml.indexOf("<w:spacing")
);
expect(xml.indexOf("<w:spacing")).toBeLessThan(
xml.indexOf("<w:jc")
);
expect(() =>
validateWordprocessingElementOrder(
document,
"word/document.xml"
)
).not.toThrow();
});
it("在序列化时规范化已有乱序属性", () => {
const document = paragraphPropertiesDocument(
"<w:pStyle w:val=\"Normal\"/>" +
"<w:pBdr/><w:keepLines/><w:keepNext/>"
);
const serialized = serializeXmlPart(document);
const reparsed = parseXmlPart(
serialized,
"word/document.xml"
);
expect(() =>
validateWordprocessingElementOrder(
reparsed,
"word/document.xml"
)
).not.toThrow();
const xml = decoder.decode(serialized);
expect(xml.indexOf("<w:keepNext")).toBeLessThan(
xml.indexOf("<w:keepLines")
);
expect(xml.indexOf("<w:keepLines")).toBeLessThan(
xml.indexOf("<w:pBdr")
);
});
it("拒绝未经规范化的乱序属性", () => {
const document = paragraphPropertiesDocument(
"<w:pStyle w:val=\"Normal\"/>" +
"<w:jc w:val=\"left\"/><w:spacing/>"
);
expect(() =>
validateWordprocessingElementOrder(
document,
"word/document.xml"
)
).toThrow(
"word/document.xml 的 w:pPr 子节点顺序无效"
);
});
it("规范化表格、单元格和边框顺序", () => {
const document = parseXmlPart(
encoder.encode(
`<w:document xmlns:w="${WORD_NAMESPACE}"><w:body>` +
"<w:tbl><w:tblPr>" +
"<w:tblLook/><w:tblW/><w:tblLayout/>" +
"</w:tblPr><w:tblGrid/><w:tr><w:tc><w:tcPr>" +
"<w:gridSpan/><w:shd/><w:tcW/>" +
"</w:tcPr><w:p/></w:tc></w:tr></w:tbl>" +
"<w:sectPr><w:docGrid/><w:vAlign/></w:sectPr>" +
"</w:body></w:document>"
),
"word/document.xml"
);
const serialized = serializeXmlPart(document);
const reparsed = parseXmlPart(
serialized,
"word/document.xml"
);
expect(() =>
validateWordprocessingElementOrder(
reparsed,
"word/document.xml"
)
).not.toThrow();
});
it("规范化表格条件样式的属性顺序", () => {
const document = parseXmlPart(
encoder.encode(
`<w:styles xmlns:w="${WORD_NAMESPACE}">` +
'<w:style w:type="table" w:styleId="Table">' +
'<w:tblStylePr w:type="firstRow">' +
"<w:tcPr/><w:rPr/><w:pPr/>" +
"</w:tblStylePr></w:style></w:styles>"
),
"word/styles.xml"
);
const xml = decoder.decode(serializeXmlPart(document));
expect(xml.indexOf("<w:pPr")).toBeLessThan(
xml.indexOf("<w:rPr")
);
expect(xml.indexOf("<w:rPr")).toBeLessThan(
xml.indexOf("<w:tcPr")
);
});
});
@@ -75,6 +75,14 @@ function input() {
keywords: [],
language: "zh-CN"
},
semanticDocument: {
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
},
media: emptyMedia
};
}
@@ -120,6 +128,20 @@ describe("Pandoc DOCX 转换器", () => {
expect(
options.env?.MD_TO_PDF_DOCX_MEDIA_MAP
).toContain("media-map.json");
expect(
options.env?.MD_TO_PDF_DOCX_STRUCTURE_PLAN
).toContain("structure-plan.json");
const structurePlan = JSON.parse(
await import("node:fs/promises").then(({ readFile }) =>
readFile(
options.env!.MD_TO_PDF_DOCX_STRUCTURE_PLAN!,
"utf8"
)
)
);
expect(structurePlan.titlePolicy.metadataTitle).toBe(
"suppress"
);
await copyFile(
argumentAfter(arguments_, "--reference-doc"),
argumentAfter(arguments_, "--output")
@@ -0,0 +1,239 @@
import { describe, expect, it } from "vitest";
import type {
SemanticDocumentModel,
SemanticDocumentNode
} from "@md-to-pdf/core";
import type {
DocxResolvedStyleSlot,
DocxStyleSlotName,
DocxThemeTokenSet
} from "@md-to-pdf/docx-theme-engine";
import {
createPandocStructurePlan,
type PandocStructureBlock
} from "../src/index.js";
function slot(
name: DocxStyleSlotName,
hidden = false
): DocxResolvedStyleSlot {
return {
slot: name,
source: "computed-css",
confidence: "exact",
style: {
fontCandidates: [],
...(hidden ? { hidden: true } : {})
}
};
}
function tokens(
slots: DocxResolvedStyleSlot[] = []
): DocxThemeTokenSet {
return {
schemaVersion: 1,
themeId: "test-theme",
themeFingerprint: "c".repeat(64),
mode: "auto-with-overrides",
basePreset: "technical",
slots,
diagnostics: []
};
}
function text(
role: Extract<SemanticDocumentNode, { kind: "text" }>["role"],
value: string,
label?: string
): SemanticDocumentNode {
return {
kind: "text",
role,
text: value,
...(label ? { label } : {})
};
}
function paragraphSegments(block: PandocStructureBlock): string[] {
if (block.kind === "paragraph") {
return block.segments;
}
if (block.kind === "container") {
return block.blocks.flatMap(paragraphSegments);
}
return [];
}
describe("Pandoc 语义结构投影", () => {
it("将公文分组投影为固定 Word 样式和可编辑段落", () => {
const model: SemanticDocumentModel = {
schemaVersion: 1,
profile: "official",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "suppress"
},
regions: [
{
kind: "prefix",
nodes: [
{
kind: "group",
role: "official-masthead",
children: [
{
kind: "group",
role: "official-classification",
children: [
text("official-secrecy", "秘密"),
text("official-urgency", "特急")
]
},
text("official-issuer", "示例单位"),
{
kind: "group",
role: "official-issue-row",
children: [
text("official-number", "示例〔20261号"),
text(
"official-signatory",
"张三",
"签发人:"
)
]
}
]
},
text("official-title", "关于开展工作的通知")
]
}
]
};
const plan = createPandocStructurePlan(model, tokens(), {
markerSeed: "official"
});
expect(plan.titlePolicy.firstBodyHeading).toBe("suppress");
expect(plan.prefix[0]).toMatchObject({
kind: "container",
styleId: "MdOfficialMasthead",
slot: "official-masthead",
startMarker:
"MD_TO_PDF_CONTAINER_START_official_1",
endMarker: "MD_TO_PDF_CONTAINER_END_official_2",
blocks: [
{
kind: "paragraph",
styleId: "MdOfficialClassification",
segments: ["秘密", "特急"],
separator: "tab"
},
{
kind: "paragraph",
styleId: "MdOfficialIssuer",
segments: ["示例单位"]
},
{
kind: "paragraph",
styleId: "MdOfficialIssueRow",
segments: ["示例〔20261号", "签发人:张三"],
separator: "tab"
}
]
});
expect(plan.prefix[1]).toMatchObject({
kind: "paragraph",
styleId: "MdOfficialTitle",
segments: ["关于开展工作的通知"]
});
});
it("按主题隐藏令牌过滤标书字段并保留封面分页", () => {
const model: SemanticDocumentModel = {
schemaVersion: 1,
profile: "tender",
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "suppress"
},
regions: [
{
kind: "cover",
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
},
nodes: [
{
kind: "group",
role: "tender-cover",
children: [
text("tender-title", "投标文件"),
text("tender-bidder", "投标单位"),
text(
"tender-representative",
"授权代表"
),
text("tender-date", "2026年7月")
]
}
]
}
]
};
const plan = createPandocStructurePlan(
model,
tokens([
slot("tender-bidder", true),
slot("tender-representative", true)
]),
{ markerSeed: "tender" }
);
expect(plan.prefix.at(-1)).toEqual({
kind: "section-break",
marker: "MD_TO_PDF_SECTION_BREAK_tender_3",
headerFooter: "none",
pageNumber: "hidden",
followingPageNumberStart: 1
});
expect(plan.prefix[0]).toMatchObject({
kind: "container",
styleId: "MdTenderCover"
});
expect(paragraphSegments(plan.prefix[0]!)).toEqual([
"投标文件",
"2026年7月"
]);
});
it("没有结构区域时只传递标题策略", () => {
const plan = createPandocStructurePlan(
{
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
regions: []
},
tokens(),
{ markerSeed: "empty" }
);
expect(plan).toEqual({
schemaVersion: 1,
titlePolicy: {
metadataTitle: "emit",
firstBodyHeading: "keep"
},
prefix: [],
suffix: []
});
});
});
@@ -229,7 +229,8 @@ describe("动态 reference.docx", () => {
fontSizePt: 14,
color: "#112233",
lineSpacing: 1.8,
firstLineIndentPt: 28
firstLineIndentPt: 28,
alignment: "justify"
}
},
{
@@ -297,7 +298,9 @@ describe("动态 reference.docx", () => {
expect(stylesXml).toContain('w:val="112233"');
expect(stylesXml).toContain('w:styleId="Heading1"');
expect(stylesXml).toContain('w:val="AA0000"');
expect(stylesXml).toContain('w:w="5000" w:type="pct"');
expect(stylesXml).toContain('<w:jc w:val="both"/>');
expect(stylesXml).not.toContain('w:val="justify"');
expect(stylesXml).not.toContain("<w:tblW");
expect(stylesXml).toContain('w:color="445566"');
expect(fontTableXml).toContain(
'w:name="Source Han Serif SC"'
@@ -435,6 +438,48 @@ describe("动态 reference.docx", () => {
);
});
it("拒绝重复样式 ID 和非法两端对齐枚举", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),
createOptions(defaultExportConfig)
);
const entries = new Map(
Object.entries(unzipSync(result.content))
);
const styles = decoder.decode(entries.get("word/styles.xml")!);
entries.set(
"word/styles.xml",
encoder.encode(
styles.replace(
"</w:styles>",
'<w:style w:type="paragraph" w:styleId="Normal"/>' +
"</w:styles>"
)
)
);
expect(() =>
validateDynamicReferenceDocx(
writeReferenceDocxPackage(entries)
)
).toThrow(/ IDNormal/u);
entries.set(
"word/styles.xml",
encoder.encode(
styles.replace(
/<w:jc w:val="[^"]+"\/>/u,
'<w:jc w:val="justify"/>'
)
)
);
expect(() =>
validateDynamicReferenceDocx(
writeReferenceDocxPackage(entries)
)
).toThrow(/使/u);
});
it("最终 DOCX 使用媒体输出上限而非模板的 2 MiB 上限", () => {
const result = createDynamicReferenceDocx(
createBaselineReference(),