diff --git a/apps/desktop/tests/electron-docx-media-capture.test.ts b/apps/desktop/tests/electron-docx-media-capture.test.ts index dabf9b3..a8fba5b 100644 --- a/apps/desktop/tests/electron-docx-media-capture.test.ts +++ b/apps/desktop/tests/electron-docx-media-capture.test.ts @@ -41,6 +41,14 @@ const request = { keywords: [], language: "zh-CN" }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "emit", + firstBodyHeading: "keep" + }, + regions: [] + }, features: [], warnings: [] }, diff --git a/apps/desktop/tests/pdf-contract.test.ts b/apps/desktop/tests/pdf-contract.test.ts index 46aa2da..0c5af97 100644 --- a/apps/desktop/tests/pdf-contract.test.ts +++ b/apps/desktop/tests/pdf-contract.test.ts @@ -26,6 +26,14 @@ const payload = createPagedDocumentPayload({ keywords: [], language: "zh-CN" }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }, features: ["mermaid"], warnings: [] }, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index e0afc43..3db5b4f 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -183,6 +183,16 @@ Playwright Chromium 151 与 Electron Chromium 150 分别对 14 套主题生成 经典标书双层边框与轮廓偏移近似,将在下一阶段由语义文档结构层消费。当前尚未将令牌接入 `reference.docx` 或 Pandoc 转换链路。 +`v0.6.0` 阶段 12A 已建立独立 `packages/semantic-document` 和版本化 +统一语义文档协议。普通文档、公文、简报、项目报告与标书现在先从 +Front Matter 和正文首个 H1 构建主题无关的语义树,再由 Renderer 投影为 +现有 HTML 节点与类名,因此网页预览和 PDF 的 DOM 保持兼容;同一模型也 +随 `RenderedMarkdownDocument` 进入 `PreparedDocxExport`。模型已表达 +版头、落款、版记、封面、标题去重策略,以及封面隐藏页眉页脚、隐藏页码、 +下一页分节和正文页码重启意图。当前尚未改变 Pandoc AST、动态 +`reference.docx` 或最终 DOCX;下一步阶段 12B 将把已归一化的主题令牌 +接入动态 Word 模板。 + ## 2. 已完成 ### 2.1 项目骨架 @@ -1112,8 +1122,12 @@ ECharts 第二阶段浏览器与 PDF 验证结果: Playwright/Electron 真实采集、主题指纹缓存和双引擎一致性矩阵已通过; CSS 计算值到 Word 样式令牌的通用归一化、清单覆盖、预设降级和结构 近似诊断已通过 14 主题双引擎矩阵;下一步由统一语义文档模型消费令牌; -- 阶段 12:建立统一语义文档模型,完成 Front Matter、封面、分节、 - 表格宽度和分页控制; +- 阶段 12A:已建立独立统一语义文档模型,Renderer 与 DOCX 准备结果 + 共用同一语义树,现有 HTML DOM 保持兼容; +- 阶段 12B:将主题令牌接入动态 `reference.docx`; +- 阶段 12C:完成 Pandoc 结构映射、封面分节、标题去重、表格宽度和 + 分页控制; +- 阶段 12D:完成 14 套主题的真实 Word/WPS 视觉与结构回归; - 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。 每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入 diff --git a/package-lock.json b/package-lock.json index 03fb801..04c5d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2207,6 +2207,10 @@ "resolved": "packages/renderer", "link": true }, + "node_modules/@md-to-pdf/semantic-document": { + "resolved": "packages/semantic-document", + "link": true + }, "node_modules/@md-to-pdf/server": { "resolved": "apps/server", "link": true @@ -9994,6 +9998,7 @@ "dependencies": { "@md-to-pdf/core": "0.1.0", "@md-to-pdf/markdown-echarts": "0.1.0", + "@md-to-pdf/semantic-document": "0.1.0", "@mdit/plugin-anchor": "^1.1.1", "@mdit/plugin-footnote": "^1.0.1", "@mdit/plugin-katex": "^1.0.1", @@ -10008,6 +10013,16 @@ "@types/sanitize-html": "^2.16.0", "vitest": "^4.1.10" } + }, + "packages/semantic-document": { + "name": "@md-to-pdf/semantic-document", + "version": "0.1.0", + "dependencies": { + "@md-to-pdf/core": "0.1.0" + }, + "devDependencies": { + "vitest": "^4.1.10" + } } } } diff --git a/package.json b/package.json index 93f701b..c522415 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,11 @@ ], "scripts": { "build": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop", - "build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web", - "dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,docx-theme-engine,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:docx-theme-engine\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", + "build:web-runtime": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/web", + "dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n markdown-echarts,core,semantic-document,docx-theme-engine,docx-engine,renderer,application,preview-engine,server,web \"npm:dev:markdown-echarts\" \"npm:dev:core\" \"npm:dev:semantic-document\" \"npm:dev:docx-theme-engine\" \"npm:dev:docx-engine\" \"npm:dev:renderer\" \"npm:dev:application\" \"npm:dev:preview-engine\" \"npm:dev:server\" \"npm:dev:web\"", "dev:markdown-echarts": "npm run dev -w @md-to-pdf/markdown-echarts", "dev:core": "npm run dev -w @md-to-pdf/core", + "dev:semantic-document": "npm run dev -w @md-to-pdf/semantic-document", "dev:docx-theme-engine": "npm run dev -w @md-to-pdf/docx-theme-engine", "dev:docx-engine": "npm run dev -w @md-to-pdf/docx-engine", "dev:renderer": "npm run dev -w @md-to-pdf/renderer", @@ -22,18 +23,18 @@ "dev:server": "npm run dev -w @md-to-pdf/server", "dev:web": "npm run dev -w @md-to-pdf/web", "dev:desktop": "npm run dev -w @md-to-pdf/desktop", - "desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"", + "desktop:dev": "npm run build -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/preview-engine && concurrently -k -n web,desktop \"npm:dev:web\" \"npm:dev:desktop\"", "desktop:package": "npm run package -w @md-to-pdf/desktop", "theme:import-typora": "node scripts/import-typora-theme.mjs", "verify:docx-reference": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:pandoc -w @md-to-pdf/docx-engine", "verify:docx-conversion": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine", "verify:docx-matrix": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine", "verify:docx-themes": "npm run build:web-runtime && npm run verify:themes -w @md-to-pdf/docx-engine", - "verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", + "verify:docx-theme-styles": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/application && npm run build -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/server && npm run verify:docx-theme-styles -w @md-to-pdf/desktop", "verify:docx-acceptance": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run build -w @md-to-pdf/desktop && npm run verify:pandoc -w @md-to-pdf/docx-engine && npm run verify:conversion -w @md-to-pdf/docx-engine && npm run verify:acceptance -w @md-to-pdf/docx-engine && npm run verify:docx-http -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop -- desktop-docx-save.test.ts", "verify:docx-http": "npm run build:web-runtime && npm run build -w @md-to-pdf/server && npm run verify:docx-http -w @md-to-pdf/server", - "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", - "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" + "test": "npm run test -w @md-to-pdf/markdown-echarts && npm run test -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run test -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run test -w @md-to-pdf/docx-theme-engine && npm run test -w @md-to-pdf/docx-engine && npm run test -w @md-to-pdf/renderer && npm run test -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run test -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run test -w @md-to-pdf/web && npm run test -w @md-to-pdf/server && npm run test -w @md-to-pdf/desktop", + "typecheck": "npm run typecheck -w @md-to-pdf/markdown-echarts && npm run build -w @md-to-pdf/markdown-echarts && npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/semantic-document && npm run build -w @md-to-pdf/semantic-document && npm run typecheck -w @md-to-pdf/docx-theme-engine && npm run build -w @md-to-pdf/docx-theme-engine && npm run typecheck -w @md-to-pdf/docx-engine && npm run build -w @md-to-pdf/docx-engine && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/application && npm run build -w @md-to-pdf/application && npm run typecheck -w @md-to-pdf/preview-engine && npm run build -w @md-to-pdf/preview-engine && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server && npm run typecheck -w @md-to-pdf/desktop" }, "engines": { "node": ">=22" diff --git a/packages/application/tests/application-service.test.ts b/packages/application/tests/application-service.test.ts index d00a6d5..067c4c3 100644 --- a/packages/application/tests/application-service.test.ts +++ b/packages/application/tests/application-service.test.ts @@ -295,6 +295,14 @@ describe("共享应用服务", () => { resources: [] }); expect(prepared.document.metadata.title).toBe("DOCX 文档"); + expect(prepared.document.semanticDocument).toEqual({ + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }); expect(prepared.theme.manifest.id).toBe("test-theme"); expect(prepared.theme.source).toBe("bundled"); expect(prepared.theme.css).toContain( diff --git a/packages/application/tests/docx-export-service.test.ts b/packages/application/tests/docx-export-service.test.ts index 39d0e43..e487891 100644 --- a/packages/application/tests/docx-export-service.test.ts +++ b/packages/application/tests/docx-export-service.test.ts @@ -59,6 +59,14 @@ const prepared: PreparedDocxExport = { keywords: [], language: "zh-CN" }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }, features: [], warnings: [] }, diff --git a/packages/application/tests/docx-media-service.test.ts b/packages/application/tests/docx-media-service.test.ts index 4dbaa27..0e9acdc 100644 --- a/packages/application/tests/docx-media-service.test.ts +++ b/packages/application/tests/docx-media-service.test.ts @@ -70,6 +70,14 @@ const prepared: PreparedDocxExport = { keywords: [], language: "zh-CN" }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }, features: [], warnings: ["原始图片已降级"] }, diff --git a/packages/core/src/document.ts b/packages/core/src/document.ts index 68a973c..3c942d4 100644 --- a/packages/core/src/document.ts +++ b/packages/core/src/document.ts @@ -1,5 +1,6 @@ import type { ExportConfig } from "./export-config.js"; import type { DocumentProfile } from "./document-profile.js"; +import type { SemanticDocumentModel } from "./semantic-document.js"; import type { ThemeFeature } from "./theme.js"; export interface MarkdownDocumentMetadata { @@ -16,6 +17,7 @@ export interface RenderedMarkdownDocument { articleHtml: string; bodyHtml: string; metadata: MarkdownDocumentMetadata; + semanticDocument: SemanticDocumentModel; features: ThemeFeature[]; warnings: string[]; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ba07d6..01e5701 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,4 +4,5 @@ export * from "./docx-style.js"; export * from "./document-profile.js"; export * from "./document-link.js"; export * from "./export-config.js"; +export * from "./semantic-document.js"; export * from "./theme.js"; diff --git a/packages/core/src/semantic-document.ts b/packages/core/src/semantic-document.ts new file mode 100644 index 0000000..3fed2da --- /dev/null +++ b/packages/core/src/semantic-document.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import { documentProfileNameSchema } from "./document-profile.js"; + +export const semanticDocumentRoleSchema = z.enum([ + "official-masthead", + "official-classification", + "official-secrecy", + "official-urgency", + "official-issuer", + "official-issue-row", + "official-number", + "official-signatory", + "official-title", + "official-signature", + "official-signature-issuer", + "official-signature-date", + "official-edition", + "official-copy-to", + "official-printing-row", + "official-printing-office", + "official-printing-date", + "briefing-masthead", + "briefing-masthead-text", + "briefing-meta", + "briefing-issue", + "briefing-publisher", + "briefing-signatory", + "briefing-date", + "briefing-title", + "briefing-contact", + "project-report-cover", + "project-report-project-name", + "project-report-title", + "project-report-version", + "project-report-owner", + "project-report-prepared-by", + "project-report-date", + "tender-cover", + "tender-copy-mark", + "tender-project-name", + "tender-project-number", + "tender-title", + "tender-volume", + "tender-bidder", + "tender-representative", + "tender-date" +]); + +export type SemanticDocumentRole = z.infer< + typeof semanticDocumentRoleSchema +>; + +export interface SemanticDocumentTextNode { + kind: "text"; + role: SemanticDocumentRole; + text: string; + label?: string | undefined; +} + +export interface SemanticDocumentGroupNode { + kind: "group"; + role: SemanticDocumentRole; + children: SemanticDocumentNode[]; +} + +export type SemanticDocumentNode = + | SemanticDocumentTextNode + | SemanticDocumentGroupNode; + +export const semanticDocumentNodeSchema: z.ZodType< + SemanticDocumentNode +> = z.lazy(() => + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("text"), + role: semanticDocumentRoleSchema, + text: z.string().min(1).max(1000), + label: z.string().min(1).max(100).optional() + }), + z.object({ + kind: z.literal("group"), + role: semanticDocumentRoleSchema, + children: z.array(semanticDocumentNodeSchema).min(1).max(30) + }) + ]) +); + +export const semanticDocumentRegionKindSchema = z.enum([ + "cover", + "prefix", + "suffix" +]); + +export type SemanticDocumentRegionKind = z.infer< + typeof semanticDocumentRegionKindSchema +>; + +export const semanticDocumentSectionIntentSchema = z.object({ + headerFooter: z.literal("none"), + pageNumber: z.literal("hidden"), + breakAfter: z.literal("next-page"), + followingPageNumberStart: z.number().int().min(0).max(100000) +}); + +export type SemanticDocumentSectionIntent = z.infer< + typeof semanticDocumentSectionIntentSchema +>; + +export const semanticDocumentRegionSchema = z.object({ + kind: semanticDocumentRegionKindSchema, + nodes: z.array(semanticDocumentNodeSchema).min(1).max(30), + section: semanticDocumentSectionIntentSchema.optional() +}); + +export type SemanticDocumentRegion = z.infer< + typeof semanticDocumentRegionSchema +>; + +export const semanticDocumentTitlePolicySchema = z.object({ + metadataTitle: z.enum(["emit", "suppress"]), + firstBodyHeading: z.enum(["keep", "suppress"]) +}); + +export type SemanticDocumentTitlePolicy = z.infer< + typeof semanticDocumentTitlePolicySchema +>; + +export const semanticDocumentModelSchema = z.object({ + schemaVersion: z.literal(1), + profile: documentProfileNameSchema.optional(), + titlePolicy: semanticDocumentTitlePolicySchema, + regions: z.array(semanticDocumentRegionSchema).max(10) +}); + +export type SemanticDocumentModel = z.infer< + typeof semanticDocumentModelSchema +>; diff --git a/packages/core/tests/document.test.ts b/packages/core/tests/document.test.ts index 0a8c615..a791b99 100644 --- a/packages/core/tests/document.test.ts +++ b/packages/core/tests/document.test.ts @@ -18,6 +18,14 @@ describe("分页文档载荷", () => { keywords: [], language: "zh-CN" }, + semanticDocument: { + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }, features: ["table"], warnings: [] }; diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 697811b..f03bbb6 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -27,6 +27,7 @@ "@mdit/plugin-tasklist": "^1.0.1", "@md-to-pdf/core": "0.1.0", "@md-to-pdf/markdown-echarts": "0.1.0", + "@md-to-pdf/semantic-document": "0.1.0", "gray-matter": "^4.0.3", "highlight.js": "^11.11.1", "markdown-it": "^14.3.0", diff --git a/packages/renderer/src/render-document-structure.ts b/packages/renderer/src/render-document-structure.ts index 00e6c0c..0a2a523 100644 --- a/packages/renderer/src/render-document-structure.ts +++ b/packages/renderer/src/render-document-structure.ts @@ -1,280 +1,226 @@ import type { - DocumentProfile, - MarkdownDocumentMetadata + SemanticDocumentModel, + SemanticDocumentNode, + SemanticDocumentRole } from "@md-to-pdf/core"; export interface RenderedDocumentStructure { - profile: DocumentProfile["profile"]; + profile: NonNullable; prefixHtml: string; suffixHtml: string; } +interface HtmlRole { + tagName: string; + className: string; +} + +const htmlRoles: Readonly> = { + "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 ? `${escapeHtml(node.label)}` : ""}${escapeHtml(node.text)}`; + return `<${role.tagName} class="${role.className}">${content}`; +} + 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 -): 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 - ? `

抄送:${escapeHtml(document.copyTo.join("、"))}

` - : "", - joinParts( - [ - renderText( - "span", - "doc-printing-office", - document.printingOffice - ), - document.date - ? `` - : "" - ], - "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 -): 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 -): 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)}` - : ""; -} - -function renderLabeledText( - tagName: string, - className: string, - label: string, - value: string | undefined -) { - return value - ? `<${tagName} class="${className}">${label}${escapeHtml(value)}` - : ""; -} - -function renderTime(className: string, value: string | undefined) { - return value - ? `` - : ""; -} - -function joinParts( - parts: readonly string[], - className: string, - tagName = "div" -) { - const content = parts.filter(Boolean).join(""); - return content - ? `<${tagName} class="${className}">${content}` - : ""; -} - function escapeHtml(value: string) { return value.replace( /[&<>"']/gu, diff --git a/packages/renderer/src/render-markdown.ts b/packages/renderer/src/render-markdown.ts index b99777f..e816872 100644 --- a/packages/renderer/src/render-markdown.ts +++ b/packages/renderer/src/render-markdown.ts @@ -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 }; diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts index 94adb23..9ddc559 100644 --- a/packages/renderer/tests/render-markdown.test.ts +++ b/packages/renderer/tests/render-markdown.test.ts @@ -32,6 +32,14 @@ const answer = 42; expect(result.bodyHtml).toContain("hljs-keyword"); expect(result.bodyHtml).toContain("footnote"); expect(result.metadata.title).toBe("示例文档"); + expect(result.semanticDocument).toEqual({ + schemaVersion: 1, + titlePolicy: { + metadataTitle: "suppress", + firstBodyHeading: "keep" + }, + regions: [] + }); expect(result.features).toEqual( expect.arrayContaining(["code", "table", "task-list", "footnote"]) ); @@ -152,6 +160,8 @@ document: expect(result.articleHtml).toContain( '