package office import ( "archive/zip" "bufio" "bytes" "encoding/xml" "fmt" "io" "io/fs" "os" "strings" ) // Contains functions to work with data from a zip file type ZipData interface { files() []*zip.File close() error } // Type for in memory zip files type ZipInMemory struct { data *zip.Reader } func (d ZipInMemory) files() []*zip.File { return d.data.File } // Since there is nothing to close for in memory, just nil the data and return nil func (d ZipInMemory) close() error { return nil } // Type for zip files read from disk type ZipFile struct { data *zip.ReadCloser } func (d ZipFile) files() []*zip.File { return d.data.File } func (d ZipFile) close() error { return d.data.Close() } type ReplaceDocx struct { zipReader ZipData content string links string headers map[string]string footers map[string]string images map[string]string } func (r *ReplaceDocx) Editable() *Docx { return &Docx{ files: r.zipReader.files(), content: r.content, links: r.links, headers: r.headers, footers: r.footers, images: r.images, } } func (r *ReplaceDocx) Close() error { return r.zipReader.close() } type Docx struct { files []*zip.File content string links string headers map[string]string footers map[string]string images map[string]string } func (d *Docx) GetContent() string { return d.content } func (d *Docx) SetContent(content string) { d.content = content } func (d *Docx) ReplaceRaw(oldString string, newString string, num int) { d.content = strings.Replace(d.content, oldString, newString, num) } func (d *Docx) Replace(oldString string, newString string, num int) (err error) { oldString, err = encode(oldString) if err != nil { return err } newString, err = encode(newString) if err != nil { return err } d.content = strings.Replace(d.content, oldString, newString, num) return nil } func (d *Docx) ReplaceLink(oldString string, newString string, num int) (err error) { oldString, err = encode(oldString) if err != nil { return err } newString, err = encode(newString) if err != nil { return err } d.links = strings.Replace(d.links, oldString, newString, num) return nil } func (d *Docx) ReplaceHeader(oldString string, newString string) (err error) { return replaceHeaderFooter(d.headers, oldString, newString) } func (d *Docx) ReplaceFooter(oldString string, newString string) (err error) { return replaceHeaderFooter(d.footers, oldString, newString) } func (d *Docx) WriteToFile(path string) (err error) { var target *os.File target, err = os.Create(path) if err != nil { return } defer target.Close() err = d.Write(target) return } func (d *Docx) Write(ioWriter io.Writer) (err error) { w := zip.NewWriter(ioWriter) for _, file := range d.files { var writer io.Writer var readCloser io.ReadCloser writer, err = w.Create(file.Name) if err != nil { return err } readCloser, err = file.Open() if err != nil { return err } if file.Name == "word/document.xml" { writer.Write([]byte(d.content)) } else if file.Name == "word/_rels/document.xml.rels" { writer.Write([]byte(d.links)) } else if strings.Contains(file.Name, "header") && d.headers[file.Name] != "" { writer.Write([]byte(d.headers[file.Name])) } else if strings.Contains(file.Name, "footer") && d.footers[file.Name] != "" { writer.Write([]byte(d.footers[file.Name])) } else if strings.HasPrefix(file.Name, "word/media/") && d.images[file.Name] != "" { newImage, err := os.Open(d.images[file.Name]) if err != nil { return err } writer.Write(streamToByte(newImage)) newImage.Close() } else { writer.Write(streamToByte(readCloser)) } } w.Close() return } func replaceHeaderFooter(headerFooter map[string]string, oldString string, newString string) (err error) { oldString, err = encode(oldString) if err != nil { return err } newString, err = encode(newString) if err != nil { return err } for k := range headerFooter { headerFooter[k] = strings.Replace(headerFooter[k], oldString, newString, -1) } return nil } // ReadDocxFromFS opens a docx file from the file system func ReadDocxFromFS(file string, fs fs.FS) (*ReplaceDocx, error) { f, err := fs.Open(file) if err != nil { return nil, err } buff := bytes.NewBuffer([]byte{}) size, err := io.Copy(buff, f) if err != nil { return nil, err } reader := bytes.NewReader(buff.Bytes()) return ReadDocxFromMemory(reader, size) } func ReadDocxFromMemory(data io.ReaderAt, size int64) (*ReplaceDocx, error) { reader, err := zip.NewReader(data, size) if err != nil { return nil, err } zipData := ZipInMemory{data: reader} return ReadDocx(zipData) } func ReadDocxFile(path string) (*ReplaceDocx, error) { reader, err := zip.OpenReader(path) if err != nil { return nil, err } zipData := ZipFile{data: reader} return ReadDocx(zipData) } func ReadDocx(reader ZipData) (*ReplaceDocx, error) { content, err := readText(reader.files()) if err != nil { return nil, err } links, err := readLinks(reader.files()) if err != nil { return nil, err } headers, footers, _ := readHeaderFooter(reader.files()) images, _ := retrieveImageFilenames(reader.files()) return &ReplaceDocx{zipReader: reader, content: content, links: links, headers: headers, footers: footers, images: images}, nil } func retrieveImageFilenames(files []*zip.File) (map[string]string, error) { images := make(map[string]string) for _, f := range files { if strings.HasPrefix(f.Name, "word/media/") { images[f.Name] = "" } } return images, nil } func readHeaderFooter(files []*zip.File) (headerText map[string]string, footerText map[string]string, err error) { h, f, err := retrieveHeaderFooterDoc(files) if err != nil { return map[string]string{}, map[string]string{}, err } headerText, err = buildHeaderFooter(h) if err != nil { return map[string]string{}, map[string]string{}, err } footerText, err = buildHeaderFooter(f) if err != nil { return map[string]string{}, map[string]string{}, err } return headerText, footerText, err } func buildHeaderFooter(headerFooter []*zip.File) (map[string]string, error) { headerFooterText := make(map[string]string) for _, element := range headerFooter { documentReader, err := element.Open() if err != nil { return map[string]string{}, err } text, err := wordDocToString(documentReader) if err != nil { return map[string]string{}, err } headerFooterText[element.Name] = text } return headerFooterText, nil } func readText(files []*zip.File) (text string, err error) { var documentFile *zip.File documentFile, err = retrieveWordDoc(files) if err != nil { return text, err } var documentReader io.ReadCloser documentReader, err = documentFile.Open() if err != nil { return text, err } text, err = wordDocToString(documentReader) return } func readLinks(files []*zip.File) (text string, err error) { var documentFile *zip.File documentFile, err = retrieveLinkDoc(files) if err != nil { return text, err } var documentReader io.ReadCloser documentReader, err = documentFile.Open() if err != nil { return text, err } text, err = wordDocToString(documentReader) return } func wordDocToString(reader io.Reader) (string, error) { b, err := io.ReadAll(reader) if err != nil { return "", err } return string(b), nil } func retrieveWordDoc(files []*zip.File) (file *zip.File, err error) { for _, f := range files { if f.Name == "word/document.xml" { file = f } } if file == nil { err = fmt.Errorf("document.xml file not found") } return } func retrieveLinkDoc(files []*zip.File) (file *zip.File, err error) { for _, f := range files { if f.Name == "word/_rels/document.xml.rels" { file = f } } if file == nil { err = fmt.Errorf("document.xml.rels file not found") } return } func retrieveHeaderFooterDoc(files []*zip.File) (headers []*zip.File, footers []*zip.File, err error) { for _, f := range files { if strings.Contains(f.Name, "header") { headers = append(headers, f) } if strings.Contains(f.Name, "footer") { footers = append(footers, f) } } if len(headers) == 0 && len(footers) == 0 { err = fmt.Errorf("headers[1-3].xml file not found and footers[1-3].xml file not found") } return } func streamToByte(stream io.Reader) []byte { buf := new(bytes.Buffer) buf.ReadFrom(stream) return buf.Bytes() } // To get Word to recognize a tab character, we have to first close off the previous // text element. This means if there are multiple consecutive tabs, there are empty // in between but it still seems to work correctly in the output document, certainly better // than other combinations I tried. const TAB = "" const NEWLINE = "" func encode(s string) (string, error) { var b bytes.Buffer enc := xml.NewEncoder(bufio.NewWriter(&b)) if err := enc.Encode(s); err != nil { return s, err } output := strings.Replace(b.String(), "", "", 1) // remove string tag output = strings.Replace(output, "", "", 1) output = strings.Replace(output, " ", NEWLINE, -1) // \r\n (Windows newline) output = strings.Replace(output, " ", NEWLINE, -1) // \r (earlier Mac newline) output = strings.Replace(output, " ", NEWLINE, -1) // \n (unix/linux/OS X newline) output = strings.Replace(output, " ", TAB, -1) // \t (tab) return output, nil } func (d *Docx) ReplaceImage(oldImage string, newImage string) (err error) { if _, ok := d.images[oldImage]; ok { d.images[oldImage] = newImage return nil } return fmt.Errorf("old image: %q, file not found", oldImage) } func (d *Docx) ImagesLen() int { return len(d.images) }