纯Go生成Word文档 - OOXML探秘
05 - 纯 Go 生成 Word 文档 — OOXML 探秘
本章目标
- 理解 OOXML(Office Open XML)的文件格式
- 用 Go 标准库
archive/zip构建 docx 文件 - 手工编写 Word 所需的 XML 文件
- 将图片嵌入 Word 文档
- 不依赖任何第三方库,纯 Go 标准库实现
5.1 OOXML 是什么?
很多人以为 Word 的 .docx 文件是一个神秘的二进制格式,实际上它是一个 ZIP 压缩包。
验证一下
找一个 .docx 文件,改后缀为 .zip,然后解压:
cp my-document.docx my-document.zip
unzip my-document.zip -d docx-contents/
tree docx-contents/
你会看到:
docx-contents/
├── [Content_Types].xml
├── _rels/
│ └── .rels
└── word/
├── document.xml ← 文档内容
├── styles.xml ← 样式定义
├── _rels/
│ └── document.xml.rels ← 资源关系
└── media/ ← 嵌入的图片
└── image1.jpg
📌 关键概念: docx 文件 = ZIP 包 + 一组符合 OOXML 规范的 XML 文件。理解了这一点,生成 docx 就变成了"生成 XML + 打包 ZIP"的简单任务。
5.2 OOXML 最小文件集
要生成一个包含文字和图片的 docx,需要 6 个核心文件:
| 文件 | 作用 |
|---|---|
[Content_Types].xml |
声明 ZIP 包内各文件的 MIME 类型 |
_rels/.rels |
包的根关系文件,指向 document.xml |
word/_rels/document.xml.rels |
文档级关系文件,指向图片和样式 |
word/styles.xml |
段落和文字样式定义 |
word/document.xml |
主文档内容(文字 + 图片引用) |
word/media/image1.jpg |
嵌入的图片文件(二进制) |
5.3 创建 WordService
创建 backend/word.go:
package backend
import (
"archive/zip"
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// WordService Word 文档生成服务
type WordService struct {
outputDir string
}
// NewWordService 创建 Word 服务
func NewWordService() *WordService {
homeDir, _ := os.UserHomeDir()
outputDir := filepath.Join(homeDir, "Documents", "PetContentCreator")
os.MkdirAll(outputDir, 0755)
return &WordService{outputDir: outputDir}
}
// DocxData 文档数据
type DocxData struct {
Title string `json:"title"`
Content string `json:"content"`
ImageURLs []string `json:"image_urls"`
}
输出目录设计:
- Windows:
C:\Users\<用户名>\Documents\PetContentCreator\ - macOS:
/Users/<用户名>/Documents/PetContentCreator/ - Linux:
/home/<用户名>/Documents/PetContentCreator/
5.4 主入口:GenerateDocx
// GenerateDocx 生成 Word 文档(前端调用)
func (ws *WordService) GenerateDocx(data DocxData, imageDataList [][]byte) (string, error) {
// 生成文件名:标题_时间戳.docx
timestamp := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("%s_%s.docx",
sanitizeFilename(data.Title), timestamp)
filePath := filepath.Join(ws.outputDir, filename)
// 构建 docx(返回字节流)
docxBytes, err := buildDocx(data.Title, data.Content, data.ImageURLs, imageDataList)
if err != nil {
return "", fmt.Errorf("生成文档失败: %w", err)
}
// 写入文件
if err := os.WriteFile(filePath, docxBytes, 0644); err != nil {
return "", fmt.Errorf("写入文件失败: %w", err)
}
return filePath, nil
}
// GetOutputDir 获取输出目录
func (ws *WordService) GetOutputDir() string {
return ws.outputDir
}
文件名清理
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_",
"?", "_", "\"", "_", "<", "_", ">", "_", "|", "_",
"\n", "", "\r", "",
)
name = replacer.Replace(name)
if len(name) > 50 {
name = name[:50]
}
if name == "" {
name = "article"
}
return name
}
⚠️ 为什么需要 sanitize: AI 生成的标题可能包含
/、:、?等字符,这些在 Windows 文件系统中是不允许的。如果不清理,os.WriteFile会直接报错。
5.5 构建 ZIP 包
func buildDocx(title, content string, imageURLs []string, imageDatas [][]byte) ([]byte, error) {
buf := new(bytes.Buffer)
zipWriter := zip.NewWriter(buf)
// 1. [Content_Types].xml
addZipFile(zipWriter, "[Content_Types].xml", buildContentTypes())
// 2. _rels/.rels
addZipFile(zipWriter, "_rels/.rels", buildRootRels())
// 3. word/_rels/document.xml.rels
addZipFile(zipWriter, "word/_rels/document.xml.rels", buildDocRelationships(imageURLs))
// 4. word/styles.xml
addZipFile(zipWriter, "word/styles.xml", buildStyles())
// 5. word/document.xml (核心)
addZipFile(zipWriter, "word/document.xml", buildDocumentXML(title, content, imageDatas))
// 6. 嵌入图片(二进制)
for i, imgData := range imageDatas {
imgName := fmt.Sprintf("image%d.jpg", i+1)
w, err := zipWriter.Create("word/media/" + imgName)
if err != nil {
return nil, fmt.Errorf("创建图片条目失败: %w", err)
}
if _, err := w.Write(imgData); err != nil {
return nil, fmt.Errorf("写入图片失败: %w", err)
}
}
if err := zipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭 ZIP 文件失败: %w", err)
}
return buf.Bytes(), nil
}
func addZipFile(zw *zip.Writer, name, content string) {
w, err := zw.Create(name)
if err != nil {
return
}
w.Write([]byte(content))
}
💡 知识点:
bytes.Buffer是一个内存缓冲区,ZIP 包直接写入内存而非磁盘,最后一次性os.WriteFile。对于文档生成这种场景,内存操作比磁盘操作快得多。
5.6 [Content_Types].xml
func buildContentTypes() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`
}
这里声明了 ZIP 包内每类文件的内容类型。Word 解析器通过它判断如何解析每个文件。
5.7 关系文件(.rels)
_rels/.rels(根关系)
func buildRootRels() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`
}
它告诉 Word:“这个包的主文档是 word/document.xml"。
word/_rels/document.xml.rels(文档关系)
func buildDocRelationships(imageURLs []string) string {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
`)
for i := range imageURLs {
sb.WriteString(fmt.Sprintf(
` <Relationship Id="rIdImg%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image%d.jpg"/>
`, i+1, i+1))
}
sb.WriteString(`</Relationships>`)
return sb.String()
}
它声明了文档引用哪些图片,每个图片都有一个唯一的 rId(关系 ID)。
5.8 样式定义(styles.xml)
func buildStyles() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:pPr>
<w:spacing w:line="360" w:lineRule="auto"/>
</w:pPr>
<w:rPr>
<w:rFonts w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:eastAsia="微软雅黑"/>
<w:sz w:val="24"/>
</w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:pPr>
<w:spacing w:before="240" w:after="120"/>
<w:jc w:val="center"/>
</w:pPr>
<w:rPr>
<w:rFonts w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:eastAsia="微软雅黑"/>
<w:b/>
<w:sz w:val="36"/>
<w:color w:val="333333"/>
</w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:pPr>
<w:spacing w:before="200" w:after="100"/>
</w:pPr>
<w:rPr>
<w:rFonts w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:eastAsia="微软雅黑"/>
<w:b/>
<w:sz w:val="28"/>
<w:color w:val="444444"/>
</w:rPr>
</w:style>
</w:styles>`
}
样式参数解释
| 参数 | 含义 |
|---|---|
w:sz w:val="24" |
字号 24 半磅 = 12pt(正文字号) |
w:sz w:val="36" |
字号 36 半磅 = 18pt(一级标题) |
w:sz w:val="28" |
字号 28 半磅 = 14pt(二级标题) |
w:b/ |
加粗 |
w:jc w:val="center" |
居中 |
w:spacing w:line="360" |
行间距 1.5 倍(360 = 240 × 1.5) |
w:rFonts w:eastAsia="微软雅黑" |
中文字体 |
5.9 主文档(document.xml)
这是最核心的部分,负责组装文章内容和图片:
func buildDocumentXML(title, content string, imageDatas [][]byte) string {
var sb strings.Builder
// 文档头部(声明命名空间)
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<w:body>
`)
// 标题(使用 Heading1 样式)
sb.WriteString(fmt.Sprintf(` <w:p>
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
<w:r><w:t xml:space="preserve">%s</w:t></w:r>
</w:p>
`, xmlEscape(title)))
// 正文内容 + 图片穿插
paragraphs := strings.Split(content, "\n")
imageIndex := 0
for _, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
if strings.HasPrefix(para, "## ") || strings.HasPrefix(para, "# ") {
// 二/一级标题
headingText := strings.TrimPrefix(para, "## ")
headingText = strings.TrimPrefix(headingText, "# ")
sb.WriteString(fmt.Sprintf(` <w:p>
<w:pPr><w:pStyle w:val="Heading2"/></w:pPr>
<w:r><w:t xml:space="preserve">%s</w:t></w:r>
</w:p>
`, xmlEscape(headingText)))
} else {
// 普通段落
sb.WriteString(fmt.Sprintf(` <w:p>
<w:r><w:t xml:space="preserve">%s</w:t></w:r>
</w:p>
`, xmlEscape(para)))
}
// 每隔约 3 段插入一张图片
if len(imageDatas) > 0 && imageIndex < len(imageDatas) &&
(imageIndex+1)*3 <= len(paragraphs) {
sb.WriteString(buildImageBlock(imageIndex + 1))
imageIndex++
}
}
// 剩余图片放在文末
for i := imageIndex; i < len(imageDatas); i++ {
sb.WriteString(buildImageBlock(i + 1))
}
sb.WriteString(` </w:body>
</w:document>`)
return sb.String()
}
XML 转义
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "<")
s = strings.ReplaceAll(s, ">", ">")
s = strings.ReplaceAll(s, "\"", """)
s = strings.ReplaceAll(s, "'", "'")
return s
}
⚠️ 关键步骤: 如果文章内容包含
<>&等 XML 特殊字符但未转义,生成的 docx 文件会损坏,Word 无法打开。
5.10 图片嵌入
在 OOXML 中嵌入图片需要两步:
- 将图片二进制写入
word/media/目录 - 在
document.xml中引用图片(通过rId关联到.rels中定义的关系)
func buildImageBlock(index int) string {
emuWidth := 5400000 // ≈ 14cm(EMU 单位:1cm = 360000 EMU)
emuHeight := 3600000 // ≈ 9.5cm
return fmt.Sprintf(` <w:p>
<w:r>
<w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="%d" cy="%d"/>
<wp:docPr id="%d" name="Picture %d" descr="配图"/>
<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic>
<pic:nvPicPr>
<pic:cNvPr id="%d" name="image%d.jpg"/>
<pic:cNvPicPr/>
</pic:nvPicPr>
<pic:blipFill>
<a:blip r:embed="rIdImg%d"/>
<a:stretch><a:fillRect/></a:stretch>
</pic:blipFill>
<pic:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="%d" cy="%d"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>
`, emuWidth, emuHeight, index, index, index, index, index, emuWidth, emuHeight)
}
💡 知识点: OOXML 中的尺寸使用 EMU(English Metric Unit)。1 英寸 = 914400 EMU,1 厘米 = 360000 EMU。这里 5400000 EMU ≈ 14cm 宽。
5.11 注册到 main.go
func main() {
// ...
wordService := backend.NewWordService()
app := application.New(application.Options{
Services: []application.Service{
application.NewService(configService),
application.NewService(agentService),
application.NewService(pexelsService),
application.NewService(wordService),
},
// ...
})
}
5.12 验证:生成第一个 docx
// backend/word_test.go
func TestGenerateDocx(t *testing.T) {
ws := NewWordService()
path, err := ws.GenerateDocx(DocxData{
Title: "测试文档",
Content: "# 标题\n\n这是正文内容。\n\n## 小标题\n\n更多内容。",
}, [][]byte{}) // 先不加图片
if err != nil {
t.Fatal(err)
}
t.Logf("文档已生成: %s", path)
}
go test ./backend -run TestGenerateDocx -v
然后用 Word 打开输出目录下的 .docx 文件验证效果。
本章总结
| 你已经学会 | 对应能力 |
|---|---|
| OOXML 格式理解 | docx = ZIP + XML |
archive/zip.Writer |
构建 ZIP 包 |
bytes.Buffer |
内存缓冲区 |
| OOXML 最小文件集 | 6 个必需文件 |
| XML 模板拼接 | 动态生成符合 OOXML 规范的 XML |
a:blip r:embed="rIdImg..." |
图片嵌入引用 |
| XML 转义 | 防止 docx 文件损坏 |
🔧 动手练习
- 修改正文字体为"宋体”,字号改为 14pt(
w:sz w:val="28") - 在文档末尾添加"生成时间:2024-01-01 12:00:00"
- 尝试将配图嵌入改为居中显示(在
<w:pPr>中添加<w:jc w:val="center"/>) - 实现页眉,显示文章标题
👉 下一章:前后端桥接 — Wails 绑定与 React UI