Files
enkly-ai/pkg/officekit/redline.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

303 lines
12 KiB
Go

// 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 <w:ins>/<w:del> 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
// <w:t> 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 <w:r>...</w:r> 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 <w:rPr>,
// 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 <w:r> to contain another <w:r> (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[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?r>`)
// wRPrLeadingRe pulls a run's leading <w:rPr>...</w:rPr> block (if
// any) off the front of its inner content.
var wRPrLeadingRe = regexp.MustCompile(`(?s)^\s*(<(?:[A-Za-z][\w.-]*:)?rPr>.*?</(?:[A-Za-z][\w.-]*:)?rPr>)`)
// wInsBlockRe / wDelBlockRe match one complete <w:ins>/<w:del> 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[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?ins>`)
var wDelBlockRe = regexp.MustCompile(`(?s)<(?:[A-Za-z][\w.-]*:)?del(\s[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?del>`)
// wDelTextElementRe matches one <w:delText>...</w:delText> element,
// the deletion-tracking equivalent of <w:t> used only inside <w:del>
// runs — RejectAllDocxChanges turns these back into ordinary <w:t>
// elements so the restored (un-deleted) text renders normally again.
var wDelTextElementRe = regexp.MustCompile(`(?s)(<(?:[A-Za-z][\w.-]*:)?delText(?:\s[^>]*)?>)(.*?)(</(?:[A-Za-z][\w.-]*:)?delText>)`)
// changeAuthorRe pulls the author="..." (optionally namespace-
// prefixed) attribute value out of a <w:ins>/<w:del> 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, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
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 <w:del>/<w:ins> 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 <w:t> 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, `<del id="%d" author="%s" date="%s"><r>%s<delText xml:space="preserve">%s</delText></r></del>`,
delID, xmlEscapeAttrVal(author), date, rPr, xmlEscapeText(oldText))
fmt.Fprintf(&sb, `<ins id="%d" author="%s" date="%s"><r>%s<t xml:space="preserve">%s</t></r></ins>`,
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 <w:ins> element's content: the tracked-insertion wrapper
// AppendDocxParagraphTracked puts around a brand-new paragraph's run,
// mirroring ReplaceDocxTextTracked's own <ins id author date><r>...
// 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 <w:ins> 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 <w:ins>, 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 <w:body> 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 (<w:del>) is
// removed for good, and inserted text (<w:ins>) 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 (<w:ins>) is discarded, and
// deleted text (<w:del>) 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 <w:ins>...</w:ins> or
// <w:del>...</w:del> 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[^>]*)?>(.*?)</(?:[A-Za-z][\w.-]*:)?(?:ins|del)>`)
// 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
}