纯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, "&", "&amp;")
    s = strings.ReplaceAll(s, "<", "&lt;")
    s = strings.ReplaceAll(s, ">", "&gt;")
    s = strings.ReplaceAll(s, "\"", "&quot;")
    s = strings.ReplaceAll(s, "'", "&apos;")
    return s
}

⚠️ 关键步骤: 如果文章内容包含 < > & 等 XML 特殊字符但未转义,生成的 docx 文件会损坏,Word 无法打开。


5.10 图片嵌入

在 OOXML 中嵌入图片需要两步:

  1. 将图片二进制写入 word/media/ 目录
  2. 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 文件损坏

🔧 动手练习

  1. 修改正文字体为"宋体”,字号改为 14pt(w:sz w:val="28"
  2. 在文档末尾添加"生成时间:2024-01-01 12:00:00"
  3. 尝试将配图嵌入改为居中显示(在 <w:pPr> 中添加 <w:jc w:val="center"/>
  4. 实现页眉,显示文章标题

👉 下一章:前后端桥接 — Wails 绑定与 React UI

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo