Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter. Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally. Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only. Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
894 lines
28 KiB
Go
894 lines
28 KiB
Go
// 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"
|
||
)
|
||
|
||
// legacyBuild is retained only for an explicitly enabled migration comparison.
|
||
// Production callers must use Generate and never fall back here after a shared
|
||
// renderer failure.
|
||
func legacyBuild(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>`
|
||
}
|