feat: 完成 DOCX 通用结构映射与严格收口
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", "示例〔2026〕1号"),
|
||||
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: ["示例〔2026〕1号", "签发人:张三"],
|
||||
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(/重复样式 ID:Normal/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(),
|
||||
|
||||
Reference in New Issue
Block a user