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
251 lines
7.3 KiB
Go
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
|
|
}
|