Files
go-onlyoffice/docx_comment.go
T
eSlider 00b55caff8
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 22s
Tests / Test (Go stable) (pull_request) Successful in 37s
fix(docs): pair comment ids with the document order, not the spec order (#92)
2026-10-07 18:45:16 +01:00

251 lines
7.3 KiB
Go

package onlyoffice
import (
"archive/zip"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"regexp"
"strings"
)
// DocxComment is one Word comment. Anchor is matched against the plain text of
// a paragraph (entity-decoded, runs concatenated), so it survives text split
// across runs. At most one comment is placed per paragraph.
type DocxComment struct {
Anchor string `json:"anchor"`
Text string `json:"text"`
}
var (
docxParaRe = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
docxPPrRe = regexp.MustCompile(`(?s)^<w:p\b[^>]*>\s*<w:pPr>.*?</w:pPr>`)
docxOpenRe = regexp.MustCompile(`^<w:p\b[^>]*>`)
docxTextRe = regexp.MustCompile(`(?s)<w:t\b[^>]*>(.*?)</w:t>`)
)
// ParseDocxComments decodes a JSON array of comments.
func ParseDocxComments(data []byte) ([]DocxComment, error) {
var comments []DocxComment
if err := json.Unmarshal(data, &comments); err != nil {
return nil, fmt.Errorf("decode comments: %w", err)
}
return comments, nil
}
// DocxParagraphs returns the concatenated text of every paragraph in a .docx
// document.xml-free input (it takes the raw document.xml bytes).
func DocxParagraphs(documentXML []byte) []string {
out := make([]string, 0, 64)
for _, p := range docxParaRe.FindAllString(string(documentXML), -1) {
if txt := strings.TrimSpace(docxPlain(p)); txt != "" {
out = append(out, txt)
}
}
return out
}
// InjectDocxComments returns a copy of the .docx with real Word comments added,
// each anchored to the first paragraph whose text contains the comment's Anchor.
// The author name is written into every comment. It reports how many comments
// were applied; anchors that match nothing are skipped.
func InjectDocxComments(src []byte, author string, comments []DocxComment) ([]byte, int, error) {
parts, order, err := readDocxZip(src)
if err != nil {
return nil, 0, err
}
doc, ok := parts["word/document.xml"]
if !ok {
return nil, 0, fmt.Errorf("not a docx: word/document.xml missing")
}
if author == "" {
author = "Comment"
}
used := make([]bool, len(comments))
applied := make([]DocxComment, 0, len(comments))
id := 0
newDoc := docxParaRe.ReplaceAllStringFunc(string(doc), func(p string) string {
txt := docxPlain(p)
for i, c := range comments {
if used[i] || c.Anchor == "" || !strings.Contains(txt, c.Anchor) {
continue
}
used[i] = true
// Record in document order so comments.xml id N matches the marker
// with id N (the spec order may differ from the document order).
applied = append(applied, c)
p = docxAddMarkers(p, id)
id++
break
}
return p
})
parts["word/document.xml"] = []byte(newDoc)
parts["word/comments.xml"] = docxBuildComments(author, applied)
parts["[Content_Types].xml"] = docxAddContentType(parts["[Content_Types].xml"])
if rels, ok := parts["word/_rels/document.xml.rels"]; ok {
parts["word/_rels/document.xml.rels"] = docxAddRel(rels)
} else {
parts["word/_rels/document.xml.rels"] = docxDefaultRels()
order = append(order, "word/_rels/document.xml.rels")
}
if !containsStr(order, "word/comments.xml") {
order = append(order, "word/comments.xml")
}
out, err := writeDocxZip(parts, order)
if err != nil {
return nil, 0, err
}
return out, len(applied), nil
}
func docxAddMarkers(p string, id int) string {
start := fmt.Sprintf(`<w:commentRangeStart w:id="%d"/>`, id)
end := fmt.Sprintf(`<w:commentRangeEnd w:id="%d"/><w:r><w:commentReference w:id="%d"/></w:r>`, id, id)
if loc := docxPPrRe.FindStringIndex(p); loc != nil {
p = p[:loc[1]] + start + p[loc[1]:]
} else if loc := docxOpenRe.FindStringIndex(p); loc != nil {
p = p[:loc[1]] + start + p[loc[1]:]
}
if i := strings.LastIndex(p, "</w:p>"); i >= 0 {
p = p[:i] + end + p[i:]
}
return p
}
func docxPlain(p string) string {
var b strings.Builder
for _, m := range docxTextRe.FindAllStringSubmatch(p, -1) {
b.WriteString(docxUnescape(m[1]))
}
return b.String()
}
func docxUnescape(s string) string {
var out strings.Builder
d := xml.NewDecoder(strings.NewReader("<x>" + s + "</x>"))
for {
tok, err := d.Token()
if err != nil {
break
}
if cd, ok := tok.(xml.CharData); ok {
out.Write(cd)
}
}
return out.String()
}
func docxBuildComments(author string, comments []DocxComment) []byte {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
b.WriteString(`<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
for i, c := range comments {
fmt.Fprintf(&b, `<w:comment w:id="%d" w:author="%s" w:initials="%s" w:date="%s"><w:p><w:r><w:t xml:space="preserve">%s</w:t></w:r></w:p></w:comment>`,
i, docxEscape(author), docxInitials(author), "2026-10-07T12:00:00Z", docxEscape(c.Text))
}
b.WriteString(`</w:comments>`)
return []byte(b.String())
}
func docxEscape(s string) string {
var b bytes.Buffer
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
// docxInitials derives up to two initials from an author name.
func docxInitials(author string) string {
var out []rune
for _, f := range strings.Fields(author) {
if r := []rune(f); len(r) > 0 {
out = append(out, r[0])
}
if len(out) == 2 {
break
}
}
return strings.ToUpper(string(out))
}
func docxAddContentType(ct []byte) []byte {
if len(ct) == 0 || strings.Contains(string(ct), "/word/comments.xml") {
return ct
}
const override = `<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>`
return []byte(strings.Replace(string(ct), "</Types>", override+"</Types>", 1))
}
func docxAddRel(rels []byte) []byte {
if strings.Contains(string(rels), "relationships/comments") {
return rels
}
const rel = `<Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>`
return []byte(strings.Replace(string(rels), "</Relationships>", rel+"</Relationships>", 1))
}
func docxDefaultRels() []byte {
return []byte(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>`)
}
func readDocxZip(data []byte) (map[string][]byte, []string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, err
}
parts := make(map[string][]byte, len(zr.File))
order := make([]string, 0, len(zr.File))
for _, f := range zr.File {
rc, err := f.Open()
if err != nil {
return nil, nil, err
}
b, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, nil, err
}
parts[f.Name] = b
order = append(order, f.Name)
}
return parts, order, nil
}
func writeDocxZip(parts map[string][]byte, order []string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
written := map[string]bool{}
for _, name := range order {
data, ok := parts[name]
if !ok || written[name] {
continue
}
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write(data); err != nil {
return nil, err
}
written[name] = true
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func containsStr(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}