303 lines
12 KiB
Go
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, "&", "&")
|
|
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 <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
|
|
}
|