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