feat: 建立统一语义文档模型
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user