diff --git a/deps/agent-document-renderer b/deps/agent-document-renderer
index d5cd8c1..0d1c512 160000
--- a/deps/agent-document-renderer
+++ b/deps/agent-document-renderer
@@ -1 +1 @@
-Subproject commit d5cd8c126045a27d5a5cc5da23f64d3c737e116a
+Subproject commit 0d1c5121d137be817db5f98d3c13320dfa919c8a
diff --git a/internal/docgen/comparison.go b/internal/docgen/comparison.go
index 8aca2d2..ad48558 100644
--- a/internal/docgen/comparison.go
+++ b/internal/docgen/comparison.go
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
+ "strconv"
"strings"
"agent-document-renderer/model"
@@ -29,6 +30,36 @@ type comparisonBlock struct {
Rows [][]string `json:"rows,omitempty"`
}
+type comparisonList struct {
+ Depth int `json:"depth"`
+ Ordered bool `json:"ordered"`
+ Start int `json:"start,omitempty"`
+ Items []comparisonListItem `json:"items"`
+}
+
+type comparisonListItem struct {
+ Text string `json:"text"`
+ Checked string `json:"checked,omitempty"`
+}
+
+type comparisonTask struct {
+ Text string `json:"text"`
+ Checked string `json:"checked"`
+}
+
+type comparisonSemantics struct {
+ Lists []comparisonList `json:"lists"`
+ Tasks []comparisonTask `json:"tasks"`
+ ThematicBreaks int `json:"thematicBreaks"`
+ TableAlignments [][][]string `json:"tableAlignments"`
+}
+
+type docxNumbering struct {
+ Level int `json:"level"`
+ Format string `json:"format"`
+ Start int `json:"start"`
+}
+
type docxComparison struct {
Paragraphs []string `json:"paragraphs"`
PageSize map[string]string `json:"pageSize"`
@@ -39,6 +70,10 @@ type docxComparison struct {
ExternalLinks int `json:"externalLinks"`
ForbiddenObject bool `json:"forbiddenObject"`
Semantics map[string]bool `json:"semantics"`
+ Numbering []docxNumbering `json:"numbering"`
+ Tasks []comparisonTask `json:"tasks"`
+ ThematicRules int `json:"thematicRules"`
+ TableAlignments [][][]string `json:"tableAlignments"`
}
func compareLegacyAndShared(title string, source []byte, canonical model.CanonicalDocument, legacy, shared []byte) ComparisonReceipt {
@@ -46,12 +81,23 @@ func compareLegacyAndShared(title string, source []byte, canonical model.Canonic
legacyStructure := legacyComparisonStructure(string(source))
sharedStructure := canonicalComparisonStructure(canonical)
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("canonical-structure", legacyStructure, sharedStructure, ""))
+ legacySemantics := sourceComparisonSemantics(string(source))
+ sharedSemantics := canonicalComparisonSemantics(canonical)
+ receipt.Dimensions = append(receipt.Dimensions,
+ compareExactDimension("list-structure", legacySemantics.Lists, sharedSemantics.Lists, ""),
+ compareExactDimension("task-state", legacySemantics.Tasks, sharedSemantics.Tasks, ""),
+ compareExactDimension("thematic-break", legacySemantics.ThematicBreaks, sharedSemantics.ThematicBreaks, ""),
+ compareExactDimension("table-alignment", legacySemantics.TableAlignments, sharedSemantics.TableAlignments, ""),
+ )
legacyDOCX, legacyErr := inspectComparisonDOCX(legacy)
sharedDOCX, sharedErr := inspectComparisonDOCX(shared)
if legacyErr != nil || sharedErr != nil {
detail := fmt.Sprintf("legacy=%v shared=%v", legacyErr, sharedErr)
- for _, name := range []string{"visible-text", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics"} {
+ for _, name := range []string{
+ "visible-text", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics",
+ "word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment",
+ } {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: name, Status: "unassessed", Detail: detail})
}
return finishComparison(receipt)
@@ -106,6 +152,10 @@ func compareLegacyAndShared(title string, source []byte, canonical model.Canonic
receipt.Dimensions = append(receipt.Dimensions,
compareExactDimension("security-objects", legacyDOCX.ForbiddenObject, sharedDOCX.ForbiddenObject, ""),
compareExactDimension("word-semantics", legacyDOCX.Semantics, sharedDOCX.Semantics, ""),
+ compareExactDimension("word-numbering", legacyDOCX.Numbering, sharedDOCX.Numbering, ""),
+ compareExactDimension("word-task-state", legacyDOCX.Tasks, sharedDOCX.Tasks, ""),
+ compareExactDimension("word-thematic-rule", legacyDOCX.ThematicRules, sharedDOCX.ThematicRules, ""),
+ compareExactDimension("word-table-alignment", legacyDOCX.TableAlignments, sharedDOCX.TableAlignments, ""),
)
return finishComparison(receipt)
}
@@ -205,6 +255,7 @@ func canonicalComparisonStructure(document model.CanonicalDocument) []comparison
}
entry.Rows = append(entry.Rows, values)
}
+ case model.NodeThematicBreak:
default:
continue
}
@@ -230,6 +281,192 @@ func visibleLegacyRuns(runs []run) string {
func normalizeComparisonText(value string) string { return strings.Join(strings.Fields(value), " ") }
+func sourceComparisonSemantics(source string) comparisonSemantics {
+ document := parseMarkdown(source)
+ result := comparisonSemantics{}
+ for _, block := range document.blocks {
+ switch block.kind {
+ case bHR:
+ result.ThematicBreaks++
+ case bTable:
+ rows := make([][]string, len(block.rows))
+ for rowIndex, row := range block.rows {
+ rows[rowIndex] = make([]string, len(row))
+ for cellIndex := range row {
+ alignment := "left"
+ if cellIndex < len(block.align) && block.align[cellIndex] != "" {
+ alignment = block.align[cellIndex]
+ }
+ rows[rowIndex][cellIndex] = alignment
+ }
+ }
+ result.TableAlignments = append(result.TableAlignments, rows)
+ }
+ }
+
+ type openList struct {
+ indent int
+ ordered bool
+ index int
+ }
+ var stack []openList
+ inFence := false
+ fence := ""
+ for _, rawLine := range strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") {
+ trimmed := strings.TrimSpace(rawLine)
+ if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
+ marker := trimmed[:3]
+ if !inFence {
+ inFence, fence = true, marker
+ } else if marker == fence {
+ inFence, fence = false, ""
+ }
+ stack = nil
+ continue
+ }
+ if inFence {
+ continue
+ }
+ line, found := parseComparisonListLine(rawLine)
+ if !found {
+ stack = nil
+ continue
+ }
+ for len(stack) > 0 && stack[len(stack)-1].indent > line.indent {
+ stack = stack[:len(stack)-1]
+ }
+ if len(stack) > 0 && stack[len(stack)-1].indent == line.indent && stack[len(stack)-1].ordered != line.ordered {
+ stack = stack[:len(stack)-1]
+ }
+ if len(stack) == 0 || stack[len(stack)-1].indent != line.indent {
+ start := 0
+ if line.ordered {
+ start = line.start
+ }
+ result.Lists = append(result.Lists, comparisonList{Depth: len(stack), Ordered: line.ordered, Start: start})
+ stack = append(stack, openList{indent: line.indent, ordered: line.ordered, index: len(result.Lists) - 1})
+ }
+ list := &result.Lists[stack[len(stack)-1].index]
+ text := normalizeComparisonText(visibleLegacyRuns(document.parseInline(line.text)))
+ list.Items = append(list.Items, comparisonListItem{Text: text, Checked: line.checked})
+ if line.checked != "" {
+ result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: line.checked})
+ }
+ }
+ return result
+}
+
+type comparisonListLine struct {
+ indent int
+ ordered bool
+ start int
+ checked string
+ text string
+}
+
+func parseComparisonListLine(raw string) (comparisonListLine, bool) {
+ line := strings.TrimRight(raw, " \t")
+ indent := 0
+ for indent < len(line) && line[indent] == ' ' {
+ indent++
+ }
+ if indent < len(line) && line[indent] == '\t' {
+ return comparisonListLine{}, false
+ }
+ rest := line[indent:]
+ parsed := comparisonListLine{indent: indent, start: 1}
+ for _, marker := range []string{"- ", "* ", "+ "} {
+ if strings.HasPrefix(rest, marker) {
+ parsed.text = strings.TrimSpace(strings.TrimPrefix(rest, marker))
+ if len(parsed.text) >= 3 && parsed.text[0] == '[' && parsed.text[2] == ']' && (parsed.text[1] == ' ' || parsed.text[1] == 'x' || parsed.text[1] == 'X') {
+ parsed.checked = strconv.FormatBool(parsed.text[1] == 'x' || parsed.text[1] == 'X')
+ parsed.text = strings.TrimSpace(parsed.text[3:])
+ }
+ return parsed, true
+ }
+ }
+ digits := 0
+ for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
+ digits++
+ }
+ if digits == 0 || digits > 9 || digits+1 >= len(rest) || (rest[digits] != '.' && rest[digits] != ')') || rest[digits+1] != ' ' {
+ return comparisonListLine{}, false
+ }
+ value, err := strconv.Atoi(rest[:digits])
+ if err != nil {
+ return comparisonListLine{}, false
+ }
+ parsed.start = value
+ parsed.ordered = true
+ parsed.text = strings.TrimSpace(rest[digits+2:])
+ return parsed, true
+}
+
+func canonicalComparisonSemantics(document model.CanonicalDocument) comparisonSemantics {
+ result := comparisonSemantics{}
+ var addList func(model.Node, int)
+ addList = func(list model.Node, depth int) {
+ ordered := nodePropertyValue(list, "ordered") == "true"
+ start := 0
+ if ordered {
+ start = 1
+ if value := nodePropertyValue(list, "start"); value != "" {
+ if parsed, err := strconv.Atoi(value); err == nil {
+ start = parsed
+ }
+ }
+ }
+ summary := comparisonList{Depth: depth, Ordered: ordered, Start: start}
+ for _, item := range list.Children {
+ checked := nodePropertyValue(item, "checked")
+ text := normalizeComparisonNodeText(item)
+ summary.Items = append(summary.Items, comparisonListItem{Text: text, Checked: checked})
+ if checked != "" {
+ result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: checked})
+ }
+ }
+ result.Lists = append(result.Lists, summary)
+ for _, item := range list.Children {
+ for _, child := range item.Children {
+ if child.Kind == model.NodeList {
+ addList(child, depth+1)
+ }
+ }
+ }
+ }
+ for _, node := range document.Root.Children {
+ switch node.Kind {
+ case model.NodeList:
+ addList(node, 0)
+ case model.NodeThematicBreak:
+ result.ThematicBreaks++
+ case model.NodeTable:
+ rows := make([][]string, len(node.Children))
+ for rowIndex, row := range node.Children {
+ rows[rowIndex] = make([]string, len(row.Children))
+ for cellIndex, cell := range row.Children {
+ alignment := nodePropertyValue(cell, "alignment")
+ if alignment == "" {
+ alignment = "left"
+ }
+ rows[rowIndex][cellIndex] = alignment
+ }
+ }
+ result.TableAlignments = append(result.TableAlignments, rows)
+ }
+ }
+ return result
+}
+
+func nodePropertyValue(node model.Node, name string) string {
+ for _, property := range node.Properties {
+ if property.Name == name {
+ return property.Value
+ }
+ }
+ return ""
+}
+
func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
@@ -255,6 +492,17 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
result := docxComparison{PageSize: map[string]string{}, Margins: map[string]string{}, Semantics: map[string]bool{}}
decoder := xml.NewDecoder(bytes.NewReader(document))
var paragraph strings.Builder
+ type rawNumberedParagraph struct {
+ Level int
+ NumID string
+ Text string
+ }
+ var numbered []rawNumberedParagraph
+ inParagraph, inText, inCell, inParagraphBorder := false, false, false, false
+ paragraphLevel, paragraphNumID, cellAlignment := 0, "", ""
+ paragraphRule := false
+ var table [][]string
+ var row []string
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
@@ -266,6 +514,40 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
+ case "p":
+ inParagraph = true
+ paragraph.Reset()
+ paragraphLevel, paragraphNumID, paragraphRule = 0, "", false
+ case "t":
+ if inParagraph {
+ inText = true
+ }
+ case "numId":
+ if inParagraph {
+ paragraphNumID = xmlAttributeValue(value.Attr, "val")
+ }
+ case "ilvl":
+ if inParagraph {
+ paragraphLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
+ }
+ case "pBdr":
+ if inParagraph {
+ inParagraphBorder = true
+ }
+ case "bottom":
+ if inParagraphBorder {
+ paragraphRule = true
+ }
+ case "tbl":
+ table = nil
+ case "tr":
+ row = nil
+ case "tc":
+ inCell, cellAlignment = true, ""
+ case "jc":
+ if inCell {
+ cellAlignment = xmlAttributeValue(value.Attr, "val")
+ }
case "pgSz":
copyAttributes(result.PageSize, value.Attr, "w", "h", "orient")
case "pgMar":
@@ -274,16 +556,71 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
result.Bookmarks++
}
case xml.CharData:
- paragraph.Write(value)
+ if inText {
+ paragraph.Write(value)
+ }
case xml.EndElement:
- if value.Name.Local == "p" {
- if text := normalizeComparisonText(paragraph.String()); text != "" {
+ switch value.Name.Local {
+ case "t":
+ inText = false
+ case "pBdr":
+ inParagraphBorder = false
+ case "p":
+ text := normalizeComparisonText(paragraph.String())
+ if text != "" {
result.Paragraphs = append(result.Paragraphs, text)
}
- paragraph.Reset()
+ if paragraphNumID != "" {
+ numbered = append(numbered, rawNumberedParagraph{Level: paragraphLevel, NumID: paragraphNumID, Text: text})
+ switch {
+ case strings.HasPrefix(text, "☑"):
+ result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☑"))), Checked: "true"})
+ case strings.HasPrefix(text, "☐"):
+ result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☐"))), Checked: "false"})
+ }
+ }
+ if paragraphRule {
+ result.ThematicRules++
+ }
+ inParagraph = false
+ case "tc":
+ if cellAlignment == "" {
+ cellAlignment = "left"
+ }
+ row = append(row, cellAlignment)
+ inCell = false
+ case "tr":
+ table = append(table, row)
+ case "tbl":
+ result.TableAlignments = append(result.TableAlignments, table)
}
}
}
+ if len(numbered) > 0 {
+ numbering, found := parts["word/numbering.xml"]
+ if !found {
+ return docxComparison{}, errors.New("word/numbering.xml missing")
+ }
+ definitions, instances, err := inspectDOCXNumbering(numbering)
+ if err != nil {
+ return docxComparison{}, err
+ }
+ for _, paragraph := range numbered {
+ instance, found := instances[paragraph.NumID]
+ if !found {
+ return docxComparison{}, fmt.Errorf("numbering instance %s missing", paragraph.NumID)
+ }
+ level := definitions[instance.AbstractID][paragraph.Level]
+ start := level.Start
+ if start == 0 {
+ start = 1
+ }
+ if override, found := instance.Overrides[paragraph.Level]; found {
+ start = override
+ }
+ result.Numbering = append(result.Numbering, docxNumbering{Level: paragraph.Level, Format: level.Format, Start: start})
+ }
+ }
for name, content := range parts {
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "word/header") && strings.HasSuffix(lower, ".xml") {
@@ -310,6 +647,96 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
return result, nil
}
+type docxNumberingLevel struct {
+ Format string
+ Start int
+}
+
+type docxNumberingInstance struct {
+ AbstractID string
+ Overrides map[int]int
+}
+
+func inspectDOCXNumbering(raw []byte) (map[string]map[int]docxNumberingLevel, map[string]docxNumberingInstance, error) {
+ definitions := map[string]map[int]docxNumberingLevel{}
+ instances := map[string]docxNumberingInstance{}
+ decoder := xml.NewDecoder(bytes.NewReader(raw))
+ currentAbstract, currentInstance := "", ""
+ currentLevel, currentOverride := -1, -1
+ for {
+ token, err := decoder.Token()
+ if errors.Is(err, io.EOF) {
+ return definitions, instances, nil
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ switch value := token.(type) {
+ case xml.StartElement:
+ switch value.Name.Local {
+ case "abstractNum":
+ currentAbstract = xmlAttributeValue(value.Attr, "abstractNumId")
+ definitions[currentAbstract] = map[int]docxNumberingLevel{}
+ case "lvl":
+ if currentAbstract != "" {
+ currentLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl"))
+ }
+ case "start":
+ if currentAbstract != "" && currentLevel >= 0 {
+ level := definitions[currentAbstract][currentLevel]
+ level.Start, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
+ definitions[currentAbstract][currentLevel] = level
+ }
+ case "numFmt":
+ if currentAbstract != "" && currentLevel >= 0 {
+ level := definitions[currentAbstract][currentLevel]
+ level.Format = xmlAttributeValue(value.Attr, "val")
+ definitions[currentAbstract][currentLevel] = level
+ }
+ case "num":
+ currentInstance = xmlAttributeValue(value.Attr, "numId")
+ instances[currentInstance] = docxNumberingInstance{Overrides: map[int]int{}}
+ case "abstractNumId":
+ if currentInstance != "" {
+ instance := instances[currentInstance]
+ instance.AbstractID = xmlAttributeValue(value.Attr, "val")
+ instances[currentInstance] = instance
+ }
+ case "lvlOverride":
+ if currentInstance != "" {
+ currentOverride, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl"))
+ }
+ case "startOverride":
+ if currentInstance != "" && currentOverride >= 0 {
+ instance := instances[currentInstance]
+ instance.Overrides[currentOverride], _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
+ instances[currentInstance] = instance
+ }
+ }
+ case xml.EndElement:
+ switch value.Name.Local {
+ case "lvl":
+ currentLevel = -1
+ case "abstractNum":
+ currentAbstract = ""
+ case "lvlOverride":
+ currentOverride = -1
+ case "num":
+ currentInstance = ""
+ }
+ }
+ }
+}
+
+func xmlAttributeValue(attributes []xml.Attr, name string) string {
+ for _, attribute := range attributes {
+ if attribute.Name.Local == name {
+ return attribute.Value
+ }
+ }
+ return ""
+}
+
func copyAttributes(target map[string]string, attributes []xml.Attr, names ...string) {
wanted := make(map[string]bool, len(names))
for _, name := range names {
diff --git a/internal/docgen/docgen_test.go b/internal/docgen/docgen_test.go
index 1cd5228..67dae8d 100644
--- a/internal/docgen/docgen_test.go
+++ b/internal/docgen/docgen_test.go
@@ -9,37 +9,42 @@ import (
"testing"
)
-// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。
+// 样例正文:会议纪要形态,也是 legacy/shared 唯一完整语义奇偶性夹具。
// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。
const sampleMinutes = "# 研发周会纪要\n" + `
## 一、基本信息
-| 项目 | 内容 |
-|---|---|
-| 时间 | 2026-09-30 14:00 |
-| 地点 | 3F 会议室 |
-| 主持人 | 陈威 |
+正文含 **粗体**、*强调*、~~删除~~、` + "\x60inline\x60" + ` 和 [安全标签](https://example.com/private-target)。
## 二、讨论要点
-- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan)
-- 排版要求:标题层级、表格、列表
- - 二级要点:交付物卡片
+- **架构**:共享渲染器
+- 排版要求
+ 1. 有序子项
+ - 深层无序子项
- [x] 已完成:预览装配
- [ ] 待办:样式打磨
> 会议结论:按周五节点交付。
+` + "\x60\x60\x60go" + `
+fmt.Println("backtick")
+` + "\x60\x60\x60" + `
+
+~~~go
+fmt.Println("tilde")
+~~~~
+
## 三、决议
-1. 采用 write_docx 工具
-2. 对话内出交付物卡片
-
-` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + `
+3. 第三项
+4. 第四项
---
-后续:周报合并。
+| 左 | 中 | 右 | 默认 |
+| :--- | :---: | ---: | --- |
+| L | C | R | D |
`
func buildSample(t *testing.T) []byte {
@@ -166,7 +171,7 @@ func TestBuildHyperlinkRels(t *testing.T) {
rc.Close()
}
}
- if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") {
+ if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/private-target") {
t.Errorf("超链接 relationship 缺失: %s", rels)
}
}
diff --git a/internal/docgen/renderer_identity_generated.go b/internal/docgen/renderer_identity_generated.go
index f997072..cae3eec 100644
--- a/internal/docgen/renderer_identity_generated.go
+++ b/internal/docgen/renderer_identity_generated.go
@@ -2,6 +2,6 @@
package docgen
-const rendererRevision = "d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
+const rendererRevision = "0d1c5121d137be817db5f98d3c13320dfa919c8a"
const RendererIdentity = "agent-document-renderer@" + rendererRevision
diff --git a/internal/docgen/renderer_integration_test.go b/internal/docgen/renderer_integration_test.go
index ca670cb..d7d4074 100644
--- a/internal/docgen/renderer_integration_test.go
+++ b/internal/docgen/renderer_integration_test.go
@@ -60,23 +60,6 @@ func workspaceRequest(markdown []byte) Request {
}
}
-const semanticMarkdown = `# **Semantic** report
-
-Paragraph with **bold**, *italic*, ~~strike~~, ` + "`inline code`" + `, and [safe **label**](https://example.com/private-target).
-
-> Quoted **evidence** with *emphasis*.
-
-` + "```go" + `
-fmt.Println("safe")
-` + "```" + `
-
-- **bold item**
-
-| Item | Meaning |
-|---|---|
-| ` + "`code cell`" + ` | [table label](#semantic) |
-`
-
func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) {
request := workspaceRequest([]byte(representativeMarkdown))
first, err := Generate(context.Background(), request)
@@ -207,14 +190,35 @@ func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) {
}
}
-func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *testing.T) {
- request := workspaceRequest([]byte(semanticMarkdown))
- request.Title = "Semantic report"
+func TestGeneratePreservesCompleteLegacyMarkdownContract(t *testing.T) {
+ request := workspaceRequest([]byte(sampleMinutes))
delivery, err := Generate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
- var quote, code *model.Node
+ replayed, err := markdownrenderer.Parse(delivery.Markdown.Bytes, markdownrenderer.Options{
+ MaxBytes: 2 << 20, MaxNodes: 10_000, MaxDepth: 64,
+ })
+ if err != nil || !reflect.DeepEqual(delivery.Document, replayed.Document) {
+ t.Fatalf("complete legacy fixture did not replay canonically: err=%v", err)
+ }
+
+ wantKinds := []model.NodeKind{
+ model.NodeHeading, model.NodeHeading, model.NodeParagraph,
+ model.NodeHeading, model.NodeList, model.NodeQuote,
+ model.NodeCodeBlock, model.NodeCodeBlock, model.NodeHeading,
+ model.NodeList, model.NodeThematicBreak, model.NodeTable,
+ }
+ gotKinds := make([]model.NodeKind, len(delivery.Document.Root.Children))
+ for index := range delivery.Document.Root.Children {
+ gotKinds[index] = delivery.Document.Root.Children[index].Kind
+ }
+ if !reflect.DeepEqual(gotKinds, wantKinds) {
+ t.Fatalf("canonical block kinds = %#v, want %#v", gotKinds, wantKinds)
+ }
+
+ var quote *model.Node
+ var codes []*model.Node
var hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink bool
var walkInline func([]model.Inline)
walkInline = func(inlines []model.Inline) {
@@ -240,7 +244,7 @@ func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *test
quote = node
}
if node.Kind == model.NodeCodeBlock {
- code = node
+ codes = append(codes, node)
}
walkInline(node.Inlines)
for index := range node.Children {
@@ -248,23 +252,71 @@ func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *test
}
}
walk(&delivery.Document.Root)
- if quote == nil || code == nil || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
- t.Fatalf("semantic canonical document incomplete: quote=%v code=%v strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, code != nil, hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
+ if quote == nil || len(codes) != 2 || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
+ t.Fatalf("semantic canonical document incomplete: quote=%v codes=%d strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, len(codes), hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
}
+ if codes[0].Text != "fmt.Println(\"backtick\")\n" || codes[1].Text != "fmt.Println(\"tilde\")\n" {
+ t.Fatalf("fenced code text = %q / %q", codes[0].Text, codes[1].Text)
+ }
+
+ unordered := delivery.Document.Root.Children[4]
+ orderedChild := unordered.Children[1].Children[0]
+ deepBullet := orderedChild.Children[0].Children[0]
+ if nodePropertyValue(unordered, "ordered") != "false" ||
+ nodePropertyValue(orderedChild, "ordered") != "true" ||
+ nodePropertyValue(deepBullet, "ordered") != "false" {
+ t.Fatalf("nested list semantics = %#v / %#v / %#v", unordered.Properties, orderedChild.Properties, deepBullet.Properties)
+ }
+ if nodePropertyValue(unordered.Children[2], "checked") != "true" || nodePropertyValue(unordered.Children[3], "checked") != "false" {
+ t.Fatalf("task states = %#v / %#v", unordered.Children[2].Properties, unordered.Children[3].Properties)
+ }
+ ordered := delivery.Document.Root.Children[9]
+ if nodePropertyValue(ordered, "ordered") != "true" || nodePropertyValue(ordered, "start") != "3" {
+ t.Fatalf("ordered-list start = %#v, want 3", ordered.Properties)
+ }
+ table := delivery.Document.Root.Children[11]
+ wantAlignments := []string{"left", "center", "right", ""}
+ for rowIndex, row := range table.Children {
+ for cellIndex, cell := range row.Children {
+ if got := nodePropertyValue(cell, "alignment"); got != wantAlignments[cellIndex] {
+ t.Fatalf("row %d cell %d alignment = %q, want %q", rowIndex, cellIndex, got, wantAlignments[cellIndex])
+ }
+ }
+ }
+
documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml")
- for _, fragment := range []string{``, ``, ``, `w:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, ``, ``} {
+ for _, fragment := range []string{
+ ``, ``, ``, `w:val="inlineCode"`, `w:val="link"`,
+ `w:val="quote"`, `w:val="code"`, ``, ``,
+ `☑ `, `☐ `,
+ ``,
+ ``, ``,
+ } {
if !strings.Contains(documentXML, fragment) {
t.Errorf("Word output missing %s", fragment)
}
}
+ numberingXML := docxPart(t, delivery.DOCX.Bytes, "word/numbering.xml")
+ for _, fragment := range []string{
+ ``, ``, ``,
+ ``,
+ } {
+ if !strings.Contains(documentXML+numberingXML, fragment) {
+ t.Errorf("Word numbering missing %s", fragment)
+ }
+ }
if bytes.Contains(delivery.DOCX.Bytes, []byte("https://example.com/private-target")) || strings.Contains(documentXML, "TargetMode=\"External\"") {
t.Fatal("safe link target leaked into DOCX package")
}
- replayed := request
- replayed.Markdown = delivery.Markdown.Bytes
- second, err := Generate(context.Background(), replayed)
- if err != nil || !reflect.DeepEqual(delivery.Document, second.Document) {
- t.Fatalf("semantic Markdown did not replay canonically: err=%v", err)
+ legacy, err := legacyBuild(request.Title, sampleMinutes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ receipt := compareLegacyAndShared(request.Title, []byte(sampleMinutes), delivery.Document, legacy, delivery.DOCX.Bytes)
+ for _, name := range []string{"list-structure", "task-state", "thematic-break", "table-alignment"} {
+ if dimension := comparisonDimension(t, receipt, name); dimension.Status != "match" {
+ t.Errorf("complete fixture comparison dimension %s = %#v, want match", name, dimension)
+ }
}
}
@@ -308,7 +360,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T)
}
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
- const want = "agent-document-renderer@d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
+ const want = "agent-document-renderer@0d1c5121d137be817db5f98d3c13320dfa919c8a"
if RendererIdentity != want {
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
}
@@ -555,7 +607,7 @@ func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
}
func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) {
- const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- 统一解析入口\n- 保留 Markdown\n\n| 项目 | 状态 |\n|---|---|\n| 解析器 | 统一 |\n"
+ const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- [x] 已完成\n- [ ] 待办\n\n1. 第一项\n2. 第二项\n\n---\n\n| 左 | 中 | 右 |\n| :--- | :---: | ---: |\n| L | C | R |\n"
request := workspaceRequest([]byte(common))
delivery, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies())
if err != nil {
@@ -575,6 +627,54 @@ func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *test
t.Errorf("comparison omitted reviewed exception %s: %#v", id, delivery.Comparison.Dimensions)
}
}
+ for _, name := range []string{
+ "list-structure", "task-state", "thematic-break", "table-alignment",
+ "word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment",
+ } {
+ dimension := comparisonDimension(t, delivery.Comparison, name)
+ if dimension.Status != "match" {
+ t.Errorf("comparison dimension %s = %#v, want match", name, dimension)
+ }
+ }
+}
+
+func TestComparisonDetectsCanonicalAndDOCXTableAlignmentLoss(t *testing.T) {
+ const aligned = "# Alignment\n\n| Left | Center | Right | Default |\n| :--- | :---: | ---: | --- |\n| L | C | R | D |\n"
+ request := workspaceRequest([]byte(aligned))
+ request.Title = "Alignment"
+ delivery, err := Generate(context.Background(), request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacy, err := legacyBuild(request.Title, aligned)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ canonicalWithoutAlignment := delivery.Document.Clone()
+ for rowIndex := range canonicalWithoutAlignment.Root.Children[1].Children {
+ for cellIndex := range canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children {
+ canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children[cellIndex].Properties = nil
+ }
+ }
+ canonicalLoss := compareLegacyAndShared(request.Title, []byte(aligned), canonicalWithoutAlignment, legacy, delivery.DOCX.Bytes)
+ if dimension := comparisonDimension(t, canonicalLoss, "table-alignment"); dimension.Status != "accidental" {
+ t.Fatalf("canonical table-alignment loss = %#v, want accidental", dimension)
+ }
+
+ docxWithoutAlignment := rewriteDOCXPart(t, delivery.DOCX.Bytes, "word/document.xml", func(content []byte) []byte {
+ for _, value := range []string{"left", "center", "right"} {
+ content = bytes.ReplaceAll(content, []byte(``), nil)
+ }
+ return content
+ })
+ docxLoss := compareLegacyAndShared(request.Title, []byte(aligned), delivery.Document, legacy, docxWithoutAlignment)
+ if docxLoss.Assessment != "differences" {
+ t.Fatalf("DOCX alignment loss assessment = %q, want differences", docxLoss.Assessment)
+ }
+ if dimension := comparisonDimension(t, docxLoss, "word-table-alignment"); dimension.Status != "accidental" {
+ t.Fatalf("DOCX table-alignment loss = %#v, want accidental", dimension)
+ }
}
func TestLegacyComparisonReportsAccidentalAndUnassessedDimensions(t *testing.T) {
@@ -660,3 +760,49 @@ func docxPart(t *testing.T, raw []byte, name string) string {
t.Fatalf("DOCX part %s missing", name)
return ""
}
+
+func comparisonDimension(t *testing.T, receipt ComparisonReceipt, name string) ComparisonDimension {
+ t.Helper()
+ for _, dimension := range receipt.Dimensions {
+ if dimension.Name == name {
+ return dimension
+ }
+ }
+ t.Fatalf("comparison dimension %s missing: %#v", name, receipt.Dimensions)
+ return ComparisonDimension{}
+}
+
+func rewriteDOCXPart(t *testing.T, raw []byte, name string, rewrite func([]byte) []byte) []byte {
+ t.Helper()
+ reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var output bytes.Buffer
+ writer := zip.NewWriter(&output)
+ for _, file := range reader.File {
+ stream, err := file.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, err := io.ReadAll(stream)
+ stream.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if file.Name == name {
+ content = rewrite(content)
+ }
+ target, err := writer.Create(file.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := target.Write(content); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return output.Bytes()
+}
diff --git a/internal/tools/exec.go b/internal/tools/exec.go
index a582435..3784bb2 100644
--- a/internal/tools/exec.go
+++ b/internal/tools/exec.go
@@ -28,6 +28,8 @@ const (
execSetting = "runtime.exec"
)
+var errDirectWordRegistration = errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
+
// execHardOff 启动级硬关闭(组合根依启动参数设置)。runtime.exec 是用户可写的
// 业务设置,多用户部署时任何人都能把它改回 true;硬关闭不读设置,设置无法覆盖。
var execHardOff atomic.Bool
@@ -122,7 +124,7 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
return "", fmt.Errorf("register_artifact: %w", err)
}
if isWordBinaryName(abs) {
- return "", errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
+ return "", errDirectWordRegistration
}
st, err := os.Stat(abs)
if err != nil {
@@ -138,6 +140,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
if err != nil {
return "", err
}
+ if isWordprocessingMLPackage(b) {
+ return "", errDirectWordRegistration
+ }
if err := ooxmlStructuralCheck(filepath.Base(abs), b); err != nil {
return "", fmt.Errorf("register_artifact: %s 结构自检未通过:%v。请用 housestyle kit 重建(见 $AGENT_SKILLS_DIR/house-style/SKILL.md),不要用 zipfile 手工拼 OOXML", a.Path, err)
}
diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go
index e0cf467..292e995 100644
--- a/internal/tools/ooxmlgate.go
+++ b/internal/tools/ooxmlgate.go
@@ -44,6 +44,22 @@ func ooxmlStructuralCheck(name string, content []byte) error {
}
}
+// isWordprocessingMLPackage identifies Word OPC packages from their mandatory
+// main document part instead of trusting the caller-controlled filename. The
+// part is shared by document/template and macro-enabled variants.
+func isWordprocessingMLPackage(content []byte) bool {
+ zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
+ if err != nil {
+ return false
+ }
+ for _, file := range zr.File {
+ if strings.EqualFold(strings.TrimPrefix(file.Name, "/"), "word/document.xml") {
+ return true
+ }
+ }
+ return false
+}
+
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
// (docx/xlsx 底线)。
func ooxmlCheckParts(content []byte, parts ...string) error {
diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go
index b6da748..b858435 100644
--- a/internal/tools/ooxmlgate_test.go
+++ b/internal/tools/ooxmlgate_test.go
@@ -3,6 +3,7 @@ package tools
import (
"archive/zip"
"bytes"
+ "encoding/json"
"os"
"path/filepath"
"strings"
@@ -349,3 +350,65 @@ func TestRegisterArtifactStructuralGate(t *testing.T) {
t.Fatalf("valid pptx must register: %s %s", res.Status, res.Output)
}
}
+
+func TestRegisterArtifactRejectsRenamedWordprocessingMLPackages(t *testing.T) {
+ r := NewRegistry()
+ if err := RegisterExecTools(r); err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
+ sandbox := filepath.Join(root, "o1", "c1")
+ if err := os.MkdirAll(sandbox, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ wordTypes := []struct {
+ name string
+ contentType string
+ }{
+ {"report.zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"},
+ {"macro.bin", "application/vnd.ms-word.document.macroEnabled.main+xml"},
+ {"template.data", "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"},
+ {"template.docx ", "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"},
+ }
+ for _, test := range wordTypes {
+ t.Run(test.name, func(t *testing.T) {
+ contentTypes := ``
+ pkg := zipOf(t, map[string]string{
+ "[Content_Types].xml": contentTypes,
+ "word/document.xml": ``,
+ })
+ if err := os.WriteFile(filepath.Join(sandbox, test.name), pkg, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, test.name)+`}`)
+ if res.Status != "error" || !strings.Contains(res.Output, "direct Word registration is forbidden") {
+ t.Fatalf("renamed WordprocessingML package must be refused: %s %s", res.Status, res.Output)
+ }
+ })
+ }
+
+ allowed := map[string][]byte{
+ "dataset.zip": zipOf(t, map[string]string{"data/payload.json": `{"ok":true}`}),
+ "payload.bin": {0x00, 0x01, 0x02, 0xff},
+ }
+ for name, content := range allowed {
+ if err := os.WriteFile(filepath.Join(sandbox, name), content, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, name)+`}`)
+ if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") {
+ t.Fatalf("non-Word artifact %s must register: %s %s", name, res.Status, res.Output)
+ }
+ }
+}
+
+func mustJSON(t *testing.T, value string) string {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}