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:
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[character] ?? character
|
||||
);
|
||||
}
|
||||
@@ -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: 示例办发〔2026〕12号
|
||||
secrecy: 内部
|
||||
urgency: 加急
|
||||
signatory: 张三
|
||||
date: 2026年7月29日
|
||||
copyTo: 市发展改革委, 市财政局
|
||||
printingOffice: 示例市人民政府办公室
|
||||
---
|
||||
|
||||
## 工作要求
|
||||
|
||||
正文内容。
|
||||
`);
|
||||
|
||||
expect(result.metadata.document).toEqual({
|
||||
profile: "official",
|
||||
issuer: "示例市人民政府办公室",
|
||||
number: "示例办发〔2026〕12号",
|
||||
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(
|
||||
"<script>alert(1)</script>"
|
||||
);
|
||||
});
|
||||
|
||||
it("服务端渲染公式并保留 Mermaid 安全占位", () => {
|
||||
const result = renderMarkdown(`
|
||||
内联公式 $E=mc^2$。
|
||||
|
||||
Reference in New Issue
Block a user