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)]*>.*?`) docxPPrRe = regexp.MustCompile(`(?s)^]*>\s*.*?`) docxOpenRe = regexp.MustCompile(`^]*>`) docxTextRe = regexp.MustCompile(`(?s)]*>(.*?)`) ) // 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(``, id) end := fmt.Sprintf(``, 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, ""); 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("" + s + "")) 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(`` + "\n") b.WriteString(``) for i, c := range comments { fmt.Fprintf(&b, `%s`, i, docxEscape(author), docxInitials(author), "2026-10-07T12:00:00Z", docxEscape(c.Text)) } b.WriteString(``) 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 = `` return []byte(strings.Replace(string(ct), "", override+"", 1)) } func docxAddRel(rels []byte) []byte { if strings.Contains(string(rels), "relationships/comments") { return rels } const rel = `` return []byte(strings.Replace(string(rels), "", rel+"", 1)) } func docxDefaultRels() []byte { return []byte(``) } 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 }