Close remaining Task 10 renderer review findings
Why: Direct artifact registration trusted filename extensions, the shared renderer parity test used a reduced fixture, and migration comparison omitted canonical list/task/rule/alignment properties plus their Word presentation. Evidence: go test -count=1 ./...; go test -race ./internal/docgen ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; PYTHONPATH=. python3 -m unittest tests.test_housestyle; git diff --check. Impact: Reject renamed WordprocessingML packages while preserving non-Word artifacts, pin the reviewed renderer semantics, and report canonical and DOCX presentation regressions as accidental differences. Refs: Task 10; /tmp/task10-backend-final-review-last.md
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-document-renderer updated: d5cd8c1260...0d1c5121d1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
package docgen
|
||||
|
||||
const rendererRevision = "d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
|
||||
const rendererRevision = "0d1c5121d137be817db5f98d3c13320dfa919c8a"
|
||||
|
||||
const RendererIdentity = "agent-document-renderer@" + rendererRevision
|
||||
|
||||
@@ -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:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`} {
|
||||
for _, fragment := range []string{
|
||||
`<w:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`,
|
||||
`w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`,
|
||||
`<w:t xml:space="preserve">☑ </w:t>`, `<w:t xml:space="preserve">☐ </w:t>`,
|
||||
`<w:pBdr><w:bottom w:val="single"`, `<w:jc w:val="left"/>`,
|
||||
`<w:jc w:val="center"/>`, `<w:jc w:val="right"/>`,
|
||||
} {
|
||||
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{
|
||||
`<w:ilvl w:val="0"/>`, `<w:ilvl w:val="1"/>`, `<w:ilvl w:val="2"/>`,
|
||||
`<w:lvlOverride w:ilvl="0"><w:startOverride w:val="3"/></w:lvlOverride>`,
|
||||
} {
|
||||
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(`<w:jc w:val="`+value+`"/>`), 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="` + test.contentType + `"/></Types>`
|
||||
pkg := zipOf(t, map[string]string{
|
||||
"[Content_Types].xml": contentTypes,
|
||||
"word/document.xml": `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user