391 lines
10 KiB
TypeScript
391 lines
10 KiB
TypeScript
import {
|
|
afterEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi
|
|
} from "vitest";
|
|
import {
|
|
mkdtemp,
|
|
mkdir,
|
|
rm,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
ApplicationRequestError,
|
|
createApplicationService
|
|
} from "../src/index.js";
|
|
import {
|
|
MAXIMUM_DOCX_MARKDOWN_LENGTH,
|
|
defaultExportConfig
|
|
} from "@md-to-pdf/core";
|
|
|
|
let temporaryDirectory: string | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (temporaryDirectory) {
|
|
await rm(temporaryDirectory, {
|
|
recursive: true,
|
|
force: true
|
|
});
|
|
}
|
|
temporaryDirectory = undefined;
|
|
});
|
|
|
|
async function createThemeFixture() {
|
|
temporaryDirectory = await mkdtemp(
|
|
join(tmpdir(), "md-to-pdf-application-")
|
|
);
|
|
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"),
|
|
JSON.stringify({
|
|
manifestVersion: 1,
|
|
id: "test-theme",
|
|
name: "测试主题",
|
|
version: "1.0.0",
|
|
description: "共享应用服务测试主题",
|
|
author: "test",
|
|
license: "MIT",
|
|
entry: "theme.css",
|
|
domPreset: "typora",
|
|
defaultFontSize: "16px",
|
|
supportedFeatures: ["code", "table"],
|
|
pageDefaults: {
|
|
margins: {
|
|
top: "37mm",
|
|
right: "26mm",
|
|
bottom: "35mm",
|
|
left: "28mm"
|
|
}
|
|
},
|
|
bundled: true
|
|
}),
|
|
"utf8"
|
|
);
|
|
await writeFile(
|
|
join(themeRoot, "theme.css"),
|
|
[
|
|
"@font-face {",
|
|
" 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"
|
|
);
|
|
await writeFile(
|
|
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 };
|
|
}
|
|
|
|
describe("共享应用服务", () => {
|
|
it("渲染安全 Markdown 文档", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
const document = await service.render({
|
|
markdown: "# 文档\n\n<script>alert('xss')</script>",
|
|
language: "zh-CN"
|
|
});
|
|
|
|
expect(document.articleHtml).toContain('id="write"');
|
|
expect(document.articleHtml).not.toContain("<script");
|
|
expect(document.metadata.title).toBe("文档");
|
|
});
|
|
|
|
it("以稳定错误协议拒绝非法请求", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
await expect(service.render({ markdown: 42 })).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 400,
|
|
code: "INVALID_MARKDOWN"
|
|
})
|
|
);
|
|
});
|
|
|
|
it("解析上传素材与桌面文档目录中的相对图片", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
const image = Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
|
]);
|
|
const assetRoot = join(temporaryDirectory!, "document");
|
|
await mkdir(join(assetRoot, "文档.assets"), { recursive: true });
|
|
await writeFile(join(assetRoot, "文档.assets", "本地.png"), image);
|
|
|
|
const local = await service.render(
|
|
{
|
|
markdown:
|
|
""
|
|
},
|
|
{ localRoot: assetRoot }
|
|
);
|
|
const uploaded = await service.render({
|
|
markdown: "",
|
|
resources: [
|
|
{
|
|
path: "文档.assets/网页.png",
|
|
data: image.toString("base64")
|
|
}
|
|
]
|
|
});
|
|
|
|
expect(local.articleHtml).toContain("data:image/png;base64,");
|
|
expect(uploaded.articleHtml).toContain("data:image/png;base64,");
|
|
expect(local.articleHtml).toContain("md-document-image");
|
|
expect(uploaded.warnings).toEqual([]);
|
|
});
|
|
|
|
it("列出主题并使用调用方提供的资源 URL", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService({
|
|
...roots,
|
|
createAssetUrl: (themeId, assetPath) =>
|
|
`mdpdf://theme/${themeId}/${assetPath}`
|
|
});
|
|
|
|
await expect(service.listThemes()).resolves.toEqual({
|
|
themes: [
|
|
expect.objectContaining({
|
|
id: "test-theme",
|
|
category: "general",
|
|
compatibleProfiles: [],
|
|
pageDefaults: {
|
|
margins: {
|
|
top: "37mm",
|
|
right: "26mm",
|
|
bottom: "35mm",
|
|
left: "28mm"
|
|
}
|
|
},
|
|
bundled: true,
|
|
source: "bundled"
|
|
})
|
|
]
|
|
});
|
|
await expect(
|
|
service.getThemeCss("test-theme")
|
|
).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 () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
await expect(
|
|
service.getThemeAsset("test-theme", "fonts/test.woff2")
|
|
).resolves.toEqual({
|
|
contentType: "font/woff2",
|
|
content: Buffer.from([0, 1, 2, 3])
|
|
});
|
|
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 () => {
|
|
const roots = await createThemeFixture();
|
|
const localThemeRoot = join(roots.localRoot, "test-theme");
|
|
await mkdir(localThemeRoot, { recursive: true });
|
|
await writeFile(
|
|
join(localThemeRoot, "theme.json"),
|
|
JSON.stringify({
|
|
manifestVersion: 1,
|
|
id: "test-theme",
|
|
name: "旧本地主题",
|
|
version: "local",
|
|
description: "迁移前的本地副本",
|
|
author: "test",
|
|
license: "local-only",
|
|
entry: "theme.css",
|
|
domPreset: "typora",
|
|
defaultFontSize: "16px",
|
|
supportedFeatures: ["code", "table"],
|
|
bundled: false
|
|
}),
|
|
"utf8"
|
|
);
|
|
await writeFile(
|
|
join(localThemeRoot, "theme.css"),
|
|
"#write { color: red; }",
|
|
"utf8"
|
|
);
|
|
const onWarning = vi.fn();
|
|
const service = createApplicationService({
|
|
...roots,
|
|
onWarning
|
|
});
|
|
|
|
await expect(service.listThemes()).resolves.toEqual({
|
|
themes: [
|
|
expect.objectContaining({
|
|
id: "test-theme",
|
|
name: "测试主题",
|
|
bundled: true
|
|
})
|
|
]
|
|
});
|
|
expect(onWarning).toHaveBeenCalledWith(
|
|
"已忽略与内置主题同 ID 的本地主题 test-theme"
|
|
);
|
|
});
|
|
|
|
it("为 DOCX 引擎准备统一文档、主题和源请求", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
const prepared = await service.prepareDocxExport({
|
|
markdown: "# DOCX 文档",
|
|
fileName: "报告.md",
|
|
language: "zh-CN",
|
|
exportConfig: {
|
|
...defaultExportConfig,
|
|
themeId: "test-theme"
|
|
}
|
|
});
|
|
|
|
expect(prepared.request).toMatchObject({
|
|
markdown: "# DOCX 文档",
|
|
fileName: "报告.md",
|
|
language: "zh-CN",
|
|
resources: []
|
|
});
|
|
expect(prepared.document.metadata.title).toBe("DOCX 文档");
|
|
expect(prepared.document.semanticDocument).toEqual({
|
|
schemaVersion: 1,
|
|
titlePolicy: {
|
|
metadataTitle: "suppress",
|
|
firstBodyHeading: "keep"
|
|
},
|
|
regions: []
|
|
});
|
|
expect(prepared.theme.manifest.id).toBe("test-theme");
|
|
expect(prepared.theme.source).toBe("bundled");
|
|
expect(prepared.theme.css).toContain(
|
|
"#write { font-family: Test; }"
|
|
);
|
|
});
|
|
|
|
it("以稳定错误码拒绝非法 DOCX 配置和缺失主题", async () => {
|
|
const roots = await createThemeFixture();
|
|
const service = createApplicationService(roots);
|
|
|
|
await expect(
|
|
service.prepareDocxExport({
|
|
markdown: "# 文档",
|
|
exportConfig: {}
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 400,
|
|
code: "INVALID_EXPORT_CONFIG"
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
service.prepareDocxExport({
|
|
markdown: "# 文档",
|
|
exportConfig: {
|
|
...defaultExportConfig,
|
|
themeId: "missing-theme"
|
|
}
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 404,
|
|
code: "THEME_NOT_FOUND"
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
service.prepareDocxExport({
|
|
markdown: 42,
|
|
exportConfig: {
|
|
...defaultExportConfig,
|
|
themeId: "test-theme"
|
|
}
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 400,
|
|
code: "INVALID_MARKDOWN"
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
service.prepareDocxExport({
|
|
markdown: "x".repeat(MAXIMUM_DOCX_MARKDOWN_LENGTH + 1),
|
|
exportConfig: {
|
|
...defaultExportConfig,
|
|
themeId: "test-theme"
|
|
}
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 413,
|
|
code: "MARKDOWN_TOO_LARGE"
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
service.prepareDocxExport({
|
|
markdown: "# 文档",
|
|
resources: [{ path: "", data: "" }],
|
|
exportConfig: {
|
|
...defaultExportConfig,
|
|
themeId: "test-theme"
|
|
}
|
|
})
|
|
).rejects.toEqual(
|
|
expect.objectContaining<ApplicationRequestError>({
|
|
statusCode: 400,
|
|
code: "INVALID_IMAGE_RESOURCES"
|
|
})
|
|
);
|
|
});
|
|
});
|