Files
Agent-System-Backend/internal/docgen/docgen.go
T
weichen 370ae1a280 Generate styled Word deliverables and pin artifact cards in chat
Why: docx deliverables were plain text saved with a .docx name (no
headings/tables/styles), and the artifact_ref part was marked Process
so the deliverable vanished into the collapsed process section — the
user saw only model-written text like "📄 xxx.docx 工件 ID …" instead
of an openable, formatted document delivered in the conversation.

Evidence:
- new internal/docgen: Markdown -> styled OOXML (Title/Heading1-4,
  numbered+bullet lists with numbering.xml, GFM tables with shaded
  header, quotes, code blocks, bold/italic/strike, real hyperlinks);
  zero third-party deps; tests cover zip structure, XML well-formedness,
  numbering refs, hyperlink rels, escaping, plain-text fallback
- ingest round-trip test proves generated docx parses back to blocks
- new write_docx builtin tool (markdown in -> styled .docx artifact);
  write_artifact prompt now routes Word docs to it; task.doc-minutes
  output contract requires write_docx delivery
- llm.go: artifact refs collected as deliverables and appended as
  content parts AFTER the final text (pinned tail, not process); mock
  #dogfood path generates a real styled docx; runmgr skips string
  indexing for binary office mimes
- go test ./... 19 packages ok (incl. new e2e asserting <w:tbl> and
  title text inside the artifact served by the API)

Impact: model-written documents open in Word/WPS with real layout and
preview with formatting (mammoth maps Heading1-4 to h1-h4); every tool
artifact now lands as a visible in-chat card; no behavior change for
text artifacts.

Refs: task feature/deliverable-docx-r1
2026-10-01 02:48:49 +00:00

892 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package docgen 把 Markdown 源文转换为带排版样式的 Word(.docx)文档。
//
// 定位(正式交付物直接可交):模型只需产出 Markdown,本包负责把
// "#/##/### 标题、有序/无序列表、任务项、GFM 表格、引用块、代码块、
// 粗体/斜体/行内代码/删除线/超链接" 渲染成真正的 Word 样式——
// Heading1-4 大纲层级、底纹表头表格、numbering.xml 真编号列表、
// 引用左线、等宽底纹代码段。打开即是有排版的正式文档,不是纯文本。
//
// 零第三方依赖:archive/zip + 手写 OOXML(WordprocessingML)。
// 兼容性约束:产物必须能被本仓 ingest.parseDocx 回读(pStyle 含
// Heading1-4、表格 tr/tc 结构),且前端 mammoth 预览可还原标题层级。
package docgen
import (
"archive/zip"
"bytes"
"fmt"
"strings"
)
// Build 生成 docx 字节。title 为文档标题;为空时取首个标题块,再无则不带 Title 段。
func Build(title, markdown string) ([]byte, error) {
doc := parseMarkdown(markdown)
title = strings.TrimSpace(title)
if title == "" {
title = doc.firstHeading()
}
var body strings.Builder
body.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
body.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>`)
if title != "" {
body.WriteString(doc.paragraph("Title", []run{{text: title}}))
}
for i := range doc.blocks {
doc.writeBlock(&body, i)
}
// sectPr:A4 + 中文文档常规页边距(上下 2.54cm、左右 3.18cm,twips)。
body.WriteString(`<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="851" w:footer="992" w:gutter="0"/></w:sectPr>`)
body.WriteString("</w:body></w:document>")
files := []struct{ name, data string }{
{"[Content_Types].xml", contentTypesXML()},
{"_rels/.rels", rootRelsXML()},
{"word/document.xml", body.String()},
{"word/_rels/document.xml.rels", doc.relsXML()},
{"word/styles.xml", stylesXML()},
{"word/numbering.xml", numberingXML()},
{"docProps/core.xml", coreXML(title)},
{"docProps/app.xml", appXML()},
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range files {
w, err := zw.Create(f.name)
if err != nil {
return nil, fmt.Errorf("docgen: create %s: %w", f.name, err)
}
if _, err := w.Write([]byte(f.data)); err != nil {
return nil, fmt.Errorf("docgen: write %s: %w", f.name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("docgen: close zip: %w", err)
}
return buf.Bytes(), nil
}
// SuggestName 由标题给一个人类可读的 .docx 文件名(调用方可不用)。
func SuggestName(title string) string {
s := strings.Map(func(r rune) rune {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\n', '\r', '\t':
return -1
}
return r
}, strings.TrimSpace(title))
if r := []rune(s); len(r) > 60 {
s = string(r[:60])
}
if s == "" {
s = "文档"
}
return s + ".docx"
}
// ---- Markdown 块级解析 ----
type blockKind int
const (
bPara blockKind = iota
bHeading
bList
bTable
bCode
bQuote
bHR
)
type block struct {
kind blockKind
level int // 标题级别 1-6
lines []string // 段落/引用/代码的原始行
items []listItem
rows [][]string
align []string
hasHeader bool
}
type listItem struct {
text string
level int // 0 起
ordered bool
checked *bool
}
// document 承载解析结果与生成期状态(超链接收集、列表编号序列分配)。
type document struct {
blocks []block
hyperlinks []string
listSeqs int
}
func (d *document) firstHeading() string {
for _, b := range d.blocks {
if b.kind == bHeading {
return strings.Join(b.lines, " ")
}
}
return ""
}
func parseMarkdown(src string) *document {
lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n")
d := &document{}
i := 0
for i < len(lines) {
line := lines[i]
trimmed := strings.TrimSpace(line)
switch {
case trimmed == "":
i++
case strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~"):
i = d.parseCode(lines, i)
case isHR(trimmed):
d.blocks = append(d.blocks, block{kind: bHR})
i++
case strings.HasPrefix(trimmed, "#"):
if lvl, text, ok := parseHeading(trimmed); ok {
d.blocks = append(d.blocks, block{kind: bHeading, level: lvl, lines: []string{text}})
i++
} else {
i = d.parsePara(lines, i)
}
case isTableStart(lines, i):
i = d.parseTable(lines, i)
case isListLine(trimmed):
i = d.parseList(lines, i)
case strings.HasPrefix(trimmed, ">"):
i = d.parseQuote(lines, i)
default:
i = d.parsePara(lines, i)
}
}
return d
}
func isHR(s string) bool {
if len(s) < 3 {
return false
}
for _, ch := range []byte{'-', '*', '_'} {
if s[0] == ch {
return strings.TrimSpace(strings.ReplaceAll(s, string(ch), "")) == ""
}
}
return false
}
func parseHeading(s string) (int, string, bool) {
n := 0
for n < len(s) && s[n] == '#' {
n++
}
if n == 0 || n > 6 || n >= len(s) || s[n] != ' ' {
return 0, "", false
}
return n, strings.TrimSpace(s[n+1:]), true
}
func (d *document) parseCode(lines []string, i int) int {
mark := strings.TrimSpace(lines[i])[:3]
var body []string
j := i + 1
for ; j < len(lines); j++ {
t := strings.TrimSpace(lines[j])
if strings.HasPrefix(t, mark) && strings.TrimLeft(t, mark) == "" {
j++
break
}
body = append(body, lines[j])
}
d.blocks = append(d.blocks, block{kind: bCode, lines: body})
return j
}
func (d *document) parseQuote(lines []string, i int) int {
var body []string
for ; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if !strings.HasPrefix(t, ">") {
break
}
body = append(body, strings.TrimPrefix(strings.TrimPrefix(t, ">"), " "))
}
// 引用内按空行切分多段。
var cur []string
for _, l := range body {
if strings.TrimSpace(l) == "" {
if len(cur) > 0 {
d.blocks = append(d.blocks, block{kind: bQuote, lines: cur})
cur = nil
}
continue
}
cur = append(cur, l)
}
if len(cur) > 0 {
d.blocks = append(d.blocks, block{kind: bQuote, lines: cur})
}
return i
}
func (d *document) parsePara(lines []string, i int) int {
var body []string
for ; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, ">") ||
strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") ||
isListLine(t) || isTableStart(lines, i) || isHR(t) {
break
}
body = append(body, lines[i])
}
if len(body) == 0 { // 防御:单行无法归块时按原文吃掉一行,避免死循环。
body = []string{lines[i]}
i++
}
d.blocks = append(d.blocks, block{kind: bPara, lines: body})
return i
}
func isListLine(s string) bool {
_, _, _, ok := splitListMarker(s)
return ok
}
// splitListMarker 解析列表行:缩进层级(每 2 空格一级,封顶 3)、是否有序、
// 去掉标记后的文本。
func splitListMarker(s string) (level int, ordered bool, text string, ok bool) {
n := 0
for n < len(s) && (s[n] == ' ' || s[n] == '\t') {
n++
}
level = n / 2
if level > 3 {
level = 3
}
rest := s[n:]
for _, m := range []string{"- ", "* ", "+ "} {
if strings.HasPrefix(rest, m) {
return level, false, rest[2:], true
}
}
k := 0
for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' {
k++
}
if k > 0 && k <= 4 && k < len(rest) && (rest[k] == '.' || rest[k] == ')') && k+1 < len(rest) && rest[k+1] == ' ' {
return level, true, rest[k+2:], true
}
return 0, false, s, false
}
func (d *document) parseList(lines []string, i int) int {
var items []listItem
for ; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if t == "" {
break
}
level, ordered, text, ok := splitListMarker(t)
if !ok {
break
}
it := listItem{level: level, ordered: ordered}
if !ordered {
switch {
case strings.HasPrefix(text, "[ ] "), text == "[ ]":
it.checked = boolPtr(false)
text = strings.TrimSpace(strings.TrimPrefix(text, "[ ]"))
case strings.HasPrefix(text, "[x] "), strings.HasPrefix(text, "[X] "), text == "[x]", text == "[X]":
it.checked = boolPtr(true)
text = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(text, "[x] "), "[X] "))
}
}
it.text = strings.TrimSpace(text)
items = append(items, it)
}
d.blocks = append(d.blocks, block{kind: bList, items: items})
return i
}
// isTableStart:以 | 开头或结尾的行,或「含 | 且下一行是分隔行」——后者覆盖
// 无边框管道写法,同时避免把正文里含 | 的普通句子误判成表格。
func isTableStart(lines []string, i int) bool {
t := strings.TrimSpace(lines[i])
if !strings.Contains(t, "|") {
return false
}
if strings.HasPrefix(t, "|") || strings.HasSuffix(t, "|") {
return true
}
return i+1 < len(lines) && isTableSeparator(strings.TrimSpace(lines[i+1]))
}
func isTableSeparator(s string) bool {
core := strings.ReplaceAll(strings.ReplaceAll(s, "|", ""), " ", "")
if core == "" {
return false
}
for _, ch := range core {
if ch != '-' && ch != ':' {
return false
}
}
return true
}
func (d *document) parseTable(lines []string, i int) int {
var rows [][]string
var align []string
hasHeader := false
for ; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if t == "" || !strings.Contains(t, "|") {
break
}
if isTableSeparator(t) {
align = parseTableAlign(t)
hasHeader = true
continue
}
rows = append(rows, splitTableRow(t))
}
if len(rows) == 0 {
return i + 1 // 只有分隔行等异常输入:吃掉一行防死循环。
}
d.blocks = append(d.blocks, block{kind: bTable, rows: rows, align: align, hasHeader: hasHeader})
return i
}
func parseTableAlign(sep string) []string {
cells := splitTableRow(sep)
out := make([]string, len(cells))
for i, c := range cells {
c = strings.TrimSpace(c)
left := strings.HasPrefix(c, ":")
right := strings.HasSuffix(c, ":")
switch {
case left && right:
out[i] = "center"
case right:
out[i] = "right"
default:
out[i] = "left"
}
}
return out
}
func splitTableRow(s string) []string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "|")
s = strings.TrimSuffix(s, "|")
parts := strings.Split(s, "|")
out := make([]string, len(parts))
for i, p := range parts {
out[i] = strings.TrimSpace(p)
}
return out
}
func boolPtr(b bool) *bool { return &b }
// ---- 行内解析:**粗**、*斜*、`码`、~~删~~、[文](链) ----
type run struct {
text string
bold bool
italic bool
code bool
strike bool
link string
}
func (d *document) parseInline(s string) []run {
var runs []run
i, start := 0, 0
flush := func(end int) {
if end > start {
runs = append(runs, run{text: s[start:end]})
}
}
for i < len(s) {
c := s[i]
switch {
case c == '\\':
flush(i)
if i+1 < len(s) {
runs = append(runs, run{text: string(s[i+1])})
}
i += 2
start = i
case c == '`':
if end := strings.IndexByte(s[i+1:], '`'); end >= 0 {
flush(i)
runs = append(runs, run{text: s[i+1 : i+1+end], code: true})
i += end + 2
start = i
} else {
i++
}
case c == '*' && i+1 < len(s) && s[i+1] == '*':
if end, ok := findClose(s, i+2, "**"); ok {
flush(i)
runs = append(runs, mapAll(d.parseInline(s[i+2:end]), func(r run) run { r.bold = true; return r })...)
i = end + 2
start = i
} else {
i++
}
case c == '~' && i+1 < len(s) && s[i+1] == '~':
if end, ok := findClose(s, i+2, "~~"); ok {
flush(i)
runs = append(runs, mapAll(d.parseInline(s[i+2:end]), func(r run) run { r.strike = true; return r })...)
i = end + 2
start = i
} else {
i++
}
case c == '*' || c == '_':
if end, ok := findClose(s, i+1, string(c)); ok && strings.TrimSpace(s[i+1:end]) != "" {
flush(i)
runs = append(runs, mapAll(d.parseInline(s[i+1:end]), func(r run) run { r.italic = true; return r })...)
i = end + 1
start = i
} else {
i++
}
case c == '[':
if text, href, next, ok := parseLink(s, i); ok {
flush(i)
runs = append(runs, run{text: text, link: href})
d.hyperlinks = appendLink(d.hyperlinks, href)
i = next
start = i
} else {
i++
}
default:
i++
}
}
flush(len(s))
return runs
}
// parseLink 解析 [text](href):href 需带协议/锚点/mailto,防把普通方括号误判。
func parseLink(s string, i int) (text, href string, next int, ok bool) {
close := strings.IndexByte(s[i:], ']')
if close <= 0 {
return "", "", 0, false
}
after := i + close + 1
if after >= len(s) || s[after] != '(' {
return "", "", 0, false
}
pclose := strings.IndexByte(s[after:], ')')
if pclose <= 0 {
return "", "", 0, false
}
h := s[after+1 : after+pclose]
if !strings.Contains(h, "://") && !strings.HasPrefix(h, "#") && !strings.HasPrefix(h, "mailto:") {
return "", "", 0, false
}
return s[i+1 : i+close], h, after + pclose + 1, true
}
func mapAll(rs []run, f func(run) run) []run {
out := make([]run, len(rs))
for i, r := range rs {
out[i] = f(r)
}
return out
}
func findClose(s string, from int, mark string) (int, bool) {
idx := strings.Index(s[from:], mark)
if idx <= 0 { // 0 = 空内容,-1 = 未闭合。
return 0, false
}
return from + idx, true
}
func appendLink(list []string, href string) []string {
for _, h := range list {
if h == href {
return list
}
}
return append(list, href)
}
// ---- OOXML 生成 ----
// paragraph 渲染一个段落:style 为样式 ID,extra 追加进 pPr(如 numPr)。
func (d *document) paragraph(style string, runs []run, extra ...string) string {
var sb strings.Builder
sb.WriteString("<w:p><w:pPr>")
if style != "" {
sb.WriteString(`<w:pStyle w:val="` + style + `"/>`)
}
for _, e := range extra {
sb.WriteString(e)
}
sb.WriteString("</w:pPr>")
for _, r := range runs {
sb.WriteString(d.runXML(r))
}
sb.WriteString("</w:p>")
return sb.String()
}
func (d *document) runsXML(runs []run) string {
var sb strings.Builder
for _, r := range runs {
sb.WriteString(d.runXML(r))
}
return sb.String()
}
func (d *document) runXML(r run) string {
if r.text == "" {
return ""
}
var props []string
if r.bold {
props = append(props, "<w:b/><w:bCs/>")
}
if r.italic {
props = append(props, "<w:i/><w:iCs/>")
}
if r.strike {
props = append(props, "<w:strike/>")
}
if r.code {
props = append(props, `<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>`)
}
if r.link != "" {
rid := d.hyperlinkRID(r.link)
return fmt.Sprintf(`<w:hyperlink r:id="%s"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/>%s</w:rPr>%s</w:r></w:hyperlink>`,
rid, strings.Join(props, ""), textRuns(r.text))
}
return "<w:r>" + rPr(props) + textRuns(r.text) + "</w:r>"
}
// textRuns 把文本渲染为 w:t;段内换行转 w:br(Word 段内不保留裸 \n)。
func textRuns(text string) string {
var sb strings.Builder
parts := strings.Split(text, "\n")
for i, p := range parts {
if i > 0 {
sb.WriteString("<w:br/>")
}
if p != "" {
sb.WriteString(`<w:t xml:space="preserve">` + esc(p) + `</w:t>`)
}
}
return sb.String()
}
func rPr(props []string) string {
if len(props) == 0 {
return ""
}
return "<w:rPr>" + strings.Join(props, "") + "</w:rPr>"
}
func (d *document) hyperlinkRID(href string) string {
for i, h := range d.hyperlinks {
if h == href {
return fmt.Sprintf("rIdH%d", i+1)
}
}
return ""
}
// 列表编号分配:numbering.xml 预生成成对 num 实例——无序 1、3..52;有序
// 2(重启)、53..102(重启)。newListNumID 按序取用;超出预生成数回落
// 常驻 1/2(Word 容忍多列表共用编号,代价是有序编号延续)。
const (
numPairBase = 3
numOrderedShift = 50
numPairsTotal = 50
numFallbackBul = 1
numFallbackOrd = 2
)
func (d *document) newListNumID(ordered bool) int {
d.listSeqs++
base := numPairBase + d.listSeqs - 1
if base >= numPairBase+numPairsTotal {
if ordered {
return numFallbackOrd
}
return numFallbackBul
}
if ordered {
return base + numOrderedShift
}
return base
}
func (d *document) writeBlock(sb *strings.Builder, i int) {
b := &d.blocks[i]
switch b.kind {
case bHeading:
lvl := b.level
if lvl > 4 {
lvl = 4
}
sb.WriteString(d.paragraph(fmt.Sprintf("Heading%d", lvl), d.parseInline(b.lines[0])))
case bPara:
sb.WriteString(d.paragraph("", d.parseInline(strings.Join(b.lines, "\n"))))
case bQuote:
for _, line := range b.lines {
sb.WriteString(d.paragraph("Quote", d.parseInline(line)))
}
case bCode:
for _, line := range b.lines {
txt := line
if txt == "" {
txt = " "
}
sb.WriteString(d.paragraph("CodePara", []run{{text: txt}}))
}
case bHR:
sb.WriteString(`<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="BFBFBF"/></w:pBdr></w:pPr></w:p>`)
case bList:
numID := d.newListNumID(isOrdered(b.items))
for _, it := range b.items {
var runs []run
if it.checked != nil {
glyph := "☐"
if *it.checked {
glyph = "☑"
}
runs = append(runs, run{text: glyph + " "})
}
runs = append(runs, d.parseInline(it.text)...)
sb.WriteString(d.paragraph("ListParagraph", runs,
fmt.Sprintf(`<w:numPr><w:ilvl w:val="%d"/><w:numId w:val="%d"/></w:numPr>`, it.level, numID)))
}
case bTable:
sb.WriteString(d.tableXML(b))
}
}
func isOrdered(items []listItem) bool {
for _, it := range items {
if it.ordered {
return true
}
}
return false
}
func (d *document) tableXML(b *block) string {
cols := 0
for _, row := range b.rows {
if len(row) > cols {
cols = len(row)
}
}
align := make([]string, cols)
for i := range align {
align[i] = "left"
if i < len(b.align) {
align[i] = b.align[i]
}
}
var sb strings.Builder
sb.WriteString(`<w:tbl><w:tblPr><w:tblW w:w="5000" w:type="pct"/><w:tblBorders>`)
for _, edge := range []string{"top", "left", "bottom", "right", "insideH", "insideV"} {
sb.WriteString(fmt.Sprintf(`<w:%s w:val="single" w:sz="4" w:space="0" w:color="BFBFBF"/>`, edge))
}
sb.WriteString(`</w:tblBorders><w:tblCellMar><w:top w:w="60" w:type="dxa"/><w:left w:w="108" w:type="dxa"/><w:bottom w:w="60" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar></w:tblPr>`)
for ri, row := range b.rows {
header := b.hasHeader && ri == 0
sb.WriteString("<w:tr>")
if header {
sb.WriteString(`<w:trPr><w:tblHeader/></w:trPr>`)
}
for ci := 0; ci < cols; ci++ {
cell := ""
if ci < len(row) {
cell = row[ci]
}
var runs []run
if header {
runs = []run{{text: cell, bold: true}}
} else {
runs = d.parseInline(cell)
}
shd := ""
if header {
shd = `<w:shd w:val="clear" w:fill="EEF2F8"/>`
}
sb.WriteString(fmt.Sprintf(`<w:tc><w:tcPr>%s</w:tcPr><w:p><w:pPr><w:spacing w:before="20" w:after="20"/><w:jc w:val="%s"/></w:pPr>%s</w:p></w:tc>`,
shd, align[ci], d.runsXML(runs)))
}
sb.WriteString("</w:tr>")
}
sb.WriteString("</w:tbl>")
// 表后补一个近零高度空段:防相邻表格被 Word 合并。
sb.WriteString(`<w:p><w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr></w:p>`)
return sb.String()
}
// ---- 包部件文件 ----
func esc(s string) string {
var sb strings.Builder
for _, r := range s {
switch r {
case '&':
sb.WriteString("&amp;")
case '<':
sb.WriteString("&lt;")
case '>':
sb.WriteString("&gt;")
case '"':
sb.WriteString("&quot;")
case '\'':
sb.WriteString("&apos;")
default:
sb.WriteRune(r)
}
}
return sb.String()
}
func contentTypesXML() 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"/>
<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>`
}
func rootRelsXML() 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"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>`
}
func (d *document) relsXML() string {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
sb.WriteString(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`)
sb.WriteString(`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`)
sb.WriteString(`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>`)
for i, href := range d.hyperlinks {
sb.WriteString(fmt.Sprintf(`<Relationship Id="rIdH%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="%s" TargetMode="External"/>`, i+1, esc(href)))
}
sb.WriteString(`</Relationships>`)
return sb.String()
}
func stylesXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults><w:rPrDefault><w:rPr>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:eastAsia="等线" w:cs="Calibri"/>
<w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="en-US" w:eastAsia="zh-CN"/>
</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:jc w:val="center"/><w:spacing w:before="120" w:after="360"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="44"/><w:szCs w:val="44"/><w:color w:val="1F3864"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="0"/><w:spacing w:before="320" w:after="160"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="32"/><w:szCs w:val="32"/><w:color w:val="1F4E79"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="1"/><w:spacing w:before="280" w:after="140"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/><w:color w:val="2E74B5"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="2"/><w:spacing w:before="240" w:after="120"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="24"/><w:szCs w:val="24"/><w:color w:val="404040"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:outlineLvl w:val="3"/><w:spacing w:before="200" w:after="100"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="22"/><w:szCs w:val="22"/><w:color w:val="595959"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:contextualSpacing/></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Quote"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:pBdr><w:left w:val="single" w:sz="18" w:space="4" w:color="4472C4"/></w:pBdr><w:ind w:left="360"/><w:spacing w:after="80"/></w:pPr>
<w:rPr><w:color w:val="595959"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="CodePara"><w:name w:val="Code Paragraph"/><w:basedOn w:val="Normal"/><w:qFormat/>
<w:pPr><w:shd w:val="clear" w:fill="F5F5F5"/><w:spacing w:after="0" w:line="264" w:lineRule="auto"/><w:ind w:left="240" w:right="240"/></w:pPr>
<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="19"/><w:szCs w:val="19"/></w:rPr></w:style>
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/>
<w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style>
</w:styles>`
}
// numberingXML:abstractNum 0(无序)/1(有序)+ 预生成成对 num 实例,
// 与 newListNumID 的分配方案对齐(见其注释)。
func numberingXML() string {
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
sb.WriteString(`<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
sb.WriteString(abstractNumXML(0, false))
sb.WriteString(abstractNumXML(1, true))
sb.WriteString(numXML(1, 0, false))
sb.WriteString(numXML(2, 1, true))
for i := 0; i < numPairsTotal; i++ {
sb.WriteString(numXML(numPairBase+i, 0, false))
sb.WriteString(numXML(numPairBase+i+numOrderedShift, 1, true))
}
sb.WriteString(`</w:numbering>`)
return sb.String()
}
func abstractNumXML(aid int, ordered bool) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf(`<w:abstractNum w:abstractNumId="%d"><w:multiLevelType w:val="hybridMultilevel"/>`, aid))
bullets := []string{"•", "◦", "▪", "·"}
for lvl := 0; lvl < 4; lvl++ {
if ordered {
sb.WriteString(fmt.Sprintf(`<w:lvl w:ilvl="%d"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%%%d."/><w:lvlJc w:val="left"/>`, lvl, lvl+1))
} else {
sb.WriteString(fmt.Sprintf(`<w:lvl w:ilvl="%d"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="%s"/><w:lvlJc w:val="left"/>`, lvl, bullets[lvl]))
}
sb.WriteString(fmt.Sprintf(`<w:pPr><w:ind w:left="%d" w:hanging="360"/></w:pPr></w:lvl>`, 720+lvl*420))
}
sb.WriteString(`</w:abstractNum>`)
return sb.String()
}
func numXML(numID, abstractID int, restart bool) string {
ov := ""
if restart {
ov = `<w:lvlOverride w:ilvl="0"><w:startOverride w:val="1"/></w:lvlOverride>`
}
return fmt.Sprintf(`<w:num w:numId="%d"><w:abstractNumId w:val="%d"/>%s</w:num>`, numID, abstractID, ov)
}
func coreXML(title string) string {
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>%s</dc:title><dc:creator>Agent-System</dc:creator><cp:lastModifiedBy>Agent-System</cp:lastModifiedBy>
</cp:coreProperties>`, esc(title))
}
func appXML() string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
<Application>Agent-System docgen</Application>
</Properties>`
}