// Package office 生成真实可用的 Word(.docx)文档。 // // 这里不引第三方 Office 库(UniOffice 为商业授权、且会显著增重依赖),而是直接 // 按 OOXML(WordprocessingML) 规范用标准库 archive/zip + 内联 XML 拼出最小但完整、 // Word / Pages / WPS 均可正常打开的 .docx 包。零额外依赖,契合 clone 即跑的目标。 package office import ( "archive/zip" "bytes" "context" "fmt" "strings" ) // Doc 累积段落,最终序列化为一个 .docx 字节流。 type Doc struct { body strings.Builder // word/document.xml 的 内部段落串 } // NewDoc 新建一个空文档。 func NewDoc() *Doc { return &Doc{} } // Title 加一行大标题(居中、加粗、约 18pt)。 func (d *Doc) Title(text string) *Doc { d.para(text, paraOpts{bold: true, sizeHalfPt: 36, center: true, spaceAfter: 240}) return d } // Heading 加一行小节标题(加粗、约 14pt)。 func (d *Doc) Heading(text string) *Doc { d.para(text, paraOpts{bold: true, sizeHalfPt: 28, spaceBefore: 240, spaceAfter: 120}) return d } // Para 加一个正文段落(约 11pt)。空串忽略。 func (d *Doc) Para(text string) *Doc { text = strings.TrimSpace(text) if text == "" { return d } d.para(text, paraOpts{sizeHalfPt: 22, spaceAfter: 120}) return d } // Body 把一段可能含多个换行的正文按行拆成多个段落。 func (d *Doc) Body(text string) *Doc { for _, line := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") { d.Para(line) } return d } type paraOpts struct { bold bool center bool sizeHalfPt int // OOXML 字号单位为半磅(half-points),22 = 11pt spaceBefore int // 段前间距(twentieths of a point) spaceAfter int } func (d *Doc) para(text string, o paraOpts) { d.body.WriteString("") // 段落属性:间距 + 居中。 d.body.WriteString("") if o.spaceBefore > 0 || o.spaceAfter > 0 { fmt.Fprintf(&d.body, ``, o.spaceBefore, o.spaceAfter) } if o.center { d.body.WriteString(``) } d.body.WriteString("") // 文本 run。 d.body.WriteString("") if o.bold { d.body.WriteString("") } if o.sizeHalfPt > 0 { fmt.Fprintf(&d.body, ``, o.sizeHalfPt, o.sizeHalfPt) } d.body.WriteString("") fmt.Fprintf(&d.body, `%s`, escapeXML(text)) d.body.WriteString("") } // Bytes 把累积的段落打包为合规 .docx(zip + 三个核心 XML 部件)。 func (d *Doc) Bytes() ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) parts := map[string]string{ "[Content_Types].xml": contentTypesXML, "_rels/.rels": relsXML, "word/document.xml": documentXML(d.body.String()), } for name, content := range parts { w, err := zw.Create(name) if err != nil { return nil, err } if _, err := w.Write([]byte(content)); err != nil { return nil, err } } if err := zw.Close(); err != nil { return nil, err } return buf.Bytes(), nil } // ---- Renderer:把结构化报告(标题 + 章节)渲染为 docx ---- // Section 是报告的一章:小节标题 + 正文。 type Section struct { Heading string `json:"heading"` Body string `json:"body"` } // Renderer 把结构化数据渲染为 docx。 type Renderer struct{} func NewRenderer() *Renderer { return &Renderer{} } // RenderReport 渲染「大标题 + 多章节」结构的报告为 .docx 字节流。 func (r *Renderer) RenderReport(_ context.Context, title string, sections []Section) ([]byte, error) { doc := NewDoc() if title != "" { doc.Title(title) } for _, s := range sections { if s.Heading != "" { doc.Heading(s.Heading) } doc.Body(s.Body) } return doc.Bytes() } // ---- OOXML 模板 ---- const contentTypesXML = ` ` const relsXML = ` ` func documentXML(body string) string { return ` ` + body + `` } func escapeXML(s string) string { r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, "'", "'", ) return r.Replace(s) }