跳到主要内容

导出功能实现

功能范围​

当前导出功能支持把 Markdown 笔记导出为以下格式:

  • PDF:.pdf
  • Word:.docx
  • Markdown:.md
  • HTML:.html
  • 图片:.png

用户可以在设置中配置启用的导出格式、默认导出目录、是否在导出后打开文件所在目录。默认导出格式为 PDF,默认导出目录策略为和原文件同一目录。

整体链路​

导出入口在渲染进程触发,例如文件树右键菜单点击“导出”。渲染进程通过 preload 暴露的安全 API 调用主进程 IPC,主进程读取当前导出配置后执行真实文件系统导出。

主流程如下:

  1. 渲染进程触发导出文件事件。
  2. preload 调用 IPC_CHANNELS.EXPORT.FILE。
  3. 主进程 registerExportIpc 接收文件路径。
  4. exportConfigManager.getConfig() 读取当前导出配置。
  5. exportFile(filePath, config) 读取 Markdown 文件并生成目标格式。
  6. 导出成功后返回 directoryPath 和 filePaths,渲染进程展示 toast。
  7. 如果配置了导出后打开目录,主进程通过 shell.openPath 打开导出目录。

核心文件:

  • src/main/ipc/export.ipc.ts:导出相关 IPC 注册。
  • src/main/export-config.ts:导出配置加载、保存和默认值合并。
  • src/main/export-service.ts:导出文件生成逻辑。
  • src/shared/types/index.ts:导出格式、配置和结果类型。

关键实现​

统一 Markdown 渲染源​

除 Markdown 原文导出外,PDF、Word、HTML、图片都先把 Markdown 渲染为同一份 HTML 文档,再基于这份 HTML 生成目标格式。

这样可以避免不同格式各自实现一套解析逻辑,导致表格、中文、加粗、列表等内容表现不一致。

当前使用 markdown-it 渲染 Markdown:

ts
const markdownRenderer = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
});

html: false 用于禁止 Markdown 原文中的 HTML 直接注入,降低导出时执行或保留非预期 HTML 的风险。

HTML 文档生成​

createHtmlDocument 会生成完整 HTML:

  • <!doctype html>
  • <meta charset="utf-8" />
  • lang="zh-CN"
  • 内联导出样式
  • Markdown 渲染后的正文

导出样式中处理了标题、段落、列表、引用、代码块、图片和表格。表格使用 border-collapse: collapse,并设置 th、td 的边框和内边距,让 HTML、PDF、Word 和图片导出都尽量保持一致。

PDF 导出​

PDF 不再手写 PDF 字符串,而是通过 Electron 的 Chromium 渲染能力生成:

  1. 创建隐藏的 BrowserWindow。
  2. 用 loadURL(data:text/html;charset=utf-8,...) 加载 HTML。
  3. 调用 window.webContents.printToPDF 输出 PDF Buffer。
  4. 用 writeFile 写入 .pdf 文件。

这种方式解决了手写 PDF 时常见的问题:

  • 中文乱码。
  • 表格布局不完整。
  • Markdown 源码被当作普通文本导出。
  • CSS 样式无法复用。

PDF 当前设置 printBackground: true,保证表格背景色等样式能被输出;页面边距使用 marginType: "none",减少额外空白。

Word 导出​

Word 使用 html-to-docx:

  1. 复用 Markdown 渲染后的 HTML。
  2. 调用 htmlToDocx(html, undefined, options)。
  3. 输出 Buffer 并写入 .docx。

配置中设置了:

  • lang: "zh-CN"
  • font: "Microsoft YaHei"
  • table.row.cantSplit: true

这样比保存 .doc 格式的 HTML 更接近真实 Word 文档,也更利于在 Microsoft Word、WPS、LibreOffice 等工具里打开。

图片导出​

图片导出基于 Electron 截图能力:

  1. 隐藏 BrowserWindow 加载 HTML。
  2. 在页面内执行 JavaScript,计算 document.body.children 的真实内容边界。
  3. 根据内容矩形调整窗口尺寸。
  4. 调用 window.capturePage(rect) 截取内容区域。
  5. 用 NativeImage.toPNG() 写入 .png。

这里没有直接截整个窗口,而是裁剪真实内容区域,避免把页面多余空白也导出到图片中。

Markdown 导出​

Markdown 格式保持原文输出,不做渲染转换。这样用户选择 md 时得到的是原始 Markdown 文件副本。

文件命名策略​

导出文件默认使用源文件名作为基础名。例如 daily.md 导出为:

  • daily.pdf
  • daily.docx
  • daily.html
  • daily.png
  • daily.md

如果目标目录中已存在同名导出文件,则整批导出使用副本名:

  • 第一次冲突:daily-副本1.pdf
  • 第二次冲突:daily-副本2.pdf
  • 以此类推

命名冲突判断是按本次启用的所有格式一起检查。只要任意一个目标格式文件已存在,就切换到下一个副本名,避免同一批导出里有的覆盖旧文件、有的使用新文件名。

配置持久化​

导出配置由 ExportConfigManager 负责:

  • 配置文件存储在 app.getPath("userData")/export-config.json。
  • 加载时会兼容旧版本缺失字段。
  • 保存时会同步内存状态并写入用户数据目录。
  • 自定义导出目录默认使用系统下载目录,避免首次选择自定义时为空路径。

配置结构:

ts
interface ExportConfig {
enabledFormats: ExportFormat[];
defaultDirectoryMode: "same-as-source" | "custom";
customDirectoryPath: string;
openDirectoryAfterExport: boolean;
}

使用的技术和 API​

第三方库:

  • markdown-it:Markdown 到 HTML 渲染,支持表格、链接、强调、列表等常见语法。
    • markdown-it 先把 Markdown 解析渲染成 HTML
  • html-to-docx:HTML 到 .docx 转换。

Electron API:

  • ipcMain.handle:注册导出配置和导出文件 IPC。
  • BrowserWindow:创建隐藏窗口渲染导出 HTML。
  • webContents.printToPDF:生成 PDF。
  • webContents.executeJavaScript:计算图片导出时的真实内容区域。
  • BrowserWindow.capturePage:截取渲染内容生成图片。
  • shell.openPath:导出后打开目录。
  • app.getPath("downloads"):自定义目录默认值。
  • app.getPath("userData"):导出配置持久化目录。

Node API:

  • fs/promises.readFile:读取源 Markdown。
  • fs/promises.writeFile:写入导出文件。
  • fs/promises.mkdir:创建导出目录。
  • fs/promises.access:判断目标文件是否存在。
  • path.basename、dirname、extname、join:路径和文件名处理。

难点和处理方式​

中文乱码​

早期手写 PDF 时只能简单写入 ASCII 友好的内容,中文会出现乱码或被替换。现在改为 Chromium 渲染 HTML 后 printToPDF,由浏览器字体和 PDF 管线处理中文编码。

表格导出不完整​

手写 Markdown 解析无法完整处理 Markdown 表格,容易把表格源码直接导出。现在统一使用 markdown-it 渲染表格,再复用 HTML 生成 PDF、Word、图片。

空表头导致多余空白行​

部分 Markdown 表格只有分隔行和数据行,没有实际表头内容,markdown-it 会生成空 <thead>。导出前会检测“所有表头单元格都为空”的情况,并移除该空表头,避免导出时多一行空白表头。

图片导出包含多余边距​

直接截取整个窗口会把页面空白一起导出。现在通过 getBoundingClientRect 计算正文子元素的合并边界,再用 capturePage(rect) 截取真实内容区域。

多格式导出命名冲突​

导出多种格式时,如果只按单个文件判断冲突,可能出现同一批导出中部分文件覆盖、部分文件改名。现在先为整批格式计算一个可用基础名,再统一写入,保证一批导出文件命名一致。

主进程和渲染进程边界​

文件系统读写、目录打开、PDF 和图片渲染都放在主进程执行。渲染进程只通过 preload 暴露的窄 API 触发导出,避免直接暴露 Node 文件系统能力。

测试覆盖​

当前导出服务测试覆盖:

  • Markdown、HTML、PDF、Word、图片的文件输出路径。
  • HTML 中中文、加粗、列表和表格的渲染结果。
  • Markdown 原文格式保持不变。
  • Word 输出 .docx 文件。
  • 图片导出按内容区域裁剪。
  • 重复导出时生成 -副本1 命名。
  • 空表头 Markdown 表格导出时移除空 <thead>。

相关测试文件:

  • src/main/export-service.test.ts
  • src/renderer/src/features/export/components/export-controller.test.tsx
  • src/renderer/src/features/export/components/export-success-toast.test.tsx
  • src/renderer/src/features/settings/components/settings-modal.test.tsx