// redline.go adds lightweight "track changes" (redlining) support on // top of the plain append/replace operations in docx.go: the same // kind of edit — change this document's text — but recorded as a // Word-reviewable / pair instead of a silent in-place // rewrite, plus accept/reject to resolve those changes afterward. // Word, LibreOffice and Google Docs (on import) all read and display // these the same way: Review > Track Changes. // // Scope, deliberately: a tracked replace marks the WHOLE run that // contained a match as changed (old text struck through, new text // inserted next to it) rather than computing a minimal character-level // diff within the run — the same "whole unit, not a sub-span" // tradeoff ReplaceDocxText already makes implicitly by working at the // level (see its doc comment in docx.go). A run in a real // document is usually already one coherent span of text in one // formatting (Word splits runs on formatting changes, not mid-word), // so in practice this reads as a natural redline rather than an // oversized strikethrough. package officekit import ( "bytes" "encoding/xml" "fmt" "regexp" "strconv" "strings" "time" ) // wRunRe matches one complete ... run — its opening tag, // any attributes, its full inner content, and its closing tag — so a // tracked replace can pull the run's formatting (its leading , // if any) out and copy it into both halves of the del/ins pair it // produces. Safe as a non-nested regex because OOXML's schema never // allows a to contain another (a run is the schema's // smallest text-formatting unit, not a container for other runs). var wRunRe = regexp.MustCompile(`(?s)<((?:[A-Za-z][\w.-]*:)?r)(\s[^>]*)?>(.*?)`) // wRPrLeadingRe pulls a run's leading ... block (if // any) off the front of its inner content. var wRPrLeadingRe = regexp.MustCompile(`(?s)^\s*(<(?:[A-Za-z][\w.-]*:)?rPr>.*?)`) // wInsBlockRe / wDelBlockRe match one complete / block, // capturing its opening tag's attributes and its inner content // separately — AcceptAllDocxChanges, RejectAllDocxChanges and // ReadDocxRevisions all work by matching one or the other and either // keeping, dropping, or reporting on what's inside. var wInsBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?ins(\s[^>]*)?>(.*?)`) var wDelBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?del(\s[^>]*)?>(.*?)`) // wDelTextElementRe matches one ... element, // the deletion-tracking equivalent of used only inside // runs — RejectAllDocxChanges turns these back into ordinary // elements so the restored (un-deleted) text renders normally again. var wDelTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?delText(?:\s[^>]*)?>)(.*?)()`) // changeAuthorRe pulls the author="..." (optionally namespace- // prefixed) attribute value out of a / opening tag's // captured attribute string. var changeAuthorRe = regexp.MustCompile(`(?:^|\s)(?:[\w.-]+:)?author="([^"]*)"`) // revisionIDRe finds every existing id="N" attribute in a // document.xml, so new tracked changes can continue numbering from // the highest ID already present rather than risking a collision with // revisions from an earlier edit (an uploaded document, or an earlier // tool call in the same session). var revisionIDRe = regexp.MustCompile(`id="(\d+)"`) func nextRevisionIDFrom(doc []byte) int { max := 0 for _, m := range revisionIDRe.FindAllSubmatch(doc, -1) { if n, err := strconv.Atoi(string(m[1])); err == nil && n > max { max = n } } return max + 1 } // revisionDate is w:date's expected format (ISO 8601, UTC, second // precision) — the same convention Word itself writes. func revisionDate() string { return time.Now().UTC().Format("2006-01-02T15:04:05Z") } func xmlEscapeAttrVal(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "\"", """) s = strings.ReplaceAll(s, "<", "<") return s } // ReplaceDocxTextTracked is ReplaceDocxText's tracked-changes sibling: // instead of silently rewriting matching text runs in place, it turns // each matching run into a / pair recording who changed // it and when, exactly as if a human reviewer had made the edit with // Track Changes switched on in Word. See this file's package doc for // the whole-run-not-sub-span scope this operates at. author defaults // to "enkly agent" if empty. func ReplaceDocxTextTracked(data []byte, find, replace, author string) ([]byte, int, error) { if author == "" { author = "enkly agent" } raw, ok, err := readZipPart(data, docxDocumentPart) if err != nil { return nil, 0, err } if !ok { return nil, 0, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart) } nextID := nextRevisionIDFrom(raw) date := revisionDate() changed := 0 newDoc := wRunRe.ReplaceAllFunc(raw, func(match []byte) []byte { g := wRunRe.FindSubmatch(match) inner := g[3] tm := wTextElementRe.FindSubmatch(inner) if tm == nil { return match // a tab, break, or field run — no to compare against } oldText := xmlUnescapeText(tm[2]) if !strings.Contains(oldText, find) { return match } newText := strings.ReplaceAll(oldText, find, replace) changed++ rPr := "" if m := wRPrLeadingRe.FindSubmatch(inner); m != nil { rPr = string(m[1]) } delID, insID := nextID, nextID+1 nextID += 2 var sb strings.Builder fmt.Fprintf(&sb, `%s%s`, delID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(oldText)) fmt.Fprintf(&sb, `%s%s`, insID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(newText)) return []byte(sb.String()) }) if changed == 0 { return nil, 0, fmt.Errorf("officekit: %q not found in any text run", find) } out, err := replaceZipPart(data, docxDocumentPart, newDoc) return out, changed, err } // wIns is a element's content: the tracked-insertion wrapper // AppendDocxParagraphTracked puts around a brand-new paragraph's run, // mirroring ReplaceDocxTextTracked's own ... // shape above but built with encoding/xml (like the rest of a freshly // generated paragraph fragment) rather than string formatting, since // here — unlike a splice into arbitrary existing XML — the whole // fragment is this package's own output. type wIns struct { ID int `xml:"id,attr"` Author string `xml:"author,attr"` Date string `xml:"date,attr"` Run wRun `xml:"r"` } // wInsParagraphFragment is wParagraphFragment's (docx.go) // tracked-insert counterpart: same paragraph content, but with its // run wrapped in so a reviewer sees the whole new paragraph as // a pending insertion rather than it silently becoming part of the // document. type wInsParagraphFragment struct { XMLName xml.Name `xml:"http://schemas.openxmlformats.org/wordprocessingml/2006/main p"` PPr *wPPr `xml:"pPr,omitempty"` Ins wIns `xml:"ins"` } // AppendDocxParagraphTracked is AppendDocxParagraph's tracked-changes // sibling: the new paragraph is spliced in exactly the same way (see // AppendDocxParagraph's doc comment in docx.go), but its run is // wrapped in , so it shows up in Word as a pending insertion // (Review > Track Changes) instead of silently becoming part of the // document. author defaults to "enkly agent" if empty. func AppendDocxParagraphTracked(data []byte, p Paragraph, author string) ([]byte, error) { if author == "" { author = "enkly agent" } raw, ok, err := readZipPart(data, docxDocumentPart) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart) } wp := toWParagraph(p) frag := wInsParagraphFragment{ PPr: wp.PPr, Ins: wIns{ID: nextRevisionIDFrom(raw), Author: author, Date: revisionDate(), Run: wp.Run}, } paraXML, err := xml.Marshal(frag) if err != nil { return nil, fmt.Errorf("officekit: marshal tracked paragraph: %w", err) } at := bodyInsertionPoint(raw) if at < 0 { return nil, fmt.Errorf("officekit: %s has no to append to (not a valid .docx)", docxDocumentPart) } newDoc := make([]byte, 0, len(raw)+len(paraXML)) newDoc = append(newDoc, raw[:at]...) newDoc = append(newDoc, paraXML...) newDoc = append(newDoc, raw[at:]...) return replaceZipPart(data, docxDocumentPart, newDoc) } // AcceptAllDocxChanges resolves every tracked change in the document // the way Word's "Accept All Changes" does: deleted text () is // removed for good, and inserted text () becomes ordinary, // no-longer-marked content. func AcceptAllDocxChanges(data []byte) ([]byte, error) { raw, ok, err := readZipPart(data, docxDocumentPart) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart) } newDoc := wDelBlockRe.ReplaceAll(raw, nil) newDoc = wInsBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte { return wInsBlockRe.FindSubmatch(m)[2] }) return replaceZipPart(data, docxDocumentPart, newDoc) } // RejectAllDocxChanges resolves every tracked change the way Word's // "Reject All Changes" does: inserted text () is discarded, and // deleted text () is restored as ordinary content. func RejectAllDocxChanges(data []byte) ([]byte, error) { raw, ok, err := readZipPart(data, docxDocumentPart) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart) } newDoc := wInsBlockRe.ReplaceAll(raw, nil) newDoc = wDelBlockRe.ReplaceAllFunc(newDoc, func(m []byte) []byte { inner := wDelBlockRe.FindSubmatch(m)[2] // delText -> t, so the restored text renders as normal content // rather than staying tagged as a (now orphaned) deletion. return wDelTextElementRe.ReplaceAllFunc(inner, func(tm []byte) []byte { g := wDelTextElementRe.FindSubmatch(tm) open := bytes.Replace(g[1], []byte("delText"), []byte("t"), 1) closeTag := bytes.Replace(g[3], []byte("delText"), []byte("t"), 1) out := make([]byte, 0, len(open)+len(g[2])+len(closeTag)) out = append(out, open...) out = append(out, g[2]...) out = append(out, closeTag...) return out }) }) return replaceZipPart(data, docxDocumentPart, newDoc) } // wChangeBlockRe matches one complete ... or // ... block, capturing which kind it is, its opening // tag's attributes, and its inner content — ReadDocxRevisions' single // pass over the document. var wChangeBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?(ins|del)(\s[^>]*)?>(.*?)`) // ReadDocxRevisions lists every tracked change (insertion or deletion) // currently in the document — author and the affected text, in // document order — so a caller can review what Track Changes has // recorded before deciding whether to accept or reject it. Returns // ("", nil) if the document has no tracked changes at all. func ReadDocxRevisions(data []byte) (string, error) { raw, ok, err := readZipPart(data, docxDocumentPart) if err != nil { return "", err } if !ok { return "", fmt.Errorf("officekit: %s not found (not a valid .docx)", docxDocumentPart) } matches := wChangeBlockRe.FindAllSubmatch(raw, -1) if len(matches) == 0 { return "", nil } var sb strings.Builder for i, m := range matches { kind, attrs, inner := string(m[1]), m[2], m[3] author := "unknown" if am := changeAuthorRe.FindSubmatch(attrs); am != nil { author = string(am[1]) } label, textRe := "inserted", wTextElementRe if kind == "del" { label, textRe = "deleted", wDelTextElementRe } var text strings.Builder for _, tm := range textRe.FindAllSubmatch(inner, -1) { text.WriteString(xmlUnescapeText(tm[2])) } fmt.Fprintf(&sb, "%d. %s by %s: %q\n", i+1, label, author, text.String()) } return strings.TrimRight(sb.String(), "\n"), nil }