release: 发布 v0.5.1

新增能力:内置 4 套红头、3 套正式文档和 3 套标书主题,支持主题推荐页边距、页面装饰、页眉页脚和页码;增加结构化公文、项目报告及标书 Front Matter,内置 Fandol 中文字体、两份教程和 14 份主题示例。

桌面工作流:重组更多菜单,增加新建、保存、另存为快捷键和未保存确认;另存为后跟随新路径,主题示例自动切换主题,Desktop 发行包完整携带教程、示例与字体。

问题修复:修正红头标题居中与正式文档字体;围栏代码按正文宽度自动换行,长内容安全断行,至少两行即可在当前页分页,并统一保留 8px 左侧内容留白;Docker Web 镜像正确打包 samples。

兼容与部署:未声明主题推荐设置时继续使用 16mm 默认页边距;Web 隐藏不适用的另存为。正式镜像 yixiong/md-to-pdf:v0.5.1 内容 ID 为 sha256:72f519a69bfd6cb2f6df30c2be938e58ceb6f20e4748a32551874de3d436b276,Compose 健康运行。

验证结果:最终修复前 65 个测试文件、286 项测试通过,最终代码块与主题定向测试 27 项通过;全项目类型检查、生产构建和 git diff --check 通过。容器检出 14 套主题、17 份 Markdown,代码块回归 PDF 为 2 页且无越界。NSIS SHA-256 为 676CCE73D782B0B15AC6BC68F253CFBC4740383583E4DAA98F746EDF9999C5CC,ZIP SHA-256 为 82BF6ADF1200604F145CC86FA3ED193955CF6741EBEE3DF8953483515CFF621F。
This commit is contained in:
SkyJourney
2026-07-29 16:58:17 +08:00
parent 58087d0c7e
commit 83e6559c2c
111 changed files with 8274 additions and 173 deletions
@@ -104,6 +104,9 @@ export function createApplicationService(
name: manifest.name,
version: manifest.version,
description: manifest.description,
category: manifest.category,
compatibleProfiles: manifest.compatibleProfiles,
pageDefaults: manifest.pageDefaults,
bundled: manifest.bundled,
source
}))
+35 -4
View File
@@ -46,6 +46,8 @@ const assetContentTypes: Record<string, string> = {
const cssImportPattern =
/@import\s+(?:url\(\s*(?:\"([^\"]+)\"|'([^']+)'|([^'\"\s)]+))\s*\)|\"([^\"]+)\"|'([^']+)')\s*;/gi;
const maximumCssImportDepth = 8;
const sharedThemeAssetId = "_shared";
const sharedThemeAssetScheme = "theme-shared:";
function isMissingFileError(error: unknown) {
return (
@@ -110,7 +112,7 @@ async function readThemeRoot(
const records: ThemeRecord[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
if (!entry.isDirectory() || entry.name.startsWith("_")) {
continue;
}
@@ -165,7 +167,8 @@ function prepareCssSegment(
css: string,
themeId: string,
cssDirectory: string,
createAssetUrl: (themeId: string, assetPath: string) => string
createAssetUrl: (themeId: string, assetPath: string) => string,
allowSharedAssets: boolean
) {
return css.replace(
/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi,
@@ -174,6 +177,18 @@ function prepareCssSegment(
if (value.startsWith("data:") || value.startsWith("#")) {
return match;
}
if (value.startsWith(sharedThemeAssetScheme)) {
if (!allowSharedAssets) {
throw new Error("本地主题不能引用内置共享资源");
}
const sharedPath = normalizeCssPath(
value.slice(sharedThemeAssetScheme.length)
);
if (!sharedPath || sharedPath === ".") {
throw new Error("内置共享资源路径不能为空");
}
return `url("${createAssetUrl(sharedThemeAssetId, sharedPath)}")`;
}
if (
value.startsWith("/") ||
value.startsWith("//") ||
@@ -250,7 +265,8 @@ async function loadThemeCssFile(
css.slice(previousEnd, matchStart),
theme.manifest.id,
cssDirectory,
createAssetUrl
createAssetUrl,
theme.source === "bundled"
);
const importedPath = readImportedPath(match);
@@ -273,7 +289,8 @@ async function loadThemeCssFile(
remainder,
theme.manifest.id,
cssDirectory,
createAssetUrl
createAssetUrl,
theme.source === "bundled"
);
if (/@import\b/i.test(result)) {
throw new Error("主题包含不支持的 CSS @import 语法");
@@ -378,6 +395,20 @@ export function createThemeRegistry(options: ThemeRegistryOptions) {
}
async function getAsset(themeId: string, assetPath: string) {
if (themeId === sharedThemeAssetId) {
const extension = extname(assetPath).toLowerCase();
const contentType = assetContentTypes[extension];
if (!contentType) {
throw new Error("不支持的主题资源类型");
}
const sharedRoot = resolve(options.bundledRoot, sharedThemeAssetId);
const path = await resolveThemeFile(sharedRoot, assetPath);
return {
contentType,
content: await readFile(path)
};
}
const theme = await get(themeId);
if (!theme) {
return undefined;
@@ -37,7 +37,13 @@ async function createThemeFixture() {
const bundledRoot = join(temporaryDirectory, "bundled");
const localRoot = join(temporaryDirectory, "local");
const themeRoot = join(bundledRoot, "test-theme");
const sharedFontRoot = join(
bundledRoot,
"_shared",
"official-fonts"
);
await mkdir(join(themeRoot, "fonts"), { recursive: true });
await mkdir(sharedFontRoot, { recursive: true });
await mkdir(localRoot, { recursive: true });
await writeFile(
join(themeRoot, "theme.json"),
@@ -53,6 +59,14 @@ async function createThemeFixture() {
domPreset: "typora",
defaultFontSize: "16px",
supportedFeatures: ["code", "table"],
pageDefaults: {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
}
},
bundled: true
}),
"utf8"
@@ -64,6 +78,10 @@ async function createThemeFixture() {
" font-family: Test;",
" src: url('./fonts/test.woff2') format('woff2');",
"}",
"@font-face {",
" font-family: SharedTest;",
" src: url('theme-shared:official-fonts/shared.woff2') format('woff2');",
"}",
"#write { font-family: Test; }"
].join("\n"),
"utf8"
@@ -72,6 +90,10 @@ async function createThemeFixture() {
join(themeRoot, "fonts", "test.woff2"),
Buffer.from([0, 1, 2, 3])
);
await writeFile(
join(sharedFontRoot, "shared.woff2"),
Buffer.from([4, 5, 6, 7])
);
return { bundledRoot, localRoot };
}
@@ -147,6 +169,16 @@ describe("共享应用服务", () => {
themes: [
expect.objectContaining({
id: "test-theme",
category: "general",
compatibleProfiles: [],
pageDefaults: {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
}
},
bundled: true,
source: "bundled"
})
@@ -157,6 +189,11 @@ describe("共享应用服务", () => {
).resolves.toContain(
"mdpdf://theme/test-theme/fonts/test.woff2"
);
await expect(
service.getThemeCss("test-theme")
).resolves.toContain(
"mdpdf://theme/_shared/official-fonts/shared.woff2"
);
});
it("安全读取主题二进制资源", async () => {
@@ -172,6 +209,18 @@ describe("共享应用服务", () => {
await expect(
service.getThemeAsset("test-theme", "../test.woff2")
).rejects.toThrow("主题资源路径不安全");
await expect(
service.getThemeAsset(
"_shared",
"official-fonts/shared.woff2"
)
).resolves.toEqual({
contentType: "font/woff2",
content: Buffer.from([4, 5, 6, 7])
});
await expect(
service.getThemeAsset("_shared", "../shared.woff2")
).rejects.toThrow("主题资源路径不安全");
});
it("内置主题优先于同 ID 的旧本地副本", async () => {
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
@@ -6,10 +6,65 @@ const bundledThemeNames = {
"typora-github": "Typora Github",
"typora-pixyll": "Typora Pixyll",
"typora-whitey": "Typora whitey",
"typora-like": "Typora Clean"
"typora-like": "Typora Clean",
"gov-red-standard": "政企红头·标准文件",
"gov-red-letter": "政企红头·标准信函",
"enterprise-red-simple": "政企红头·企业简版",
"red-briefing": "政企红头·纪要简报",
"formal-report": "政企正式·工作报告",
"formal-regulation": "政企正式·规章制度",
"formal-feasibility": "政企正式·可研报告",
"tender-classic": "标书正式·经典黑白",
"tender-business-blue": "标书正式·商务蓝",
"tender-blind": "标书正式·技术暗标"
} as const;
describe("内置主题清单", () => {
it("每个内置主题都有同名 Markdown 示例", async () => {
const samplesRoot = fileURLToPath(
new URL("../../../samples/themes/", import.meta.url)
);
const sampleFiles = (await readdir(samplesRoot))
.filter((fileName) => fileName.endsWith(".md"))
.sort();
const expectedFiles = Object.keys(bundledThemeNames)
.map((id) => `${id}.md`)
.sort();
expect(sampleFiles).toEqual(expectedFiles);
expect(sampleFiles).not.toContain("_shared.md");
for (const fileName of sampleFiles) {
const markdown = await readFile(
`${samplesRoot}${fileName}`,
"utf8"
);
expect(markdown.length).toBeGreaterThan(300);
expect(markdown).toMatch(/^---[\s\S]+---/u);
}
});
it("提供可随桌面发行包分发的内置教程", async () => {
const tutorialsRoot = fileURLToPath(
new URL("../../../samples/tutorials/", import.meta.url)
);
const tutorialFiles = (await readdir(tutorialsRoot))
.filter((fileName) => fileName.endsWith(".md"))
.sort();
expect(tutorialFiles).toEqual([
"echarts-tutorial.md",
"formal-document-tutorial.md"
]);
for (const fileName of tutorialFiles) {
const markdown = await readFile(
`${tutorialsRoot}${fileName}`,
"utf8"
);
expect(markdown.startsWith("---")).toBe(true);
expect(markdown.length).toBeGreaterThan(1_000);
}
});
it("使用稳定主题 ID 和面向用户的显示名称", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
@@ -27,4 +82,175 @@ describe("内置主题清单", () => {
expect(manifest.name).toBe(name);
}
});
it("为红头主题声明分类与兼容的结构化文档", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
const expectedProfiles = {
"gov-red-standard": "official",
"gov-red-letter": "official",
"enterprise-red-simple": "official",
"red-briefing": "briefing"
} as const;
for (const [id, profile] of Object.entries(expectedProfiles)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as {
category?: unknown;
compatibleProfiles?: unknown;
};
expect(manifest.category).toBe("red-letter");
expect(manifest.compatibleProfiles).toContain(profile);
}
});
it("标准信函主题提供首页版头和末页底部红色双线", async () => {
const [themeCss, printCss] = await Promise.all([
readFile(
new URL(
"../../../themes/gov-red-letter/theme.css",
import.meta.url
),
"utf8"
),
readFile(
new URL(
"../../../themes/gov-red-letter/print.css",
import.meta.url
),
"utf8"
)
]);
expect(themeCss).toContain(".doc-masthead-official::after");
expect(printCss).toContain(
".pagedjs_pages > .pagedjs_page:last-child .pagedjs_pagebox::after"
);
expect(printCss).toContain("bottom: 20mm");
});
it("为正式文档与标书主题声明正确分类", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
const expectedThemes = {
"formal-report": {
category: "formal",
profile: undefined
},
"formal-regulation": {
category: "formal",
profile: undefined
},
"formal-feasibility": {
category: "formal",
profile: "project-report"
},
"tender-classic": {
category: "tender",
profile: "tender"
},
"tender-business-blue": {
category: "tender",
profile: "tender"
},
"tender-blind": {
category: "tender",
profile: "tender"
}
} as const;
for (const [id, expected] of Object.entries(expectedThemes)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as {
category?: unknown;
compatibleProfiles?: unknown[];
};
expect(manifest.category).toBe(expected.category);
if (expected.profile) {
expect(manifest.compatibleProfiles).toContain(
expected.profile
);
} else {
expect(manifest.compatibleProfiles).toEqual([]);
}
}
});
it("技术暗标主题隐藏结构化身份字段", async () => {
const css = await readFile(
new URL(
"../../../themes/tender-blind/theme.css",
import.meta.url
),
"utf8"
);
expect(css).toMatch(
/\.doc-cover-bidder,[\s\S]*\.doc-cover-representative\s*\{[\s\S]*display:\s*none\s*!important;/u
);
});
it("正式主题声明符合用途的推荐页边距", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
const expectedMargins = {
"gov-red-standard": ["37mm", "26mm", "35mm", "28mm"],
"gov-red-letter": ["37mm", "26mm", "35mm", "28mm"],
"enterprise-red-simple": ["25mm", "25mm", "25mm", "28mm"],
"red-briefing": ["25mm", "25mm", "25mm", "25mm"],
"formal-report": ["37mm", "26mm", "35mm", "28mm"],
"formal-regulation": ["37mm", "26mm", "35mm", "28mm"],
"formal-feasibility": ["25mm", "20mm", "25mm", "30mm"],
"tender-classic": ["25mm", "25mm", "25mm", "30mm"],
"tender-business-blue": ["25mm", "25mm", "25mm", "30mm"],
"tender-blind": ["25.4mm", "25.4mm", "25.4mm", "31.7mm"]
} as const;
for (const [id, expected] of Object.entries(expectedMargins)) {
const manifest = JSON.parse(
await readFile(`${themesRoot}${id}/theme.json`, "utf8")
) as {
pageDefaults?: {
margins?: Record<"top" | "right" | "bottom" | "left", string>;
};
};
const margins = manifest.pageDefaults?.margins;
expect([
margins?.top,
margins?.right,
margins?.bottom,
margins?.left
]).toEqual(expected);
}
});
it("红头机关名称不继承正文首行缩进", async () => {
const themesRoot = fileURLToPath(
new URL("../../../themes/", import.meta.url)
);
for (const id of [
"gov-red-standard",
"gov-red-letter",
"enterprise-red-simple"
]) {
const css = await readFile(
`${themesRoot}${id}/theme.css`,
"utf8"
);
expect(css).toMatch(
/#write \.doc-issuer\s*\{[^}]*text-align:\s*center;[^}]*text-indent:\s*0;/su
);
}
});
});
+99
View File
@@ -0,0 +1,99 @@
import { z } from "zod";
const scalarTextSchema = (maximumLength: number) =>
z.preprocess(
(value) => {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return String(value).trim();
}
return value;
},
z.string().min(1).max(maximumLength)
);
const shortTextSchema = scalarTextSchema(120);
const longTextSchema = scalarTextSchema(500);
const textListSchema = z.preprocess(
(value) =>
typeof value === "string"
? value
.split(/[,]/u)
.map((item) => item.trim())
.filter(Boolean)
: value,
z.array(shortTextSchema).max(30)
);
export const documentProfileNameSchema = z.enum([
"official",
"briefing",
"project-report",
"tender"
]);
const officialDocumentProfileSchema = z
.object({
profile: z.literal("official"),
issuer: shortTextSchema.optional(),
number: shortTextSchema.optional(),
secrecy: shortTextSchema.optional(),
urgency: shortTextSchema.optional(),
signatory: shortTextSchema.optional(),
date: shortTextSchema.optional(),
copyTo: textListSchema.optional(),
printingOffice: shortTextSchema.optional()
})
.strict();
const briefingDocumentProfileSchema = z
.object({
profile: z.literal("briefing"),
masthead: shortTextSchema.optional(),
issue: shortTextSchema.optional(),
publisher: shortTextSchema.optional(),
signatory: shortTextSchema.optional(),
date: shortTextSchema.optional(),
contact: longTextSchema.optional()
})
.strict();
const projectReportDocumentProfileSchema = z
.object({
profile: z.literal("project-report"),
projectName: longTextSchema.optional(),
documentType: shortTextSchema.optional(),
owner: longTextSchema.optional(),
preparedBy: longTextSchema.optional(),
version: shortTextSchema.optional(),
date: shortTextSchema.optional()
})
.strict();
const tenderDocumentProfileSchema = z
.object({
profile: z.literal("tender"),
copyMark: shortTextSchema.optional(),
projectName: longTextSchema.optional(),
projectNumber: shortTextSchema.optional(),
volume: shortTextSchema.optional(),
bidder: longTextSchema.optional(),
representative: shortTextSchema.optional(),
date: shortTextSchema.optional()
})
.strict();
export const documentProfileSchema = z.discriminatedUnion("profile", [
officialDocumentProfileSchema,
briefingDocumentProfileSchema,
projectReportDocumentProfileSchema,
tenderDocumentProfileSchema
]);
export type DocumentProfileName = z.infer<
typeof documentProfileNameSchema
>;
export type DocumentProfile = z.infer<typeof documentProfileSchema>;
+2
View File
@@ -1,4 +1,5 @@
import type { ExportConfig } from "./export-config.js";
import type { DocumentProfile } from "./document-profile.js";
import type { ThemeFeature } from "./theme.js";
export interface MarkdownDocumentMetadata {
@@ -7,6 +8,7 @@ export interface MarkdownDocumentMetadata {
subject: string;
keywords: string[];
language: string;
document?: DocumentProfile;
}
export interface RenderedMarkdownDocument {
+66 -15
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
export const EXPORT_CONFIG_VERSION = 3;
export const EXPORT_CONFIG_VERSION = 4;
export const CSS_PIXELS_PER_INCH = 96;
export const PDF_POINTS_PER_INCH = 72;
export const MILLIMETERS_PER_INCH = 25.4;
@@ -86,23 +86,39 @@ export function millimetersToPdfPoints(value: number) {
return (value * PDF_POINTS_PER_INCH) / MILLIMETERS_PER_INCH;
}
const marginSchema = z.object({
export const pageMarginsSchema = z.object({
top: lengthSchema,
right: lengthSchema,
bottom: lengthSchema,
left: lengthSchema
});
const headerSlotSchema = z.object({
export const marginModeSchema = z.enum(["theme", "custom"]);
export const pageDecorationsModeSchema = z.enum(["theme", "custom"]);
export const headerSlotSchema = z.object({
enabled: z.boolean(),
content: z.string().max(500)
});
const headerSchema = z.object({
const pageDecorationFontFamilySchema = z
.string()
.trim()
.min(1)
.max(300)
.regex(
/^[\p{L}\p{N}\s,"'-]+$/u,
"页眉页码字体只能包含字体名称、引号和逗号"
);
export const headerSchema = z.object({
enabled: z.boolean(),
height: lengthSchema,
showDivider: z.boolean(),
fontSize: lengthSchema,
fontFamily: pageDecorationFontFamilySchema.default(
'"Segoe UI", "Microsoft YaHei", sans-serif'
),
color: z.string(),
left: headerSlotSchema,
center: headerSlotSchema,
@@ -114,26 +130,39 @@ export const pageNumberFormatSchema = z.enum([
"page-total",
"chinese-page-total",
"dash-page",
"official-page",
"custom"
]);
const footerSchema = z.object({
export const footerAlignmentSchema = z.enum([
"left",
"center",
"right",
"outer"
]);
export const footerSchema = z.object({
enabled: z.boolean(),
height: lengthSchema,
showDivider: z.boolean(),
fontSize: lengthSchema,
fontFamily: pageDecorationFontFamilySchema.default(
'"Segoe UI", "Microsoft YaHei", sans-serif'
),
color: z.string(),
alignment: z.enum(["left", "center", "right"]),
alignment: footerAlignmentSchema,
format: pageNumberFormatSchema,
template: z.string().max(500).optional(),
startFrom: z.number().int().positive()
startFrom: z.number().int().positive(),
showOnFirstPage: z.boolean().default(true)
});
const paperSchema = z
.object({
format: z.enum(supportedPaperFormats),
orientation: z.enum(["portrait", "landscape"]),
margins: marginSchema
marginMode: marginModeSchema,
margins: pageMarginsSchema
})
.superRefine((paper, context) => {
const sourceDimensions = paperDimensionsMm[paper.format];
@@ -178,6 +207,7 @@ export const exportConfigSchema = z.object({
version: z.literal(EXPORT_CONFIG_VERSION),
name: z.string().min(1).max(100),
themeId: z.string().min(1),
pageDecorationsMode: pageDecorationsModeSchema.default("theme"),
mermaid: mermaidConfigSchema,
paper: paperSchema,
header: headerSchema,
@@ -200,9 +230,30 @@ export type ExportConfig = z.infer<typeof exportConfigSchema>;
export type MermaidExportConfig = ExportConfig["mermaid"];
export type PaperFormat = ExportConfig["paper"]["format"];
export type PaperOrientation = ExportConfig["paper"]["orientation"];
export type PageMargins = ExportConfig["paper"]["margins"];
export type MarginMode = ExportConfig["paper"]["marginMode"];
export type PageDecorationsMode = ExportConfig["pageDecorationsMode"];
export type HeaderConfig = ExportConfig["header"];
export type FooterConfig = ExportConfig["footer"];
export const defaultPageMargins: PageMargins = {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
};
export function resolvePageMargins(
paper: Pick<ExportConfig["paper"], "marginMode" | "margins">,
themeMargins?: PageMargins
): PageMargins {
const source =
paper.marginMode === "theme"
? themeMargins ?? defaultPageMargins
: paper.margins;
return { ...source };
}
export function getPaperDimensionsMm(
format: PaperFormat,
orientation: PaperOrientation
@@ -222,6 +273,7 @@ export const defaultExportConfig: ExportConfig = {
version: EXPORT_CONFIG_VERSION,
name: "默认 A4 文档",
themeId: "typora-github",
pageDecorationsMode: "theme",
mermaid: {
layout: "dagre",
theme: "default",
@@ -231,18 +283,15 @@ export const defaultExportConfig: ExportConfig = {
paper: {
format: "A4",
orientation: "portrait",
margins: {
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
}
marginMode: "theme",
margins: { ...defaultPageMargins }
},
header: {
enabled: false,
height: "8mm",
showDivider: false,
fontSize: "3mm",
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
color: "#6b7280",
left: disabledHeaderSlot,
center: disabledHeaderSlot,
@@ -253,10 +302,12 @@ export const defaultExportConfig: ExportConfig = {
height: "8mm",
showDivider: false,
fontSize: "3mm",
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
color: "#6b7280",
alignment: "center",
format: "page-total",
startFrom: 1
startFrom: 1,
showOnFirstPage: true
},
metadata: {
title: "",
+1
View File
@@ -1,4 +1,5 @@
export * from "./document.js";
export * from "./document-profile.js";
export * from "./document-link.js";
export * from "./export-config.js";
export * from "./theme.js";
+25
View File
@@ -1,4 +1,10 @@
import { z } from "zod";
import { documentProfileNameSchema } from "./document-profile.js";
import {
footerSchema,
headerSchema,
pageMarginsSchema
} from "./export-config.js";
export const THEME_MANIFEST_VERSION = 1;
@@ -12,6 +18,13 @@ export const themeFeatureSchema = z.enum([
"echarts"
]);
export const themeCategorySchema = z.enum([
"general",
"red-letter",
"formal",
"tender"
]);
export const themeManifestSchema = z.object({
manifestVersion: z.literal(THEME_MANIFEST_VERSION),
id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
@@ -26,8 +39,20 @@ export const themeManifestSchema = z.object({
domPreset: z.enum(["typora", "github", "generic"]),
defaultFontSize: z.string(),
supportedFeatures: z.array(themeFeatureSchema),
category: themeCategorySchema.default("general"),
compatibleProfiles: z
.array(documentProfileNameSchema)
.default([]),
pageDefaults: z
.object({
margins: pageMarginsSchema,
header: headerSchema.optional(),
footer: footerSchema.optional()
})
.optional(),
bundled: z.boolean()
});
export type ThemeManifest = z.infer<typeof themeManifestSchema>;
export type ThemeFeature = z.infer<typeof themeFeatureSchema>;
export type ThemeCategory = z.infer<typeof themeCategorySchema>;
+100
View File
@@ -7,6 +7,7 @@ import {
millimetersToCssPixels,
millimetersToPdfPoints,
paperDimensionsMm,
resolvePageMargins,
supportedMermaidLayouts,
supportedMermaidLooks,
supportedMermaidThemes,
@@ -33,9 +34,11 @@ describe("导出配置", () => {
it("使用 A4 和 16mm 作为默认纸张配置", () => {
expect(defaultExportConfig.themeId).toBe("typora-github");
expect(defaultExportConfig.pageDecorationsMode).toBe("theme");
expect(defaultExportConfig.paper).toEqual({
format: "A4",
orientation: "portrait",
marginMode: "theme",
margins: {
top: "16mm",
right: "16mm",
@@ -46,6 +49,103 @@ describe("导出配置", () => {
expect(exportConfigSchema.safeParse(defaultExportConfig).success).toBe(true);
});
it("提供可由主题推荐的页眉和页码配置", () => {
expect(defaultExportConfig.header).toMatchObject({
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif'
});
expect(defaultExportConfig.footer).toMatchObject({
fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
alignment: "center",
format: "page-total",
showOnFirstPage: true
});
const parsedLegacyV4 = exportConfigSchema.parse({
...defaultExportConfig,
pageDecorationsMode: undefined,
header: {
...defaultExportConfig.header,
fontFamily: undefined
},
footer: {
...defaultExportConfig.footer,
fontFamily: undefined,
showOnFirstPage: undefined
}
});
expect(parsedLegacyV4.pageDecorationsMode).toBe("theme");
expect(parsedLegacyV4.header.fontFamily).toContain("Microsoft YaHei");
expect(parsedLegacyV4.footer.fontFamily).toContain("Microsoft YaHei");
expect(parsedLegacyV4.footer.showOnFirstPage).toBe(true);
});
it("支持公文页码格式和奇偶页外侧对齐", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
footer: {
...defaultExportConfig.footer,
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
fontSize: "4.94mm",
color: "#000000",
alignment: "outer",
format: "official-page",
showOnFirstPage: false
}
}).success
).toBe(true);
});
it("拒绝可能逃逸 CSS 字体声明的页眉页码字体", () => {
expect(
exportConfigSchema.safeParse({
...defaultExportConfig,
footer: {
...defaultExportConfig.footer,
fontFamily: 'serif; color: red'
}
}).success
).toBe(false);
});
it("按主题、自定义和全局默认顺序解析页边距", () => {
const themeMargins = {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
};
expect(resolvePageMargins(defaultExportConfig.paper, themeMargins)).toEqual(
themeMargins
);
expect(resolvePageMargins(defaultExportConfig.paper)).toEqual({
top: "16mm",
right: "16mm",
bottom: "16mm",
left: "16mm"
});
expect(
resolvePageMargins(
{
marginMode: "custom",
margins: {
top: "10mm",
right: "11mm",
bottom: "12mm",
left: "13mm"
}
},
themeMargins
)
).toEqual({
top: "10mm",
right: "11mm",
bottom: "12mm",
left: "13mm"
});
});
it("提供 Mermaid 官方布局、主题、外观和字体默认值", () => {
expect(supportedMermaidLayouts).toEqual(["dagre", "elk"]);
expect(supportedMermaidLooks).toEqual([
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { themeManifestSchema } from "../src/theme.js";
describe("主题清单", () => {
it("允许主题推荐页边距、页眉和页码配置", () => {
const parsed = themeManifestSchema.parse({
manifestVersion: 1,
id: "official-test",
name: "公文测试主题",
version: "0.5.1",
description: "测试主题推荐页面配置。",
author: "md-to-pdf contributors",
license: "项目自有",
entry: "theme.css",
domPreset: "generic",
defaultFontSize: "21.333px",
supportedFeatures: ["table"],
category: "red-letter",
compatibleProfiles: ["official"],
pageDefaults: {
margins: {
top: "37mm",
right: "26mm",
bottom: "35mm",
left: "28mm"
},
header: {
enabled: false,
height: "8mm",
showDivider: false,
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
color: "#000000",
left: { enabled: false, content: "" },
center: { enabled: false, content: "" },
right: { enabled: false, content: "" }
},
footer: {
enabled: true,
height: "7mm",
showDivider: false,
fontSize: "4.94mm",
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
color: "#000000",
alignment: "outer",
format: "official-page",
startFrom: 1,
showOnFirstPage: false
}
},
bundled: true
});
expect(parsed.pageDefaults?.footer).toMatchObject({
alignment: "outer",
format: "official-page",
showOnFirstPage: false
});
});
});
@@ -0,0 +1,134 @@
const PREPARED_ATTRIBUTE = "data-code-pagination-prepared";
const CHUNK_CLASS = "md-code-pagination-chunk";
const CHUNK_POSITION_ATTRIBUTE = "data-code-pagination-position";
const LINE_GROUP_CLASS = "md-code-line-group";
const LINE_CLASS = "md-code-line";
function splitNodeIntoLines(node: Node): Node[][] {
const documentRef = node.ownerDocument;
if (!documentRef) {
throw new Error("代码块分页节点缺少 ownerDocument");
}
if (node.nodeType === node.TEXT_NODE) {
return (node.textContent ?? "").split("\n").map((text) => [
documentRef.createTextNode(text)
]);
}
if (node.nodeType !== node.ELEMENT_NODE) {
return [[node.cloneNode(true)]];
}
const lineChildren = splitNodesIntoLines(
Array.from(node.childNodes)
);
return lineChildren.map((children) => {
const clone = node.cloneNode(false) as Element;
clone.append(...children);
return [clone];
});
}
function splitNodesIntoLines(nodes: Node[]): Node[][] {
const lines: Node[][] = [[]];
for (const node of nodes) {
const nodeLines = splitNodeIntoLines(node);
lines.at(-1)?.push(...(nodeLines[0] ?? []));
for (const line of nodeLines.slice(1)) {
lines.push([...line]);
}
}
return lines;
}
function lineHasContent(nodes: Node[]) {
return nodes.some((node) => (node.textContent ?? "").length > 0);
}
function createLineGroups(
documentRef: Document,
lines: Node[][]
) {
const groups: HTMLElement[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
const remaining = lines.length - lineIndex;
const groupSize =
remaining === 1 ? 1 : remaining === 3 ? 3 : 2;
const group = documentRef.createElement("span");
group.className = LINE_GROUP_CLASS;
for (
let offset = 0;
offset < groupSize && lineIndex < lines.length;
offset += 1, lineIndex += 1
) {
const line = documentRef.createElement("span");
line.className = LINE_CLASS;
line.append(...(lines[lineIndex] ?? []));
group.append(line);
}
groups.push(group);
}
return groups;
}
export function prepareCodeBlockPagination(root: ParentNode) {
const codeBlocks = Array.from(
root.querySelectorAll<HTMLElement>(
`pre.md-fences:not([${PREPARED_ATTRIBUTE}])`
)
);
for (const codeBlock of codeBlocks) {
const code = codeBlock.querySelector<HTMLElement>(
":scope > code"
);
if (!code) {
continue;
}
const sourceEndsWithNewline =
code.textContent?.endsWith("\n") ?? false;
const lines = splitNodesIntoLines(Array.from(code.childNodes));
if (
sourceEndsWithNewline &&
lines.length > 1 &&
!lineHasContent(lines.at(-1) ?? [])
) {
lines.pop();
}
const groups = createLineGroups(code.ownerDocument, lines);
const chunks = groups.map((group, index) => {
const chunk = code.ownerDocument.createElement("div");
for (const attribute of Array.from(codeBlock.attributes)) {
chunk.setAttribute(attribute.name, attribute.value);
}
chunk.classList.add(CHUNK_CLASS);
chunk.setAttribute(PREPARED_ATTRIBUTE, "true");
chunk.setAttribute(
CHUNK_POSITION_ATTRIBUTE,
groups.length === 1
? "only"
: index === 0
? "first"
: index === groups.length - 1
? "last"
: "middle"
);
const chunkCode = code.cloneNode(false) as HTMLElement;
chunkCode.append(group);
chunk.append(chunkCode);
return chunk;
});
codeBlock.replaceWith(...chunks);
}
}
@@ -11,6 +11,7 @@ import {
findCommonPrefixLength,
mountContinuousRenderStage
} from "./continuous-preview.js";
import { prepareCodeBlockPagination } from "./code-block-pagination.js";
import { fitOversizedEChartsToPage } from "./echarts-page-fit.js";
import {
fitDocumentImagesToPage,
@@ -44,6 +45,8 @@ import {
documentGeometryCss,
documentInteractionCss,
formatPageNumber,
resolvePageNumberAlignment,
shouldRenderPageNumber,
type PagedPreviewPayload
} from "./paged-preview.js";
import type { PagedRenderTarget } from "./paged-render-target.js";
@@ -156,12 +159,23 @@ function applyPageNumbers(
return;
}
const alignment = payload.exportConfig.footer.alignment;
const pages = Array.from(
container.querySelectorAll<HTMLElement>(".pagedjs_page")
);
for (const [pageIndex, page] of pages.entries()) {
if (
!shouldRenderPageNumber(
payload.exportConfig.footer,
pageIndex
)
) {
continue;
}
const alignment = resolvePageNumberAlignment(
payload.exportConfig.footer,
pageIndex
);
const content = page.querySelector<HTMLElement>(
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
);
@@ -640,6 +654,7 @@ export class PagedDocumentRuntime {
this.resetPagedState();
this.root.replaceChildren();
}
prepareCodeBlockPagination(nextArticle);
const setupMs = performance.now() - setupStartedAt;
const mermaidStartedAt = performance.now();
+81 -2
View File
@@ -75,10 +75,22 @@ svg {
max-width: 100%;
}
#write pre.md-fences > code {
#write pre.md-fences,
#write .md-code-pagination-chunk {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
#write pre.md-fences > code,
#write .md-code-pagination-chunk > code {
display: block;
min-width: 0;
max-width: 100%;
padding: 0;
padding-left: 8px;
border: 0;
border-radius: 0;
background: transparent;
@@ -87,6 +99,9 @@ svg {
font-size: inherit;
font-weight: inherit;
line-height: inherit;
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
}
.md-document-image-block {
@@ -250,6 +265,9 @@ export function formatPageNumber(
if (config.format === "dash-page") {
return `- ${page} -`;
}
if (config.format === "official-page") {
return `${page}`;
}
return (config.template || "${page} / ${pages}")
.replace(/\$\{page\}/g, String(page))
@@ -262,6 +280,7 @@ function marginBox(
content: string,
options: {
color: string;
fontFamily: string;
fontSize: string;
height: string;
showDivider: boolean;
@@ -280,7 +299,7 @@ function marginBox(
content: ${content};
height: ${options.height};
color: ${options.color};
font-family: inherit;
font-family: ${options.fontFamily};
font-size: ${options.fontSize};
font-weight: 400;
line-height: 1.25;
@@ -300,6 +319,7 @@ function buildHeaderCss(
const options = {
color: config.header.color,
fontFamily: config.header.fontFamily,
fontSize: config.header.fontSize,
height: config.header.height,
showDivider: config.header.showDivider
@@ -328,6 +348,7 @@ function buildFooterCss(config: ExportConfig) {
const options = {
color: config.footer.color,
fontFamily: config.footer.fontFamily,
fontSize: config.footer.fontSize,
height: config.footer.height,
showDivider: config.footer.showDivider
@@ -347,6 +368,23 @@ function buildFooterCss(config: ExportConfig) {
.join("\n");
}
export function resolvePageNumberAlignment(
config: ExportConfig["footer"],
pageIndex: number
): "left" | "center" | "right" {
if (config.alignment !== "outer") {
return config.alignment;
}
return pageIndex % 2 === 0 ? "right" : "left";
}
export function shouldRenderPageNumber(
config: ExportConfig["footer"],
pageIndex: number
) {
return config.showOnFirstPage || pageIndex !== 0;
}
export function buildPagedMediaCss(
config: ExportConfig,
payload: Pick<PagedPreviewPayload, "fileName" | "metadata">
@@ -398,6 +436,47 @@ ${buildFooterCss(config)}
break-inside: avoid;
}
#write .md-code-pagination-chunk {
break-inside: avoid;
page-break-inside: avoid;
}
#write .md-code-pagination-chunk > code {
display: block;
}
#write .md-code-pagination-chunk:not(
[data-code-pagination-position="first"]
):not([data-code-pagination-position="only"]) {
margin-top: 0 !important;
padding-top: 0 !important;
border-top: 0 !important;
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
}
#write .md-code-pagination-chunk:not(
[data-code-pagination-position="last"]
):not([data-code-pagination-position="only"]) {
margin-bottom: 0 !important;
padding-bottom: 0 !important;
border-bottom: 0 !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
#write .md-code-line-group {
display: block;
}
#write .md-code-line {
display: block;
min-height: 1lh;
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
}
#write table[data-empty-split-table="true"] {
display: none;
}
@@ -0,0 +1,141 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { prepareCodeBlockPagination } from "../src/code-block-pagination.js";
function createRoot(codeHtml: string) {
const root = document.createElement("article");
root.innerHTML = `<pre class="md-fences"><code>${codeHtml}</code></pre>`;
return root;
}
function getGroups(root: ParentNode) {
return Array.from(
root.querySelectorAll<HTMLElement>(".md-code-line-group")
);
}
function getLines(root: ParentNode) {
return Array.from(
root.querySelectorAll<HTMLElement>(".md-code-line")
);
}
function getChunks(root: ParentNode) {
return Array.from(
root.querySelectorAll<HTMLElement>(".md-code-pagination-chunk")
);
}
describe("代码块分页预处理", () => {
it("偶数行按两行分组", () => {
const root = createRoot("line-1\nline-2\nline-3\nline-4\n");
prepareCodeBlockPagination(root);
expect(getGroups(root).map((group) => group.children.length)).toEqual([
2,
2
]);
expect(getLines(root).map((line) => line.textContent)).toEqual([
"line-1",
"line-2",
"line-3",
"line-4"
]);
expect(
getChunks(root).map((chunk) =>
chunk.getAttribute("data-code-pagination-position")
)
).toEqual(["first", "last"]);
});
it("奇数行将最后三行合组并保留单行代码块", () => {
const oddRoot = createRoot(
"line-1\nline-2\nline-3\nline-4\nline-5\n"
);
const singleRoot = createRoot("only-line\n");
prepareCodeBlockPagination(oddRoot);
prepareCodeBlockPagination(singleRoot);
expect(
getGroups(oddRoot).map((group) => group.children.length)
).toEqual([2, 3]);
expect(
getGroups(singleRoot).map((group) => group.children.length)
).toEqual([1]);
expect(
getChunks(singleRoot)[0]?.getAttribute(
"data-code-pagination-position"
)
).toBe("only");
});
it("跨行克隆语法高亮结构并保留空行", () => {
const root = createRoot(
'<span class="hljs-string">first\nsecond</span>\n\n' +
'<span class="hljs-keyword">return</span> value;\n'
);
prepareCodeBlockPagination(root);
const lines = getLines(root);
expect(lines.map((line) => line.textContent)).toEqual([
"first",
"second",
"",
"return value;"
]);
expect(
lines[0]?.querySelector(".hljs-string")?.textContent
).toBe("first");
expect(
lines[1]?.querySelector(".hljs-string")?.textContent
).toBe("second");
expect(
lines[3]?.querySelector(".hljs-keyword")?.textContent
).toBe("return");
});
it("重复调用不会再次包装已经准备的代码块", () => {
const root = createRoot("line-1\nline-2\n");
prepareCodeBlockPagination(root);
const firstHtml = root.innerHTML;
prepareCodeBlockPagination(root);
expect(root.innerHTML).toBe(firstHtml);
expect(root.querySelector("pre.md-fences")).toBeNull();
const paginationChunks = getChunks(root);
expect(paginationChunks).toHaveLength(1);
expect(
paginationChunks[0]?.classList.contains("md-fences")
).toBe(true);
expect(
paginationChunks[0]?.getAttribute(
"data-code-pagination-prepared"
)
).toBe("true");
});
it("复制代码元素属性并把每个双行组拆成同级分页片段", () => {
const root = document.createElement("article");
root.innerHTML =
'<pre class="md-fences custom" data-source="demo">' +
'<code class="hljs language-js" data-language="js">' +
"line-1\nline-2\nline-3\nline-4\n" +
"</code></pre>";
prepareCodeBlockPagination(root);
const chunks = getChunks(root);
expect(chunks).toHaveLength(2);
expect(Array.from(root.children)).toEqual(chunks);
expect(chunks[0]?.classList.contains("custom")).toBe(true);
expect(chunks[1]?.getAttribute("data-source")).toBe("demo");
expect(
chunks[1]?.querySelector("code")?.getAttribute("data-language")
).toBe("js");
});
});
@@ -11,6 +11,8 @@ import {
isPagedPreviewFrameMessage,
isPagedPreviewRenderRequest,
PAGED_PREVIEW_MESSAGE_SCOPE,
resolvePageNumberAlignment,
shouldRenderPageNumber,
type PagedPreviewPayload
} from "../src/paged-preview.js";
@@ -36,6 +38,9 @@ describe("分页预览协议", () => {
expect(css).toContain("size: 210mm 297mm");
expect(css).toContain("margin: 16mm 16mm");
expect(css).toContain("@bottom-center");
expect(css).toContain(
'font-family: "Segoe UI", "Microsoft YaHei", sans-serif'
);
expect(css).not.toContain("counter-reset: page");
expect(css).toContain("#write thead");
expect(css).toContain("display: table-header-group");
@@ -149,6 +154,36 @@ describe("分页预览协议", () => {
5
)
).toBe("- 5 -");
expect(
formatPageNumber(
{
...defaultExportConfig.footer,
format: "official-page"
},
4,
5
)
).toBe("— 5 —");
});
it("将公文页码放在奇偶页外侧并支持首页隐藏", () => {
const officialFooter = {
...defaultExportConfig.footer,
alignment: "outer" as const,
showOnFirstPage: false
};
expect(resolvePageNumberAlignment(officialFooter, 0)).toBe(
"right"
);
expect(resolvePageNumberAlignment(officialFooter, 1)).toBe(
"left"
);
expect(resolvePageNumberAlignment(officialFooter, 2)).toBe(
"right"
);
expect(shouldRenderPageNumber(officialFooter, 0)).toBe(false);
expect(shouldRenderPageNumber(officialFooter, 1)).toBe(true);
});
it("在主题 CSS 之后强制分页画布保持透明", () => {
@@ -168,6 +203,39 @@ describe("分页预览协议", () => {
);
});
it("只在至少两行的代码组之间分页", () => {
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write .md-code-pagination-chunk");
expect(css).toContain("#write .md-code-line-group");
expect(css).toContain("page-break-inside: avoid;");
expect(css).toContain(
'[data-code-pagination-position="first"]'
);
expect(css).toContain(
'[data-code-pagination-position="last"]'
);
expect(css).toContain("#write .md-code-line");
expect(css).toContain("min-height: 1lh;");
});
it("代码围栏按正文宽度保留缩进并自动折行", () => {
expect(documentBaseCss).toContain("#write pre.md-fences,");
expect(documentBaseCss).toContain(
"#write .md-code-pagination-chunk"
);
expect(documentBaseCss).toContain("max-width: 100%;");
expect(documentBaseCss).toContain("padding-left: 8px;");
expect(documentBaseCss).toContain("white-space: pre-wrap;");
expect(documentBaseCss).toContain("overflow-wrap: anywhere;");
expect(documentBaseCss).toContain("word-break: break-word;");
const css = buildPagedMediaCss(defaultExportConfig, payload);
expect(css).toContain("#write .md-code-line");
expect(css).toContain("overflow-wrap: inherit;");
expect(css).toContain("word-break: inherit;");
});
it("以低优先级兜底样式保留链接识别和焦点反馈", () => {
expect(documentInteractionCss).toContain(":where(#write a[href])");
expect(documentInteractionCss).toContain(
@@ -0,0 +1,290 @@
import type {
DocumentProfile,
MarkdownDocumentMetadata
} from "@md-to-pdf/core";
export interface RenderedDocumentStructure {
profile: DocumentProfile["profile"];
prefixHtml: string;
suffixHtml: string;
}
export function renderDocumentStructure(
metadata: MarkdownDocumentMetadata
): RenderedDocumentStructure | undefined {
const document = metadata.document;
if (!document) {
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"
);
return {
profile: document.profile,
prefixHtml:
masthead + renderText("h1", "doc-title", title),
suffixHtml: signature + edition
};
}
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,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[character] ?? character
);
}
+29 -2
View File
@@ -3,12 +3,14 @@ import { footnote } from "@mdit/plugin-footnote";
import { katex } from "@mdit/plugin-katex";
import { tasklist } from "@mdit/plugin-tasklist";
import type {
DocumentProfile,
MarkdownDocumentMetadata,
RenderedMarkdownDocument,
ThemeFeature
} from "@md-to-pdf/core";
import {
classifyDocumentLink,
documentProfileSchema,
isSafeDocumentLink
} from "@md-to-pdf/core";
import {
@@ -22,6 +24,7 @@ import type { Options as MarkdownItOptions } from "markdown-it";
import type Renderer from "markdown-it/lib/renderer.mjs";
import type Token from "markdown-it/lib/token.mjs";
import sanitizeHtml from "sanitize-html";
import { renderDocumentStructure } from "./render-document-structure.js";
export const RENDERER_VERSION = 1;
@@ -350,7 +353,11 @@ export function renderMarkdown(
options.language ?? "zh-CN",
extractFirstHeading(parsed.content)
);
const articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}">${bodyHtml}</article>`;
const structure = renderDocumentStructure(metadata);
const profileAttribute = structure
? ` data-document-profile="${structure.profile}"`
: "";
const articleHtml = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}"${profileAttribute}>${structure?.prefixHtml ?? ""}${bodyHtml}${structure?.suffixHtml ?? ""}</article>`;
return {
rendererVersion: RENDERER_VERSION,
@@ -405,10 +412,30 @@ function normalizeMetadata(
author: readString(data.author),
subject: readString(data.subject) || readString(data.description),
keywords: readStringList(data.keywords ?? data.tags),
language: readString(data.language ?? data.lang) || fallbackLanguage
language: readString(data.language ?? data.lang) || fallbackLanguage,
...(data.document === undefined
? {}
: { document: normalizeDocumentProfile(data.document) })
};
}
function normalizeDocumentProfile(value: unknown): DocumentProfile {
const parsed = documentProfileSchema.safeParse(value);
if (parsed.success) {
return parsed.data;
}
const details = parsed.error.issues
.map((issue) => {
const path = ["document", ...issue.path].join(".");
return `${path}${issue.message}`;
})
.join("");
throw new MarkdownDocumentParseError(
`文档结构配置无效:${details}`
);
}
function readString(value: unknown): string {
if (typeof value === "string") {
return value.trim();
@@ -106,6 +106,122 @@ title: [未闭合
).toThrow(MarkdownDocumentParseError);
});
it("生成政企公文的语义化版头、落款和版记", () => {
const result = renderMarkdown(`---
title: 关于推进示范项目建设的通知
document:
profile: official
issuer: 示例市人民政府办公室
number: 示例办发〔202612号
secrecy: 内部
urgency: 加急
signatory: 张三
date: 2026年7月29日
copyTo: 市发展改革委, 市财政局
printingOffice: 示例市人民政府办公室
---
## 工作要求
正文内容。
`);
expect(result.metadata.document).toEqual({
profile: "official",
issuer: "示例市人民政府办公室",
number: "示例办发〔202612号",
secrecy: "内部",
urgency: "加急",
signatory: "张三",
date: "2026年7月29日",
copyTo: ["市发展改革委", "市财政局"],
printingOffice: "示例市人民政府办公室"
});
expect(result.articleHtml).toContain(
'data-document-profile="official"'
);
expect(result.articleHtml).toContain(
'<header class="doc-masthead doc-masthead-official">'
);
expect(result.articleHtml).toContain(
'<h1 class="doc-title">关于推进示范项目建设的通知</h1>'
);
expect(result.articleHtml).toContain(
'<section class="doc-signature">'
);
expect(result.articleHtml).toContain(
'<footer class="doc-edition">'
);
expect(result.bodyHtml).not.toContain("doc-masthead");
});
it("生成项目报告和标书封面的稳定语义节点", () => {
const projectReport = renderMarkdown(`---
title: 可行性研究报告
document:
profile: project-report
projectName: 智慧园区建设项目
documentType: 可行性研究报告
owner: 示例建设集团
preparedBy: 示例咨询有限公司
version: V1.0
date: 2026年7月
---
# 编制说明
`);
const tender = renderMarkdown(`---
title: 投标文件
document:
profile: tender
copyMark: 正本
projectName: 数据中心建设项目
projectNumber: ZB-2026-001
volume: 技术标
bidder: 示例科技有限公司
representative: 李四
date: 2026年7月29日
---
# 技术方案
`);
expect(projectReport.articleHtml).toContain(
'class="doc-cover doc-cover-project-report"'
);
expect(tender.articleHtml).toContain(
'class="doc-cover doc-cover-tender"'
);
expect(tender.articleHtml).toContain(
'<p class="doc-cover-copy-mark">正本</p>'
);
});
it("拒绝未知结构字段并转义语义节点内容", () => {
expect(() =>
renderMarkdown(`---
document:
profile: official
unknown: value
---
正文`)
).toThrow(MarkdownDocumentParseError);
const result = renderMarkdown(`---
title: "<img src=x onerror=alert(1)>"
document:
profile: briefing
masthead: "<script>alert(1)</script>"
---
正文`);
expect(result.articleHtml).not.toContain("<script>");
expect(result.articleHtml).not.toContain("<img");
expect(result.articleHtml).toContain(
"&lt;script&gt;alert(1)&lt;/script&gt;"
);
});
it("服务端渲染公式并保留 Mermaid 安全占位", () => {
const result = renderMarkdown(`
内联公式 $E=mc^2$