package office /* docx_reader.go - Parse a Word (.docx) file into a Document. Converts the common WordprocessingML subset back to the Docs editor HTML: paragraphs, heading/title styles, alignment, bold/italic/ underline/strikethrough, font color/size, hyperlinks, bulleted and numbered lists, tables, embedded images (as data URLs), line breaks and page geometry from the section properties. Headers/footers come back as plain text. Tracked changes, footnotes, text boxes and other advanced features are ignored. Legacy binary .doc is rejected. */ import ( "archive/zip" "bytes" "errors" "io" "path" "strconv" "strings" ) // ParseDocx converts raw .docx bytes into a Document func ParseDocx(data []byte) (*Document, error) { if len(data) > 8 && data[0] == 0xD0 && data[1] == 0xCF { return nil, errors.New("legacy binary .doc files are not supported - save the file as .docx first") } zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, errors.New("not a valid docx (zip) file") } files := map[string][]byte{} for _, f := range zr.File { name := path.Clean(f.Name) if strings.HasSuffix(name, ".xml") || strings.HasSuffix(name, ".rels") || strings.HasPrefix(name, "word/media/") { rc, err := f.Open() if err != nil { continue } b, err := io.ReadAll(rc) rc.Close() if err != nil { continue } files[name] = b } } docXML, ok := files["word/document.xml"] if !ok { return nil, errors.New("docx is missing word/document.xml") } tree, err := parseXMLTree(docXML) if err != nil { return nil, errors.New("cannot parse document.xml: " + err.Error()) } body := tree.first("body") if body == nil { return nil, errors.New("document has no body") } rels := parseRels(files["word/_rels/document.xml.rels"]) numFmt := parseNumberingFormats(files["word/numbering.xml"]) cv := &docxConv{files: files, rels: rels, numFmt: numFmt, bodyNode: body} doc := &Document{} // page geometry (parsed first: multi-column affects HTML conversion) if sect := body.first("sectPr"); sect != nil { pc := &PageConf{Size: "A4", Orientation: "portrait"} if sz := sect.first("pgSz"); sz != nil { w, _ := strconv.Atoi(sz.attr("w")) h, _ := strconv.Atoi(sz.attr("h")) if sz.attr("orient") == "landscape" || w > h { pc.Orientation = "landscape" w, h = h, w } best := "A4" bestD := 1 << 30 for name, dim := range pageSizesTwips { d := abs(dim[0]-w) + abs(dim[1]-h) if d < bestD { bestD = d best = name } } pc.Size = best } if mar := sect.first("pgMar"); mar != nil { m := &MarginsMM{Top: 25.4, Right: 25.4, Bottom: 25.4, Left: 25.4} if v, err := strconv.Atoi(mar.attr("top")); err == nil { m.Top = round1(twipsToMm(v)) } if v, err := strconv.Atoi(mar.attr("right")); err == nil { m.Right = round1(twipsToMm(v)) } if v, err := strconv.Atoi(mar.attr("bottom")); err == nil { m.Bottom = round1(twipsToMm(v)) } if v, err := strconv.Atoi(mar.attr("left")); err == nil { m.Left = round1(twipsToMm(v)) } pc.Margins = m } if sect.first("titlePg") != nil { // "different first page" with no first-page part: the editor // calls that "every page except the first" doc.HFMode = HFModeExceptFirst } if cols := sect.first("cols"); cols != nil { if n, err := strconv.Atoi(cols.attr("num")); err == nil && n > 1 { pc.Columns = n if sp, err := strconv.Atoi(cols.attr("space")); err == nil && sp > 0 { pc.ColGap = round1(twipsToMm(sp)) } } } doc.Page = pc } // Word writes IEEE-style spanning titles as leading single-column // sections; map those blocks back to .col-span-all if doc.Page != nil && doc.Page.Columns > 1 { cv.markSpanSections(body) } doc.HTML = cv.blocksToHTML(body) // header / footer text (first part of each kind) for name, raw := range files { if strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml") && doc.Header == "" { doc.Header = partPlainText(raw) } if strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml") && doc.Footer == "" { txt, hasPage := footerTextAndPageField(raw) doc.Footer = txt doc.PageNumbers = doc.PageNumbers || hasPage } } return doc, nil } func abs(v int) int { if v < 0 { return -v } return v } func round1(v float64) float64 { return float64(int(v*10+0.5)) / 10 } func partPlainText(raw []byte) string { tree, err := parseXMLTree(raw) if err != nil { return "" } var texts []string collectText(tree, &texts) return strings.TrimSpace(strings.Join(texts, " ")) } // footerTextAndPageField extracts footer text and whether it has a PAGE field func footerTextAndPageField(raw []byte) (string, bool) { tree, err := parseXMLTree(raw) if err != nil { return "", false } hasPage := false var walk func(n *xnode) var texts []string walk = func(n *xnode) { if n.XMLName.Local == "instrText" { if strings.Contains(strings.ToUpper(n.Text), "PAGE") { hasPage = true } return } if n.XMLName.Local == "t" { texts = append(texts, n.Text) return } for i := range n.Nodes { walk(&n.Nodes[i]) } } walk(tree) txt := strings.TrimSpace(strings.Join(texts, "")) txt = strings.TrimSuffix(txt, "-") return strings.TrimSpace(txt), hasPage } // parseNumberingFormats maps numId -> "bullet"|"decimal" (level 0 format) func parseNumberingFormats(raw []byte) map[string]string { out := map[string]string{} if raw == nil { return out } tree, err := parseXMLTree(raw) if err != nil { return out } abstract := map[string]string{} // abstractNumId -> fmt for _, an := range tree.all("abstractNum") { id := an.attr("abstractNumId") if lvl := an.first("lvl"); lvl != nil { if nf := lvl.first("numFmt"); nf != nil { if nf.attr("val") == "bullet" { abstract[id] = "bullet" } else { abstract[id] = "decimal" } } } } for _, num := range tree.all("num") { id := num.attr("numId") if ref := num.first("abstractNumId"); ref != nil { if f, ok := abstract[ref.attr("val")]; ok { out[id] = f } } } return out } /* ---------- conversion ---------- */ type docxConv struct { files map[string][]byte rels map[string]string numFmt map[string]string bodyNode *xnode spanIdx map[int]bool // top-level block indexes that span all columns skipIdx map[int]bool // empty section-divider paragraphs to drop } // markSpanSections finds paragraph-embedded sectPr elements (section // dividers). Blocks belonging to a single-column section of a multi-column // document are IEEE-style spanning blocks. func (cv *docxConv) markSpanSections(body *xnode) { cv.spanIdx = map[int]bool{} cv.skipIdx = map[int]bool{} var pending []int for i := range body.Nodes { n := &body.Nodes[i] local := n.XMLName.Local if local != "p" && local != "tbl" { continue } if local == "p" { if pPr := n.first("pPr"); pPr != nil { if sp := pPr.first("sectPr"); sp != nil { single := true if cols := sp.first("cols"); cols != nil { if num, err := strconv.Atoi(cols.attr("num")); err == nil && num > 1 { single = false } } if single { for _, j := range pending { cv.spanIdx[j] = true } cv.spanIdx[i] = true } if strings.TrimSpace(cv.runsToHTML(n)) == "" { cv.skipIdx[i] = true // pure divider paragraph } pending = nil continue } } } pending = append(pending, i) } } // blocksToHTML renders the children of w:body (or a table cell) func (cv *docxConv) blocksToHTML(parent *xnode) string { var sb strings.Builder listOpen := "" // "" | "ul" | "ol" closeList := func() { if listOpen != "" { sb.WriteString("" + listOpen + ">") listOpen = "" } } isTop := parent == cv.bodyNode for i := range parent.Nodes { n := &parent.Nodes[i] if isTop && cv.skipIdx != nil && cv.skipIdx[i] { continue } spanAll := isTop && cv.spanIdx != nil && cv.spanIdx[i] switch n.XMLName.Local { case "p": listKind := "" // "ul" | "ol" if pPr := n.first("pPr"); pPr != nil { if numPr := pPr.first("numPr"); numPr != nil { if nid := numPr.first("numId"); nid != nil { if cv.numFmt[nid.attr("val")] == "decimal" { listKind = "ol" } else { listKind = "ul" } } } // style-based lists (e.g. python-docx "List Bullet") if listKind == "" { if ps := pPr.first("pStyle"); ps != nil { v := ps.attr("val") if strings.HasPrefix(v, "ListBullet") { listKind = "ul" } else if strings.HasPrefix(v, "ListNumber") { listKind = "ol" } } } } if listKind != "" { if listOpen != listKind { closeList() sb.WriteString("<" + listKind + ">") listOpen = listKind } sb.WriteString("
| " + inner + " | ") } sb.WriteString("