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>
815 lines
28 KiB
Go
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[:])
|
|
}
|