feat: 建立统一语义文档模型

This commit is contained in:
SkyJourney
2026-07-30 23:47:51 +08:00
parent 7f72258126
commit 459a079f3b
22 changed files with 921 additions and 271 deletions
+15
View File
@@ -0,0 +1,15 @@
# @md-to-pdf/semantic-document
统一语义文档模型构建器。该包把规范化 Front Matter 和正文标题信息转换为
主题无关、渲染器无关的结构树,供 HTML/PDF 与 DOCX 共用。
当前支持:
- 普通文档标题去重策略;
- 政企公文版头、文号、签发人、落款和版记;
- 工作简报版头、期号、发布单位、签发和联系信息;
- 项目报告独立封面;
- 标书独立封面;
- 封面无页眉页脚、不显示页码、正文重新从 1 编号的分节意图。
本包不生成 HTML、不调用 Pandoc、不处理 OOXML,也不包含主题 ID 分支。
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@md-to-pdf/semantic-document",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
},
"dependencies": {
"@md-to-pdf/core": "0.1.0"
},
"devDependencies": {
"vitest": "^4.1.10"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./model-builder.js";
@@ -0,0 +1,268 @@
import {
semanticDocumentModelSchema,
type DocumentProfile,
type MarkdownDocumentMetadata,
type SemanticDocumentGroupNode,
type SemanticDocumentModel,
type SemanticDocumentNode,
type SemanticDocumentRegion,
type SemanticDocumentRole,
type SemanticDocumentTextNode
} from "@md-to-pdf/core";
export interface CreateSemanticDocumentModelOptions {
metadata: MarkdownDocumentMetadata;
firstBodyHeading?: string;
}
function normalizeTitle(value: string | undefined): string {
return (value ?? "")
.normalize("NFKC")
.replace(/\s+/gu, " ")
.trim();
}
function text(
role: SemanticDocumentRole,
value: string | undefined,
label?: string
): SemanticDocumentTextNode | undefined {
const normalized = value?.trim();
if (!normalized) {
return undefined;
}
return {
kind: "text",
role,
text: normalized,
...(label ? { label } : {})
};
}
function group(
role: SemanticDocumentRole,
children: readonly (SemanticDocumentNode | undefined)[]
): SemanticDocumentGroupNode | undefined {
const resolved = children.filter(
(child): child is SemanticDocumentNode => child !== undefined
);
return resolved.length
? {
kind: "group",
role,
children: resolved
}
: undefined;
}
function region(
kind: SemanticDocumentRegion["kind"],
nodes: readonly (SemanticDocumentNode | undefined)[],
cover = false
): SemanticDocumentRegion | undefined {
const resolved = nodes.filter(
(node): node is SemanticDocumentNode => node !== undefined
);
if (!resolved.length) {
return undefined;
}
return {
kind,
nodes: resolved,
...(cover
? {
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
} as const
}
: {})
};
}
function officialRegions(
title: string,
document: Extract<DocumentProfile, { profile: "official" }>
): SemanticDocumentRegion[] {
const classification = group("official-classification", [
text("official-secrecy", document.secrecy),
text("official-urgency", document.urgency)
]);
const issueRow = group("official-issue-row", [
text("official-number", document.number),
text("official-signatory", document.signatory, "签发人:")
]);
const masthead = group("official-masthead", [
classification,
text("official-issuer", document.issuer),
issueRow
]);
const signature = group("official-signature", [
text("official-signature-issuer", document.issuer),
text("official-signature-date", document.date)
]);
const printingRow = group("official-printing-row", [
text("official-printing-office", document.printingOffice),
text(
"official-printing-date",
document.date ? `${document.date}印发` : undefined
)
]);
const edition = group("official-edition", [
text(
"official-copy-to",
document.copyTo?.join("、"),
"抄送:"
),
printingRow
]);
return [
region("prefix", [
masthead,
text("official-title", title)
]),
region("suffix", [signature, edition])
].filter(
(item): item is SemanticDocumentRegion => item !== undefined
);
}
function briefingRegions(
title: string,
document: Extract<DocumentProfile, { profile: "briefing" }>
): SemanticDocumentRegion[] {
const details = group("briefing-meta", [
text("briefing-issue", document.issue),
text("briefing-publisher", document.publisher),
text("briefing-signatory", document.signatory, "签发:"),
text("briefing-date", document.date)
]);
const masthead = group("briefing-masthead", [
text("briefing-masthead-text", document.masthead),
details
]);
return [
region("prefix", [
masthead,
text("briefing-title", title)
]),
region("suffix", [
text("briefing-contact", document.contact)
])
].filter(
(item): item is SemanticDocumentRegion => item !== undefined
);
}
function projectReportRegions(
title: string,
document: Extract<
DocumentProfile,
{ profile: "project-report" }
>
): SemanticDocumentRegion[] {
const cover = group("project-report-cover", [
text(
"project-report-project-name",
document.projectName
),
text(
"project-report-title",
document.documentType || title
),
text("project-report-version", document.version),
text("project-report-owner", document.owner, "建设单位:"),
text(
"project-report-prepared-by",
document.preparedBy,
"编制单位:"
),
text("project-report-date", document.date)
]);
const coverRegion = region("cover", [cover], true);
return coverRegion ? [coverRegion] : [];
}
function tenderRegions(
title: string,
document: Extract<DocumentProfile, { profile: "tender" }>
): SemanticDocumentRegion[] {
const cover = group("tender-cover", [
text("tender-copy-mark", document.copyMark),
text("tender-project-name", document.projectName),
text("tender-project-number", document.projectNumber),
text("tender-title", title),
text("tender-volume", document.volume),
text("tender-bidder", document.bidder, "投标人:"),
text(
"tender-representative",
document.representative,
"法定代表人或授权代表:"
),
text("tender-date", document.date)
]);
const coverRegion = region("cover", [cover], true);
return coverRegion ? [coverRegion] : [];
}
function createRegions(
metadata: MarkdownDocumentMetadata
): SemanticDocumentRegion[] {
const document = metadata.document;
if (!document) {
return [];
}
switch (document.profile) {
case "official":
return officialRegions(metadata.title, document);
case "briefing":
return briefingRegions(metadata.title, document);
case "project-report":
return projectReportRegions(metadata.title, document);
case "tender":
return tenderRegions(metadata.title, document);
}
}
function emittedTitle(metadata: MarkdownDocumentMetadata): string {
const document = metadata.document;
if (!document) {
return "";
}
return document.profile === "project-report"
? document.documentType || metadata.title
: metadata.title;
}
export function createSemanticDocumentModel(
options: CreateSemanticDocumentModelOptions
): SemanticDocumentModel {
const { metadata } = options;
const firstHeading = normalizeTitle(options.firstBodyHeading);
const metadataTitle = normalizeTitle(metadata.title);
const structuralTitle = normalizeTitle(emittedTitle(metadata));
const hasStructure = metadata.document !== undefined;
const duplicatedMetadataTitle =
Boolean(metadataTitle) && metadataTitle === firstHeading;
const duplicatedStructuralTitle =
Boolean(structuralTitle) && structuralTitle === firstHeading;
return semanticDocumentModelSchema.parse({
schemaVersion: 1,
...(metadata.document
? { profile: metadata.document.profile }
: {}),
titlePolicy: {
metadataTitle:
hasStructure || duplicatedMetadataTitle
? "suppress"
: "emit",
firstBodyHeading:
hasStructure && duplicatedStructuralTitle
? "suppress"
: "keep"
},
regions: createRegions(metadata)
});
}
@@ -0,0 +1,139 @@
import {
semanticDocumentModelSchema,
type MarkdownDocumentMetadata
} from "@md-to-pdf/core";
import { describe, expect, it } from "vitest";
import { createSemanticDocumentModel } from "../src/index.js";
function metadata(
input: Partial<MarkdownDocumentMetadata> = {}
): MarkdownDocumentMetadata {
return {
title: "",
author: "",
subject: "",
keywords: [],
language: "zh-CN",
...input
};
}
describe("统一语义文档模型", () => {
it("为普通文档记录标题去重策略但不创建结构区域", () => {
const model = createSemanticDocumentModel({
metadata: metadata({ title: "项目报告" }),
firstBodyHeading: " 项目报告 "
});
expect(model).toEqual({
schemaVersion: 1,
titlePolicy: {
metadataTitle: "suppress",
firstBodyHeading: "keep"
},
regions: []
});
});
it("完整表达公文版头、落款和版记的稳定顺序", () => {
const model = createSemanticDocumentModel({
metadata: metadata({
title: "关于推进项目建设的通知",
document: {
profile: "official",
issuer: "示例市人民政府办公室",
number: "示例办发〔202612号",
secrecy: "内部",
urgency: "加急",
signatory: "张三",
date: "2026年7月30日",
copyTo: ["市发展改革委", "市财政局"],
printingOffice: "示例市人民政府办公室"
}
})
});
expect(model.profile).toBe("official");
expect(model.regions.map((item) => item.kind)).toEqual([
"prefix",
"suffix"
]);
expect(JSON.stringify(model)).toContain("official-masthead");
expect(JSON.stringify(model)).toContain("抄送:");
expect(JSON.stringify(model)).toContain("2026年7月30日印发");
expect(() => semanticDocumentModelSchema.parse(model))
.not.toThrow();
});
it("表达简报前后结构并省略空字段", () => {
const model = createSemanticDocumentModel({
metadata: metadata({
title: "工作简报",
document: {
profile: "briefing",
masthead: "项目工作简报",
issue: "第12期",
contact: "联系人:张三"
}
})
});
expect(model.regions).toHaveLength(2);
expect(JSON.stringify(model)).not.toContain(
"briefing-signatory"
);
expect(JSON.stringify(model)).toContain("briefing-contact");
});
it.each([
[
"project-report",
{
profile: "project-report",
projectName: "智慧园区建设项目",
documentType: "可行性研究报告",
owner: "示例集团"
}
],
[
"tender",
{
profile: "tender",
copyMark: "正本",
projectName: "数据中心建设项目",
bidder: "示例科技有限公司"
}
]
] as const)("为 %s 创建独立封面分节意图", (_profile, document) => {
const model = createSemanticDocumentModel({
metadata: metadata({
title: "项目文件",
document
})
});
expect(model.regions).toHaveLength(1);
expect(model.regions[0]).toMatchObject({
kind: "cover",
section: {
headerFooter: "none",
pageNumber: "hidden",
breakAfter: "next-page",
followingPageNumberStart: 1
}
});
});
it("结构标题与首个 H1 一致时记录正文标题抑制", () => {
const model = createSemanticDocumentModel({
metadata: metadata({
title: "投标文件",
document: {
profile: "tender",
projectName: "示例项目"
}
}),
firstBodyHeading: "投标文件"
});
expect(model.titlePolicy).toEqual({
metadataTitle: "suppress",
firstBodyHeading: "suppress"
});
});
});
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src"
]
}