feat: 完成 DOCX 通用结构映射与严格收口
This commit is contained in:
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Reference in New Issue
Block a user