feat(docs): oo docs comment — add Word comments to a .docx (#88)
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 stable) (pull_request) Successful in 2m51s
Tests / Test (Go 1.25) (pull_request) Successful in 2m51s
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 stable) (pull_request) Successful in 2m51s
Tests / Test (Go 1.25) (pull_request) Successful in 2m51s
This commit is contained in:
+252
@@ -0,0 +1,252 @@
|
||||
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))
|
||||
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
|
||||
p = docxAddMarkers(p, id)
|
||||
id++
|
||||
break
|
||||
}
|
||||
return p
|
||||
})
|
||||
|
||||
applied := make([]DocxComment, 0, len(comments))
|
||||
for i, c := range comments {
|
||||
if used[i] {
|
||||
applied = append(applied, c)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user