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:
@@ -1,12 +1,23 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APP_VERSION,
|
||||
APP_VERSION_LABEL
|
||||
} from "../src/app-version";
|
||||
|
||||
const webRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
describe("应用版本", () => {
|
||||
it("从统一构建版本生成标题徽标", () => {
|
||||
expect(APP_VERSION).toBe("0.5.0");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.0");
|
||||
expect(APP_VERSION).toBe("0.5.1");
|
||||
expect(APP_VERSION_LABEL).toBe("v0.5.1");
|
||||
});
|
||||
|
||||
it("允许浏览器加载同源 Web Manifest", () => {
|
||||
const indexHtml = readFileSync(`${webRoot}/index.html`, "utf8");
|
||||
|
||||
expect(indexHtml).toContain('rel="manifest"');
|
||||
expect(indexHtml).toContain("manifest-src 'self'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
builtinThemeSamples,
|
||||
getBuiltinThemeSample
|
||||
} from "../src/builtin-theme-samples";
|
||||
|
||||
const expectedThemeIds = [
|
||||
"enterprise-red-simple",
|
||||
"formal-feasibility",
|
||||
"formal-regulation",
|
||||
"formal-report",
|
||||
"gov-red-letter",
|
||||
"gov-red-standard",
|
||||
"red-briefing",
|
||||
"tender-blind",
|
||||
"tender-business-blue",
|
||||
"tender-classic",
|
||||
"typora-github",
|
||||
"typora-like",
|
||||
"typora-pixyll",
|
||||
"typora-whitey"
|
||||
];
|
||||
|
||||
describe("内置主题示例", () => {
|
||||
it("为全部内置主题注册同名 Markdown 文件", () => {
|
||||
expect(
|
||||
builtinThemeSamples.map((sample) => sample.themeId).sort()
|
||||
).toEqual(expectedThemeIds);
|
||||
expect(
|
||||
builtinThemeSamples.every(
|
||||
(sample) => sample.fileName === `${sample.themeId}.md`
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("提供可直接编辑的非空 Markdown 内容", () => {
|
||||
for (const sample of builtinThemeSamples) {
|
||||
expect(sample.markdown.startsWith("---")).toBe(true);
|
||||
expect(sample.markdown.length).toBeGreaterThan(300);
|
||||
expect(getBuiltinThemeSample(sample.themeId)).toBe(sample);
|
||||
}
|
||||
expect(getBuiltinThemeSample("unknown-theme")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { builtinTutorials } from "../src/builtin-tutorials";
|
||||
|
||||
describe("内置教程", () => {
|
||||
it("注册 ECharts 与公文主题教程", () => {
|
||||
expect(
|
||||
builtinTutorials.map(({ id, name, themeId, fileName }) => ({
|
||||
id,
|
||||
name,
|
||||
themeId,
|
||||
fileName
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: "echarts-tutorial",
|
||||
name: "ECharts 图表教程",
|
||||
themeId: "typora-github",
|
||||
fileName: "echarts-tutorial.md"
|
||||
},
|
||||
{
|
||||
id: "formal-document-tutorial",
|
||||
name: "公文主题使用教程",
|
||||
themeId: "typora-github",
|
||||
fileName: "formal-document-tutorial.md"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("教程内容完整且能够直接作为 Markdown 打开", () => {
|
||||
for (const tutorial of builtinTutorials) {
|
||||
expect(tutorial.markdown.startsWith("---")).toBe(true);
|
||||
expect(tutorial.markdown.length).toBeGreaterThan(1_000);
|
||||
}
|
||||
expect(builtinTutorials[0].markdown).toContain("```echarts");
|
||||
expect(builtinTutorials[1].markdown).toContain(
|
||||
"document.profile: official"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDocumentShortcut } from "../src/document-shortcuts";
|
||||
|
||||
function shortcutEvent(
|
||||
key: string,
|
||||
overrides: Partial<Parameters<typeof getDocumentShortcut>[0]> = {}
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
repeat: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("文档快捷键", () => {
|
||||
it("在桌面端识别新建、保存、另存为和关闭", () => {
|
||||
expect(getDocumentShortcut(shortcutEvent("n"), true)).toBe("new");
|
||||
expect(getDocumentShortcut(shortcutEvent("S"), true)).toBe("save");
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("S", { shiftKey: true }),
|
||||
true
|
||||
)
|
||||
).toBe("save-as");
|
||||
expect(getDocumentShortcut(shortcutEvent("w"), true)).toBe("close");
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { ctrlKey: false, metaKey: true }),
|
||||
true
|
||||
)
|
||||
).toBe("save");
|
||||
});
|
||||
|
||||
it("Web 端只接管保存", () => {
|
||||
expect(getDocumentShortcut(shortcutEvent("n"), false)).toBeUndefined();
|
||||
expect(getDocumentShortcut(shortcutEvent("w"), false)).toBeUndefined();
|
||||
expect(getDocumentShortcut(shortcutEvent("s"), false)).toBe("save");
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { shiftKey: true }),
|
||||
false
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("忽略组合输入、长按和带附加修饰键的按键", () => {
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { isComposing: true }),
|
||||
true
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(shortcutEvent("s", { repeat: true }), true)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(shortcutEvent("s", { altKey: true }), true)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("n", { shiftKey: true }),
|
||||
true
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getDocumentShortcut(
|
||||
shortcutEvent("s", { shiftKey: true, altKey: true }),
|
||||
true
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY,
|
||||
LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY,
|
||||
loadExportConfig,
|
||||
parseStoredExportConfig,
|
||||
saveExportConfig
|
||||
@@ -10,14 +11,15 @@ import {
|
||||
|
||||
function createMemoryStorage(
|
||||
initialValue: string | null = null,
|
||||
legacyValue: string | null = null
|
||||
legacyValue: string | null = null,
|
||||
legacyKey = LEGACY_EXPORT_CONFIG_V3_STORAGE_KEY
|
||||
) {
|
||||
const values = new Map<string, string>();
|
||||
if (initialValue) {
|
||||
values.set(EXPORT_CONFIG_STORAGE_KEY, initialValue);
|
||||
}
|
||||
if (legacyValue) {
|
||||
values.set(LEGACY_EXPORT_CONFIG_STORAGE_KEY, legacyValue);
|
||||
values.set(legacyKey, legacyValue);
|
||||
}
|
||||
return {
|
||||
getItem(key: string) {
|
||||
@@ -52,26 +54,106 @@ describe("导出设置缓存", () => {
|
||||
).toEqual(defaultExportConfig);
|
||||
});
|
||||
|
||||
it("将 v2 缓存迁移为 v3 并保留已有设置", () => {
|
||||
it("将 v2 缓存迁移为 v4 并让默认页边距跟随主题", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 2,
|
||||
mermaid: undefined,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: undefined,
|
||||
format: "A5"
|
||||
}
|
||||
};
|
||||
const storage = createMemoryStorage(
|
||||
null,
|
||||
JSON.stringify(legacyConfig)
|
||||
JSON.stringify(legacyConfig),
|
||||
LEGACY_EXPORT_CONFIG_V2_STORAGE_KEY
|
||||
);
|
||||
|
||||
const migrated = loadExportConfig(storage);
|
||||
expect(migrated.version).toBe(3);
|
||||
expect(migrated.version).toBe(4);
|
||||
expect(migrated.paper.format).toBe("A5");
|
||||
expect(migrated.paper.marginMode).toBe("theme");
|
||||
expect(migrated.mermaid).toEqual(defaultExportConfig.mermaid);
|
||||
expect(storage.read()).toContain('"version":3');
|
||||
expect(storage.read()).toContain('"version":4');
|
||||
});
|
||||
|
||||
it("将改过页边距的 v3 缓存迁移为自定义模式", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
version: 3,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: undefined,
|
||||
margins: {
|
||||
top: "20mm",
|
||||
right: "18mm",
|
||||
bottom: "20mm",
|
||||
left: "18mm"
|
||||
}
|
||||
}
|
||||
};
|
||||
const migrated = parseStoredExportConfig(
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
expect(migrated.version).toBe(4);
|
||||
expect(migrated.paper.marginMode).toBe("custom");
|
||||
expect(migrated.paper.margins).toEqual(legacyConfig.paper.margins);
|
||||
});
|
||||
|
||||
it("将未改页眉页码的旧 v4 缓存迁移为跟随主题", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: undefined,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
fontFamily: undefined
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
fontFamily: undefined,
|
||||
showOnFirstPage: undefined
|
||||
}
|
||||
};
|
||||
const migrated = parseStoredExportConfig(
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
expect(migrated.pageDecorationsMode).toBe("theme");
|
||||
expect(migrated.header.fontFamily).toContain("Microsoft YaHei");
|
||||
expect(migrated.footer.showOnFirstPage).toBe(true);
|
||||
});
|
||||
|
||||
it("保留旧 v4 缓存中用户修改过的页眉页码", () => {
|
||||
const legacyConfig = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: undefined,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
enabled: true,
|
||||
fontFamily: undefined,
|
||||
center: {
|
||||
enabled: true,
|
||||
content: "${title}"
|
||||
}
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "right",
|
||||
fontFamily: undefined,
|
||||
showOnFirstPage: undefined
|
||||
}
|
||||
};
|
||||
const migrated = parseStoredExportConfig(
|
||||
JSON.stringify(legacyConfig)
|
||||
);
|
||||
|
||||
expect(migrated.pageDecorationsMode).toBe("custom");
|
||||
expect(migrated.header.enabled).toBe(true);
|
||||
expect(migrated.header.center.content).toBe("${title}");
|
||||
expect(migrated.footer.alignment).toBe("right");
|
||||
});
|
||||
|
||||
it("保存并恢复经过校验的配置", () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ThemeSummary } from "../src/application-backend";
|
||||
import { groupThemesByCategory } from "../src/theme-groups";
|
||||
|
||||
function createTheme(
|
||||
id: string,
|
||||
name: string,
|
||||
category: ThemeSummary["category"]
|
||||
): ThemeSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
compatibleProfiles: [],
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupThemesByCategory", () => {
|
||||
it("按稳定顺序输出非空主题分组", () => {
|
||||
const groups = groupThemesByCategory([
|
||||
createTheme("tender", "标书", "tender"),
|
||||
createTheme("general", "通用", "general"),
|
||||
createTheme("red", "红头", "red-letter")
|
||||
]);
|
||||
|
||||
expect(groups.map(({ category, label }) => ({ category, label })))
|
||||
.toEqual([
|
||||
{ category: "general", label: "通用主题" },
|
||||
{ category: "red-letter", label: "政企红头" },
|
||||
{ category: "tender", label: "标书正式" }
|
||||
]);
|
||||
expect(groups[0]?.themes[0]?.id).toBe("general");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import type { ThemeSummary } from "../src/application-backend";
|
||||
import {
|
||||
applyThemeDefaults,
|
||||
applyThemeMargins,
|
||||
selectTheme
|
||||
} from "../src/theme-margins";
|
||||
|
||||
function createTheme(
|
||||
id: string,
|
||||
margins?: ThemeSummary["pageDefaults"]
|
||||
): ThemeSummary {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
category: "formal",
|
||||
compatibleProfiles: [],
|
||||
pageDefaults: margins,
|
||||
bundled: true,
|
||||
source: "bundled"
|
||||
};
|
||||
}
|
||||
|
||||
const formalTheme = createTheme("formal", {
|
||||
margins: {
|
||||
top: "37mm",
|
||||
right: "26mm",
|
||||
bottom: "35mm",
|
||||
left: "28mm"
|
||||
}
|
||||
});
|
||||
|
||||
const officialTheme = createTheme("official", {
|
||||
...formalTheme.pageDefaults,
|
||||
header: {
|
||||
...defaultExportConfig.header,
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif'
|
||||
},
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
height: "7mm",
|
||||
fontSize: "4.94mm",
|
||||
fontFamily: '"Mdpdf Fandol Song", SimSun, serif',
|
||||
color: "#000000",
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
showOnFirstPage: false
|
||||
}
|
||||
});
|
||||
|
||||
describe("主题推荐页边距", () => {
|
||||
it("跟随模式切换主题时同步推荐值", () => {
|
||||
const selected = selectTheme(defaultExportConfig, formalTheme);
|
||||
|
||||
expect(selected.themeId).toBe("formal");
|
||||
expect(selected.paper.marginMode).toBe("theme");
|
||||
expect(selected.paper.margins).toEqual(
|
||||
formalTheme.pageDefaults?.margins
|
||||
);
|
||||
});
|
||||
|
||||
it("主题未声明推荐值时恢复全局 16mm", () => {
|
||||
const config = selectTheme(defaultExportConfig, formalTheme);
|
||||
const selected = selectTheme(config, createTheme("general"));
|
||||
|
||||
expect(selected.paper.margins).toEqual({
|
||||
top: "16mm",
|
||||
right: "16mm",
|
||||
bottom: "16mm",
|
||||
left: "16mm"
|
||||
});
|
||||
});
|
||||
|
||||
it("自定义模式切换主题时保留用户值", () => {
|
||||
const config = {
|
||||
...defaultExportConfig,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
marginMode: "custom" as const,
|
||||
margins: {
|
||||
top: "20mm",
|
||||
right: "18mm",
|
||||
bottom: "20mm",
|
||||
left: "18mm"
|
||||
}
|
||||
}
|
||||
};
|
||||
const selected = selectTheme(config, formalTheme);
|
||||
|
||||
expect(selected.themeId).toBe("formal");
|
||||
expect(selected.paper.margins).toEqual(config.paper.margins);
|
||||
});
|
||||
|
||||
it("主题清单加载后补齐当前主题推荐值", () => {
|
||||
expect(
|
||||
applyThemeMargins(defaultExportConfig, formalTheme).paper.margins
|
||||
).toEqual(formalTheme.pageDefaults?.margins);
|
||||
});
|
||||
|
||||
it("跟随主题时同步推荐的页眉和页码", () => {
|
||||
const selected = selectTheme(defaultExportConfig, officialTheme);
|
||||
|
||||
expect(selected.pageDecorationsMode).toBe("theme");
|
||||
expect(selected.header.fontFamily).toContain("Fandol Song");
|
||||
expect(selected.footer).toMatchObject({
|
||||
alignment: "outer",
|
||||
format: "official-page",
|
||||
showOnFirstPage: false
|
||||
});
|
||||
});
|
||||
|
||||
it("页面装饰自定义模式切换主题时保留用户配置", () => {
|
||||
const config = {
|
||||
...defaultExportConfig,
|
||||
pageDecorationsMode: "custom" as const,
|
||||
footer: {
|
||||
...defaultExportConfig.footer,
|
||||
alignment: "left" as const,
|
||||
format: "page" as const
|
||||
}
|
||||
};
|
||||
const selected = selectTheme(config, officialTheme);
|
||||
|
||||
expect(selected.header).toEqual(config.header);
|
||||
expect(selected.footer).toEqual(config.footer);
|
||||
});
|
||||
|
||||
it("主题清单加载后补齐当前主题页面装饰", () => {
|
||||
const applied = applyThemeDefaults(
|
||||
defaultExportConfig,
|
||||
officialTheme
|
||||
);
|
||||
|
||||
expect(applied.footer.format).toBe("official-page");
|
||||
expect(applied.footer.alignment).toBe("outer");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user