把 Markdown 一键变成公文 Word:我做了一个原生 Mac 转换器
文章目录
把 Markdown 一键变成公文 Word:我做了一个原生 Mac 转换器
我习惯用 Markdown 写材料:标题层级清楚,改结构方便,也不用在写作过程中反复调整字体、缩进和行距。
但到了交付环节,很多工作材料必须变成有明确版式要求的 Word 文档。标题用什么字体、正文用几号字、一级标题和二级标题如何区分、每页多少行、每行多少字、落款放在哪里,都有固定规则。
过去的流程通常是:在 Markdown 里写完,复制进 Word,再从头排一遍。内容越长,机械劳动越多,而且很容易漏掉某个标题或数字字体。
于是我做了一个只解决这件事的原生 Mac 小工具:把 Markdown 文件拖进去,点击一次,得到排好版的 DOCX;或者直接在 Finder 里右键 Markdown 文件,选择“Markdown 转公文 Word”。

隐私说明: 本文不展示真实单位名称、个人路径、内部材料或原始截图。配图中的界面、文件内容和机构名称均为重新制作的匿名示例;示例只保留技术结构,不含真实业务信息。
一、最后做成了什么
这个工具当前提供两种入口。
第一种是普通 Mac App:
- 拖入或选择一个
.md文件; - 选择排版模板;
- 检查所需字体是否安装;
- 点击“转换为 DOCX”;
- 转换完成后直接打开文档,或在 Finder 中定位。
第二种更适合日常使用:
Finder 中选中 Markdown 文件
→ 右键
→ 快速操作
→ Markdown 转公文 Word
→ 在原文件旁生成 DOCX
支持同时选中多个 Markdown 文件批量转换。如果输出文件已经存在,快速操作会自动追加时间戳,避免覆盖旧版本。
整个过程都在本地完成,不上传文档,也不要求安装 Python、Pandoc 或额外服务。
二、为什么没有直接使用 Pandoc
Pandoc 很适合通用格式转换,例如从 Markdown 生成普通 Word 文档,再通过 reference.docx 复用样式。
但公文排版有一些更细的要求:
- 中文和英文数字需要分别指定字体;
- 标题层级不仅影响外观,还应写入 Word 大纲级别;
- 页面需要设置字符网格和行网格;
- 页边距、页眉、页脚、页码位置必须可控;
- 标题下方的单位和日期,与文末落款的对齐方式不同;
- 表格、空行、段落缩进不能只依赖 Markdown 的默认解释。
这些细节如果全部交给通用转换器,再通过模板和后处理修正,调试成本并不低。
我最后选择直接生成 DOCX 的 OOXML。这样每一个段落、字体、缩进和页面参数都能明确写入文档,不需要猜测转换器会怎样处理。
三、为什么改成纯 Swift
最早的原型是一个 Python 脚本,使用 python-docx 生成 Word。这个方向开发快,也很适合验证规则。
但如果最终目标是一个 Mac App,就会遇到运行环境问题:
- 用户是否已经安装 Python;
- 应该依赖系统 Python、虚拟环境,还是把解释器一起打包;
- App 签名和分发时如何携带 Python 依赖;
- Finder 快速操作如何稳定找到脚本和运行环境。
因此最终版本改成了纯 Swift:
SwiftUI
├── 文件拖放与选择
├── 模板与输出设置
├── 字体状态检查
└── 转换结果与错误提示
Swift 转换核心
├── Markdown 结构解析
├── OOXML 文档生成
├── DOCX 包结构写入
└── 系统 zip 封装
这样生成的 App 很小,启动快,也没有额外运行时。Finder 快速操作只需要调用 App 内的可执行文件。
四、DOCX 本质上是什么
DOCX 并不是一个不可理解的二进制文件,而是一个符合约定目录结构的 ZIP 包。最小可用文档大致包含:
[Content_Types].xml
_rels/
.rels
docProps/
app.xml
core.xml
word/
document.xml
styles.xml
settings.xml
fontTable.xml
footer1.xml
_rels/
document.xml.rels
其中:
| 文件 | 作用 |
|---|---|
| word/document.xml | 正文段落、标题、表格和页面设置 |
| word/styles.xml | 默认字体、字号和段落样式 |
| word/settings.xml | 字体语言、字段更新等文档设置 |
| word/fontTable.xml | 文档声明使用的字体 |
| word/footer1.xml | 页脚和页码域 |
| document.xml.rels | 正文与样式、页脚等部件的关系 |
Swift 负责生成这些 XML 文件,写入临时目录,再调用 macOS 自带的 /usr/bin/zip 封装成 .docx。
生成后还要做两类检查:
unzip -t output.docx
xmllint --noout word/document.xml
前者检查压缩包是否完整,后者检查 XML 是否有效。文件能生成,不代表 Word 一定能正确打开;这两步能提前发现不少低级错误。
五、Markdown 如何映射到公文结构
为了让源稿简单可读,我采用了一套很克制的约定:
| Markdown | Word 中的含义 |
|---|---|
| # 标题 | 公文大标题,居中 |
| ## 一、标题 | 一级标题 |
| ### (一)标题 | 二级标题 |
| #### 1. 标题 | 三级标题 |
| 普通段落 | 正文,首行缩进两个字符 |
| **重点内容** | 行内加粗 |
| Markdown 表格 | Word 表格 |
一份匿名示例可以这样写:
# 关于年度重点工作推进情况的报告
某某单位
2026年9月26日
今年以来,我单位围绕年度目标,稳步推进各项重点任务。
## 一、总体情况
重点任务按计划实施,协同机制持续完善。
### (一)完善工作机制
建立定期调度和跟踪反馈机制。
#### 1. 加强协同配合
及时研究解决实际问题。
生成后的匿名示例效果如下。这里的标题、日期和正文均为虚构内容。

六、最容易忽略的细节:空行不是空段落
开发过程中最直观的一个问题,是生成的 Word 段落之间出现了大量空白。
原因并不在 Word,而在 Markdown 语义处理:Markdown 中的空行通常只表示“前后是两个段落”,它本身不是一个需要显示的空段落。
如果把每个 Markdown 空行都转换为 Word 空段落,再叠加固定值 30 磅行距,页面就会被一行一行地撑开。
错误逻辑类似这样:
if line.isEmpty {
blocks.append(.blank)
}
修正后,普通空行只负责分隔,不产生任何 Word 节点:
if line.isEmpty {
index += 1
continue
}
if line == " " {
blocks.append(.blank)
}
也就是说,只有源稿明确写了一行 ,转换器才会有意保留一个空白行。
这个问题很小,却说明了格式转换中的一个基本原则:不能只看字符,还要理解源格式的语义。
七、标题下日期与文末落款不是一回事
仅靠“检测日期格式,然后右对齐”还不够。
同样是一行日期:
- 位于标题下方时,通常和单位名称一起居中;
- 位于正文末尾时,通常属于落款区域,需要右对齐。
因此解析器还要维护上下文状态:是否已经读到标题、正文是否开始、日期前一行是不是短单位名称。根据位置把内容分别标记为:
case centeredMetadata(String)
case signature(String)
生成 OOXML 时,再应用不同的对齐方式。
这种上下文判断比单纯的正则表达式更可靠,同时仍然允许用户通过 Markdown 结构自然表达文档。
八、字体问题比想象中复杂
在公文排版中,字体往往不是“看起来相似”就可以。常见配置会明确指定:
- 标题字体;
- 正文字体;
- 一级、二级、三级标题字体;
- 数字和英文字体。
但字体文件名、字体的中文显示名和字体内部 PostScript 名称可能完全不同。例如一个字体文件可能同时暴露中文名称、英文名称和缩写别名。
因此 App 的字体检测不能只比较一个字符串,而要维护别名集合:
("某中文字体名", ["EnglishAlias", "PostScriptName"])
即使本机没有安装字体,转换器仍然可以把目标字体名称写进 DOCX;但当前电脑预览时可能使用替代字体。为了避免误判,App 会显示字体状态,并允许用户打开字体文件夹自行安装。
字体文件可能有单独的授权限制,所以我没有把第三方字体直接打进 App。工具只负责检测和引用,字体使用仍应遵守各自的许可证。
九、把转换做成 Finder 右键操作
有了命令行转换入口以后,Finder 快速操作就很轻量了。
App 支持下面这种调用方式:
MarkdownGongwen --convert input.md output.docx
然后创建一个 Automator 快速操作,让它接收 Finder 传入的文件列表。核心逻辑可以概括为:
APP="$HOME/Applications/Markdown公文转换器.app/Contents/MacOS/MarkdownGongwen"
for input in "$@"; do
extension="${input##*.}"
[[ "${extension:l}" == "md" ]] || continue
output_base="${input%.*}_公文格式"
output="${output_base}.docx"
if [[ -e "$output" ]]; then
output="${output_base}_$(date +%Y%m%d-%H%M%S).docx"
fi
"$APP" --convert "$input" "$output"
done
快速操作安装到:
~/Library/Services/Markdown 转公文 Word.workflow
App 则安装到当前用户自己的应用目录:
~/Applications/Markdown公文转换器.app
这样不需要管理员权限,也不会依赖开发项目原来的绝对路径。
十、页面参数如何落到 OOXML
Word 的页面尺寸和边距使用 twip 作为单位。1 英寸等于 1440 twip,1 厘米约等于 567 twip。
以 A4 页面为例,页面设置写在 w:sectPr 中:
<w:sectPr>
<w:pgSz w:w="11906" w:h="16838"/>
<w:pgMar
w:top="..."
w:right="..."
w:bottom="..."
w:left="..."
w:header="..."
w:footer="..."/>
<w:docGrid
w:type="linesAndChars"
w:linePitch="600"
w:charSpace="..."/>
</w:sectPr>
固定值 30 磅行距则写在段落属性中:
<w:spacing
w:before="0"
w:after="0"
w:line="600"
w:lineRule="exact"/>
这里最重要的不是某一组具体数字,而是把单位内部规范抽象成模板参数。不同单位或不同材料类型可以使用不同配置,不必修改 Markdown 内容本身。
十一、如何验证生成结果
文档生成器不能只测试“文件存在”。我最终保留了四层验证:
1. 解析层
确认标题、正文、表格、日期和落款被识别成正确的内容块。
2. 包结构层
使用 unzip -t 检查 DOCX 是否完整,使用 XML 工具检查每个部件是否符合语法。
3. 渲染层
使用 macOS Quick Look 和办公软件打开匿名示例,检查:
- 中文是否完整显示;
- 标题和落款是否对齐;
- 段落之间是否出现意外空行;
- 页码是否位于页脚;
- 字体缺失时是否给出明确提示。
4. 使用层
实际执行 Finder 快速操作,验证它能接收文件路径、处理中文文件名、批量转换,并避免覆盖已有结果。
这四层分别覆盖“理解错了、写坏了、显示错了、用不了”四类问题。
十二、隐私和安全方面的取舍
这个工具处理的往往是尚未公开的工作材料,所以本地优先不是一句口号,而是产品约束:
- 文档不上传到任何服务器;
- 不调用在线转换接口;
- 不记录正文内容;
- 不把真实单位名称写入程序默认示例;
- 快速操作只接收用户主动选择的文件;
- 输出默认放在源文件旁,路径清晰可见;
- 同名输出不直接覆盖。
写这篇文章时,我也采用了同一套原则:不使用真实工作文档截图,而是重新生成一份只含“某某单位”的虚构材料;所有路径都使用 $HOME 或占位符;配图不包含真实文件名、账号、组织名称和内部标准文件。
十三、目前的边界
当前版本主要解决“普通文字材料从 Markdown 转为规范正文 DOCX”,还不是完整的电子公文制作系统。
暂未覆盖的内容包括:
- 红色版头和分隔线;
- 发文字号、签发人;
- 完整版记;
- 图片题注和复杂图文混排;
- 跨页复杂表格;
- Word 修订、批注和域的高级行为;
- Apple 公证和面向公众的正式分发。
对于内部汇报、总结、通知正文和常规材料,它已经能够省掉大量重复排版。涉及正式发文时,仍应使用本单位审定模板,并在提交前人工复核。
十四、这次开发带来的几个结论
第一,自动化最值得处理的,往往不是技术上最难的步骤,而是每天都要重复的小动作。
第二,格式转换不能只做“文本搬运”。Markdown 的空行、标题、列表和 Word 的段落、样式、大纲之间,需要一层明确的语义映射。
第三,真正好用的工具不应要求用户记住命令。拖放是一个入口,Finder 右键才是更接近日常习惯的入口。
第四,文档类工具一定要用真实渲染结果验收。XML 正确、压缩包完整,只能说明文件没有坏,不能说明排版已经对。
现在我的日常流程变成了:在熟悉的 Markdown 编辑器中专心写内容,写完以后右键转换,再在 Word 中做最后一次检查。Markdown 负责写作,DOCX 负责交付,两者终于不再互相妥协。