diff --git a/README.md b/README.md index b3f4443..3adad46 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Tauri 2 + React + TypeScript + Milkdown(ProseMirror)。 ## 当前进度 -**阶段 0–4 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** -阶段 4:SQLite 落库、自动保存、崩溃恢复、外部修改检测、工作区文件管理,报告见 `docs/08-stage4-report.md`。 +**阶段 0–5 已完成(自动化验证全绿),待 Windows 真机验收清单确认。** +阶段 5:7 套文档主题、自定义 CSS、导出 HTML、打印/PDF、导入 HTML/Word,报告见 `docs/09-stage5-report.md`。 阶段 1:Tauri 桌面骨架 + 打开/编辑/保存全链路,报告见 `docs/05-stage1-report.md` §4 清单。 在 Windows 上运行桌面版: diff --git a/docs/09-stage5-report.md b/docs/09-stage5-report.md new file mode 100644 index 0000000..f528853 --- /dev/null +++ b/docs/09-stage5-report.md @@ -0,0 +1,36 @@ +# 阶段 5 · 主题系统与导入导出报告(实测记录) + +> 日期:2026-11-13 · 范围(需求 §23 阶段 5):内置主题、自定义 CSS、导出 HTML/PDF、导入导出与打印。 + +## 1. 本阶段实现内容 + +- **文档主题系统**(`src/themes.ts` + styles.css 变量层):7 套内置主题——默认、GitHub、Notion、简约蓝、深色护眼(自带深色纸面)、专注写作(衬线窄栏)、经典纸张。主题只作用于编辑区排版(字体/行距/标题/链接/引用/代码底/纸面色),经 CSS 变量切换,与 UI 框架明暗相互独立。新增顶部「主题」菜单可直接切换(带勾选态)。 +- **当前文档临时主题**(需求 §14.3):外观弹窗里可为当前标签单独指定主题,覆盖全局、仅本会话有效,不写进文档文件。 +- **自定义 CSS**(需求 §14.2):外观弹窗内编辑、即时注入生效、随偏好落库;弹窗同时补齐了阶段 4 遗留的自动保存间隔设置(开关 + 10–600 秒)。 +- **导出 HTML**(`src/services/exportHtml.ts`):独立管线 unified/remark(gfm+math)→ rehype → rehype-katex 公式渲染 → 代码高亮 → Mermaid 渲染 SVG → 独立 HTML 文档(内联与主题同源的导出样式、`
导出 HTML 为独立文件:公式以 KaTeX 样式呈现(样式表走 CDN,离线时公式退化为 MathML 显示),Mermaid 图表渲染为 SVG,代码块带语法高亮。
+ 做 Shiki 高亮与 Mermaid 替换的 rehype 后处理。 */
+async function postProcess(tree: Root, opts: ExportOptions): Promise {
+ const jobs: CodeJob[] = []
+ const images: { node: Element }[] = []
+ visit(tree, 'element', (node: Element, index, parent) => {
+ if (node.tagName === 'img') images.push({ node })
+ if (node.tagName !== 'pre' || index == null || !parent) return
+ const codeEl = node.children.find(
+ (c): c is Element => c.type === 'element' && c.tagName === 'code',
+ )
+ if (!codeEl) return
+ const classes = (codeEl.properties?.className as string[] | undefined) ?? []
+ const langClass = classes.find((c) => c.startsWith('language-'))
+ if (!langClass) return
+ const lang = langClass.slice('language-'.length)
+ const code = codeEl.children
+ .filter((c): c is Text => c.type === 'text')
+ .map((c) => c.value)
+ .join('')
+ jobs.push({ parent: parent as Element | Root, index: (parent as Element).children.indexOf(node), lang, code })
+ })
+
+ // 图片内嵌
+ if (opts.imageMode === 'embed' && opts.resolveImage) {
+ for (const { node } of images) {
+ const src = node.properties?.src
+ if (typeof src === 'string' && !/^(https?:|data:)/i.test(src)) {
+ const data = await opts.resolveImage(src)
+ if (data) node.properties.src = data
+ }
+ }
+ }
+
+ // 代码块替换(从后往前,避免索引漂移)
+ const mermaidJobs = jobs.filter((j) => j.lang === 'mermaid')
+ const codeJobs = jobs.filter((j) => j.lang !== 'mermaid')
+ let highlighter: Awaited> | null = null
+ if (opts.highlight !== false && codeJobs.length > 0) {
+ try {
+ highlighter = await loadHighlighter()
+ } catch {
+ highlighter = null // 高亮不可用时保留普通代码块,不阻塞导出
+ }
+ }
+ let mermaidApi: (typeof import('mermaid'))['default'] | null = null
+ if (opts.renderMermaid !== false && mermaidJobs.length > 0) {
+ try {
+ mermaidApi = (await import('mermaid')).default
+ mermaidApi.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict' })
+ } catch {
+ mermaidApi = null
+ }
+ }
+ const all = [...jobs].sort((a, b) => b.index - a.index)
+ for (const job of all) {
+ let rawHtml: string | null = null
+ if (job.lang === 'mermaid' && mermaidApi) {
+ try {
+ const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}`, job.code)
+ rawHtml = `${svg}`
+ } catch {
+ rawHtml = null
+ }
+ } else if (job.lang !== 'mermaid' && highlighter) {
+ try {
+ rawHtml = highlighter.codeToHtml(job.code, {
+ lang: job.lang,
+ theme: 'github-light',
+ })
+ } catch {
+ rawHtml = null // 未知语言保留原样
+ }
+ }
+ if (rawHtml) {
+ const rawNode = { type: 'raw', value: rawHtml } as unknown as Element
+ ;(job.parent as Element).children.splice(job.index, 1, rawNode)
+ }
+ }
+}
+
+function hashCode(text: string): number {
+ let h = 0
+ for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0
+ return h
+}
+
+let highlighterPromise: Promise | null = null
+
+const EXPORT_LANGS = [
+ 'javascript', 'typescript', 'tsx', 'jsx', 'json', 'html', 'css', 'markdown',
+ 'bash', 'shell', 'powershell', 'python', 'java', 'go', 'rust', 'c', 'cpp',
+ 'csharp', 'sql', 'yaml', 'xml', 'diff', 'ini',
+] as const
+
+/**
+ * Shiki 高亮器:用 core + JavaScript 正则引擎(不用 Oniguruma WASM——
+ * WASM 加载依赖 fetch/stream,在部分 WebView/测试环境会失败,JS 引擎 everywhere 可用)。
+ */
+function loadHighlighter(): Promise {
+ if (!highlighterPromise) {
+ highlighterPromise = (async () => {
+ const { createHighlighterCore } = await import('shiki/core')
+ const { createJavaScriptRegexEngine } = await import('shiki/engine/javascript')
+ const theme = (await import('@shikijs/themes/github-light')).default
+ const langs = await Promise.all(
+ EXPORT_LANGS.map(async (name) => (await import(`@shikijs/langs/${name}`)).default),
+ )
+ return createHighlighterCore({
+ themes: [theme],
+ langs,
+ engine: createJavaScriptRegexEngine(),
+ })
+ })()
+ }
+ return highlighterPromise
+}
+
+/** 导出用样式:与应用内文档主题同源的精简版(按 themeId 给纸面/字体变体)。 */
+export function exportCss(themeId: string): string {
+ const base = `
+body { margin: 0; background: #fff; }
+main { max-width: 820px; margin: 0 auto; padding: 42px 26px 70px; font-family: var(--f); color: var(--ink); line-height: 1.75; font-size: 16.5px; }
+h1 { font-size: 1.9em; border-bottom: 1px solid var(--hb); padding-bottom: 8px; line-height: 1.3; }
+h2 { font-size: 1.5em; } h3 { font-size: 1.25em; }
+a { color: var(--link); }
+code { background: var(--cb); border-radius: 6px; padding: 2px 5px; font-family: "Cascadia Code", Consolas, monospace; font-size: 0.88em; }
+pre { background: #f6f8fa; border-radius: 8px; padding: 14px 16px; overflow-x: auto; }
+pre code { background: transparent; padding: 0; }
+blockquote { border-left: 3px solid var(--hb); margin-left: 0; padding-left: 14px; color: #667085; }
+table { border-collapse: collapse; margin: 12px 0; }
+th, td { border: 1px solid #d8dee4; padding: 6px 12px; }
+img { max-width: 100%; }
+.mermaid-diagram { text-align: center; }
+.mermaid-diagram svg { max-width: 100%; height: auto; }
+`
+ const variants: Record = {
+ default: `:root{--f:"Segoe UI","Microsoft YaHei UI",sans-serif;--ink:#1d2433;--link:#43536a;--cb:#eef1f5;--hb:#dde2ea;}`,
+ github: `:root{--f:-apple-system,"Segoe UI",sans-serif;--ink:#1f2328;--link:#0969da;--cb:#eff1f3;--hb:#d8dee4;}`,
+ notion: `:root{--f:ui-sans-serif,"Segoe UI",sans-serif;--ink:#37352f;--link:#37352f;--cb:#f1f1ef;--hb:#e9e9e7;} body{background:#fbfbfa;}`,
+ 'simple-blue': `:root{--f:"Segoe UI",sans-serif;--ink:#1d2433;--link:#2563eb;--cb:#eef3fd;--hb:#dbe7fb;} h1,h2{color:#1d4ed8;}`,
+ 'dark-eye': `:root{--f:"Segoe UI",sans-serif;--ink:#c9d1c0;--link:#9ec49a;--cb:#2a322a;--hb:#39412f;} body{background:#20261f;} pre{background:#262d24;}`,
+ focus: `:root{--f:Georgia,"Songti SC",SimSun,serif;--ink:#26221c;--link:#6b4f2a;--cb:#f3efe6;--hb:transparent;} main{max-width:720px;line-height:1.95;}`,
+ paper: `:root{--f:Georgia,"Songti SC",SimSun,serif;--ink:#433422;--link:#8a5a2b;--cb:#efe6d0;--hb:#e0d3ae;} body{background:#f7f1e3;}`,
+ }
+ return (variants[themeId] ?? variants.default) + base
+}
+
+export async function markdownToExportHtml(markdown: string, opts: ExportOptions): Promise {
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkGfm)
+ .use(remarkMath)
+ .use(remarkRehype, { allowDangerousHtml: true })
+ .use(rehypeKatex)
+ const tree = (await processor.run(processor.parse(markdown))) as unknown as Root
+ await postProcess(tree, opts)
+ const body = await unified()
+ .use(rehypeStringify, { allowDangerousHtml: true })
+ .stringify(tree as never)
+ const katexCssLink = ''
+ return [
+ '',
+ '',
+ '',
+ '',
+ `${escapeHtml(opts.title)} `,
+ katexCssLink,
+ ``,
+ '',
+ '',
+ `${String(body)} `,
+ '',
+ '',
+ ].join('\n')
+}
+
+function escapeHtml(text: string): string {
+ return text.replace(/&/g, '&').replace(//g, '>')
+}
+
+/** HTML → Markdown(导入用,懒加载 turndown)。 */
+export async function htmlToMarkdown(html: string): Promise {
+ const TurndownService = (await import('turndown')).default
+ const service = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', bulletListMarker: '-' })
+ return service.turndown(html)
+}
+
+/** DOCX → Markdown(mammoth 先转 HTML 再走 turndown,懒加载)。 */
+export async function docxToMarkdown(arrayBuffer: ArrayBuffer): Promise {
+ const mammoth = await import('mammoth')
+ const result = await mammoth.convertToHtml({ arrayBuffer })
+ return htmlToMarkdown(result.value)
+}
diff --git a/src/services/platform.ts b/src/services/platform.ts
index 3970b87..88019c7 100644
--- a/src/services/platform.ts
+++ b/src/services/platform.ts
@@ -130,8 +130,8 @@ export function pickAndReadWebFile(): Promise<{ name: string; content: string }
}
/** Web 降级:把内容作为下载交付(等价于另存为)。 */
-export function downloadTextFile(name: string, content: string): void {
- const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
+export function downloadTextFile(name: string, content: string, mime = 'text/markdown;charset=utf-8'): void {
+ const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -224,3 +224,53 @@ export async function dbRecoverySave(entry: RecoveryEntry): Promise {
export async function dbRecoveryDelete(docKey: string): Promise {
return invoke('db_recovery_delete', { docKey })
}
+
+/** 导出 HTML 时另存路径选择。 */
+export async function pickSaveHtmlFile(defaultName: string): Promise {
+ if (!isTauri()) return null
+ const { save } = await import('@tauri-apps/plugin-dialog')
+ const picked = await save({ defaultPath: defaultName, filters: [{ name: 'HTML', extensions: ['html', 'htm'] }] })
+ return typeof picked === 'string' ? picked : null
+}
+
+/** 导入文件选择(HTML / Word)。 */
+export async function pickImportFile(): Promise {
+ if (!isTauri()) return null
+ const { open } = await import('@tauri-apps/plugin-dialog')
+ const picked = await open({
+ multiple: false, directory: false,
+ filters: [{ name: '可导入文件', extensions: ['html', 'htm', 'docx'] }],
+ })
+ return typeof picked === 'string' ? picked : null
+}
+
+/** 读文件并返回 base64(导出图片内嵌用)。 */
+export async function readFileBase64(path: string): Promise {
+ return invoke('read_file_base64', { path })
+}
+
+/** 读二进制文件(DOCX 导入用,Web 降级不可用时抛错)。 */
+export async function readBinaryFile(path: string): Promise {
+ const base64 = await readFileBase64(path)
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+ return bytes.buffer
+}
+
+/** Web 降级:选 HTML/DOCX 文件并读入(导入用)。 */
+export function pickAndReadWebImport(): Promise<{ name: string; text?: string; buffer?: ArrayBuffer } | null> {
+ return new Promise((resolve) => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.html,.htm,.docx'
+ input.onchange = async () => {
+ const file = input.files?.[0]
+ if (!file) return resolve(null)
+ if (/\.docx$/i.test(file.name)) resolve({ name: file.name, buffer: await file.arrayBuffer() })
+ else resolve({ name: file.name, text: await file.text() })
+ }
+ input.oncancel = () => resolve(null)
+ input.click()
+ })
+}
diff --git a/src/styles.css b/src/styles.css
index 344f0bf..15345f1 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -160,3 +160,109 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.tree-context-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.tree-context-menu button:hover { background: var(--accent-weak); }
.tree-context-menu button.danger { color: #b3261e; }
+
+/* ——— 阶段 5:文档主题(变量层,[data-doc-theme] 挂在 html 上) ——— */
+:root {
+ --doc-font: "Segoe UI", "Microsoft YaHei UI", sans-serif;
+ --doc-h-font: var(--doc-font);
+ --doc-lh: 1.78;
+ --doc-link: var(--accent);
+ --doc-quote-border: var(--border);
+ --doc-quote-color: var(--text-dim);
+ --doc-paper: var(--bg-editor);
+ --doc-ink: var(--text);
+ --doc-code-bg: var(--code-bg);
+ --doc-h-border: var(--border);
+}
+:root[data-doc-theme='github'] {
+ --doc-font: -apple-system, "Segoe UI", "Microsoft YaHei UI", sans-serif;
+ --doc-lh: 1.62;
+ --doc-link: #0969da;
+ --doc-code-bg: #f6f8fa;
+ --doc-h-border: #d8dee4;
+}
+:root[data-doc-theme='notion'] {
+ --doc-font: ui-sans-serif, "Segoe UI", "Microsoft YaHei UI", sans-serif;
+ --doc-lh: 1.66;
+ --doc-paper: #fbfbfa;
+ --doc-quote-border: #e3e2de;
+ --doc-code-bg: #f7f6f3;
+ --doc-h-border: transparent;
+}
+:root[data-doc-theme='simple-blue'] {
+ --doc-lh: 1.74;
+ --doc-link: #2563eb;
+ --doc-h-border: #dbe7fb;
+ --doc-quote-border: #bcd2f7;
+}
+:root[data-doc-theme='dark-eye'] {
+ --doc-lh: 1.8;
+ --doc-paper: #20261f;
+ --doc-ink: #c9d1c0;
+ --doc-link: #9ec49a;
+ --doc-code-bg: #2a322a;
+ --doc-quote-border: #43503f;
+ --doc-quote-color: #9aa78f;
+ --doc-h-border: #39412f;
+}
+:root[data-doc-theme='focus'] {
+ --doc-font: Georgia, "Times New Roman", "Songti SC", SimSun, serif;
+ --doc-h-font: Georgia, "Songti SC", SimSun, serif;
+ --doc-lh: 1.95;
+ --doc-h-border: transparent;
+}
+:root[data-doc-theme='paper'] {
+ --doc-font: Georgia, "Songti SC", SimSun, serif;
+ --doc-lh: 1.85;
+ --doc-paper: #f7f1e3;
+ --doc-ink: #433422;
+ --doc-link: #8a5a2b;
+ --doc-code-bg: #efe6d0;
+ --doc-quote-border: #d9c9a3;
+ --doc-quote-color: #7a6748;
+ --doc-h-border: #e0d3ae;
+}
+.milkdown .ProseMirror {
+ font-family: var(--doc-font);
+ line-height: var(--doc-lh);
+ color: var(--doc-ink);
+}
+.editor-scroll { background: var(--doc-paper); }
+.milkdown .ProseMirror h1, .milkdown .ProseMirror h2, .milkdown .ProseMirror h3,
+.milkdown .ProseMirror h4, .milkdown .ProseMirror h5, .milkdown .ProseMirror h6 {
+ font-family: var(--doc-h-font);
+ border-bottom-color: var(--doc-h-border);
+}
+.milkdown .ProseMirror a { color: var(--doc-link); }
+.milkdown .ProseMirror code { background: var(--doc-code-bg); }
+.milkdown .ProseMirror blockquote { border-left-color: var(--doc-quote-border); color: var(--doc-quote-color); }
+:root[data-doc-theme='github'] .milkdown .ProseMirror h1 { font-size: 1.9em; }
+:root[data-doc-theme='github'] .milkdown .ProseMirror h2 { border-bottom: 1px solid var(--doc-h-border); padding-bottom: 6px; }
+:root[data-doc-theme='notion'] .milkdown .ProseMirror h1 { font-size: 1.65em; }
+:root[data-doc-theme='notion'] .milkdown .ProseMirror h2 { font-size: 1.38em; }
+:root[data-doc-theme='simple-blue'] .milkdown .ProseMirror h1,
+:root[data-doc-theme='simple-blue'] .milkdown .ProseMirror h2 { color: #1d4ed8; }
+:root[data-doc-theme='focus'] .editor-pane-wrap, :root[data-doc-theme='focus'] .source-cm { max-width: 760px; margin: 0 auto; }
+:root[data-doc-theme='dark-eye'] .milkdown .ProseMirror table th,
+:root[data-doc-theme='dark-eye'] .milkdown .ProseMirror table td { border-color: #39412f; }
+:root[data-doc-theme='dark-eye'] .mermaid-block, :root[data-doc-theme='paper'] .mermaid-block { border-color: var(--doc-h-border); }
+
+/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */
+@media print {
+ .titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu,
+ .table-toolbar, .tree-context-menu { display: none !important; }
+ .app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
+ .editor-scroll { background: #fff !important; }
+ .milkdown .ProseMirror { font-size: 11.5pt; }
+ @page { margin: 18mm 16mm; }
+}
+.setting-label { font-size: 13px; font-weight: 600; margin: 6px 0 8px; }
+.theme-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
+.theme-card { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; text-align: left; border: 1px solid var(--border); background: var(--bg); border-radius: 8px; padding: 9px 11px; cursor: pointer; }
+.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+.theme-card span { color: var(--text-dim); font-size: 12px; line-height: 1.45; }
+.css-input { font-family: "Cascadia Code", Consolas, monospace; font-size: 12.5px; resize: vertical; }
+.setting-row { display: flex; gap: 22px; align-items: center; margin-top: 12px; font-size: 13px; }
+.seconds-input { width: 72px; margin: 0 6px; }
+.modal-actions.left { align-items: stretch; }
+.modal-actions.left button { text-align: left; }
diff --git a/src/themes.ts b/src/themes.ts
new file mode 100644
index 0000000..f77164e
--- /dev/null
+++ b/src/themes.ts
@@ -0,0 +1,23 @@
+// 文档主题注册表(阶段 5):主题只影响编辑区排版(字体/字号/行距/标题/链接/引用/代码底色/纸面色),
+// 具体数值在 styles.css 里以 [data-doc-theme=''] 变量与规则落地;UI 框架的明暗仍由 ui.theme 独立控制。
+export interface DocTheme {
+ id: string
+ name: string
+ description: string
+ /** 该主题自带深色纸面(编辑区强制深色,不随 UI 明暗) */
+ darkPaper?: boolean
+}
+
+export const DOC_THEMES: DocTheme[] = [
+ { id: 'default', name: '默认', description: '石板中性、适度行距,当前默认排版' },
+ { id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接' },
+ { id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔' },
+ { id: 'simple-blue', name: '简约蓝', description: '蓝色标题与链接,清爽的轻量排版' },
+ { id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', darkPaper: true },
+ { id: 'focus', name: '专注写作', description: '衬线正文、加宽行距、窄栏,适合长文写作' },
+ { id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感' },
+]
+
+export function docThemeById(id: string): DocTheme {
+ return DOC_THEMES.find((t) => t.id === id) ?? DOC_THEMES[0]
+}
diff --git a/tests/stage5.test.ts b/tests/stage5.test.ts
new file mode 100644
index 0000000..686b3c9
--- /dev/null
+++ b/tests/stage5.test.ts
@@ -0,0 +1,54 @@
+// 阶段 5 验收:主题注册表完整性、导出 HTML 管线(结构/表格/公式/代码)、HTML 导入转换。
+import { describe, it, expect } from 'vitest'
+import { DOC_THEMES, docThemeById } from '../src/themes'
+import { markdownToExportHtml, htmlToMarkdown, exportCss } from '../src/services/exportHtml'
+
+describe('文档主题注册表', () => {
+ it('7 套主题 id 唯一且可查', () => {
+ expect(DOC_THEMES).toHaveLength(7)
+ expect(new Set(DOC_THEMES.map((t) => t.id)).size).toBe(7)
+ expect(docThemeById('paper').name).toBe('经典纸张')
+ expect(docThemeById('不存在').id).toBe('default')
+ for (const t of DOC_THEMES) expect(exportCss(t.id)).toContain('--f:')
+ })
+})
+
+describe('导出 HTML 管线', () => {
+ it('标题/表格/行内公式/代码块都转成对应 HTML(关高亮与 Mermaid 时走纯管线)', async () => {
+ const md = '# 导出标题\n\n正文含 $E = mc^2$ 公式。\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\n```ts\nconst v = 1;\n```\n'
+ const html = await markdownToExportHtml(md, {
+ title: '导出标题', themeId: 'github', imageMode: 'relative', highlight: false, renderMermaid: false,
+ })
+ expect(html).toContain('')
+ expect(html).toContain('导出标题
')
+ expect(html).toContain('')
+ expect(html).toContain('katex') // 公式经 rehype-katex 渲染(含 MathML)
+ expect(html).toContain('const v = 1;')
+ expect(html).toContain('导出标题 ')
+ })
+
+ it('Shiki 高亮开启时代码块带着色 span', async () => {
+ const html = await markdownToExportHtml('```ts\nconst v: number = 42;\n```\n', {
+ title: 't', themeId: 'default', imageMode: 'relative', renderMermaid: false,
+ })
+ expect(html).toContain('style="color:')
+ expect(html).toContain('42')
+ })
+
+ it('图片内嵌模式用 resolveImage 替换为 data URL', async () => {
+ const html = await markdownToExportHtml('\n', {
+ title: 't', themeId: 'default', imageMode: 'embed', highlight: false, renderMermaid: false,
+ resolveImage: async (src) => (src.endsWith('a.png') ? 'data:image/png;base64,QUJD' : null),
+ })
+ expect(html).toContain('src="data:image/png;base64,QUJD"')
+ })
+})
+
+describe('导入转换', () => {
+ it('HTML 转 Markdown 保留标题与列表', async () => {
+ const md = await htmlToMarkdown('标题
- 第一项
- 第二项
正文加粗
')
+ expect(md).toContain('# 标题')
+ expect(md).toMatch(/-\s+第一项/)
+ expect(md).toContain('**加粗**')
+ })
+})