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
+207 -261
View File
@@ -1,280 +1,226 @@
import type {
DocumentProfile,
MarkdownDocumentMetadata
SemanticDocumentModel,
SemanticDocumentNode,
SemanticDocumentRole
} from "@md-to-pdf/core";
export interface RenderedDocumentStructure {
profile: DocumentProfile["profile"];
profile: NonNullable<SemanticDocumentModel["profile"]>;
prefixHtml: string;
suffixHtml: string;
}
interface HtmlRole {
tagName: string;
className: string;
}
const htmlRoles: Readonly<Record<SemanticDocumentRole, HtmlRole>> = {
"official-masthead": {
tagName: "header",
className: "doc-masthead doc-masthead-official"
},
"official-classification": {
tagName: "div",
className: "doc-classification"
},
"official-secrecy": {
tagName: "span",
className: "doc-secrecy"
},
"official-urgency": {
tagName: "span",
className: "doc-urgency"
},
"official-issuer": {
tagName: "p",
className: "doc-issuer"
},
"official-issue-row": {
tagName: "div",
className: "doc-issue-row"
},
"official-number": {
tagName: "span",
className: "doc-number"
},
"official-signatory": {
tagName: "span",
className: "doc-signatory"
},
"official-title": {
tagName: "h1",
className: "doc-title"
},
"official-signature": {
tagName: "section",
className: "doc-signature"
},
"official-signature-issuer": {
tagName: "p",
className: "doc-signature-issuer"
},
"official-signature-date": {
tagName: "time",
className: "doc-signature-date"
},
"official-edition": {
tagName: "footer",
className: "doc-edition"
},
"official-copy-to": {
tagName: "p",
className: "doc-copy-to"
},
"official-printing-row": {
tagName: "div",
className: "doc-printing-row"
},
"official-printing-office": {
tagName: "span",
className: "doc-printing-office"
},
"official-printing-date": {
tagName: "time",
className: "doc-printing-date"
},
"briefing-masthead": {
tagName: "header",
className: "doc-masthead doc-masthead-briefing"
},
"briefing-masthead-text": {
tagName: "p",
className: "doc-briefing-masthead"
},
"briefing-meta": {
tagName: "div",
className: "doc-briefing-meta"
},
"briefing-issue": {
tagName: "span",
className: "doc-briefing-issue"
},
"briefing-publisher": {
tagName: "span",
className: "doc-briefing-publisher"
},
"briefing-signatory": {
tagName: "span",
className: "doc-briefing-signatory"
},
"briefing-date": {
tagName: "time",
className: "doc-briefing-date"
},
"briefing-title": {
tagName: "h1",
className: "doc-title"
},
"briefing-contact": {
tagName: "footer",
className: "doc-briefing-contact"
},
"project-report-cover": {
tagName: "header",
className: "doc-cover doc-cover-project-report"
},
"project-report-project-name": {
tagName: "p",
className: "doc-cover-project-name"
},
"project-report-title": {
tagName: "h1",
className: "doc-cover-title"
},
"project-report-version": {
tagName: "p",
className: "doc-cover-version"
},
"project-report-owner": {
tagName: "p",
className: "doc-cover-owner"
},
"project-report-prepared-by": {
tagName: "p",
className: "doc-cover-prepared-by"
},
"project-report-date": {
tagName: "time",
className: "doc-cover-date"
},
"tender-cover": {
tagName: "header",
className: "doc-cover doc-cover-tender"
},
"tender-copy-mark": {
tagName: "p",
className: "doc-cover-copy-mark"
},
"tender-project-name": {
tagName: "p",
className: "doc-cover-project-name"
},
"tender-project-number": {
tagName: "p",
className: "doc-cover-project-number"
},
"tender-title": {
tagName: "h1",
className: "doc-cover-title"
},
"tender-volume": {
tagName: "p",
className: "doc-cover-volume"
},
"tender-bidder": {
tagName: "p",
className: "doc-cover-bidder"
},
"tender-representative": {
tagName: "p",
className: "doc-cover-representative"
},
"tender-date": {
tagName: "time",
className: "doc-cover-date"
}
};
function renderNode(node: SemanticDocumentNode): string {
const role = htmlRoles[node.role];
const content =
node.kind === "group"
? node.children.map(renderNode).join("")
: `${node.label ? `<span>${escapeHtml(node.label)}</span>` : ""}${escapeHtml(node.text)}`;
return `<${role.tagName} class="${role.className}">${content}</${role.tagName}>`;
}
export function renderDocumentStructure(
metadata: MarkdownDocumentMetadata
model: SemanticDocumentModel
): RenderedDocumentStructure | undefined {
const document = metadata.document;
if (!document) {
if (!model.profile) {
return undefined;
}
switch (document.profile) {
case "official":
return renderOfficialStructure(metadata.title, document);
case "briefing":
return renderBriefingStructure(metadata.title, document);
case "project-report":
return renderProjectReportStructure(metadata.title, document);
case "tender":
return renderTenderStructure(metadata.title, document);
}
}
function renderOfficialStructure(
title: string,
document: Extract<DocumentProfile, { profile: "official" }>
): RenderedDocumentStructure {
const classification = joinParts(
[
renderText("span", "doc-secrecy", document.secrecy),
renderText("span", "doc-urgency", document.urgency)
],
"doc-classification"
);
const issueRow = joinParts(
[
renderText("span", "doc-number", document.number),
renderLabeledText(
"span",
"doc-signatory",
"签发人:",
document.signatory
)
],
"doc-issue-row"
);
const masthead = joinParts(
[
classification,
renderText("p", "doc-issuer", document.issuer),
issueRow
],
"doc-masthead doc-masthead-official",
"header"
);
const signature = joinParts(
[
renderText("p", "doc-signature-issuer", document.issuer),
renderTime("doc-signature-date", document.date)
],
"doc-signature",
"section"
);
const edition = joinParts(
[
document.copyTo?.length
? `<p class="doc-copy-to"><span>抄送:</span>${escapeHtml(document.copyTo.join("、"))}</p>`
: "",
joinParts(
[
renderText(
"span",
"doc-printing-office",
document.printingOffice
),
document.date
? `<time class="doc-printing-date">${escapeHtml(document.date)}印发</time>`
: ""
],
"doc-printing-row"
)
],
"doc-edition",
"footer"
);
const prefixHtml = model.regions
.filter(
(region) =>
region.kind === "cover" || region.kind === "prefix"
)
.flatMap((region) => region.nodes)
.map(renderNode)
.join("");
const suffixHtml = model.regions
.filter((region) => region.kind === "suffix")
.flatMap((region) => region.nodes)
.map(renderNode)
.join("");
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: signature + edition
profile: model.profile,
prefixHtml,
suffixHtml
};
}
function renderBriefingStructure(
title: string,
document: Extract<DocumentProfile, { profile: "briefing" }>
): RenderedDocumentStructure {
const details = joinParts(
[
renderText("span", "doc-briefing-issue", document.issue),
renderText(
"span",
"doc-briefing-publisher",
document.publisher
),
renderLabeledText(
"span",
"doc-briefing-signatory",
"签发:",
document.signatory
),
renderTime("doc-briefing-date", document.date)
],
"doc-briefing-meta"
);
const masthead = joinParts(
[
renderText(
"p",
"doc-briefing-masthead",
document.masthead
),
details
],
"doc-masthead doc-masthead-briefing",
"header"
);
const contact = renderText(
"footer",
"doc-briefing-contact",
document.contact
);
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: contact
};
}
function renderProjectReportStructure(
title: string,
document: Extract<
DocumentProfile,
{ profile: "project-report" }
>
): RenderedDocumentStructure {
const cover = joinParts(
[
renderText(
"p",
"doc-cover-project-name",
document.projectName
),
renderText(
"h1",
"doc-cover-title",
document.documentType || title
),
renderText("p", "doc-cover-version", document.version),
renderLabeledText(
"p",
"doc-cover-owner",
"建设单位:",
document.owner
),
renderLabeledText(
"p",
"doc-cover-prepared-by",
"编制单位:",
document.preparedBy
),
renderTime("doc-cover-date", document.date)
],
"doc-cover doc-cover-project-report",
"header"
);
return {
profile: document.profile,
prefixHtml: cover,
suffixHtml: ""
};
}
function renderTenderStructure(
title: string,
document: Extract<DocumentProfile, { profile: "tender" }>
): RenderedDocumentStructure {
const cover = joinParts(
[
renderText("p", "doc-cover-copy-mark", document.copyMark),
renderText(
"p",
"doc-cover-project-name",
document.projectName
),
renderText(
"p",
"doc-cover-project-number",
document.projectNumber
),
renderText("h1", "doc-cover-title", title),
renderText("p", "doc-cover-volume", document.volume),
renderLabeledText(
"p",
"doc-cover-bidder",
"投标人:",
document.bidder
),
renderLabeledText(
"p",
"doc-cover-representative",
"法定代表人或授权代表:",
document.representative
),
renderTime("doc-cover-date", document.date)
],
"doc-cover doc-cover-tender",
"header"
);
return {
profile: document.profile,
prefixHtml: cover,
suffixHtml: ""
};
}
function renderText(
tagName: string,
className: string,
value: string | undefined
) {
return value
? `<${tagName} class="${className}">${escapeHtml(value)}</${tagName}>`
: "";
}
function renderLabeledText(
tagName: string,
className: string,
label: string,
value: string | undefined
) {
return value
? `<${tagName} class="${className}"><span>${label}</span>${escapeHtml(value)}</${tagName}>`
: "";
}
function renderTime(className: string, value: string | undefined) {
return value
? `<time class="${className}">${escapeHtml(value)}</time>`
: "";
}
function joinParts(
parts: readonly string[],
className: string,
tagName = "div"
) {
const content = parts.filter(Boolean).join("");
return content
? `<${tagName} class="${className}">${content}</${tagName}>`
: "";
}
function escapeHtml(value: string) {
return value.replace(
/[&<>"']/gu,
+9 -2
View File
@@ -13,6 +13,7 @@ import {
documentProfileSchema,
isSafeDocumentLink
} from "@md-to-pdf/core";
import { createSemanticDocumentModel } from "@md-to-pdf/semantic-document";
import {
markdownItECharts,
type MarkdownItEChartsErrorContext
@@ -348,12 +349,17 @@ export function renderMarkdown(
};
const rendered = markdown.render(parsed.content, environment);
const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions);
const firstBodyHeading = extractFirstHeading(parsed.content);
const metadata = normalizeMetadata(
parsed.data,
options.language ?? "zh-CN",
extractFirstHeading(parsed.content)
firstBodyHeading
);
const structure = renderDocumentStructure(metadata);
const semanticDocument = createSemanticDocumentModel({
metadata,
firstBodyHeading
});
const structure = renderDocumentStructure(semanticDocument);
const profileAttribute = structure
? ` data-document-profile="${structure.profile}"`
: "";
@@ -364,6 +370,7 @@ export function renderMarkdown(
articleHtml,
bodyHtml,
metadata,
semanticDocument,
features: detectFeatures(bodyHtml),
warnings
};