新增通用 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 产物仍为未签名内部发行。
257 lines
6.7 KiB
TypeScript
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
|
|
};
|
|
}
|