docx_reader.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. package office
  2. /*
  3. docx_reader.go - Parse a Word (.docx) file into a Document.
  4. Converts the common WordprocessingML subset back to the Docs editor
  5. HTML: paragraphs, heading/title styles, alignment, bold/italic/
  6. underline/strikethrough, font color/size, hyperlinks, bulleted and
  7. numbered lists, tables, embedded images (as data URLs), line breaks
  8. and page geometry from the section properties. Headers/footers come
  9. back as plain text. Tracked changes, footnotes, text boxes and other
  10. advanced features are ignored. Legacy binary .doc is rejected.
  11. */
  12. import (
  13. "archive/zip"
  14. "bytes"
  15. "errors"
  16. "io"
  17. "path"
  18. "strconv"
  19. "strings"
  20. )
  21. // ParseDocx converts raw .docx bytes into a Document
  22. func ParseDocx(data []byte) (*Document, error) {
  23. if len(data) > 8 && data[0] == 0xD0 && data[1] == 0xCF {
  24. return nil, errors.New("legacy binary .doc files are not supported - save the file as .docx first")
  25. }
  26. zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
  27. if err != nil {
  28. return nil, errors.New("not a valid docx (zip) file")
  29. }
  30. files := map[string][]byte{}
  31. for _, f := range zr.File {
  32. name := path.Clean(f.Name)
  33. if strings.HasSuffix(name, ".xml") || strings.HasSuffix(name, ".rels") ||
  34. strings.HasPrefix(name, "word/media/") {
  35. rc, err := f.Open()
  36. if err != nil {
  37. continue
  38. }
  39. b, err := io.ReadAll(rc)
  40. rc.Close()
  41. if err != nil {
  42. continue
  43. }
  44. files[name] = b
  45. }
  46. }
  47. docXML, ok := files["word/document.xml"]
  48. if !ok {
  49. return nil, errors.New("docx is missing word/document.xml")
  50. }
  51. tree, err := parseXMLTree(docXML)
  52. if err != nil {
  53. return nil, errors.New("cannot parse document.xml: " + err.Error())
  54. }
  55. body := tree.first("body")
  56. if body == nil {
  57. return nil, errors.New("document has no body")
  58. }
  59. rels := parseRels(files["word/_rels/document.xml.rels"])
  60. numFmt := parseNumberingFormats(files["word/numbering.xml"])
  61. cv := &docxConv{files: files, rels: rels, numFmt: numFmt, bodyNode: body}
  62. doc := &Document{}
  63. // page geometry (parsed first: multi-column affects HTML conversion)
  64. if sect := body.first("sectPr"); sect != nil {
  65. pc := &PageConf{Size: "A4", Orientation: "portrait"}
  66. if sz := sect.first("pgSz"); sz != nil {
  67. w, _ := strconv.Atoi(sz.attr("w"))
  68. h, _ := strconv.Atoi(sz.attr("h"))
  69. if sz.attr("orient") == "landscape" || w > h {
  70. pc.Orientation = "landscape"
  71. w, h = h, w
  72. }
  73. best := "A4"
  74. bestD := 1 << 30
  75. for name, dim := range pageSizesTwips {
  76. d := abs(dim[0]-w) + abs(dim[1]-h)
  77. if d < bestD {
  78. bestD = d
  79. best = name
  80. }
  81. }
  82. pc.Size = best
  83. }
  84. if mar := sect.first("pgMar"); mar != nil {
  85. m := &MarginsMM{Top: 25.4, Right: 25.4, Bottom: 25.4, Left: 25.4}
  86. if v, err := strconv.Atoi(mar.attr("top")); err == nil {
  87. m.Top = round1(twipsToMm(v))
  88. }
  89. if v, err := strconv.Atoi(mar.attr("right")); err == nil {
  90. m.Right = round1(twipsToMm(v))
  91. }
  92. if v, err := strconv.Atoi(mar.attr("bottom")); err == nil {
  93. m.Bottom = round1(twipsToMm(v))
  94. }
  95. if v, err := strconv.Atoi(mar.attr("left")); err == nil {
  96. m.Left = round1(twipsToMm(v))
  97. }
  98. pc.Margins = m
  99. }
  100. if sect.first("titlePg") != nil {
  101. // "different first page" with no first-page part: the editor
  102. // calls that "every page except the first"
  103. doc.HFMode = HFModeExceptFirst
  104. }
  105. if cols := sect.first("cols"); cols != nil {
  106. if n, err := strconv.Atoi(cols.attr("num")); err == nil && n > 1 {
  107. pc.Columns = n
  108. if sp, err := strconv.Atoi(cols.attr("space")); err == nil && sp > 0 {
  109. pc.ColGap = round1(twipsToMm(sp))
  110. }
  111. }
  112. }
  113. doc.Page = pc
  114. }
  115. // Word writes IEEE-style spanning titles as leading single-column
  116. // sections; map those blocks back to .col-span-all
  117. if doc.Page != nil && doc.Page.Columns > 1 {
  118. cv.markSpanSections(body)
  119. }
  120. doc.HTML = cv.blocksToHTML(body)
  121. // header / footer text (first part of each kind)
  122. for name, raw := range files {
  123. if strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml") && doc.Header == "" {
  124. doc.Header = partPlainText(raw)
  125. }
  126. if strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml") && doc.Footer == "" {
  127. txt, hasPage := footerTextAndPageField(raw)
  128. doc.Footer = txt
  129. doc.PageNumbers = doc.PageNumbers || hasPage
  130. }
  131. }
  132. return doc, nil
  133. }
  134. func abs(v int) int {
  135. if v < 0 {
  136. return -v
  137. }
  138. return v
  139. }
  140. func round1(v float64) float64 {
  141. return float64(int(v*10+0.5)) / 10
  142. }
  143. func partPlainText(raw []byte) string {
  144. tree, err := parseXMLTree(raw)
  145. if err != nil {
  146. return ""
  147. }
  148. var texts []string
  149. collectText(tree, &texts)
  150. return strings.TrimSpace(strings.Join(texts, " "))
  151. }
  152. // footerTextAndPageField extracts footer text and whether it has a PAGE field
  153. func footerTextAndPageField(raw []byte) (string, bool) {
  154. tree, err := parseXMLTree(raw)
  155. if err != nil {
  156. return "", false
  157. }
  158. hasPage := false
  159. var walk func(n *xnode)
  160. var texts []string
  161. walk = func(n *xnode) {
  162. if n.XMLName.Local == "instrText" {
  163. if strings.Contains(strings.ToUpper(n.Text), "PAGE") {
  164. hasPage = true
  165. }
  166. return
  167. }
  168. if n.XMLName.Local == "t" {
  169. texts = append(texts, n.Text)
  170. return
  171. }
  172. for i := range n.Nodes {
  173. walk(&n.Nodes[i])
  174. }
  175. }
  176. walk(tree)
  177. txt := strings.TrimSpace(strings.Join(texts, ""))
  178. txt = strings.TrimSuffix(txt, "-")
  179. return strings.TrimSpace(txt), hasPage
  180. }
  181. // parseNumberingFormats maps numId -> "bullet"|"decimal" (level 0 format)
  182. func parseNumberingFormats(raw []byte) map[string]string {
  183. out := map[string]string{}
  184. if raw == nil {
  185. return out
  186. }
  187. tree, err := parseXMLTree(raw)
  188. if err != nil {
  189. return out
  190. }
  191. abstract := map[string]string{} // abstractNumId -> fmt
  192. for _, an := range tree.all("abstractNum") {
  193. id := an.attr("abstractNumId")
  194. if lvl := an.first("lvl"); lvl != nil {
  195. if nf := lvl.first("numFmt"); nf != nil {
  196. if nf.attr("val") == "bullet" {
  197. abstract[id] = "bullet"
  198. } else {
  199. abstract[id] = "decimal"
  200. }
  201. }
  202. }
  203. }
  204. for _, num := range tree.all("num") {
  205. id := num.attr("numId")
  206. if ref := num.first("abstractNumId"); ref != nil {
  207. if f, ok := abstract[ref.attr("val")]; ok {
  208. out[id] = f
  209. }
  210. }
  211. }
  212. return out
  213. }
  214. /* ---------- conversion ---------- */
  215. type docxConv struct {
  216. files map[string][]byte
  217. rels map[string]string
  218. numFmt map[string]string
  219. bodyNode *xnode
  220. spanIdx map[int]bool // top-level block indexes that span all columns
  221. skipIdx map[int]bool // empty section-divider paragraphs to drop
  222. }
  223. // markSpanSections finds paragraph-embedded sectPr elements (section
  224. // dividers). Blocks belonging to a single-column section of a multi-column
  225. // document are IEEE-style spanning blocks.
  226. func (cv *docxConv) markSpanSections(body *xnode) {
  227. cv.spanIdx = map[int]bool{}
  228. cv.skipIdx = map[int]bool{}
  229. var pending []int
  230. for i := range body.Nodes {
  231. n := &body.Nodes[i]
  232. local := n.XMLName.Local
  233. if local != "p" && local != "tbl" {
  234. continue
  235. }
  236. if local == "p" {
  237. if pPr := n.first("pPr"); pPr != nil {
  238. if sp := pPr.first("sectPr"); sp != nil {
  239. single := true
  240. if cols := sp.first("cols"); cols != nil {
  241. if num, err := strconv.Atoi(cols.attr("num")); err == nil && num > 1 {
  242. single = false
  243. }
  244. }
  245. if single {
  246. for _, j := range pending {
  247. cv.spanIdx[j] = true
  248. }
  249. cv.spanIdx[i] = true
  250. }
  251. if strings.TrimSpace(cv.runsToHTML(n)) == "" {
  252. cv.skipIdx[i] = true // pure divider paragraph
  253. }
  254. pending = nil
  255. continue
  256. }
  257. }
  258. }
  259. pending = append(pending, i)
  260. }
  261. }
  262. // blocksToHTML renders the children of w:body (or a table cell)
  263. func (cv *docxConv) blocksToHTML(parent *xnode) string {
  264. var sb strings.Builder
  265. listOpen := "" // "" | "ul" | "ol"
  266. closeList := func() {
  267. if listOpen != "" {
  268. sb.WriteString("</" + listOpen + ">")
  269. listOpen = ""
  270. }
  271. }
  272. isTop := parent == cv.bodyNode
  273. for i := range parent.Nodes {
  274. n := &parent.Nodes[i]
  275. if isTop && cv.skipIdx != nil && cv.skipIdx[i] {
  276. continue
  277. }
  278. spanAll := isTop && cv.spanIdx != nil && cv.spanIdx[i]
  279. switch n.XMLName.Local {
  280. case "p":
  281. listKind := "" // "ul" | "ol"
  282. if pPr := n.first("pPr"); pPr != nil {
  283. if numPr := pPr.first("numPr"); numPr != nil {
  284. if nid := numPr.first("numId"); nid != nil {
  285. if cv.numFmt[nid.attr("val")] == "decimal" {
  286. listKind = "ol"
  287. } else {
  288. listKind = "ul"
  289. }
  290. }
  291. }
  292. // style-based lists (e.g. python-docx "List Bullet")
  293. if listKind == "" {
  294. if ps := pPr.first("pStyle"); ps != nil {
  295. v := ps.attr("val")
  296. if strings.HasPrefix(v, "ListBullet") {
  297. listKind = "ul"
  298. } else if strings.HasPrefix(v, "ListNumber") {
  299. listKind = "ol"
  300. }
  301. }
  302. }
  303. }
  304. if listKind != "" {
  305. if listOpen != listKind {
  306. closeList()
  307. sb.WriteString("<" + listKind + ">")
  308. listOpen = listKind
  309. }
  310. sb.WriteString("<li>" + cv.runsToHTML(n) + "</li>")
  311. continue
  312. }
  313. closeList()
  314. sb.WriteString(cv.paragraphToHTML(n, spanAll))
  315. case "tbl":
  316. closeList()
  317. sb.WriteString(cv.tableToHTML(n))
  318. }
  319. }
  320. closeList()
  321. return sb.String()
  322. }
  323. func (cv *docxConv) paragraphToHTML(p *xnode, spanAll bool) string {
  324. tag := "p"
  325. var classes []string
  326. if spanAll {
  327. classes = append(classes, "col-span-all")
  328. }
  329. align := ""
  330. if pPr := p.first("pPr"); pPr != nil {
  331. if ps := pPr.first("pStyle"); ps != nil {
  332. v := ps.attr("val")
  333. switch {
  334. case strings.HasPrefix(v, "Heading") && len(v) == 8 && v[7] >= '1' && v[7] <= '6':
  335. tag = "h" + string(v[7])
  336. case v == "Title":
  337. tag = "h1"
  338. classes = append(classes, "doc-title")
  339. }
  340. }
  341. if jc := pPr.first("jc"); jc != nil {
  342. switch jc.attr("val") {
  343. case "center":
  344. align = "center"
  345. case "right", "end":
  346. align = "right"
  347. case "both":
  348. align = "justify"
  349. }
  350. }
  351. if ind := pPr.first("ind"); ind != nil && tag == "p" {
  352. if l, err := strconv.Atoi(ind.attr("left")); err == nil && l >= 600 {
  353. tag = "blockquote"
  354. }
  355. }
  356. }
  357. cls := ""
  358. if len(classes) > 0 {
  359. cls = ` class="` + strings.Join(classes, " ") + `"`
  360. }
  361. style := ""
  362. if align != "" {
  363. style = ` style="text-align:` + align + `;"`
  364. }
  365. inner := cv.runsToHTML(p)
  366. if inner == "" {
  367. inner = "<br>"
  368. }
  369. return "<" + tag + cls + style + ">" + inner + "</" + tag + ">"
  370. }
  371. // runsToHTML renders the runs (and hyperlinks) of a paragraph
  372. func (cv *docxConv) runsToHTML(p *xnode) string {
  373. var sb strings.Builder
  374. for i := range p.Nodes {
  375. n := &p.Nodes[i]
  376. switch n.XMLName.Local {
  377. case "r":
  378. sb.WriteString(cv.runToHTML(n))
  379. case "hyperlink":
  380. href := ""
  381. for _, a := range n.Attrs {
  382. if a.Name.Local == "id" {
  383. href = cv.rels[a.Value]
  384. }
  385. }
  386. var inner strings.Builder
  387. for j := range n.Nodes {
  388. if n.Nodes[j].XMLName.Local == "r" {
  389. inner.WriteString(cv.runToHTML(&n.Nodes[j]))
  390. }
  391. }
  392. if href != "" {
  393. sb.WriteString(`<a href="` + xmlEscape(href) + `">` + inner.String() + "</a>")
  394. } else {
  395. sb.WriteString(inner.String())
  396. }
  397. }
  398. }
  399. return sb.String()
  400. }
  401. func (cv *docxConv) runToHTML(r *xnode) string {
  402. var open, close string
  403. var styleProps []string
  404. if rPr := r.first("rPr"); rPr != nil {
  405. if rPr.first("b") != nil && rPr.first("b").attr("val") != "0" && rPr.first("b").attr("val") != "false" {
  406. open += "<b>"
  407. close = "</b>" + close
  408. }
  409. if rPr.first("i") != nil {
  410. open += "<i>"
  411. close = "</i>" + close
  412. }
  413. if u := rPr.first("u"); u != nil && u.attr("val") != "none" {
  414. open += "<u>"
  415. close = "</u>" + close
  416. }
  417. if rPr.first("strike") != nil {
  418. open += "<s>"
  419. close = "</s>" + close
  420. }
  421. if c := rPr.first("color"); c != nil {
  422. v := c.attr("val")
  423. if len(v) == 6 && v != "000000" && strings.ToUpper(v) != "AUTO" {
  424. styleProps = append(styleProps, "color:#"+strings.ToLower(v))
  425. }
  426. }
  427. if sz := rPr.first("sz"); sz != nil {
  428. if hp, err := strconv.ParseFloat(sz.attr("val"), 64); err == nil && hp > 0 && hp != 22 {
  429. px := halfPointsToPx(hp)
  430. styleProps = append(styleProps, "font-size:"+strconv.Itoa(int(px+0.5))+"px")
  431. }
  432. }
  433. }
  434. var body strings.Builder
  435. for i := range r.Nodes {
  436. n := &r.Nodes[i]
  437. switch n.XMLName.Local {
  438. case "t":
  439. body.WriteString(xmlEscape(n.Text))
  440. case "br", "cr":
  441. if n.attr("type") == "page" {
  442. // explicit page break -> the editor's page break block
  443. body.WriteString(`<div class="doc-pagebreak" contenteditable="false"></div>`)
  444. } else {
  445. body.WriteString("<br>")
  446. }
  447. case "tab":
  448. body.WriteString("&nbsp;&nbsp;&nbsp;&nbsp;")
  449. case "drawing", "pict", "object":
  450. body.WriteString(cv.imageToHTML(n))
  451. }
  452. }
  453. out := body.String()
  454. if out == "" {
  455. return ""
  456. }
  457. if len(styleProps) > 0 {
  458. open += `<span style="` + strings.Join(styleProps, ";") + `;">`
  459. close = "</span>" + close
  460. }
  461. return open + out + close
  462. }
  463. // imageToHTML finds the blip relationship inside a drawing and inlines it
  464. func (cv *docxConv) imageToHTML(n *xnode) string {
  465. rid := ""
  466. var wPx int
  467. var findBlip func(x *xnode)
  468. findBlip = func(x *xnode) {
  469. if x.XMLName.Local == "blip" {
  470. for _, a := range x.Attrs {
  471. if a.Name.Local == "embed" {
  472. rid = a.Value
  473. }
  474. }
  475. }
  476. if x.XMLName.Local == "extent" && wPx == 0 {
  477. if cx, err := strconv.ParseInt(x.attr("cx"), 10, 64); err == nil {
  478. wPx = int(emuToPx(cx, 1.0))
  479. }
  480. }
  481. for i := range x.Nodes {
  482. findBlip(&x.Nodes[i])
  483. }
  484. }
  485. findBlip(n)
  486. if rid == "" {
  487. return ""
  488. }
  489. target, ok := cv.rels[rid]
  490. if !ok {
  491. return ""
  492. }
  493. mediaPath := resolvePartPath("word", target)
  494. data, ok2 := cv.files[mediaPath]
  495. if !ok2 {
  496. return ""
  497. }
  498. ext := strings.TrimPrefix(strings.ToLower(path.Ext(mediaPath)), ".")
  499. attrs := ""
  500. if wPx > 10 {
  501. attrs = ` style="width:` + strconv.Itoa(wPx) + `px;"`
  502. }
  503. return `<img src="` + encodeDataURL(data, ext) + `"` + attrs + ">"
  504. }
  505. func (cv *docxConv) tableToHTML(tbl *xnode) string {
  506. var sb strings.Builder
  507. // table width: tblW pct (fiftieths of a percent) or dxa (twips of the
  508. // ~9026-twip text column); auto/absent = the editor's default 100%
  509. widthStyle := ""
  510. if tblPr := tbl.first("tblPr"); tblPr != nil {
  511. if tw := tblPr.first("tblW"); tw != nil {
  512. if v, err := strconv.ParseFloat(tw.attr("w"), 64); err == nil && v > 0 {
  513. pct := 0.0
  514. switch tw.attr("type") {
  515. case "pct":
  516. pct = v / 50.0
  517. case "dxa":
  518. pct = v * 100 / 9026.0
  519. }
  520. if pct > 100 {
  521. pct = 100
  522. }
  523. // full-width tables need no inline style
  524. if pct > 1 && pct < 99.5 {
  525. widthStyle = ` style="width:` + trimFloat(pct) + `%;"`
  526. }
  527. }
  528. }
  529. }
  530. sb.WriteString(`<table class="of-table"` + widthStyle + `>`)
  531. // column proportions -> the editor's colgroup
  532. if grid := tbl.first("tblGrid"); grid != nil {
  533. var ws []float64
  534. sum := 0.0
  535. for _, gc := range grid.all("gridCol") {
  536. if v, err := strconv.ParseFloat(gc.attr("w"), 64); err == nil && v > 0 {
  537. ws = append(ws, v)
  538. sum += v
  539. }
  540. }
  541. if len(ws) > 1 && sum > 0 {
  542. sb.WriteString("<colgroup>")
  543. for _, w := range ws {
  544. sb.WriteString(`<col style="width:` + trimFloat(w*100/sum) + `%">`)
  545. }
  546. sb.WriteString("</colgroup>")
  547. }
  548. }
  549. for i := range tbl.Nodes {
  550. tr := &tbl.Nodes[i]
  551. if tr.XMLName.Local != "tr" {
  552. continue
  553. }
  554. sb.WriteString("<tr>")
  555. for j := range tr.Nodes {
  556. tc := &tr.Nodes[j]
  557. if tc.XMLName.Local != "tc" {
  558. continue
  559. }
  560. // cell shading survives as an inline background
  561. tdStyle := ""
  562. if tcPr := tc.first("tcPr"); tcPr != nil {
  563. if shd := tcPr.first("shd"); shd != nil {
  564. if fill := shd.attr("fill"); len(fill) == 6 && fill != "auto" {
  565. tdStyle = ` style="background-color:#` + strings.ToLower(fill) + `;"`
  566. }
  567. }
  568. }
  569. inner := cv.blocksToHTML(tc)
  570. // unwrap a single plain paragraph for cleaner cells
  571. if strings.HasPrefix(inner, "<p>") && strings.HasSuffix(inner, "</p>") &&
  572. strings.Count(inner, "<p>") == 1 {
  573. inner = strings.TrimSuffix(strings.TrimPrefix(inner, "<p>"), "</p>")
  574. }
  575. sb.WriteString("<td" + tdStyle + ">" + inner + "</td>")
  576. }
  577. sb.WriteString("</tr>")
  578. }
  579. sb.WriteString("</table>")
  580. return sb.String()
  581. }