Files
MorphDoc/packages/docx-engine/src/pandoc-structure.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

257 lines
6.7 KiB
TypeScript

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;
layout?: "space-between" | 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;
metadataPolicy: {
author: "emit" | "suppress";
};
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"
]);
const semanticRoleSlotAliases: Readonly<
Partial<Record<SemanticDocumentRole, DocxStyleSlotName>>
> = {};
function slotName(
role: SemanticDocumentRole
): DocxStyleSlotName | undefined {
const alias = semanticRoleSlotAliases[role];
if (alias) {
return alias;
}
return Object.prototype.hasOwnProperty.call(
DOCX_SLOT_WORD_STYLE_BINDINGS,
role
)
? (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 } : {}),
...(node.layout ? { layout: node.layout } : {}),
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,
metadataPolicy: {
author: "suppress"
},
titlePolicy: model.titlePolicy,
prefix,
suffix
};
}