Files
Agent-System-Backend/internal/docgen/comparison.go
weichenandCursor c34279ad55
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 1m2s
Bump the renderer to 3f25999 and bind documents to published profile versions
Why: Agent-Document-Renderer main publishes company.default@1.1.0 with a
declarative TOC (spec 04, acceptance 10 §4 §7) and keeps 1.0.0 loadable by
version. The fixed defaultProfileVersion "1.0.0" no longer matched
LoadCompanyDefaultContext (1.1.0), so every delivery failed with a profile
mismatch. New documents must take the latest published version, while
documents that already bind a published version keep rendering with exactly
that version, because published rules are immutable and old versions remain
retrievable (spec 08).

Evidence: go vet ./... ok; go test -count=1 ./... ok (all packages);
go run ./internal/docgen/internal/genrendereridentity -check ok;
scripts/agent-exec validate --repo . ok; scripts/dm governance check --repo .
ok; gofmt -l clean; git diff --cached --check clean. New tests cover the
default binding (1.1.0, TOC present), pinned 1.0.0 (no TOC) and 1.1.0 for
both a request pin and a persisted-Markdown replay, and refusal of unknown
versions and other profile IDs before any profile load.

Impact: docgen resolves the profile version from the persisted Markdown
binding or the request, defaults new documents to
profiles.LatestCompanyDefaultVersion, and loads it through
LoadCompanyDefaultVersionContext. The legacy migration comparison records the
declared TOC as an intentional corporate-layout difference
(RDR-T10-CMP-002) and keeps it out of the body visible-text dimension.
RendererIdentity is regenerated for 3f259990b5d4.

Refs: unified renderer plan Task 13; renderer PR #7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 06:24:36 +00:00

815 lines
28 KiB
Go

package docgen
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
"agent-document-renderer/model"
)
const (
comparisonExceptionTitle = "RDR-T10-CMP-001"
comparisonExceptionCorporate = "RDR-T10-CMP-002"
comparisonExceptionAnchors = "RDR-T10-CMP-003"
comparisonExceptionSafeLinks = "RDR-T10-CMP-004"
)
type comparisonBlock struct {
Kind string `json:"kind"`
Level int `json:"level,omitempty"`
Text []string `json:"text,omitempty"`
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"`
TOC []string `json:"toc"`
PageSize map[string]string `json:"pageSize"`
Margins map[string]string `json:"margins"`
Headers int `json:"headers"`
Footers int `json:"footers"`
Bookmarks int `json:"bookmarks"`
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 {
receipt := ComparisonReceipt{Enabled: true, LegacySHA256: digestBytes(legacy), SharedSHA256: digestBytes(shared)}
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", "table-of-contents", "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)
}
legacyParagraphs := append([]string(nil), legacyDOCX.Paragraphs...)
if len(legacyParagraphs) >= 2 && legacyParagraphs[0] == normalizeComparisonText(title) && legacyParagraphs[1] == normalizeComparisonText(title) {
withoutDuplicate := append([]string(nil), legacyParagraphs[1:]...)
if equalJSON(withoutDuplicate, sharedDOCX.Paragraphs) {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs,
comparisonExceptionTitle, "legacy synthetic Title duplicated the first canonical heading"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
}
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
}
if len(legacyDOCX.TOC) == 0 && len(sharedDOCX.TOC) > 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("table-of-contents", legacyDOCX.TOC, sharedDOCX.TOC,
comparisonExceptionCorporate, "published organization default declares a table of contents"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("table-of-contents", legacyDOCX.TOC, sharedDOCX.TOC, ""))
}
if equalJSON(legacyDOCX.PageSize, sharedDOCX.PageSize) {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-size", legacyDOCX.PageSize, sharedDOCX.PageSize, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: "page-size", Status: "accidental", LegacyHash: digestJSON(legacyDOCX.PageSize), SharedHash: digestJSON(sharedDOCX.PageSize), Detail: "page size changed"})
}
if equalJSON(legacyDOCX.Margins, sharedDOCX.Margins) {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins,
comparisonExceptionCorporate, "published organization default owns page margins"))
}
if legacyDOCX.Headers == sharedDOCX.Headers && legacyDOCX.Footers == sharedDOCX.Footers {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers}, ""))
} else {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers},
comparisonExceptionCorporate, "published organization default adds approved header/footer rules"))
}
if legacyDOCX.Bookmarks == sharedDOCX.Bookmarks {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
} else if legacyDOCX.Bookmarks == 0 && sharedDOCX.Bookmarks > 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks,
comparisonExceptionAnchors, "shared output anchors canonical node IDs"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
}
if legacyDOCX.ExternalLinks == sharedDOCX.ExternalLinks {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
} else if legacyDOCX.ExternalLinks > 0 && sharedDOCX.ExternalLinks == 0 {
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks,
comparisonExceptionSafeLinks, "shared links are inert labels with no external relationship or target leakage"))
} else {
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
}
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)
}
func finishComparison(receipt ComparisonReceipt) ComparisonReceipt {
for _, dimension := range receipt.Dimensions {
switch dimension.Status {
case "intentional":
receipt.IntentionalDifferences = append(receipt.IntentionalDifferences, dimension.Name+": "+dimension.Detail)
case "accidental":
receipt.AccidentalDifferences = append(receipt.AccidentalDifferences, dimension.Name+": "+dimension.Detail)
case "unassessed":
receipt.Unassessed = append(receipt.Unassessed, dimension.Name+": "+dimension.Detail)
}
}
switch {
case len(receipt.Unassessed) > 0:
receipt.Assessment = "unassessed"
case len(receipt.AccidentalDifferences) > 0:
receipt.Assessment = "differences"
default:
receipt.Assessment = "assessed"
}
return receipt
}
func compareExactDimension(name string, legacy, shared any, detail string) ComparisonDimension {
status := "match"
if !equalJSON(legacy, shared) {
status = "accidental"
if detail == "" {
detail = "normalized values differ"
}
}
return ComparisonDimension{Name: name, Status: status, LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), Detail: detail}
}
func intentionalDimension(name string, legacy, shared any, exceptionID, detail string) ComparisonDimension {
return ComparisonDimension{Name: name, Status: "intentional", LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), ExceptionID: exceptionID, Detail: detail}
}
func legacyComparisonStructure(source string) []comparisonBlock {
document := parseMarkdown(source)
blocks := make([]comparisonBlock, 0, len(document.blocks))
for _, block := range document.blocks {
entry := comparisonBlock{Level: block.level}
switch block.kind {
case bHeading:
entry.Kind, entry.Text = "heading", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
case bPara:
entry.Kind, entry.Text = "paragraph", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
case bQuote:
entry.Kind, entry.Text = "quote", []string{normalizeComparisonText(strings.Join(block.lines, "\n"))}
case bCode:
entry.Kind, entry.Text = "code-block", []string{strings.Join(block.lines, "\n") + "\n"}
case bList:
entry.Kind = "list"
for _, item := range block.items {
entry.Text = append(entry.Text, visibleLegacyRuns(document.parseInline(item.text)))
}
case bTable:
entry.Kind = "table"
for _, row := range block.rows {
visible := make([]string, len(row))
for index := range row {
visible[index] = visibleLegacyRuns(document.parseInline(row[index]))
}
entry.Rows = append(entry.Rows, visible)
}
case bHR:
entry.Kind = "thematic-break"
}
blocks = append(blocks, entry)
}
return blocks
}
func canonicalComparisonStructure(document model.CanonicalDocument) []comparisonBlock {
blocks := make([]comparisonBlock, 0, len(document.Root.Children))
for _, node := range document.Root.Children {
entry := comparisonBlock{Kind: string(node.Kind)}
if node.Kind == model.NodeHeading {
fmt.Sscanf(node.Semantic, "heading%d", &entry.Level)
}
switch node.Kind {
case model.NodeHeading, model.NodeParagraph, model.NodeQuote, model.NodeCodeBlock, model.NodeCallout:
entry.Text = []string{normalizeComparisonNodeText(node)}
case model.NodeList:
for _, item := range node.Children {
entry.Text = append(entry.Text, normalizeComparisonNodeText(item))
}
case model.NodeTable:
for _, row := range node.Children {
values := make([]string, len(row.Children))
for index := range row.Children {
values[index] = normalizeComparisonNodeText(row.Children[index])
}
entry.Rows = append(entry.Rows, values)
}
case model.NodeThematicBreak:
default:
continue
}
blocks = append(blocks, entry)
}
return blocks
}
func normalizeComparisonNodeText(node model.Node) string {
if node.Kind == model.NodeCodeBlock {
return node.Text
}
return normalizeComparisonText(node.Text)
}
func visibleLegacyRuns(runs []run) string {
var value strings.Builder
for _, run := range runs {
value.WriteString(run.text)
}
return normalizeComparisonText(value.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 {
return docxComparison{}, err
}
parts := make(map[string][]byte, len(reader.File))
for _, file := range reader.File {
stream, err := file.Open()
if err != nil {
return docxComparison{}, err
}
content, err := io.ReadAll(stream)
stream.Close()
if err != nil {
return docxComparison{}, err
}
parts[file.Name] = content
}
document, found := parts["word/document.xml"]
if !found {
return docxComparison{}, errors.New("word/document.xml missing")
}
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
// A TOC is its field's paragraphs plus an immediately preceding caption
// paragraph excluded from the outline (outlineLvl 9).
inInstruction, paragraphTOC, paragraphCaption := false, false, false
fieldDepth, tocFieldDepth := 0, 0
var pendingCaption []string
var table [][]string
var row []string
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return docxComparison{}, err
}
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
case "p":
inParagraph = true
paragraph.Reset()
paragraphLevel, paragraphNumID, paragraphRule = 0, "", false
paragraphTOC, paragraphCaption = tocFieldDepth > 0, false
case "t":
if inParagraph {
inText = true
}
case "outlineLvl":
if inParagraph && xmlAttributeValue(value.Attr, "val") == "9" {
paragraphCaption = true
}
case "fldChar":
switch xmlAttributeValue(value.Attr, "fldCharType") {
case "begin":
fieldDepth++
case "end":
if fieldDepth == tocFieldDepth {
tocFieldDepth = 0
}
fieldDepth = max(fieldDepth-1, 0)
}
case "instrText":
inInstruction = 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":
copyAttributes(result.Margins, value.Attr, "top", "right", "bottom", "left", "header", "footer", "gutter")
case "bookmarkStart":
result.Bookmarks++
}
case xml.CharData:
if inText {
paragraph.Write(value)
}
if inInstruction && tocFieldDepth == 0 && fieldDepth > 0 {
if fields := strings.Fields(string(value)); len(fields) > 0 && strings.EqualFold(fields[0], "TOC") {
tocFieldDepth, paragraphTOC = fieldDepth, inParagraph
}
}
case xml.EndElement:
switch value.Name.Local {
case "t":
inText = false
case "instrText":
inInstruction = false
case "pBdr":
inParagraphBorder = false
case "p":
text := normalizeComparisonText(paragraph.String())
if paragraphTOC {
result.TOC = append(result.TOC, pendingCaption...)
pendingCaption = nil
if text != "" {
result.TOC = append(result.TOC, text)
}
inParagraph = false
break
}
result.Paragraphs = append(result.Paragraphs, pendingCaption...)
pendingCaption = nil
if text != "" && paragraphCaption {
pendingCaption = []string{text}
} else if text != "" {
result.Paragraphs = append(result.Paragraphs, text)
}
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)
}
}
}
result.Paragraphs = append(result.Paragraphs, pendingCaption...)
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") {
result.Headers++
}
if strings.HasPrefix(lower, "word/footer") && strings.HasSuffix(lower, ".xml") {
result.Footers++
}
if strings.HasSuffix(lower, ".rels") {
result.ExternalLinks += strings.Count(strings.ToLower(string(content)), `targetmode="external"`)
}
if strings.Contains(lower, "vba") || strings.Contains(lower, "activex") || strings.Contains(lower, "embeddings") {
result.ForbiddenObject = true
}
}
xmlText := string(document)
result.Semantics = map[string]bool{
"bold": strings.Contains(xmlText, "<w:b"), "italic": strings.Contains(xmlText, "<w:i"),
"strike": strings.Contains(xmlText, "<w:strike"), "inline-code": strings.Contains(xmlText, "inlineCode") || strings.Contains(xmlText, "Consolas"),
"link-label": strings.Contains(xmlText, `w:val="link"`) || strings.Contains(xmlText, `w:val="Hyperlink"`),
"quote": strings.Contains(strings.ToLower(xmlText), `w:val="quote"`), "code-block": strings.Contains(strings.ToLower(xmlText), `w:val="code`),
"list": strings.Contains(xmlText, "<w:numPr>"), "table": strings.Contains(xmlText, "<w:tbl>"),
}
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 {
wanted[name] = true
}
for _, attribute := range attributes {
if wanted[attribute.Name.Local] {
target[attribute.Name.Local] = attribute.Value
}
}
}
func equalJSON(left, right any) bool { return bytes.Equal(canonicalJSON(left), canonicalJSON(right)) }
func digestJSON(value any) string { return digestBytes(canonicalJSON(value)) }
func canonicalJSON(value any) []byte {
raw, _ := json.Marshal(value)
return raw
}
func digestBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}