Generate styled Word deliverables and pin artifact cards in chat #31
@@ -0,0 +1,891 @@
|
||||
// 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("&")
|
||||
case '<':
|
||||
sb.WriteString("<")
|
||||
case '>':
|
||||
sb.WriteString(">")
|
||||
case '"':
|
||||
sb.WriteString(""")
|
||||
case '\'':
|
||||
sb.WriteString("'")
|
||||
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>`
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。
|
||||
// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。
|
||||
const sampleMinutes = "# 研发周会纪要\n" + `
|
||||
## 一、基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 时间 | 2026-09-30 14:00 |
|
||||
| 地点 | 3F 会议室 |
|
||||
| 主持人 | 陈威 |
|
||||
|
||||
## 二、讨论要点
|
||||
|
||||
- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan)
|
||||
- 排版要求:标题层级、表格、列表
|
||||
- 二级要点:交付物卡片
|
||||
- [x] 已完成:预览装配
|
||||
- [ ] 待办:样式打磨
|
||||
|
||||
> 会议结论:按周五节点交付。
|
||||
|
||||
## 三、决议
|
||||
|
||||
1. 采用 write_docx 工具
|
||||
2. 对话内出交付物卡片
|
||||
|
||||
` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + `
|
||||
|
||||
---
|
||||
|
||||
后续:周报合并。
|
||||
`
|
||||
|
||||
func buildSample(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
b, err := Build("", sampleMinutes)
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestBuildZipStructure(t *testing.T) {
|
||||
b := buildSample(t)
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("zip 打开失败(产物不是合法 docx 容器): %v", err)
|
||||
}
|
||||
want := map[string]bool{
|
||||
"[Content_Types].xml": false, "word/document.xml": false,
|
||||
"word/styles.xml": false, "word/numbering.xml": false,
|
||||
"word/_rels/document.xml.rels": false, "_rels/.rels": false,
|
||||
"docProps/core.xml": false,
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if _, ok := want[f.Name]; ok {
|
||||
want[f.Name] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("docx 缺少部件 %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDocumentXMLWellFormed(t *testing.T) {
|
||||
b := buildSample(t)
|
||||
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
var doc string
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, _ := f.Open()
|
||||
doc = string(readAll(rc))
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
if doc == "" {
|
||||
t.Fatal("word/document.xml 为空")
|
||||
}
|
||||
dec := xml.NewDecoder(strings.NewReader(doc))
|
||||
for {
|
||||
_, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("document.xml 不是良构 XML: %v", err)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{
|
||||
`<w:pStyle w:val="Title"/>`,
|
||||
`<w:pStyle w:val="Heading1"/>`,
|
||||
`<w:pStyle w:val="Heading2"/>`,
|
||||
`<w:tbl>`,
|
||||
`<w:tblHeader/>`,
|
||||
`<w:numPr>`,
|
||||
`<w:hyperlink r:id="rIdH1">`,
|
||||
`<w:strike/>`,
|
||||
} {
|
||||
if !strings.Contains(doc, marker) {
|
||||
t.Errorf("document.xml 缺少排版标记 %s", marker)
|
||||
}
|
||||
}
|
||||
// 标题文本与转义:一级标题进 Title 段(title 取首个标题)。
|
||||
if !strings.Contains(doc, "研发周会纪要") {
|
||||
t.Errorf("document.xml 缺少标题文本")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNumberingRefsResolve(t *testing.T) {
|
||||
b := buildSample(t)
|
||||
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
read := func(name string) string {
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
rc, _ := f.Open()
|
||||
s, _ := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
return string(s)
|
||||
}
|
||||
}
|
||||
t.Fatalf("缺少 %s", name)
|
||||
return ""
|
||||
}
|
||||
doc, numbering := read("word/document.xml"), read("word/numbering.xml")
|
||||
// 收集 document 里引用的 numId,逐一确认 numbering.xml 有对应 w:num。
|
||||
refs := map[string]bool{}
|
||||
for _, seg := range strings.Split(doc, "<w:numId w:val=\"")[1:] {
|
||||
id := seg[:strings.IndexByte(seg, '"')]
|
||||
refs[id] = true
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("列表未引用任何 numId")
|
||||
}
|
||||
for id := range refs {
|
||||
if !strings.Contains(numbering, `<w:num w:numId="`+id+`">`) {
|
||||
t.Errorf("numId %s 在 numbering.xml 无定义", id)
|
||||
}
|
||||
}
|
||||
// 有序/无序列表分属不同编号实例(同文档两类列表都出现时 numId 应不同)。
|
||||
if len(refs) < 2 {
|
||||
t.Errorf("样例含无序+有序两类列表,numId 应至少 2 个,得 %d", len(refs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHyperlinkRels(t *testing.T) {
|
||||
b := buildSample(t)
|
||||
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
var rels string
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/_rels/document.xml.rels" {
|
||||
rc, _ := f.Open()
|
||||
rels = string(readAll(rc))
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") {
|
||||
t.Errorf("超链接 relationship 缺失: %s", rels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEscapesXML(t *testing.T) {
|
||||
b, err := Build("标题<a>&'", "包含 <标签> & \"引号\" 的正文")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
zr, _ := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, _ := f.Open()
|
||||
doc := readAll(rc)
|
||||
rc.Close()
|
||||
if !strings.Contains(string(doc), "<标签>") || !strings.Contains(string(doc), "&") {
|
||||
t.Errorf("正文 XML 转义缺失")
|
||||
}
|
||||
dec := xml.NewDecoder(bytes.NewReader(doc))
|
||||
for {
|
||||
_, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("转义后 XML 不良构: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlainTextFallback(t *testing.T) {
|
||||
// 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。
|
||||
b, err := Build("", "第一行\n第二行\n\n第二段")
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("zip: %v", err)
|
||||
}
|
||||
var doc string
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, _ := f.Open()
|
||||
doc = string(readAll(rc))
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
if !strings.Contains(doc, "第二段") {
|
||||
t.Errorf("纯文本段落丢失")
|
||||
}
|
||||
// 段内换行应转 w:br,且不残留裸换行进 w:t。
|
||||
if !strings.Contains(doc, "<w:br/>") {
|
||||
t.Errorf("段内换行未转 w:br")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestName(t *testing.T) {
|
||||
if got := SuggestName("季度/报告:2026"); got != "季度报告2026.docx" {
|
||||
t.Errorf("SuggestName = %q", got)
|
||||
}
|
||||
if got := SuggestName(" "); got != "文档.docx" {
|
||||
t.Errorf("SuggestName 空 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func readAll(r io.Reader) []byte {
|
||||
b, _ := io.ReadAll(r)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
)
|
||||
|
||||
// TestParseDocxFromDocgen 回读契约:docgen 产出的 docx 必须能被 builtin:docx
|
||||
// 解析器还原为块结构(标题层级、表格、列表项文本)——这是「生成→检索索引→
|
||||
// 预览」链路闭合的保证(生成侧改动须两头痛检查)。
|
||||
func TestParseDocxFromDocgen(t *testing.T) {
|
||||
md := "# 周会纪要\n\n## 一、基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-09-30 |\n\n## 二、要点\n\n- 架构定案\n- 排版齐全\n\n结论段。"
|
||||
b, err := docgen.Build("", md)
|
||||
if err != nil {
|
||||
t.Fatalf("docgen.Build: %v", err)
|
||||
}
|
||||
blocks, err := parseDocx(b)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDocx 回读失败: %v", err)
|
||||
}
|
||||
|
||||
var h2, title int
|
||||
var sawTable bool
|
||||
var tableRows int
|
||||
var sawArch bool
|
||||
for _, blk := range blocks {
|
||||
switch {
|
||||
case blk.Type == core.BlockHeading && blk.Level == 3 && blk.Text == "周会纪要":
|
||||
title++ // Title 样式在回读侧归 level 3(headingLevel 的 title 映射)。
|
||||
case blk.Type == core.BlockHeading && blk.Level == 2:
|
||||
h2++
|
||||
if blk.Text != "一、基本信息" && blk.Text != "二、要点" {
|
||||
t.Errorf("H2 文本不符: %q", blk.Text)
|
||||
}
|
||||
case blk.Type == core.BlockTable:
|
||||
sawTable = true
|
||||
tableRows = len(blk.Rows)
|
||||
if len(blk.Rows) >= 2 {
|
||||
if blk.Rows[0][0] != "项目" || blk.Rows[1][1] != "2026-09-30" {
|
||||
t.Errorf("表格内容不符: %+v", blk.Rows)
|
||||
}
|
||||
}
|
||||
case blk.Type == core.BlockPara && blk.Text == "架构定案":
|
||||
sawArch = true // 列表项以 ListParagraph 落盘,回读侧按段落文本取回。
|
||||
}
|
||||
}
|
||||
if title != 1 {
|
||||
t.Errorf("标题段(Title)回读数 = %d, want 1(blocks: %+v)", title, blocks)
|
||||
}
|
||||
if h2 != 2 {
|
||||
t.Errorf("H2 数量 = %d, want 2(blocks: %+v)", h2, blocks)
|
||||
}
|
||||
if !sawTable || tableRows < 2 {
|
||||
t.Errorf("表格未回读: sawTable=%v rows=%d", sawTable, tableRows)
|
||||
}
|
||||
if !sawArch {
|
||||
t.Errorf("列表项文本未回读(blocks: %+v)", blocks)
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,4 @@ triggers:
|
||||
- 区分三件事:讨论过程(谁说了什么)、形成的决议(集体结论)、待办事项(谁在何时前做什么)——不要把发言当决议。
|
||||
- 内容完整详实,除非用户明确要求精简,不要压缩篇幅;原文模糊或矛盾的点在"未决问题"中列出而不是擅自裁决。
|
||||
- 待办事项若原文未指明负责人或期限,写"未指明",不要编造。
|
||||
- 交付形态:用 write_docx 生成正式 Word 文档(Markdown 表达结构:一级标题为纪要标题、基本信息与待办用表格、决议用编号列表),name 用「<主题>会议纪要.docx」;对话内只简述结论,全文以文档交付,不要在回复里复述全文。
|
||||
|
||||
@@ -5,4 +5,6 @@ version: 1
|
||||
title: write_artifact 工具描述
|
||||
description: 工件写入工具的模型侧说明文案
|
||||
---
|
||||
把生成内容写入工件(文档/数据),返回工件 ID 与名称。用于正式交付物:报告、纪要、代码文件、数据集等需要保存、后续引用或继续编辑的内容。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。
|
||||
把生成内容写入工件(文档/数据),返回工件 ID 与名称。用于正式交付物:代码文件、数据集、Markdown/纯文本资料等需要保存、后续引用或继续编辑的内容。对话内的临时说明不需要写工件。写入时 name 用人类可读的文件名(含扩展名),kind 按内容选 document/code/data;同一交付物修订时写新版本而不是覆盖引用旧名。
|
||||
|
||||
分工:需要**带 Word 排版的正式文档**(报告、纪要、方案、说明书)时改用 write_docx——它把 Markdown 转成标题/表格/列表齐全的 .docx;本工具写的是原样字节,不要用它写 .docx(会产生无排版的伪 Word 文件)。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
id: tool.write_docx
|
||||
layer: tool
|
||||
version: 1
|
||||
title: write_docx 工具描述
|
||||
description: Word 文档生成工具的模型侧说明文案
|
||||
---
|
||||
生成带排版样式的 Word 文档(.docx 工件):content 用 Markdown 书写——`#/##/###` 表达章节层级、`|表格|`、`-` 列表、`1.` 编号、`- [x]` 任务项、`>` 引用、`**粗体**`、`[链接](url)`——系统自动转换为正式 Word 排版(标题样式、表头底纹表格、编号列表、引用线、代码段),用户打开即是成品文档。
|
||||
|
||||
用于正式文档交付物:报告、会议纪要、方案、说明书、总结等需要以 Word 交付或打印的内容。name 用人类可读的中文文件名(可不含扩展名,自动补 .docx);文档标题默认取正文第一个一级标题。对话里的临时说明、代码片段、纯文本/Markdown/JSON 数据仍用 write_artifact——需要"Word 排版"才用本工具。
|
||||
@@ -353,7 +353,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
return art, err
|
||||
}
|
||||
// 工件文本进检索索引(管线分块+全文+可选向量;未注入回落整文档单块)。
|
||||
if kind == "document" && len(content) < 200000 {
|
||||
// 二进制 Office/PDF 不进字符串索引(zip 字节当文本是垃圾数据);
|
||||
// docx 的可检索文本由 ingest 管线在上传路径解析,生成路径暂不索引。
|
||||
if kind == "document" && !binaryDocMime(mime) && len(content) < 200000 {
|
||||
if c.mgr.indexer != nil {
|
||||
_ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, string(content))
|
||||
} else {
|
||||
@@ -363,6 +365,22 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
return art, nil
|
||||
}
|
||||
|
||||
// binaryDocMime 判定二进制文档 mime:此类工件字节不能按 UTF-8 文本进索引。
|
||||
func binaryDocMime(mime string) bool {
|
||||
switch mime {
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/msword",
|
||||
"application/pdf",
|
||||
"application/zip":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if q.TopK <= 0 {
|
||||
q.TopK = int(c.eff.GetNumber("retrieval.topK", 5))
|
||||
|
||||
@@ -245,6 +245,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
}
|
||||
toolCallsMade := 0
|
||||
thinkingIdx, textIdx := 0, 0
|
||||
// 交付物引用(对话内直接给出):工具轮产生的工件不进过程折叠区,
|
||||
// 收集后作为内容部件钉在消息尾部(最终正文之后)——回复完给文件卡。
|
||||
var deliverables []*core.ArtifactRef
|
||||
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
select {
|
||||
@@ -299,6 +302,10 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
if len(finalParts) == 0 {
|
||||
finalParts = append(finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
|
||||
}
|
||||
// 交付物卡片钉尾(内容部件,不折叠):live 投影(live.ts)与落库同构。
|
||||
for _, ref := range deliverables {
|
||||
finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||
}
|
||||
rc.SetAssistantParts(finalParts)
|
||||
return nil
|
||||
}
|
||||
@@ -478,8 +485,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: statusOf(err), Summary: summary}},
|
||||
)
|
||||
if artifactRef != nil {
|
||||
// 工件引用与工具调用同轮产生,同标过程(否则终态折叠时残留在回复区)。
|
||||
finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Process: true, Artifact: artifactRef})
|
||||
deliverables = append(deliverables, artifactRef)
|
||||
}
|
||||
// 回灌工具结果(截断保护)。
|
||||
if len(out) > 16000 {
|
||||
|
||||
@@ -267,9 +267,9 @@ func TestLLMRunnerPhaseEventsPerTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMRunnerArtifactPartIsProcess 验证:工具产生的工件引用部件同样标记为过程,
|
||||
// 终态随过程折叠(不残留在回复区)。
|
||||
func TestLLMRunnerArtifactPartIsProcess(t *testing.T) {
|
||||
// TestLLMRunnerDeliverablePinnedAfterText 验证:工具产生的工件引用作为内容部件
|
||||
// (不进过程折叠区),且钉在最终正文之后——对话内直接给出交付物卡片。
|
||||
func TestLLMRunnerDeliverablePinnedAfterText(t *testing.T) {
|
||||
turns := [][]agentllm.Event{
|
||||
{ // 第 1 轮:解说文本 + 工具调用(产生工件)。
|
||||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "我生成一份报表。"},
|
||||
@@ -278,7 +278,7 @@ func TestLLMRunnerArtifactPartIsProcess(t *testing.T) {
|
||||
}}},
|
||||
},
|
||||
{ // 第 2 轮(终轮):最终回复文本。
|
||||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "见上方工件。"},
|
||||
agentllm.Event{Kind: agentllm.KindTextDelta, Text: "报表已生成。"},
|
||||
agentllm.Event{Kind: agentllm.KindMessageEnd},
|
||||
},
|
||||
}
|
||||
@@ -293,22 +293,30 @@ func TestLLMRunnerArtifactPartIsProcess(t *testing.T) {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, p := range rc.assistant {
|
||||
if p.Type != core.PartArtifactRef {
|
||||
continue
|
||||
refIdx, lastTextIdx := -1, -1
|
||||
for i, p := range rc.assistant {
|
||||
if p.Type == core.PartText {
|
||||
lastTextIdx = i
|
||||
}
|
||||
found = true
|
||||
if !p.Process {
|
||||
t.Errorf("artifact_ref 部件未标记 Process: %+v", p)
|
||||
}
|
||||
if p.Artifact == nil || p.Artifact.ArtifactID != "a1" {
|
||||
t.Errorf("artifact 引用不符: %+v", p.Artifact)
|
||||
if p.Type == core.PartArtifactRef {
|
||||
if refIdx != -1 {
|
||||
t.Errorf("artifact_ref 部件重复: %+v", rc.assistant)
|
||||
}
|
||||
refIdx = i
|
||||
if p.Process {
|
||||
t.Errorf("artifact_ref 不得标记 Process(须为内容部件): %+v", p)
|
||||
}
|
||||
if p.Artifact == nil || p.Artifact.ArtifactID != "a1" {
|
||||
t.Errorf("artifact 引用不符: %+v", p.Artifact)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if refIdx == -1 {
|
||||
t.Fatalf("未找到 artifact_ref 部件: %+v", rc.assistant)
|
||||
}
|
||||
if lastTextIdx == -1 || refIdx < lastTextIdx {
|
||||
t.Errorf("artifact_ref 应钉在最终正文之后: refIdx=%d lastTextIdx=%d parts=%+v", refIdx, lastTextIdx, rc.assistant)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMRunnerSubmitCardGatedBySetting 验证 D39/D42:ui.cardflow 开启时 submit_card
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -171,18 +172,27 @@ func (m *MockRunner) Execute(rc RunCtx) error {
|
||||
case strings.Contains(user, "#doc"):
|
||||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
|
||||
parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
|
||||
content := "# 演示文档\n\n这是 mock runner 生成的示例工件。\n\n## 要点\n\n- 由 #doc 指令触发\n- 写入 artifacts 并可在右栏查看\n"
|
||||
art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content))
|
||||
// 真 docx 演示(与 write_docx 工具同一生成器):mock 模式即可看到
|
||||
// 「带排版 Word + 交付物卡片」全链路——标题层级/表格/列表都在文档字节里。
|
||||
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
|
||||
buf, derr := docgen.Build("", md)
|
||||
if derr != nil {
|
||||
addText("演示文档生成失败:" + derr.Error())
|
||||
break
|
||||
}
|
||||
addText("Word 文档已生成——点击下方交付卡片右栏预览,或 ⌘1 工件面板查看。")
|
||||
art, err := rc.WriteArtifact("demo-会议纪要.docx", "document",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", buf)
|
||||
if err == nil {
|
||||
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||||
// 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现——
|
||||
// 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard
|
||||
// 返回空 ID,跳过卡部件。
|
||||
if fc, ferr := rc.SubmitCard(artifactFacadeCard("write_artifact", art, "演示工件已生成(#doc)")); ferr == nil && fc.ID != "" {
|
||||
if fc, ferr := rc.SubmitCard(artifactFacadeCard("write_docx", art, "演示 Word 工件已生成(#doc,带排版)")); ferr == nil && fc.ID != "" {
|
||||
parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||||
}
|
||||
}
|
||||
addText("文档已生成,见右侧 Artifacts 标签(也可点击消息中的工件引用)。")
|
||||
|
||||
case strings.Contains(user, "#squad"):
|
||||
rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "调用 Agent-Squad 引擎编排……"})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -444,8 +445,24 @@ func TestArtifactFlow(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "演示文档") {
|
||||
t.Error("artifact content mismatch")
|
||||
// mock #doc 走 docgen:内容是 zip 容器,标题/表格排版在 word/document.xml 里。
|
||||
body := rec.Body.Bytes()
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatalf("artifact 不是合法 docx 容器: %v", err)
|
||||
}
|
||||
var docXML []byte
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, oerr := f.Open()
|
||||
if oerr == nil {
|
||||
docXML, _ = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(docXML), "演示会议纪要") || !strings.Contains(string(docXML), "<w:tbl>") {
|
||||
t.Error("docx 缺少标题/表格排版(仍是纯文本?)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +484,7 @@ func TestArtifactFacadeCard(t *testing.T) {
|
||||
var facade map[string]any
|
||||
for _, ci := range cards {
|
||||
c := ci.(map[string]any)
|
||||
if c["kind"] == "result" && c["title"] == "工具大结果 · demo-报告.md" {
|
||||
if c["kind"] == "result" && c["title"] == "工具大结果 · demo-会议纪要.docx" {
|
||||
facade = c
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// write_docx 工具(提案:正式交付物直接可交):模型交 Markdown,后端用
|
||||
// docgen 转成带 Word 样式的 .docx 工件(标题层级/表格/编号列表/引用/代码段)。
|
||||
// 对话内临时说明与纯文本/数据交付物仍走 write_artifact——两者分工见
|
||||
// 提示词库 tool.write_docx / tool.write_artifact。
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
)
|
||||
|
||||
const docxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
// registerWriteDocx 注册 docx 生成工具:name 建议含 .docx(缺省自动补),
|
||||
// content 为 Markdown 源文(排版结构由 Markdown 表达,样式由生成器保证)。
|
||||
func registerWriteDocx(r *Registry) error {
|
||||
return r.Register(core.ToolDesc{
|
||||
Name: "write_docx", Source: core.SourceBuiltin,
|
||||
Description: "生成带排版样式的 Word 文档(.docx 工件):标题层级、表格、编号列表、引用、代码段、粗斜体与超链接。content 用 Markdown 书写,自动转换为正式 Word 排版。用于正式文档交付物(报告、纪要、方案、说明书)。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"name":{"type":"string","description":"文件名(建议 中文标题.docx;缺扩展名自动补)"},` +
|
||||
`"title":{"type":"string","description":"文档标题(缺省取正文第一个标题)"},` +
|
||||
`"content":{"type":"string","description":"Markdown 正文:#/##/### 表达章节,|表格|、- 列表、> 引用、**粗体**"}}` +
|
||||
`,"required":["name","content"]}`),
|
||||
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if len(args) > 0 {
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return "", fmt.Errorf("write_docx: bad args: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(a.Name) == "" {
|
||||
return "", fmt.Errorf("write_docx: name required")
|
||||
}
|
||||
if strings.TrimSpace(a.Content) == "" {
|
||||
return "", fmt.Errorf("write_docx: content required")
|
||||
}
|
||||
name := strings.TrimSpace(a.Name)
|
||||
if strings.ToLower(filepath.Ext(name)) != ".docx" {
|
||||
name += ".docx"
|
||||
}
|
||||
buf, err := docgen.Build(a.Title, a.Content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write_docx: %w", err)
|
||||
}
|
||||
art, err := rc.WriteArtifact(name, "document", docxMIME, buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("已生成 Word 文档 %s(%s,%d 字节,含标题/表格/列表排版)", art.Name, art.ID, art.Size), nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// write_docx 工具测试:Markdown 进、合法 OOXML docx 工件出(标题/表格排版
|
||||
// 落在文档字节里,而不是纯文本改扩展名);参数校验与扩展名补全。
|
||||
package tools
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteDocxProducesStyledOOXML(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if err := r.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := &fakeFSCtx{}
|
||||
res := call(t, r, rc, "write_docx", `{"name":"评审会议纪要","content":"# 纪要\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-10-01 |\n\n- 要点一\n"}`)
|
||||
if res.Status != "ok" {
|
||||
t.Fatalf("write_docx: %s", res.Output)
|
||||
}
|
||||
if len(rc.written) != 1 || rc.written[0] != "评审会议纪要.docx" {
|
||||
t.Fatalf("工件名不符(应自动补 .docx): %+v", rc.written)
|
||||
}
|
||||
if !strings.Contains(rc.lastMime, "wordprocessingml") {
|
||||
t.Errorf("docx 工件 mime 不符: %s", rc.lastMime)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(rc.lastContent), int64(len(rc.lastContent)))
|
||||
if err != nil {
|
||||
t.Fatalf("产物不是合法 zip/docx 容器: %v", err)
|
||||
}
|
||||
var doc string
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc2, _ := f.Open()
|
||||
b, _ := io.ReadAll(rc2)
|
||||
rc2.Close()
|
||||
doc = string(b)
|
||||
}
|
||||
if doc == "" {
|
||||
t.Fatal("word/document.xml 缺失")
|
||||
}
|
||||
dec := xml.NewDecoder(strings.NewReader(doc))
|
||||
for {
|
||||
_, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("document.xml 不良构: %v", err)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{`w:val="Heading1"`, `<w:tbl>`, `<w:numPr>`} {
|
||||
if !strings.Contains(doc, marker) {
|
||||
t.Errorf("docx 缺少排版标记 %s(仍是纯文本?)", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDocxArgValidation(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if err := r.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct{ name, args string }{
|
||||
{"缺 name", `{"content":"x"}`},
|
||||
{"缺 content", `{"name":"a.docx"}`},
|
||||
{"坏 JSON", `{"name":`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
res := call(t, r, &fakeFSCtx{}, "write_docx", c.args)
|
||||
if res.Status != "error" {
|
||||
t.Errorf("%s: 期望 error,得 %s(%s)", c.name, res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
)
|
||||
|
||||
type fakeFSCtx struct {
|
||||
root string
|
||||
owner string
|
||||
conv string
|
||||
fsToolsOn bool
|
||||
written []string // 登记的工件名
|
||||
root string
|
||||
owner string
|
||||
conv string
|
||||
fsToolsOn bool
|
||||
written []string // 登记的工件名
|
||||
lastContent []byte // 最近一次写入的工件字节
|
||||
lastMime string
|
||||
}
|
||||
|
||||
func (c *fakeFSCtx) ProjectID() string { return "p1" }
|
||||
@@ -32,6 +34,8 @@ func (c *fakeFSCtx) SettingBool(key string, def bool) bool {
|
||||
func (c *fakeFSCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
|
||||
func (c *fakeFSCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
c.written = append(c.written, name)
|
||||
c.lastContent = content
|
||||
c.lastMime = mime
|
||||
return core.Artifact{ID: "art_" + name, Name: name, Kind: kind, MIME: mime, Size: int64(len(content))}, nil
|
||||
}
|
||||
func (c *fakeFSCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil }
|
||||
|
||||
@@ -180,5 +180,8 @@ func (r *Registry) RegisterBuiltin() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerWriteDocx(r); err != nil {
|
||||
return err
|
||||
}
|
||||
return registerPeerTools(r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user