package office /* docx_writer.go - Build a Word (.docx) file from a Document. The Docs editor HTML subset is converted to WordprocessingML: paragraphs, headings (Heading1-4 + Title styles), bold/italic/ underline/strikethrough, font color/size, hyperlinks, bulleted and numbered lists (numbering.xml), block quotes, code blocks, tables, horizontal rules, inline images (data URLs become embedded media) and line breaks. Header/footer text and optional page numbers are written as real header/footer parts; page size/orientation/margins map to the section properties. */ import ( "archive/zip" "bytes" "errors" "fmt" "image" _ "image/gif" // natural-size probing in buildImageRun _ "image/jpeg" // natural-size probing in buildImageRun _ "image/png" // natural-size probing in buildImageRun "strconv" "strings" "golang.org/x/net/html" ) const docxNs = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"` // runFormat is the inline formatting state while walking the HTML tree type runFormat struct { b, i, u, strike bool color string // #rrggbb sizePx float64 // 0 = default mono bool link string // href when inside } type docxBuilder struct { body strings.Builder rels []string // relationship XML fragments (rId offset +100 to avoid fixed ids) media []mediaEntry imgCount int hasList bool // multi-column documents: blocks with class "col-span-all" (IEEE-style // title/author rows) are emitted into their own single-column section, // separated from the columned body by a continuous section break multiCol bool sectDivider string // paragraph-embedded sectPr closing a span-all run lastSpan bool anyBlock bool usedDivider bool } // BuildDocx serializes a Document into a complete .docx file func BuildDocx(doc *Document) ([]byte, error) { if doc == nil { return nil, errors.New("nil document") } root, err := html.Parse(strings.NewReader("" + doc.HTML + "")) if err != nil { return nil, errors.New("cannot parse document HTML: " + err.Error()) } b := &docxBuilder{} if doc.Page != nil && doc.Page.Columns > 1 { b.multiCol = true b.sectDivider = `` + pgGeometry(doc.Page) + `` } if bodyNode := findHTMLNode(root, "body"); bodyNode != nil { b.walkTop(bodyNode, runFormat{}) } buf := new(bytes.Buffer) zw := zip.NewWriter(buf) addFile := func(name, content string) error { w, err := zw.Create(name) if err != nil { return err } _, err = w.Write([]byte(content)) return err } addBin := func(name string, data []byte) error { w, err := zw.Create(name) if err != nil { return err } _, err = w.Write(data) return err } // hfMode "none" drops the text; "except-first" keeps the parts and lets // Word blank page 1 through (no "first" reference = empty) hfText := doc.HFMode != HFModeNone hasHeader := hfText && strings.TrimSpace(doc.Header) != "" hasFooter := (hfText && strings.TrimSpace(doc.Footer) != "") || doc.PageNumbers // [Content_Types].xml var ct strings.Builder ct.WriteString(`` + "\n") ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) ct.WriteString(``) if hasHeader { ct.WriteString(``) } if hasFooter { ct.WriteString(``) } ct.WriteString(``) if err := addFile("[Content_Types].xml", ct.String()); err != nil { return nil, err } if err := addFile("_rels/.rels", ``+"\n"+ ``+ ``+ ``); err != nil { return nil, err } // document rels: styles + numbering + optional header/footer + images/links var rels strings.Builder rels.WriteString(`` + "\n") rels.WriteString(``) rels.WriteString(``) rels.WriteString(``) headerRef, footerRef := "", "" if hasHeader { rels.WriteString(``) headerRef = `` } if hasFooter { rels.WriteString(``) footerRef = `` } for _, r := range b.rels { rels.WriteString(r) } rels.WriteString(``) if err := addFile("word/_rels/document.xml.rels", rels.String()); err != nil { return nil, err } // section properties (page setup) sect := buildSectPr(doc.Page, headerRef, footerRef, b.usedDivider, doc.HFMode == HFModeExceptFirst) docXML := `` + "\n" + `` + b.body.String() + sect + `` if err := addFile("word/document.xml", docXML); err != nil { return nil, err } if err := addFile("word/styles.xml", docxStyles); err != nil { return nil, err } if err := addFile("word/numbering.xml", docxNumbering); err != nil { return nil, err } if hasHeader { if err := addFile("word/header1.xml", buildHfPart("hdr", doc.Header, false)); err != nil { return nil, err } } if hasFooter { footerText := doc.Footer if !hfText { footerText = "" } if err := addFile("word/footer1.xml", buildHfPart("ftr", footerText, doc.PageNumbers)); err != nil { return nil, err } } for _, m := range b.media { if err := addBin(fmt.Sprintf("word/media/image%d.%s", m.index, m.ext), m.data); err != nil { return nil, err } } if err := zw.Close(); err != nil { return nil, err } return buf.Bytes(), nil } func findHTMLNode(n *html.Node, tag string) *html.Node { if n.Type == html.ElementNode && n.Data == tag { return n } for c := n.FirstChild; c != nil; c = c.NextSibling { if f := findHTMLNode(c, tag); f != nil { return f } } return nil } func htmlAttr(n *html.Node, name string) string { for _, a := range n.Attr { if a.Key == name { return a.Val } } return "" } // styleProp extracts one property from an inline style attribute func styleProp(style, prop string) string { for _, decl := range strings.Split(style, ";") { kv := strings.SplitN(decl, ":", 2) if len(kv) == 2 && strings.TrimSpace(strings.ToLower(kv[0])) == prop { return strings.TrimSpace(kv[1]) } } return "" } func applyInlineFormat(f runFormat, n *html.Node) runFormat { switch n.Data { case "b", "strong": f.b = true case "i", "em": f.i = true case "u": f.u = true case "s", "strike", "del": f.strike = true case "code", "tt": f.mono = true case "a": if href := htmlAttr(n, "href"); href != "" { f.link = href } case "font": if c := htmlAttr(n, "color"); c != "" { f.color = c } } style := htmlAttr(n, "style") if style != "" { if c := styleProp(style, "color"); c != "" { f.color = c } if fw := styleProp(style, "font-weight"); fw == "bold" || fw == "700" { f.b = true } if fs := styleProp(style, "font-style"); fs == "italic" { f.i = true } if td := styleProp(style, "text-decoration"); strings.Contains(td, "underline") { f.u = true } else if strings.Contains(td, "line-through") { f.strike = true } if sz := styleProp(style, "font-size"); strings.HasSuffix(sz, "px") { if v, err := strconv.ParseFloat(strings.TrimSuffix(sz, "px"), 64); err == nil { f.sizePx = v } } else if strings.HasSuffix(sz, "pt") { if v, err := strconv.ParseFloat(strings.TrimSuffix(sz, "pt"), 64); err == nil { f.sizePx = v / 0.75 } } } return f } func rprFor(f runFormat) string { var sb strings.Builder sb.WriteString("") if f.mono { sb.WriteString(``) } if f.b { sb.WriteString("") } if f.i { sb.WriteString("") } if f.strike { sb.WriteString("") } if f.u { sb.WriteString(``) } if f.color != "" { sb.WriteString(``) } if f.link != "" { sb.WriteString(``) } if f.sizePx > 0 { hp := pxToHalfPoints(f.sizePx) sb.WriteString(fmt.Sprintf(``, hp, hp)) } sb.WriteString("") return sb.String() } /* ---------- block walking ---------- */ // paragraph collects runs then flushes them as one w:p type paraOut struct { runs strings.Builder any bool pPr string owner *docxBuilder } func (p *paraOut) flush() { if !p.any { return } p.owner.body.WriteString("" + p.pPr + p.runs.String() + "") p.runs.Reset() p.any = false } // walkTop emits the direct children of , inserting continuous // single-column section breaks after runs of .col-span-all blocks so // IEEE-style spanning titles survive in real Word multi-column layouts func (b *docxBuilder) walkTop(n *html.Node, f runFormat) { for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.TextNode { if strings.TrimSpace(c.Data) != "" { p := ¶Out{owner: b} b.inlineRuns(c, f, p) p.flush() } continue } if c.Type != html.ElementNode { continue } if b.multiCol { span := strings.Contains(" "+htmlAttr(c, "class")+" ", " col-span-all ") if b.anyBlock && b.lastSpan && !span { b.body.WriteString(b.sectDivider) b.usedDivider = true } b.lastSpan = span } b.anyBlock = true b.dispatchBlock(c, f, 0, "") } } // walkBlocks emits block-level content. listLevel/listType track nesting. func (b *docxBuilder) walkBlocks(n *html.Node, f runFormat, listLevel int, listType string) { for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.TextNode { if strings.TrimSpace(c.Data) != "" { // stray text: wrap into a paragraph p := ¶Out{owner: b} b.inlineRuns(c, f, p) p.flush() } continue } if c.Type != html.ElementNode { continue } b.dispatchBlock(c, f, listLevel, listType) } } // dispatchBlock emits ONE block-level element func (b *docxBuilder) dispatchBlock(c *html.Node, f runFormat, listLevel int, listType string) { // explicit page break inserted in the editor (Insert > Page break) if strings.Contains(" "+htmlAttr(c, "class")+" ", " doc-pagebreak ") { b.body.WriteString(``) return } switch c.Data { case "p", "div": b.emitParagraph(c, f, paraProps(c, ""), listLevel, listType) case "h1", "h2", "h3", "h4", "h5", "h6": style := "Heading" + string(c.Data[1]) if strings.Contains(" "+htmlAttr(c, "class")+" ", " doc-title ") { style = "Title" } b.emitParagraph(c, f, paraProps(c, style), 0, "") case "blockquote": qf := f qf.i = true inner := paraProps(c, "") inner = strings.Replace(inner, "", ``, 1) b.walkBlocksOrParagraph(c, qf, inner) case "pre": b.emitPre(c, f) case "ul": b.walkList(c, f, listLevel, "bullet") case "ol": b.walkList(c, f, listLevel, "decimal") case "table": b.emitTable(c, f) case "hr": b.body.WriteString(``) default: // unknown block-ish or inline at top level: treat as paragraph b.emitParagraph(c, f, paraProps(c, ""), listLevel, listType) } } // walkBlocksOrParagraph handles containers that may hold either inline // content or nested blocks (blockquote, li) func (b *docxBuilder) walkBlocksOrParagraph(n *html.Node, f runFormat, pPr string) { if hasBlockChild(n) { b.walkBlocks(n, f, 0, "") } else { p := ¶Out{owner: b, pPr: pPr} b.inlineRuns(n, f, p) if !p.any { p.any = true // keep empty paragraphs (spacing) } p.flush() } } var blockTags = map[string]bool{ "p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true, "ul": true, "ol": true, "table": true, "blockquote": true, "pre": true, "hr": true, "li": true, } func hasBlockChild(n *html.Node) bool { for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.ElementNode && blockTags[c.Data] { return true } } return false } func paraProps(n *html.Node, style string) string { var sb strings.Builder sb.WriteString("") if style != "" { sb.WriteString(``) } st := htmlAttr(n, "style") switch styleProp(st, "text-align") { case "center": sb.WriteString(``) case "right": sb.WriteString(``) case "justify": sb.WriteString(``) } sb.WriteString("") return sb.String() } func (b *docxBuilder) emitParagraph(n *html.Node, f runFormat, pPr string, listLevel int, listType string) { if hasBlockChild(n) { b.walkBlocks(n, f, listLevel, listType) return } p := ¶Out{owner: b, pPr: pPr} b.inlineRuns(n, f, p) p.any = true // keep empties: they are visible blank lines in the editor p.flush() } func (b *docxBuilder) emitPre(n *html.Node, f runFormat) { mono := f mono.mono = true text := textContent(n) lines := strings.Split(strings.TrimRight(text, "\n"), "\n") for _, line := range lines { b.body.WriteString(`` + `` + rprFor(mono) + `` + xmlEscape(line) + ``) } } func (b *docxBuilder) walkList(n *html.Node, f runFormat, level int, listType string) { if level > 3 { level = 3 } numID := "1" // bullet if listType == "decimal" { numID = "2" } for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type != html.ElementNode || c.Data != "li" { continue } b.hasList = true pPr := fmt.Sprintf(``, level, numID) // checklist items render their state as a leading glyph run p := ¶Out{owner: b, pPr: pPr} b.inlineRuns(c, f, p) p.any = true p.flush() // nested lists inside this li for gc := c.FirstChild; gc != nil; gc = gc.NextSibling { if gc.Type == html.ElementNode && gc.Data == "ul" { b.walkList(gc, f, level+1, "bullet") } else if gc.Type == html.ElementNode && gc.Data == "ol" { b.walkList(gc, f, level+1, "decimal") } } } } func (b *docxBuilder) emitTable(n *html.Node, f runFormat) { // the spec requires w:tblGrid with one gridCol per column cols := 0 var countCols func(node *html.Node) countCols = func(node *html.Node) { for c := node.FirstChild; c != nil && cols == 0; c = c.NextSibling { if c.Type != html.ElementNode { continue } switch c.Data { case "thead", "tbody", "tfoot": countCols(c) case "tr": for td := c.FirstChild; td != nil; td = td.NextSibling { if td.Type == html.ElementNode && (td.Data == "td" || td.Data == "th") { cols++ } } } } } countCols(n) if cols == 0 { cols = 1 } // mirror the editor: fixed layout, the table's own width (inline px/% // after a resize, 100% otherwise) and per-column widths from the // colgroup (px or %, equal split when absent) pcts := tableColPercents(n, cols) tblPct := tableWidthPct(n) const tblTwips = 9026.0 // A4 text width (210mm - 2x25.4mm margins) var grid strings.Builder grid.WriteString("") for i := 0; i < cols; i++ { grid.WriteString(fmt.Sprintf(``, int(tblTwips*(tblPct/100)*pcts[i]/100))) } grid.WriteString("") b.body.WriteString(`` + fmt.Sprintf(``, int(tblPct*50)) + `` + `` + `` + grid.String()) var walkRows func(node *html.Node) walkRows = func(node *html.Node) { for c := node.FirstChild; c != nil; c = c.NextSibling { if c.Type != html.ElementNode { continue } switch c.Data { case "thead", "tbody", "tfoot": walkRows(c) case "tr": b.body.WriteString("") ci := 0 for td := c.FirstChild; td != nil; td = td.NextSibling { if td.Type != html.ElementNode || (td.Data != "td" && td.Data != "th") { continue } cf := f if td.Data == "th" { cf.b = true } st := htmlAttr(td, "style") if fw := styleProp(st, "font-weight"); fw == "700" || fw == "bold" { cf.b = true } pct := 100.0 / float64(cols) if ci < len(pcts) { pct = pcts[ci] } // tcW in fiftieths of a percent keeps the editor's // column proportions under the fixed layout tcPr := fmt.Sprintf(``, int(pct*50)) if bg := cssColorHex(styleProp(st, "background-color")); bg != "" { tcPr += `` } b.body.WriteString(`` + tcPr + ``) p := ¶Out{owner: b} b.inlineRuns(td, cf, p) p.any = true p.flush() b.body.WriteString("") ci++ } b.body.WriteString("") } } } walkRows(n) b.body.WriteString("") } // tableColPercents reads the editor's widths (percent OR // pixel units - the column resizer writes px), normalized to 100; equal // split when absent or malformed func tableColPercents(tbl *html.Node, cols int) []float64 { out := make([]float64, cols) got := 0 for cg := tbl.FirstChild; cg != nil; cg = cg.NextSibling { if cg.Type != html.ElementNode || cg.Data != "colgroup" { continue } for col := cg.FirstChild; col != nil && got < cols; col = col.NextSibling { if col.Type != html.ElementNode || col.Data != "col" { continue } ws := strings.TrimSpace(styleProp(htmlAttr(col, "style"), "width")) num := strings.TrimSuffix(strings.TrimSuffix(ws, "%"), "px") if (strings.HasSuffix(ws, "%") || strings.HasSuffix(ws, "px")) && num != ws { if v, err := strconv.ParseFloat(num, 64); err == nil && v > 0 { out[got] = v // any unit: normalized by the sum below got++ continue } } got = 0 // one bad entry: fall back to the equal split break } break } if got != cols { for i := range out { out[i] = 100.0 / float64(cols) } return out } sum := 0.0 for _, v := range out { sum += v } if sum > 0 { for i := range out { out[i] = out[i] * 100 / sum } } return out } // tableWidthPct reads the table's own inline width (set by the editor's // column resizer in px, or a percent) as a percentage of the text width; // 100 when absent func tableWidthPct(tbl *html.Node) float64 { const textWpx = 620.0 ws := strings.TrimSpace(styleProp(htmlAttr(tbl, "style"), "width")) if strings.HasSuffix(ws, "%") { if v, err := strconv.ParseFloat(strings.TrimSuffix(ws, "%"), 64); err == nil && v > 1 { if v > 100 { v = 100 } return v } } if strings.HasSuffix(ws, "px") { if v, err := strconv.ParseFloat(strings.TrimSuffix(ws, "px"), 64); err == nil && v > 10 { pct := v * 100 / textWpx if pct > 100 { pct = 100 } return pct } } return 100 } // cssColorHex normalizes "#rgb", "#rrggbb" or "rgb(r, g, b)" to "RRGGBB" // ("" when unparseable or transparent) func cssColorHex(c string) string { c = strings.TrimSpace(c) if c == "" || c == "transparent" { return "" } if strings.HasPrefix(c, "#") { h := hexColor(c, "") return h } if strings.HasPrefix(c, "rgb") { open := strings.Index(c, "(") close := strings.Index(c, ")") if open < 0 || close <= open { return "" } parts := strings.Split(c[open+1:close], ",") if len(parts) < 3 { return "" } out := "" for i := 0; i < 3; i++ { v, err := strconv.Atoi(strings.TrimSpace(parts[i])) if err != nil || v < 0 || v > 255 { return "" } out += fmt.Sprintf("%02X", v) } return out } return "" } /* ---------- inline runs ---------- */ func (b *docxBuilder) inlineRuns(n *html.Node, f runFormat, p *paraOut) { for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.TextNode { t := strings.ReplaceAll(c.Data, "\n", " ") if t == "" { continue } p.any = true run := `` + rprFor(f) + `` + xmlEscape(t) + `` if f.link != "" { rid := b.addLinkRel(f.link) run = `` + run + `` } p.runs.WriteString(run) continue } if c.Type != html.ElementNode { continue } switch c.Data { case "br": p.any = true p.runs.WriteString("") case "img": if x := b.buildImageRun(c); x != "" { p.any = true p.runs.WriteString(x) } case "ul", "ol", "table", "p", "div", "blockquote", "pre": // nested block inside an inline context: flush and emit it alone p.flush() b.dispatchBlock(c, f, 0, "") default: b.inlineRuns(c, applyInlineFormat(f, c), p) } } } func (b *docxBuilder) addLinkRel(href string) string { rid := fmt.Sprintf("rId%d", 100+len(b.rels)) b.rels = append(b.rels, ``) return rid } func (b *docxBuilder) buildImageRun(n *html.Node) string { src := htmlAttr(n, "src") data, ext, ok := decodeDataURL(src) if !ok { return "" // non-inlined images are skipped (webapp inlines before export) } b.imgCount++ idx := b.imgCount rid := fmt.Sprintf("rId%d", 100+len(b.rels)) b.rels = append(b.rels, fmt.Sprintf(``, rid, idx, ext)) b.media = append(b.media, mediaEntry{index: idx, ext: ext, data: data}) // natural pixel size from the image bytes - the truth for aspect ratio natW, natH := 0, 0 if imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { natW, natH = imgCfg.Width, imgCfg.Height } pxAttr := func(name string) float64 { if a := htmlAttr(n, name); a != "" { if v, err := strconv.ParseFloat(strings.TrimSuffix(a, "px"), 64); err == nil && v > 0 { return v } } if s := styleProp(htmlAttr(n, "style"), name); strings.HasSuffix(s, "px") { if v, err := strconv.ParseFloat(strings.TrimSuffix(s, "px"), 64); err == nil && v > 0 { return v } } return 0 } wPx := pxAttr("width") hPx := pxAttr("height") // derive the missing dimension from the natural aspect (the editor // resizes with height:auto, so usually only width is present) switch { case wPx > 0 && hPx <= 0: if natW > 0 && natH > 0 { hPx = wPx * float64(natH) / float64(natW) } else { hPx = wPx * 3 / 4 } case hPx > 0 && wPx <= 0: if natW > 0 && natH > 0 { wPx = hPx * float64(natW) / float64(natH) } else { wPx = hPx * 4 / 3 } case wPx <= 0 && hPx <= 0: if natW > 0 && natH > 0 { wPx, hPx = float64(natW), float64(natH) } else { wPx, hPx = 400, 300 } } // keep the picture inside the text column (A4 with default margins) const maxWpx = 620.0 if wPx > maxWpx { hPx = hPx * maxWpx / wPx wPx = maxWpx } cx, cy := pxToEmu(wPx), pxToEmu(hPx) return fmt.Sprintf(``+ ``+ ``+ ``+ ``+ ``+ ``+ ``, cx, cy, idx, idx, idx, idx, rid, cx, cy) } func textContent(n *html.Node) string { var sb strings.Builder var walk func(*html.Node) walk = func(x *html.Node) { if x.Type == html.TextNode { sb.WriteString(x.Data) return } if x.Type == html.ElementNode && x.Data == "br" { sb.WriteString("\n") } for c := x.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(n) return sb.String() } /* ---------- section / header / footer ---------- */ // pgGeometry renders the pgSz + pgMar pair for a page config func pgGeometry(pc *PageConf) string { size := "A4" orient := "portrait" mT, mR, mB, mL := 25.4, 25.4, 25.4, 25.4 if pc != nil { if _, ok := pageSizesTwips[pc.Size]; ok { size = pc.Size } if pc.Orientation == "landscape" { orient = "landscape" } if pc.Margins != nil { mT, mR, mB, mL = pc.Margins.Top, pc.Margins.Right, pc.Margins.Bottom, pc.Margins.Left } } dim := pageSizesTwips[size] w, h := dim[0], dim[1] orientAttr := "" if orient == "landscape" { w, h = h, w orientAttr = ` w:orient="landscape"` } return fmt.Sprintf(``+ ``, w, h, orientAttr, mmToTwips(mT), mmToTwips(mR), mmToTwips(mB), mmToTwips(mL)) } func buildSectPr(pc *PageConf, headerRef, footerRef string, continuous, titlePg bool) string { typ := "" if continuous { // the columned body continues on the same page as the spanning // title section it follows typ = `` } cols := "" if pc != nil && pc.Columns > 1 { gap := pc.ColGap if gap <= 0 { gap = 8 } cols = fmt.Sprintf(``, pc.Columns, mmToTwips(gap)) } first := "" if titlePg { // "different first page" with no first-page reference: Word leaves // page 1's header and footer empty first = `` } return `` + headerRef + footerRef + typ + pgGeometry(pc) + cols + first + `` } // buildHfPart renders a header (root "hdr") or footer ("ftr") part func buildHfPart(root, text string, pageNumbers bool) string { var runs strings.Builder if strings.TrimSpace(text) != "" { runs.WriteString(`` + xmlEscape(text) + ``) } if pageNumbers { if runs.Len() > 0 { runs.WriteString(` - `) } runs.WriteString(`` + ` PAGE ` + ``) } // no : the editor renders header/footer text left aligned, so the // export must not silently centre it return `` + "\n" + `` + runs.String() + `` } /* ---------- static parts ---------- */ // The style sheet pins the EDITOR's typography explicitly (Arial 11pt, // 1.5 line height, zero paragraph spacing; headings 14pt/6pt margins at // 1.25) so Word cannot substitute its own Normal defaults - that // substitution is what made exported pages break earlier than the // editor's page preview. Spacing units: half-points for sz, twentieths // of a point for spacing, 240ths of a line for w:line (auto rule). const docxStyles = ` ` const docxNumbering = ` `