From fa07472428f551d5b29be9a0fd6c4c55a68646f3 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Sat, 25 Jul 2026 21:52:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 192 +++++ README.md | 37 +- apps/server/package.json | 4 +- apps/server/src/app.ts | 149 ++++ apps/server/src/index.ts | 32 +- apps/server/src/theme-registry.ts | 246 +++++++ apps/server/tests/app.test.ts | 137 ++++ apps/web/package.json | 3 + apps/web/src/App.tsx | 481 +++++++++++-- apps/web/src/styles.css | 379 ++++++---- apps/web/src/vite-env.d.ts | 1 + docs/PROGRESS.md | 240 +++++++ package-lock.json | 1095 ++++++++++++++++++++++++++++- package.json | 3 +- scripts/import-typora-theme.mjs | 78 ++ 16 files changed, 2860 insertions(+), 218 deletions(-) create mode 100644 AGENTS.md create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/theme-registry.ts create mode 100644 apps/server/tests/app.test.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 docs/PROGRESS.md create mode 100644 scripts/import-typora-theme.mjs diff --git a/.gitignore b/.gitignore index e43f70e..3884f09 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ coverage/ .env .env.local +.local/ *.log .DS_Store Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6a78f6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,192 @@ +# AGENTS.md + +本文件适用于仓库根目录及其所有子目录。进入本项目的新会话必须先完整阅读本文件与 `docs/PROGRESS.md`。 + +## 1. 语言与沟通 + +- 所有解释、询问、进度汇报、提交说明和项目文档必须使用简体中文,代码及必要的技术标识除外。 +- 涉及架构调整、新功能模块或明显改变现有实现方向时,先用文字或 Mermaid 对齐设计,等待用户确认后再编码。 +- 开始实质性任务前列出任务清单,等待用户明确确认。 +- 修改、新建或删除文件前,简要说明具体变更范围,等待用户确认。 +- 代码量较大或逻辑复杂时分步执行;每完成一个阶段,汇报结果并等待用户确认是否继续。 +- Mermaid 中的中文标题和节点文字必须使用英文双引号包裹。 + +## 2. 会话启动检查 + +新会话、上下文压缩后或对当前目录不确定时,在执行其他 Shell 命令前先运行: + +```powershell +Get-Location +``` + +确认目录为: + +```text +C:\Projects\md-to-pdf +``` + +随后依次执行只读检查: + +```powershell +git status --short +git log --oneline -5 +Get-Content -LiteralPath 'docs\PROGRESS.md' -Encoding UTF8 +``` + +当前工作区可能包含用户或上一个会话留下的未提交修改。禁止使用 `git reset --hard`、`git checkout --`、`git clean` 等方式丢弃修改,除非用户明确要求。 + +## 3. Shell 与文件操作 + +- 当前默认 Shell 为 PowerShell,使用 PowerShell 语法。 +- 不要通过绝对路径 `cd` 前缀拼接命令;工具支持时优先使用 `workdir`。 +- 子目录操作使用相对路径或直接设置 `workdir`。 +- PowerShell 读取、写入、追加或替换文本时显式指定 UTF-8 编码。 +- 本地文件修改优先使用补丁工具,避免使用不透明的大段覆盖命令。 +- 不执行破坏性删除;如确需删除,先确认精确目标并向用户说明。 + +## 4. 项目目标 + +本项目是一个可在内网部署的 Markdown 排版与 PDF 导出工具,目标包括: + +- 浏览器选择或编辑 Markdown; +- 使用服务端统一渲染 Markdown; +- 网页预览和 PDF 使用同一份 HTML、主题 CSS 与资源; +- 使用固定版本 Chromium 生成真实、可搜索的 PDF 文件; +- 支持纸张尺寸、方向、页边距、页眉、页脚和多种页码样式; +- 支持主题清单与 CSS 主题扩展; +- 支持 Front Matter、表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; +- 使用 Docker Compose 在内网部署; +- 文档只在当前请求中临时处理,不建立文档历史数据库。 + +核心渲染链路: + +```mermaid +flowchart LR + A["Markdown 与资源"] --> B["安全 Markdown 渲染"] + B --> C["统一 HTML 结构"] + D["主题 CSS"] --> C + E["导出配置"] --> C + C --> F["网页预览"] + C --> G["Playwright Chromium"] + G --> H["真实 PDF 文件"] +``` + +## 5. 技术架构 + +- 前端:React、TypeScript、Vite。 +- 后端:Node.js、TypeScript、Fastify。 +- 共享模型:`packages/core`。 +- Markdown 渲染:`packages/renderer`。 +- PDF 引擎:计划使用 Playwright Chromium。 +- Markdown:markdown-it 及 `@mdit/plugin-*` 插件。 +- 元数据:gray-matter。 +- 安全过滤:sanitize-html。 +- 代码高亮:highlight.js。 +- 数学公式:KaTeX。 +- 图表:Mermaid。 +- 校验:Zod。 +- 测试:Vitest。 +- 部署:Docker、Docker Compose。 + +主要目录职责: + +```text +apps/web/ 前端编辑、配置与预览界面 +apps/server/ HTTP API、主题读取及未来 PDF 服务 +packages/core/ 导出配置和主题清单等共享模型 +packages/renderer/ Markdown 到安全 HTML 的渲染管线 +themes/ 内置主题及未来可安装主题 +docker/ 容器化配置 +tests/ 跨包和端到端测试 +docs/ 进度、架构和部署文档 +``` + +## 6. 必须保持的实现约束 + +### 6.1 预览与 PDF 一致性 + +- 预览和 PDF 必须复用 Markdown 渲染结果、`#write` DOM、主题 CSS 和打印 CSS。 +- 不允许前端和 PDF 服务分别维护两套 Markdown 解析逻辑。 +- PDF 使用容器中固定版本的 Chromium,确保不同部署环境结果可复现。 +- Mermaid、字体、图片和公式完成渲染后,才能调用 PDF 输出。 + +### 6.2 主题扩展 + +- 主题通过版本化 `theme.json` 清单接入,不在业务代码中写死主题行为。 +- 主题负责字体、颜色、标题、表格、引用、代码块等视觉样式。 +- 纸张、页边距、页眉页脚、页码和 PDF 元数据属于导出配置,不属于主题。 +- 主题需要声明作者、版本、许可证、DOM 预设和支持能力。 +- Typora 官方默认主题仓库目前没有明确许可证,不得直接复制并打包。 +- 可以自行实现视觉接近的主题,或使用许可证明确允许的第三方主题。 + +### 6.3 安全与隐私 + +- 默认不保存用户 Markdown、图片或生成历史。 +- 资源上传和 PDF 生成使用每请求独立临时目录,并在 `finally` 中清理。 +- 阻止 `../` 路径穿越、任意本地文件读取和符号链接越界。 +- Markdown 原始 HTML 默认不执行;渲染结果必须经过安全过滤。 +- Mermaid 使用严格安全模式。 +- 自定义 CSS、远程图片和远程字体需要限制网络访问,避免信息泄漏和 SSRF。 +- 对 Markdown、资源数量、单文件大小、总大小、渲染时间和 Chromium 并发设置上限。 + +### 6.4 页眉页脚与页码 + +- 普通页眉页脚优先使用 Chromium 的 header/footer template。 +- 模板样式必须内联,不假设继承正文 CSS。 +- 基础页码支持当前页、总页数、中英文组合及左右中位置。 +- 罗马数字、章节页码、封面不计页码等复杂能力放在 PDF 后处理扩展层,不阻塞首版。 + +## 7. 开发和验证 + +要求 Node.js 22 或更高版本。 + +安装依赖: + +```powershell +npm install +``` + +本地开发: + +```powershell +npm run dev +``` + +完整验证: + +```powershell +npm test +npm run typecheck +npm run build +``` + +任何功能提交前至少满足: + +- 相关单元测试通过; +- 全项目类型检查通过; +- 生产构建通过; +- `git diff --check` 无空白错误; +- 未意外纳入 `node_modules`、`dist`、临时文件或用户文档。 + +如涉及 PDF 输出,还必须使用包含中文、长表格、代码块、公式、Mermaid、图片和分页边界的样例进行端到端验证,并对生成 PDF 做页面渲染检查。 + +## 8. Git 规范 + +- 默认分支为 `main`。 +- 提交前先检查 `git status --short` 和 `git diff`。 +- 不覆盖或混入与当前任务无关的用户修改。 +- 一个提交只表达一个清晰阶段。 +- 提交信息前缀使用标准英文,正文使用简体中文,例如: + +```text +feat: 实现网页实时预览 +fix: 修复表格跨页断裂 +docs: 更新内网部署说明 +chore: 初始化项目骨架 +``` + +- 不修改或重写已有提交,除非用户明确要求。 + +## 9. 当前交接入口 + +项目的实时状态、已完成内容、未提交修改和下一步顺序以 `docs/PROGRESS.md` 为准。新会话不得仅凭 Git 提交标题判断进度。 diff --git a/README.md b/README.md index 42dd7e5..418b701 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,19 @@ ## 当前状态 -项目处于骨架阶段,已经建立: +已经实现: -- React 前端; -- Fastify 后端; +- React Markdown 编辑与本地文件读取; +- Fastify 预览接口; - 共享导出配置模型; -- 版本化主题清单接口; +- 动态主题清单、CSS 和静态资源接口; - Markdown、Front Matter、安全过滤和扩展语法渲染核心; -- 内置 Typora 风格主题目录; -- Docker 与测试目录预留。 +- 表格、任务列表、脚注、代码高亮、KaTeX 和 Mermaid; +- iframe 隔离的 A4 页面预览与主题切换; +- 内置 Typora 风格主题,以及仅供本机使用的 Typora 默认主题导入工具; +- Docker 与 PDF 渲染目录预留。 -网页预览和 PDF 生成将在后续阶段实现。 +Chromium PDF 下载、完整导出配置面板和本地资源文件处理将在后续阶段实现。 ## 本地开发 @@ -31,12 +33,29 @@ npm run dev - 后端:http://localhost:3001 - 健康检查:http://localhost:3001/api/health -## 构建 +页面支持直接编辑 Markdown,或选择本地 `.md` 文件。文件内容将读取到当前浏览器页面并发送给内网转换服务,不写入数据库。 + +## 本地导入 Typora 默认主题 + +项目不会打包或提交 Typora 官方默认主题。如本机已安装 Typora,可以将其 GitHub 默认主题复制到 Git 忽略的本地主题目录: ```powershell +npm run theme:import-typora +``` + +默认读取 `%APPDATA%\Typora\themes`,写入 `.local\themes\typora-github`。启动开发服务后,该主题会出现在预览页面的主题选择器中。导入工具不会覆盖已存在的目标目录;如需重新导入,请先自行确认并移走旧目录。 + +本地主题由动态主题注册器加载,CSS 中的相对字体和图片会通过受限资源接口提供。外部 URL、越界路径和符号链接逃逸会被拒绝。 + +## 测试与构建 + +```powershell +npm test +npm run typecheck npm run build +git diff --check ``` ## 隐私原则 -文档和资源文件仅用于当前转换请求。正式实现将为每次请求创建隔离的临时目录,并在成功或失败后统一清理,不接入数据库或文档历史存储。 +文档和资源文件仅用于当前转换请求。PDF 阶段将为每次请求创建隔离的临时目录,并在成功或失败后统一清理,不接入数据库或文档历史存储。 diff --git a/apps/server/package.json b/apps/server/package.json index 01736d8..6ff6597 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,6 +7,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", + "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { @@ -16,6 +17,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "vitest": "^4.1.10" } } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..6b45717 --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,149 @@ +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import Fastify from "fastify"; +import { + EXPORT_CONFIG_VERSION, + defaultExportConfig, + supportedPaperFormats +} from "@md-to-pdf/core"; +import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer"; +import { createThemeRegistry } from "./theme-registry.js"; + +interface RenderRequestBody { + markdown?: unknown; + language?: unknown; +} + +const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); + +export interface BuildAppOptions { + localThemeRoot?: string; + logger?: boolean; +} + +export function buildApp(options: BuildAppOptions = {}) { + const app = Fastify({ + logger: options.logger ?? true, + bodyLimit: 2 * 1024 * 1024 + }); + const themes = createThemeRegistry({ + bundledRoot: resolve(projectRoot, "themes"), + localRoot: + options.localThemeRoot ?? resolve(projectRoot, ".local", "themes") + }); + + app.get("/api/health", async () => ({ + status: "ok", + service: "md-to-pdf", + configVersion: EXPORT_CONFIG_VERSION, + rendererVersion: RENDERER_VERSION + })); + + app.get("/api/capabilities", async () => ({ + defaultExportConfig, + supportedPaperFormats, + implemented: [ + "project-skeleton", + "export-config", + "theme-manifest", + "markdown-render", + "html-preview" + ], + planned: ["pdf-export"] + })); + + app.get("/api/themes", async () => ({ + themes: (await themes.list()).map(({ manifest, source }) => ({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + bundled: manifest.bundled, + source + })) + })); + + app.get<{ Params: { themeId: string } }>( + "/api/themes/:themeId/css", + async (request, reply) => { + const { themeId } = request.params; + + const css = await themes.getCss(themeId); + if (css === undefined) { + return reply.code(404).send({ + error: "THEME_NOT_FOUND", + message: "未找到指定主题" + }); + } + + return reply + .header("content-type", "text/css; charset=utf-8") + .header("cache-control", "public, max-age=300") + .send(css); + } + ); + + app.get<{ Params: { themeId: string; "*": string } }>( + "/api/themes/:themeId/assets/*", + async (request, reply) => { + try { + const asset = await themes.getAsset( + request.params.themeId, + request.params["*"] + ); + if (!asset) { + return reply.code(404).send({ + error: "THEME_NOT_FOUND", + message: "未找到指定主题" + }); + } + + return reply + .header("content-type", asset.contentType) + .header("cache-control", "public, max-age=300") + .header("content-security-policy", "default-src 'none'; sandbox") + .header("x-content-type-options", "nosniff") + .send(asset.content); + } catch { + return reply.code(404).send({ + error: "THEME_ASSET_NOT_FOUND", + message: "未找到指定主题资源" + }); + } + } + ); + + app.post<{ Body: RenderRequestBody }>( + "/api/render", + async (request, reply) => { + const { markdown, language } = request.body ?? {}; + + if (typeof markdown !== "string") { + return reply.code(400).send({ + error: "INVALID_MARKDOWN", + message: "markdown 必须是字符串" + }); + } + + if (markdown.length > 1_500_000) { + return reply.code(413).send({ + error: "MARKDOWN_TOO_LARGE", + message: "Markdown 内容不能超过 1.5 MB" + }); + } + + if (language !== undefined && typeof language !== "string") { + return reply.code(400).send({ + error: "INVALID_LANGUAGE", + message: "language 必须是字符串" + }); + } + + return renderMarkdown(markdown, { + ...(typeof language === "string" ? { language } : {}) + }); + } + ); + + return app; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8191199..081c9dc 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,36 +1,8 @@ -import Fastify from "fastify"; -import { - EXPORT_CONFIG_VERSION, - defaultExportConfig, - supportedPaperFormats -} from "@md-to-pdf/core"; -import { RENDERER_VERSION } from "@md-to-pdf/renderer"; +import { buildApp } from "./app.js"; const port = Number.parseInt(process.env.PORT ?? "3001", 10); const host = process.env.HOST ?? "0.0.0.0"; - -const app = Fastify({ - logger: true -}); - -app.get("/api/health", async () => ({ - status: "ok", - service: "md-to-pdf", - configVersion: EXPORT_CONFIG_VERSION, - rendererVersion: RENDERER_VERSION -})); - -app.get("/api/capabilities", async () => ({ - defaultExportConfig, - supportedPaperFormats, - implemented: [ - "project-skeleton", - "export-config", - "theme-manifest", - "markdown-render" - ], - planned: ["html-preview", "pdf-export"] -})); +const app = buildApp(); async function start() { try { diff --git a/apps/server/src/theme-registry.ts b/apps/server/src/theme-registry.ts new file mode 100644 index 0000000..cc4502f --- /dev/null +++ b/apps/server/src/theme-registry.ts @@ -0,0 +1,246 @@ +import { + readdir, + readFile, + realpath, + stat +} from "node:fs/promises"; +import { + extname, + isAbsolute, + relative, + resolve, + sep +} from "node:path"; +import { + themeManifestSchema, + type ThemeManifest +} from "@md-to-pdf/core"; + +export interface ThemeRecord { + manifest: ThemeManifest; + directory: string; + source: "bundled" | "local"; +} + +export interface ThemeRegistryOptions { + bundledRoot: string; + localRoot: string; +} + +const assetContentTypes: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ttf": "font/ttf", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2" +}; + +function isMissingFileError(error: unknown) { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +function isInsideDirectory(directory: string, path: string) { + const pathFromDirectory = relative(directory, path); + return ( + pathFromDirectory === "" || + (!pathFromDirectory.startsWith(`..${sep}`) && + pathFromDirectory !== ".." && + !isAbsolute(pathFromDirectory)) + ); +} + +async function resolveThemeFile(directory: string, relativePath: string) { + if ( + isAbsolute(relativePath) || + relativePath.includes("\0") || + relativePath.split(/[\\/]/).includes("..") + ) { + throw new Error("主题资源路径不安全"); + } + + const realDirectory = await realpath(directory); + const candidate = resolve(realDirectory, relativePath); + if (!isInsideDirectory(realDirectory, candidate)) { + throw new Error("主题资源路径越界"); + } + + const realCandidate = await realpath(candidate); + if (!isInsideDirectory(realDirectory, realCandidate)) { + throw new Error("主题资源符号链接越界"); + } + + const fileStat = await stat(realCandidate); + if (!fileStat.isFile()) { + throw new Error("主题资源不是文件"); + } + + return realCandidate; +} + +async function readThemeRoot( + root: string, + source: ThemeRecord["source"] +): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissingFileError(error) && source === "local") { + return []; + } + throw error; + } + + const records: ThemeRecord[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const directory = resolve(root, entry.name); + try { + const manifestPath = await resolveThemeFile(directory, "theme.json"); + const manifest = themeManifestSchema.parse( + JSON.parse(await readFile(manifestPath, "utf8")) + ); + + if (manifest.id !== entry.name) { + throw new Error("主题目录名必须与主题 ID 一致"); + } + if (manifest.bundled !== (source === "bundled")) { + throw new Error("主题 bundled 标记与来源不一致"); + } + + await resolveThemeFile(directory, manifest.entry); + if (manifest.print) { + await resolveThemeFile(directory, manifest.print); + } + + records.push({ + manifest, + directory, + source + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`主题 ${entry.name} 无效:${message}`); + } + } + + return records; +} + +function encodeAssetPath(path: string) { + return path + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function prepareThemeCss(css: string, themeId: string) { + const withoutTyporaExportIncludes = css.replace( + /^\s*@include-when-export\s+url\([^;\r\n]+;\s*$/gim, + "" + ); + + return withoutTyporaExportIncludes.replace( + /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, + (match, _quote: string, rawValue: string) => { + const value = rawValue.trim(); + if (value.startsWith("data:") || value.startsWith("#")) { + return match; + } + if ( + value.startsWith("/") || + value.startsWith("//") || + /^[a-z][a-z0-9+.-]*:/i.test(value) + ) { + throw new Error(`主题包含不允许的外部资源:${value}`); + } + + const normalized = value.replaceAll("\\", "/").replace(/^\.\//, ""); + if (normalized.split("/").includes("..")) { + throw new Error(`主题资源路径不安全:${value}`); + } + + return `url("/api/themes/${encodeURIComponent(themeId)}/assets/${encodeAssetPath(normalized)}")`; + } + ); +} + +export function createThemeRegistry(options: ThemeRegistryOptions) { + async function list() { + const [bundledThemes, localThemes] = await Promise.all([ + readThemeRoot(options.bundledRoot, "bundled"), + readThemeRoot(options.localRoot, "local") + ]); + + const themes = [...bundledThemes, ...localThemes]; + const themeIds = new Set(); + for (const theme of themes) { + if (themeIds.has(theme.manifest.id)) { + throw new Error(`主题 ID 重复:${theme.manifest.id}`); + } + themeIds.add(theme.manifest.id); + } + + return themes; + } + + async function get(themeId: string) { + return (await list()).find((theme) => theme.manifest.id === themeId); + } + + async function getCss(themeId: string) { + const theme = await get(themeId); + if (!theme) { + return undefined; + } + + const cssFiles = [ + await resolveThemeFile(theme.directory, theme.manifest.entry), + ...(theme.manifest.print + ? [await resolveThemeFile(theme.directory, theme.manifest.print)] + : []) + ]; + const css = ( + await Promise.all(cssFiles.map((path) => readFile(path, "utf8"))) + ).join("\n"); + + return prepareThemeCss(css, themeId); + } + + async function getAsset(themeId: string, assetPath: string) { + const theme = await get(themeId); + if (!theme) { + return undefined; + } + + const extension = extname(assetPath).toLowerCase(); + const contentType = assetContentTypes[extension]; + if (!contentType) { + throw new Error("不支持的主题资源类型"); + } + + const path = await resolveThemeFile(theme.directory, assetPath); + return { + contentType, + content: await readFile(path) + }; + } + + return { + get, + getAsset, + getCss, + list + }; +} diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts new file mode 100644 index 0000000..ff776ec --- /dev/null +++ b/apps/server/tests/app.test.ts @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildApp } from "../src/app.js"; + +let app: FastifyInstance | undefined; +let temporaryDirectory: string | undefined; + +afterEach(async () => { + await app?.close(); + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + app = undefined; + temporaryDirectory = undefined; +}); + +describe("预览 API", () => { + it("渲染 Markdown 并返回安全文章结构", async () => { + app = buildApp(); + const response = await app.inject({ + method: "POST", + url: "/api/render", + payload: { + markdown: "# 文档\n\n" + } + }); + + expect(response.statusCode).toBe(200); + const result = response.json(); + expect(result.articleHtml).toContain('id="write"'); + expect(result.articleHtml).not.toContain(" { + app = buildApp(); + const response = await app.inject({ + method: "POST", + url: "/api/render", + payload: { + markdown: 42 + } + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("INVALID_MARKDOWN"); + }); + + it("返回内置主题 CSS", async () => { + app = buildApp(); + const response = await app.inject({ + method: "GET", + url: "/api/themes/typora-like/css" + }); + + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toContain("text/css"); + expect(response.body).toContain("#write"); + }); + + it("发现本地主题并安全提供相对字体资源", async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); + const themeDirectory = join(temporaryDirectory, "local-test"); + await mkdir(join(themeDirectory, "fonts"), { recursive: true }); + await writeFile( + join(themeDirectory, "theme.json"), + JSON.stringify({ + manifestVersion: 1, + id: "local-test", + 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(themeDirectory, "theme.css"), + [ + "@include-when-export url(https://example.com/font.css);", + "@font-face { src: url('./fonts/test.woff2') format('woff2'); }", + "#write { font-family: LocalTest; }" + ].join("\n"), + "utf8" + ); + await writeFile( + join(themeDirectory, "fonts", "test.woff2"), + Buffer.from([0, 1, 2, 3]) + ); + + app = buildApp({ + localThemeRoot: temporaryDirectory, + logger: false + }); + const themesResponse = await app.inject({ + method: "GET", + url: "/api/themes" + }); + expect(themesResponse.statusCode).toBe(200); + expect(themesResponse.json().themes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "local-test", + bundled: false, + source: "local" + }) + ]) + ); + + const cssResponse = await app.inject({ + method: "GET", + url: "/api/themes/local-test/css" + }); + expect(cssResponse.statusCode).toBe(200); + expect(cssResponse.body).not.toContain("@include-when-export"); + expect(cssResponse.body).toContain( + "/api/themes/local-test/assets/fonts/test.woff2" + ); + + const assetResponse = await app.inject({ + method: "GET", + url: "/api/themes/local-test/assets/fonts/test.woff2" + }); + expect(assetResponse.statusCode).toBe(200); + expect(assetResponse.headers["content-type"]).toContain("font/woff2"); + expect(assetResponse.rawPayload).toEqual(Buffer.from([0, 1, 2, 3])); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 89407da..4278644 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,9 @@ }, "dependencies": { "@md-to-pdf/core": "0.1.0", + "highlight.js": "^11.11.1", + "katex": "^0.16.28", + "mermaid": "^11.12.3", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6662e38..22a0a4d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,60 +1,441 @@ -import { defaultExportConfig, paperFormatLabels } from "@md-to-pdf/core"; +import { + type ChangeEvent, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import highlightCss from "highlight.js/styles/github.css?inline"; +import katexCss from "katex/dist/katex.min.css?inline"; -const capabilities = [ - "Markdown 本地预览", - "Chromium PDF 下载", - "纸张与页边距", - "页眉、页脚与页码", - "可扩展 CSS 主题" -]; +interface RenderedMarkdown { + articleHtml: string; + metadata: { + title: string; + author: string; + subject: string; + keywords: string[]; + language: string; + }; + features: string[]; + warnings: string[]; +} + +interface ThemeSummary { + id: string; + name: string; + version: string; + description: string; + bundled: boolean; + source: "bundled" | "local"; +} + +const sampleMarkdown = `--- +title: Markdown PDF 示例 +author: 内网文档团队 +keywords: + - Markdown + - PDF +--- + +# Markdown PDF 示例 + +这是一份使用 **Typora 风格主题** 渲染的预览文档。 + +## 常用内容 + +- [x] Markdown 实时预览 +- [x] 表格与任务列表 +- [x] 数学公式与 Mermaid +- [ ] Chromium PDF 导出 + +| 功能 | 当前状态 | +| --- | --- | +| HTML 预览 | 已实现 | +| PDF 下载 | 下一阶段 | + +行内公式:$E = mc^2$ + +\`\`\`mermaid +flowchart LR + A["Markdown"] --> B["统一 HTML"] + B --> C["主题 CSS"] + C --> D["网页预览"] + C --> E["PDF 导出"] +\`\`\` + +> 预览和 PDF 将共用同一份 HTML 与 CSS。 +`; + +const previewBaseCss = ` +:root { + color-scheme: light; + background: #fff; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-width: 0; + background: #fff; +} + +img, +svg { + max-width: 100%; +} + +.mermaid { + display: flex; + justify-content: center; + margin: 1.5em 0; + overflow: auto; +} +`; + +let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | undefined; +let mermaidRenderSequence = 0; + +async function loadMermaid() { + if (!mermaidPromise) { + mermaidPromise = import("mermaid").then(({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: "neutral", + fontFamily: "Segoe UI, Microsoft YaHei, sans-serif" + }); + return mermaid; + }); + } + + return mermaidPromise; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function escapeStyleContent(value: string) { + return value.replace(/<\/style/gi, "<\\/style"); +} + +function buildPreviewDocument( + result: RenderedMarkdown, + themeCss: string +) { + return ` + + + + + + ${escapeHtml(result.metadata.title || "Markdown 预览")} + + + + + + + ${result.articleHtml} + +`; +} export function App() { + const [markdown, setMarkdown] = useState(sampleMarkdown); + const [fileName, setFileName] = useState("示例文档.md"); + const [result, setResult] = useState(null); + const [themes, setThemes] = useState([]); + const [themeId, setThemeId] = useState("typora-like"); + const [themeCss, setThemeCss] = useState(""); + const [status, setStatus] = useState("正在准备预览…"); + const [renderError, setRenderError] = useState(""); + const [themeError, setThemeError] = useState(""); + const [mermaidError, setMermaidError] = useState(""); + const previewFrameRef = useRef(null); + + const error = renderError || themeError || mermaidError; + const selectedTheme = themes.find((theme) => theme.id === themeId); + const previewDocument = useMemo( + () => + result && themeCss ? buildPreviewDocument(result, themeCss) : undefined, + [result, themeCss] + ); + + useEffect(() => { + const controller = new AbortController(); + + void fetch("/api/themes", { + signal: controller.signal + }) + .then(async (response) => { + if (!response.ok) { + throw new Error("无法加载主题清单"); + } + return response.json() as Promise<{ themes: ThemeSummary[] }>; + }) + .then(({ themes: availableThemes }) => { + setThemes(availableThemes); + const preferredTheme = + availableThemes.find((theme) => theme.id === "typora-github") ?? + availableThemes.find((theme) => theme.id === "typora-like") ?? + availableThemes[0]; + if (preferredTheme) { + setThemeId(preferredTheme.id); + } + }) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setThemeError( + reason instanceof Error ? reason.message : "无法加载主题清单" + ); + } + }); + + return () => controller.abort(); + }, []); + + useEffect(() => { + const controller = new AbortController(); + setThemeCss(""); + setThemeError(""); + + void fetch(`/api/themes/${encodeURIComponent(themeId)}/css`, { + signal: controller.signal + }) + .then(async (response) => { + if (!response.ok) { + throw new Error("无法加载主题"); + } + return response.text(); + }) + .then(setThemeCss) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setThemeError( + reason instanceof Error ? reason.message : "无法加载主题" + ); + } + }); + + return () => controller.abort(); + }, [themeId]); + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setStatus("正在渲染…"); + setRenderError(""); + setMermaidError(""); + + void fetch("/api/render", { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + markdown, + language: "zh-CN" + }), + signal: controller.signal + }) + .then(async (response) => { + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.message ?? "渲染失败"); + } + return payload as RenderedMarkdown; + }) + .then((payload) => { + setResult(payload); + setStatus("预览已更新"); + }) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setRenderError( + reason instanceof Error ? reason.message : "渲染失败" + ); + setStatus("预览失败"); + } + }); + }, 250); + + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [markdown]); + + async function handlePreviewLoad() { + const frame = previewFrameRef.current; + const document = frame?.contentDocument; + if (!frame || !document || !result) { + return; + } + + const resizeFrame = () => { + frame.style.height = `${Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + 500 + )}px`; + }; + + resizeFrame(); + await document.fonts.ready; + + if (result.features.includes("mermaid")) { + const nodes = Array.from( + document.querySelectorAll( + ".mermaid[data-mermaid-pending]" + ) + ); + + if (nodes.length > 0) { + try { + const mermaid = await loadMermaid(); + for (const node of nodes) { + const definition = node.textContent ?? ""; + mermaidRenderSequence += 1; + const { svg, bindFunctions } = await mermaid.render( + `mermaid-preview-${mermaidRenderSequence}`, + definition + ); + node.innerHTML = svg; + node.removeAttribute("data-mermaid-pending"); + bindFunctions?.(node); + } + } catch (reason: unknown) { + setMermaidError( + reason instanceof Error + ? `Mermaid:${reason.message}` + : "Mermaid 渲染失败" + ); + } + } + } + + resizeFrame(); + } + + async function handleFileChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + try { + setMarkdown(await file.text()); + setFileName(file.name); + } catch { + setRenderError("无法读取所选 Markdown 文件"); + } finally { + event.target.value = ""; + } + } + return ( -
-
-

内网文档工具

-

Markdown PDF 导出器

-

- 使用同一份 HTML 与主题 CSS 完成网页预览和 Chromium PDF 渲染。 -

-
-
-
- -
+
+
-

默认导出预设

-

{defaultExportConfig.name}

+

内网文档工具

+

Markdown PDF 导出器

-
-
-
纸张
-
{paperFormatLabels[defaultExportConfig.paper.format]}
-
-
-
方向
-
- {defaultExportConfig.paper.orientation === "portrait" - ? "纵向" - : "横向"} -
-
-
-
主题
-
{defaultExportConfig.themeId}
-
-
-
+
+ + +
+ -
- {capabilities.map((item, index) => ( -
- {String(index + 1).padStart(2, "0")} -

{item}

-
- ))} +
+