docx_writer.go 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. package office
  2. /*
  3. docx_writer.go - Build a Word (.docx) file from a Document.
  4. The Docs editor HTML subset is converted to WordprocessingML:
  5. paragraphs, headings (Heading1-4 + Title styles), bold/italic/
  6. underline/strikethrough, font color/size, hyperlinks, bulleted and
  7. numbered lists (numbering.xml), block quotes, code blocks, tables,
  8. horizontal rules, inline images (data URLs become embedded media) and
  9. line breaks. Header/footer text and optional page numbers are written
  10. as real header/footer parts; page size/orientation/margins map to the
  11. section properties.
  12. */
  13. import (
  14. "archive/zip"
  15. "bytes"
  16. "errors"
  17. "fmt"
  18. "image"
  19. _ "image/gif" // natural-size probing in buildImageRun
  20. _ "image/jpeg" // natural-size probing in buildImageRun
  21. _ "image/png" // natural-size probing in buildImageRun
  22. "strconv"
  23. "strings"
  24. "golang.org/x/net/html"
  25. )
  26. 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"`
  27. // runFormat is the inline formatting state while walking the HTML tree
  28. type runFormat struct {
  29. b, i, u, strike bool
  30. color string // #rrggbb
  31. sizePx float64 // 0 = default
  32. mono bool
  33. link string // href when inside <a>
  34. }
  35. type docxBuilder struct {
  36. body strings.Builder
  37. rels []string // relationship XML fragments (rId offset +100 to avoid fixed ids)
  38. media []mediaEntry
  39. imgCount int
  40. hasList bool
  41. // multi-column documents: blocks with class "col-span-all" (IEEE-style
  42. // title/author rows) are emitted into their own single-column section,
  43. // separated from the columned body by a continuous section break
  44. multiCol bool
  45. sectDivider string // paragraph-embedded sectPr closing a span-all run
  46. lastSpan bool
  47. anyBlock bool
  48. usedDivider bool
  49. }
  50. // BuildDocx serializes a Document into a complete .docx file
  51. func BuildDocx(doc *Document) ([]byte, error) {
  52. if doc == nil {
  53. return nil, errors.New("nil document")
  54. }
  55. root, err := html.Parse(strings.NewReader("<body>" + doc.HTML + "</body>"))
  56. if err != nil {
  57. return nil, errors.New("cannot parse document HTML: " + err.Error())
  58. }
  59. b := &docxBuilder{}
  60. if doc.Page != nil && doc.Page.Columns > 1 {
  61. b.multiCol = true
  62. b.sectDivider = `<w:p><w:pPr><w:sectPr><w:type w:val="continuous"/>` +
  63. pgGeometry(doc.Page) + `<w:cols w:num="1"/></w:sectPr></w:pPr></w:p>`
  64. }
  65. if bodyNode := findHTMLNode(root, "body"); bodyNode != nil {
  66. b.walkTop(bodyNode, runFormat{})
  67. }
  68. buf := new(bytes.Buffer)
  69. zw := zip.NewWriter(buf)
  70. addFile := func(name, content string) error {
  71. w, err := zw.Create(name)
  72. if err != nil {
  73. return err
  74. }
  75. _, err = w.Write([]byte(content))
  76. return err
  77. }
  78. addBin := func(name string, data []byte) error {
  79. w, err := zw.Create(name)
  80. if err != nil {
  81. return err
  82. }
  83. _, err = w.Write(data)
  84. return err
  85. }
  86. // hfMode "none" drops the text; "except-first" keeps the parts and lets
  87. // Word blank page 1 through <w:titlePg/> (no "first" reference = empty)
  88. hfText := doc.HFMode != HFModeNone
  89. hasHeader := hfText && strings.TrimSpace(doc.Header) != ""
  90. hasFooter := (hfText && strings.TrimSpace(doc.Footer) != "") || doc.PageNumbers
  91. // [Content_Types].xml
  92. var ct strings.Builder
  93. ct.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
  94. ct.WriteString(`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`)
  95. ct.WriteString(`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`)
  96. ct.WriteString(`<Default Extension="xml" ContentType="application/xml"/>`)
  97. ct.WriteString(`<Default Extension="png" ContentType="image/png"/>`)
  98. ct.WriteString(`<Default Extension="jpeg" ContentType="image/jpeg"/>`)
  99. ct.WriteString(`<Default Extension="gif" ContentType="image/gif"/>`)
  100. ct.WriteString(`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
  101. ct.WriteString(`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`)
  102. ct.WriteString(`<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`)
  103. if hasHeader {
  104. ct.WriteString(`<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>`)
  105. }
  106. if hasFooter {
  107. ct.WriteString(`<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>`)
  108. }
  109. ct.WriteString(`</Types>`)
  110. if err := addFile("[Content_Types].xml", ct.String()); err != nil {
  111. return nil, err
  112. }
  113. if err := addFile("_rels/.rels",
  114. `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+"\n"+
  115. `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
  116. `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>`+
  117. `</Relationships>`); err != nil {
  118. return nil, err
  119. }
  120. // document rels: styles + numbering + optional header/footer + images/links
  121. var rels strings.Builder
  122. rels.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
  123. rels.WriteString(`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`)
  124. rels.WriteString(`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`)
  125. rels.WriteString(`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>`)
  126. headerRef, footerRef := "", ""
  127. if hasHeader {
  128. rels.WriteString(`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>`)
  129. headerRef = `<w:headerReference w:type="default" r:id="rId3"/>`
  130. }
  131. if hasFooter {
  132. rels.WriteString(`<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>`)
  133. footerRef = `<w:footerReference w:type="default" r:id="rId4"/>`
  134. }
  135. for _, r := range b.rels {
  136. rels.WriteString(r)
  137. }
  138. rels.WriteString(`</Relationships>`)
  139. if err := addFile("word/_rels/document.xml.rels", rels.String()); err != nil {
  140. return nil, err
  141. }
  142. // section properties (page setup)
  143. sect := buildSectPr(doc.Page, headerRef, footerRef, b.usedDivider,
  144. doc.HFMode == HFModeExceptFirst)
  145. docXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n" +
  146. `<w:document ` + docxNs + `><w:body>` + b.body.String() + sect + `</w:body></w:document>`
  147. if err := addFile("word/document.xml", docXML); err != nil {
  148. return nil, err
  149. }
  150. if err := addFile("word/styles.xml", docxStyles); err != nil {
  151. return nil, err
  152. }
  153. if err := addFile("word/numbering.xml", docxNumbering); err != nil {
  154. return nil, err
  155. }
  156. if hasHeader {
  157. if err := addFile("word/header1.xml", buildHfPart("hdr", doc.Header, false)); err != nil {
  158. return nil, err
  159. }
  160. }
  161. if hasFooter {
  162. footerText := doc.Footer
  163. if !hfText {
  164. footerText = ""
  165. }
  166. if err := addFile("word/footer1.xml", buildHfPart("ftr", footerText, doc.PageNumbers)); err != nil {
  167. return nil, err
  168. }
  169. }
  170. for _, m := range b.media {
  171. if err := addBin(fmt.Sprintf("word/media/image%d.%s", m.index, m.ext), m.data); err != nil {
  172. return nil, err
  173. }
  174. }
  175. if err := zw.Close(); err != nil {
  176. return nil, err
  177. }
  178. return buf.Bytes(), nil
  179. }
  180. func findHTMLNode(n *html.Node, tag string) *html.Node {
  181. if n.Type == html.ElementNode && n.Data == tag {
  182. return n
  183. }
  184. for c := n.FirstChild; c != nil; c = c.NextSibling {
  185. if f := findHTMLNode(c, tag); f != nil {
  186. return f
  187. }
  188. }
  189. return nil
  190. }
  191. func htmlAttr(n *html.Node, name string) string {
  192. for _, a := range n.Attr {
  193. if a.Key == name {
  194. return a.Val
  195. }
  196. }
  197. return ""
  198. }
  199. // styleProp extracts one property from an inline style attribute
  200. func styleProp(style, prop string) string {
  201. for _, decl := range strings.Split(style, ";") {
  202. kv := strings.SplitN(decl, ":", 2)
  203. if len(kv) == 2 && strings.TrimSpace(strings.ToLower(kv[0])) == prop {
  204. return strings.TrimSpace(kv[1])
  205. }
  206. }
  207. return ""
  208. }
  209. func applyInlineFormat(f runFormat, n *html.Node) runFormat {
  210. switch n.Data {
  211. case "b", "strong":
  212. f.b = true
  213. case "i", "em":
  214. f.i = true
  215. case "u":
  216. f.u = true
  217. case "s", "strike", "del":
  218. f.strike = true
  219. case "code", "tt":
  220. f.mono = true
  221. case "a":
  222. if href := htmlAttr(n, "href"); href != "" {
  223. f.link = href
  224. }
  225. case "font":
  226. if c := htmlAttr(n, "color"); c != "" {
  227. f.color = c
  228. }
  229. }
  230. style := htmlAttr(n, "style")
  231. if style != "" {
  232. if c := styleProp(style, "color"); c != "" {
  233. f.color = c
  234. }
  235. if fw := styleProp(style, "font-weight"); fw == "bold" || fw == "700" {
  236. f.b = true
  237. }
  238. if fs := styleProp(style, "font-style"); fs == "italic" {
  239. f.i = true
  240. }
  241. if td := styleProp(style, "text-decoration"); strings.Contains(td, "underline") {
  242. f.u = true
  243. } else if strings.Contains(td, "line-through") {
  244. f.strike = true
  245. }
  246. if sz := styleProp(style, "font-size"); strings.HasSuffix(sz, "px") {
  247. if v, err := strconv.ParseFloat(strings.TrimSuffix(sz, "px"), 64); err == nil {
  248. f.sizePx = v
  249. }
  250. } else if strings.HasSuffix(sz, "pt") {
  251. if v, err := strconv.ParseFloat(strings.TrimSuffix(sz, "pt"), 64); err == nil {
  252. f.sizePx = v / 0.75
  253. }
  254. }
  255. }
  256. return f
  257. }
  258. func rprFor(f runFormat) string {
  259. var sb strings.Builder
  260. sb.WriteString("<w:rPr>")
  261. if f.mono {
  262. sb.WriteString(`<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>`)
  263. }
  264. if f.b {
  265. sb.WriteString("<w:b/>")
  266. }
  267. if f.i {
  268. sb.WriteString("<w:i/>")
  269. }
  270. if f.strike {
  271. sb.WriteString("<w:strike/>")
  272. }
  273. if f.u {
  274. sb.WriteString(`<w:u w:val="single"/>`)
  275. }
  276. if f.color != "" {
  277. sb.WriteString(`<w:color w:val="` + hexColor(f.color, "000000") + `"/>`)
  278. }
  279. if f.link != "" {
  280. sb.WriteString(`<w:color w:val="1A58C2"/><w:u w:val="single"/>`)
  281. }
  282. if f.sizePx > 0 {
  283. hp := pxToHalfPoints(f.sizePx)
  284. sb.WriteString(fmt.Sprintf(`<w:sz w:val="%d"/><w:szCs w:val="%d"/>`, hp, hp))
  285. }
  286. sb.WriteString("</w:rPr>")
  287. return sb.String()
  288. }
  289. /* ---------- block walking ---------- */
  290. // paragraph collects runs then flushes them as one w:p
  291. type paraOut struct {
  292. runs strings.Builder
  293. any bool
  294. pPr string
  295. owner *docxBuilder
  296. }
  297. func (p *paraOut) flush() {
  298. if !p.any {
  299. return
  300. }
  301. p.owner.body.WriteString("<w:p>" + p.pPr + p.runs.String() + "</w:p>")
  302. p.runs.Reset()
  303. p.any = false
  304. }
  305. // walkTop emits the direct children of <body>, inserting continuous
  306. // single-column section breaks after runs of .col-span-all blocks so
  307. // IEEE-style spanning titles survive in real Word multi-column layouts
  308. func (b *docxBuilder) walkTop(n *html.Node, f runFormat) {
  309. for c := n.FirstChild; c != nil; c = c.NextSibling {
  310. if c.Type == html.TextNode {
  311. if strings.TrimSpace(c.Data) != "" {
  312. p := &paraOut{owner: b}
  313. b.inlineRuns(c, f, p)
  314. p.flush()
  315. }
  316. continue
  317. }
  318. if c.Type != html.ElementNode {
  319. continue
  320. }
  321. if b.multiCol {
  322. span := strings.Contains(" "+htmlAttr(c, "class")+" ", " col-span-all ")
  323. if b.anyBlock && b.lastSpan && !span {
  324. b.body.WriteString(b.sectDivider)
  325. b.usedDivider = true
  326. }
  327. b.lastSpan = span
  328. }
  329. b.anyBlock = true
  330. b.dispatchBlock(c, f, 0, "")
  331. }
  332. }
  333. // walkBlocks emits block-level content. listLevel/listType track nesting.
  334. func (b *docxBuilder) walkBlocks(n *html.Node, f runFormat, listLevel int, listType string) {
  335. for c := n.FirstChild; c != nil; c = c.NextSibling {
  336. if c.Type == html.TextNode {
  337. if strings.TrimSpace(c.Data) != "" {
  338. // stray text: wrap into a paragraph
  339. p := &paraOut{owner: b}
  340. b.inlineRuns(c, f, p)
  341. p.flush()
  342. }
  343. continue
  344. }
  345. if c.Type != html.ElementNode {
  346. continue
  347. }
  348. b.dispatchBlock(c, f, listLevel, listType)
  349. }
  350. }
  351. // dispatchBlock emits ONE block-level element
  352. func (b *docxBuilder) dispatchBlock(c *html.Node, f runFormat, listLevel int, listType string) {
  353. // explicit page break inserted in the editor (Insert > Page break)
  354. if strings.Contains(" "+htmlAttr(c, "class")+" ", " doc-pagebreak ") {
  355. b.body.WriteString(`<w:p><w:r><w:br w:type="page"/></w:r></w:p>`)
  356. return
  357. }
  358. switch c.Data {
  359. case "p", "div":
  360. b.emitParagraph(c, f, paraProps(c, ""), listLevel, listType)
  361. case "h1", "h2", "h3", "h4", "h5", "h6":
  362. style := "Heading" + string(c.Data[1])
  363. if strings.Contains(" "+htmlAttr(c, "class")+" ", " doc-title ") {
  364. style = "Title"
  365. }
  366. b.emitParagraph(c, f, paraProps(c, style), 0, "")
  367. case "blockquote":
  368. qf := f
  369. qf.i = true
  370. inner := paraProps(c, "")
  371. inner = strings.Replace(inner, "</w:pPr>", `<w:ind w:left="720"/></w:pPr>`, 1)
  372. b.walkBlocksOrParagraph(c, qf, inner)
  373. case "pre":
  374. b.emitPre(c, f)
  375. case "ul":
  376. b.walkList(c, f, listLevel, "bullet")
  377. case "ol":
  378. b.walkList(c, f, listLevel, "decimal")
  379. case "table":
  380. b.emitTable(c, f)
  381. case "hr":
  382. b.body.WriteString(`<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="1" w:color="AAAAAA"/></w:pBdr></w:pPr></w:p>`)
  383. default:
  384. // unknown block-ish or inline at top level: treat as paragraph
  385. b.emitParagraph(c, f, paraProps(c, ""), listLevel, listType)
  386. }
  387. }
  388. // walkBlocksOrParagraph handles containers that may hold either inline
  389. // content or nested blocks (blockquote, li)
  390. func (b *docxBuilder) walkBlocksOrParagraph(n *html.Node, f runFormat, pPr string) {
  391. if hasBlockChild(n) {
  392. b.walkBlocks(n, f, 0, "")
  393. } else {
  394. p := &paraOut{owner: b, pPr: pPr}
  395. b.inlineRuns(n, f, p)
  396. if !p.any {
  397. p.any = true // keep empty paragraphs (spacing)
  398. }
  399. p.flush()
  400. }
  401. }
  402. var blockTags = map[string]bool{
  403. "p": true, "div": true, "h1": true, "h2": true, "h3": true, "h4": true,
  404. "h5": true, "h6": true, "ul": true, "ol": true, "table": true,
  405. "blockquote": true, "pre": true, "hr": true, "li": true,
  406. }
  407. func hasBlockChild(n *html.Node) bool {
  408. for c := n.FirstChild; c != nil; c = c.NextSibling {
  409. if c.Type == html.ElementNode && blockTags[c.Data] {
  410. return true
  411. }
  412. }
  413. return false
  414. }
  415. func paraProps(n *html.Node, style string) string {
  416. var sb strings.Builder
  417. sb.WriteString("<w:pPr>")
  418. if style != "" {
  419. sb.WriteString(`<w:pStyle w:val="` + style + `"/>`)
  420. }
  421. st := htmlAttr(n, "style")
  422. switch styleProp(st, "text-align") {
  423. case "center":
  424. sb.WriteString(`<w:jc w:val="center"/>`)
  425. case "right":
  426. sb.WriteString(`<w:jc w:val="right"/>`)
  427. case "justify":
  428. sb.WriteString(`<w:jc w:val="both"/>`)
  429. }
  430. sb.WriteString("</w:pPr>")
  431. return sb.String()
  432. }
  433. func (b *docxBuilder) emitParagraph(n *html.Node, f runFormat, pPr string, listLevel int, listType string) {
  434. if hasBlockChild(n) {
  435. b.walkBlocks(n, f, listLevel, listType)
  436. return
  437. }
  438. p := &paraOut{owner: b, pPr: pPr}
  439. b.inlineRuns(n, f, p)
  440. p.any = true // keep empties: they are visible blank lines in the editor
  441. p.flush()
  442. }
  443. func (b *docxBuilder) emitPre(n *html.Node, f runFormat) {
  444. mono := f
  445. mono.mono = true
  446. text := textContent(n)
  447. lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
  448. for _, line := range lines {
  449. b.body.WriteString(`<w:p><w:pPr><w:shd w:val="clear" w:color="auto" w:fill="F1F3F4"/></w:pPr>` +
  450. `<w:r>` + rprFor(mono) + `<w:t xml:space="preserve">` + xmlEscape(line) + `</w:t></w:r></w:p>`)
  451. }
  452. }
  453. func (b *docxBuilder) walkList(n *html.Node, f runFormat, level int, listType string) {
  454. if level > 3 {
  455. level = 3
  456. }
  457. numID := "1" // bullet
  458. if listType == "decimal" {
  459. numID = "2"
  460. }
  461. for c := n.FirstChild; c != nil; c = c.NextSibling {
  462. if c.Type != html.ElementNode || c.Data != "li" {
  463. continue
  464. }
  465. b.hasList = true
  466. pPr := fmt.Sprintf(`<w:pPr><w:numPr><w:ilvl w:val="%d"/><w:numId w:val="%s"/></w:numPr></w:pPr>`, level, numID)
  467. // checklist items render their state as a leading glyph run
  468. p := &paraOut{owner: b, pPr: pPr}
  469. b.inlineRuns(c, f, p)
  470. p.any = true
  471. p.flush()
  472. // nested lists inside this li
  473. for gc := c.FirstChild; gc != nil; gc = gc.NextSibling {
  474. if gc.Type == html.ElementNode && gc.Data == "ul" {
  475. b.walkList(gc, f, level+1, "bullet")
  476. } else if gc.Type == html.ElementNode && gc.Data == "ol" {
  477. b.walkList(gc, f, level+1, "decimal")
  478. }
  479. }
  480. }
  481. }
  482. func (b *docxBuilder) emitTable(n *html.Node, f runFormat) {
  483. // the spec requires w:tblGrid with one gridCol per column
  484. cols := 0
  485. var countCols func(node *html.Node)
  486. countCols = func(node *html.Node) {
  487. for c := node.FirstChild; c != nil && cols == 0; c = c.NextSibling {
  488. if c.Type != html.ElementNode {
  489. continue
  490. }
  491. switch c.Data {
  492. case "thead", "tbody", "tfoot":
  493. countCols(c)
  494. case "tr":
  495. for td := c.FirstChild; td != nil; td = td.NextSibling {
  496. if td.Type == html.ElementNode && (td.Data == "td" || td.Data == "th") {
  497. cols++
  498. }
  499. }
  500. }
  501. }
  502. }
  503. countCols(n)
  504. if cols == 0 {
  505. cols = 1
  506. }
  507. // mirror the editor: fixed layout, the table's own width (inline px/%
  508. // after a resize, 100% otherwise) and per-column widths from the
  509. // colgroup (px or %, equal split when absent)
  510. pcts := tableColPercents(n, cols)
  511. tblPct := tableWidthPct(n)
  512. const tblTwips = 9026.0 // A4 text width (210mm - 2x25.4mm margins)
  513. var grid strings.Builder
  514. grid.WriteString("<w:tblGrid>")
  515. for i := 0; i < cols; i++ {
  516. grid.WriteString(fmt.Sprintf(`<w:gridCol w:w="%d"/>`, int(tblTwips*(tblPct/100)*pcts[i]/100)))
  517. }
  518. grid.WriteString("</w:tblGrid>")
  519. b.body.WriteString(`<w:tbl><w:tblPr><w:tblStyle w:val="TableGrid"/>` +
  520. fmt.Sprintf(`<w:tblW w:w="%d" w:type="pct"/><w:tblLayout w:type="fixed"/>`, int(tblPct*50)) +
  521. `<w:tblBorders><w:top w:val="single" w:sz="4" w:color="999999"/><w:left w:val="single" w:sz="4" w:color="999999"/>` +
  522. `<w:bottom w:val="single" w:sz="4" w:color="999999"/><w:right w:val="single" w:sz="4" w:color="999999"/>` +
  523. `<w:insideH w:val="single" w:sz="4" w:color="999999"/><w:insideV w:val="single" w:sz="4" w:color="999999"/></w:tblBorders></w:tblPr>` +
  524. grid.String())
  525. var walkRows func(node *html.Node)
  526. walkRows = func(node *html.Node) {
  527. for c := node.FirstChild; c != nil; c = c.NextSibling {
  528. if c.Type != html.ElementNode {
  529. continue
  530. }
  531. switch c.Data {
  532. case "thead", "tbody", "tfoot":
  533. walkRows(c)
  534. case "tr":
  535. b.body.WriteString("<w:tr>")
  536. ci := 0
  537. for td := c.FirstChild; td != nil; td = td.NextSibling {
  538. if td.Type != html.ElementNode || (td.Data != "td" && td.Data != "th") {
  539. continue
  540. }
  541. cf := f
  542. if td.Data == "th" {
  543. cf.b = true
  544. }
  545. st := htmlAttr(td, "style")
  546. if fw := styleProp(st, "font-weight"); fw == "700" || fw == "bold" {
  547. cf.b = true
  548. }
  549. pct := 100.0 / float64(cols)
  550. if ci < len(pcts) {
  551. pct = pcts[ci]
  552. }
  553. // tcW in fiftieths of a percent keeps the editor's
  554. // column proportions under the fixed layout
  555. tcPr := fmt.Sprintf(`<w:tcW w:w="%d" w:type="pct"/>`, int(pct*50))
  556. if bg := cssColorHex(styleProp(st, "background-color")); bg != "" {
  557. tcPr += `<w:shd w:val="clear" w:color="auto" w:fill="` + bg + `"/>`
  558. }
  559. b.body.WriteString(`<w:tc><w:tcPr>` + tcPr + `</w:tcPr>`)
  560. p := &paraOut{owner: b}
  561. b.inlineRuns(td, cf, p)
  562. p.any = true
  563. p.flush()
  564. b.body.WriteString("</w:tc>")
  565. ci++
  566. }
  567. b.body.WriteString("</w:tr>")
  568. }
  569. }
  570. }
  571. walkRows(n)
  572. b.body.WriteString("</w:tbl>")
  573. }
  574. // tableColPercents reads the editor's <colgroup><col> widths (percent OR
  575. // pixel units - the column resizer writes px), normalized to 100; equal
  576. // split when absent or malformed
  577. func tableColPercents(tbl *html.Node, cols int) []float64 {
  578. out := make([]float64, cols)
  579. got := 0
  580. for cg := tbl.FirstChild; cg != nil; cg = cg.NextSibling {
  581. if cg.Type != html.ElementNode || cg.Data != "colgroup" {
  582. continue
  583. }
  584. for col := cg.FirstChild; col != nil && got < cols; col = col.NextSibling {
  585. if col.Type != html.ElementNode || col.Data != "col" {
  586. continue
  587. }
  588. ws := strings.TrimSpace(styleProp(htmlAttr(col, "style"), "width"))
  589. num := strings.TrimSuffix(strings.TrimSuffix(ws, "%"), "px")
  590. if (strings.HasSuffix(ws, "%") || strings.HasSuffix(ws, "px")) && num != ws {
  591. if v, err := strconv.ParseFloat(num, 64); err == nil && v > 0 {
  592. out[got] = v // any unit: normalized by the sum below
  593. got++
  594. continue
  595. }
  596. }
  597. got = 0 // one bad entry: fall back to the equal split
  598. break
  599. }
  600. break
  601. }
  602. if got != cols {
  603. for i := range out {
  604. out[i] = 100.0 / float64(cols)
  605. }
  606. return out
  607. }
  608. sum := 0.0
  609. for _, v := range out {
  610. sum += v
  611. }
  612. if sum > 0 {
  613. for i := range out {
  614. out[i] = out[i] * 100 / sum
  615. }
  616. }
  617. return out
  618. }
  619. // tableWidthPct reads the table's own inline width (set by the editor's
  620. // column resizer in px, or a percent) as a percentage of the text width;
  621. // 100 when absent
  622. func tableWidthPct(tbl *html.Node) float64 {
  623. const textWpx = 620.0
  624. ws := strings.TrimSpace(styleProp(htmlAttr(tbl, "style"), "width"))
  625. if strings.HasSuffix(ws, "%") {
  626. if v, err := strconv.ParseFloat(strings.TrimSuffix(ws, "%"), 64); err == nil && v > 1 {
  627. if v > 100 {
  628. v = 100
  629. }
  630. return v
  631. }
  632. }
  633. if strings.HasSuffix(ws, "px") {
  634. if v, err := strconv.ParseFloat(strings.TrimSuffix(ws, "px"), 64); err == nil && v > 10 {
  635. pct := v * 100 / textWpx
  636. if pct > 100 {
  637. pct = 100
  638. }
  639. return pct
  640. }
  641. }
  642. return 100
  643. }
  644. // cssColorHex normalizes "#rgb", "#rrggbb" or "rgb(r, g, b)" to "RRGGBB"
  645. // ("" when unparseable or transparent)
  646. func cssColorHex(c string) string {
  647. c = strings.TrimSpace(c)
  648. if c == "" || c == "transparent" {
  649. return ""
  650. }
  651. if strings.HasPrefix(c, "#") {
  652. h := hexColor(c, "")
  653. return h
  654. }
  655. if strings.HasPrefix(c, "rgb") {
  656. open := strings.Index(c, "(")
  657. close := strings.Index(c, ")")
  658. if open < 0 || close <= open {
  659. return ""
  660. }
  661. parts := strings.Split(c[open+1:close], ",")
  662. if len(parts) < 3 {
  663. return ""
  664. }
  665. out := ""
  666. for i := 0; i < 3; i++ {
  667. v, err := strconv.Atoi(strings.TrimSpace(parts[i]))
  668. if err != nil || v < 0 || v > 255 {
  669. return ""
  670. }
  671. out += fmt.Sprintf("%02X", v)
  672. }
  673. return out
  674. }
  675. return ""
  676. }
  677. /* ---------- inline runs ---------- */
  678. func (b *docxBuilder) inlineRuns(n *html.Node, f runFormat, p *paraOut) {
  679. for c := n.FirstChild; c != nil; c = c.NextSibling {
  680. if c.Type == html.TextNode {
  681. t := strings.ReplaceAll(c.Data, "\n", " ")
  682. if t == "" {
  683. continue
  684. }
  685. p.any = true
  686. run := `<w:r>` + rprFor(f) + `<w:t xml:space="preserve">` + xmlEscape(t) + `</w:t></w:r>`
  687. if f.link != "" {
  688. rid := b.addLinkRel(f.link)
  689. run = `<w:hyperlink r:id="` + rid + `">` + run + `</w:hyperlink>`
  690. }
  691. p.runs.WriteString(run)
  692. continue
  693. }
  694. if c.Type != html.ElementNode {
  695. continue
  696. }
  697. switch c.Data {
  698. case "br":
  699. p.any = true
  700. p.runs.WriteString("<w:r><w:br/></w:r>")
  701. case "img":
  702. if x := b.buildImageRun(c); x != "" {
  703. p.any = true
  704. p.runs.WriteString(x)
  705. }
  706. case "ul", "ol", "table", "p", "div", "blockquote", "pre":
  707. // nested block inside an inline context: flush and emit it alone
  708. p.flush()
  709. b.dispatchBlock(c, f, 0, "")
  710. default:
  711. b.inlineRuns(c, applyInlineFormat(f, c), p)
  712. }
  713. }
  714. }
  715. func (b *docxBuilder) addLinkRel(href string) string {
  716. rid := fmt.Sprintf("rId%d", 100+len(b.rels))
  717. b.rels = append(b.rels, `<Relationship Id="`+rid+
  718. `" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="`+
  719. xmlEscape(href)+`" TargetMode="External"/>`)
  720. return rid
  721. }
  722. func (b *docxBuilder) buildImageRun(n *html.Node) string {
  723. src := htmlAttr(n, "src")
  724. data, ext, ok := decodeDataURL(src)
  725. if !ok {
  726. return "" // non-inlined images are skipped (webapp inlines before export)
  727. }
  728. b.imgCount++
  729. idx := b.imgCount
  730. rid := fmt.Sprintf("rId%d", 100+len(b.rels))
  731. b.rels = append(b.rels, fmt.Sprintf(`<Relationship Id="%s" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image%d.%s"/>`, rid, idx, ext))
  732. b.media = append(b.media, mediaEntry{index: idx, ext: ext, data: data})
  733. // natural pixel size from the image bytes - the truth for aspect ratio
  734. natW, natH := 0, 0
  735. if imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil {
  736. natW, natH = imgCfg.Width, imgCfg.Height
  737. }
  738. pxAttr := func(name string) float64 {
  739. if a := htmlAttr(n, name); a != "" {
  740. if v, err := strconv.ParseFloat(strings.TrimSuffix(a, "px"), 64); err == nil && v > 0 {
  741. return v
  742. }
  743. }
  744. if s := styleProp(htmlAttr(n, "style"), name); strings.HasSuffix(s, "px") {
  745. if v, err := strconv.ParseFloat(strings.TrimSuffix(s, "px"), 64); err == nil && v > 0 {
  746. return v
  747. }
  748. }
  749. return 0
  750. }
  751. wPx := pxAttr("width")
  752. hPx := pxAttr("height")
  753. // derive the missing dimension from the natural aspect (the editor
  754. // resizes with height:auto, so usually only width is present)
  755. switch {
  756. case wPx > 0 && hPx <= 0:
  757. if natW > 0 && natH > 0 {
  758. hPx = wPx * float64(natH) / float64(natW)
  759. } else {
  760. hPx = wPx * 3 / 4
  761. }
  762. case hPx > 0 && wPx <= 0:
  763. if natW > 0 && natH > 0 {
  764. wPx = hPx * float64(natW) / float64(natH)
  765. } else {
  766. wPx = hPx * 4 / 3
  767. }
  768. case wPx <= 0 && hPx <= 0:
  769. if natW > 0 && natH > 0 {
  770. wPx, hPx = float64(natW), float64(natH)
  771. } else {
  772. wPx, hPx = 400, 300
  773. }
  774. }
  775. // keep the picture inside the text column (A4 with default margins)
  776. const maxWpx = 620.0
  777. if wPx > maxWpx {
  778. hPx = hPx * maxWpx / wPx
  779. wPx = maxWpx
  780. }
  781. cx, cy := pxToEmu(wPx), pxToEmu(hPx)
  782. return fmt.Sprintf(`<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">`+
  783. `<wp:extent cx="%d" cy="%d"/><wp:docPr id="%d" name="Picture %d"/>`+
  784. `<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">`+
  785. `<pic:pic><pic:nvPicPr><pic:cNvPr id="%d" name="Picture %d"/><pic:cNvPicPr/></pic:nvPicPr>`+
  786. `<pic:blipFill><a:blip r:embed="%s"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>`+
  787. `<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="%d" cy="%d"/></a:xfrm>`+
  788. `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>`+
  789. `</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`,
  790. cx, cy, idx, idx, idx, idx, rid, cx, cy)
  791. }
  792. func textContent(n *html.Node) string {
  793. var sb strings.Builder
  794. var walk func(*html.Node)
  795. walk = func(x *html.Node) {
  796. if x.Type == html.TextNode {
  797. sb.WriteString(x.Data)
  798. return
  799. }
  800. if x.Type == html.ElementNode && x.Data == "br" {
  801. sb.WriteString("\n")
  802. }
  803. for c := x.FirstChild; c != nil; c = c.NextSibling {
  804. walk(c)
  805. }
  806. }
  807. walk(n)
  808. return sb.String()
  809. }
  810. /* ---------- section / header / footer ---------- */
  811. // pgGeometry renders the pgSz + pgMar pair for a page config
  812. func pgGeometry(pc *PageConf) string {
  813. size := "A4"
  814. orient := "portrait"
  815. mT, mR, mB, mL := 25.4, 25.4, 25.4, 25.4
  816. if pc != nil {
  817. if _, ok := pageSizesTwips[pc.Size]; ok {
  818. size = pc.Size
  819. }
  820. if pc.Orientation == "landscape" {
  821. orient = "landscape"
  822. }
  823. if pc.Margins != nil {
  824. mT, mR, mB, mL = pc.Margins.Top, pc.Margins.Right, pc.Margins.Bottom, pc.Margins.Left
  825. }
  826. }
  827. dim := pageSizesTwips[size]
  828. w, h := dim[0], dim[1]
  829. orientAttr := ""
  830. if orient == "landscape" {
  831. w, h = h, w
  832. orientAttr = ` w:orient="landscape"`
  833. }
  834. return fmt.Sprintf(`<w:pgSz w:w="%d" w:h="%d"%s/>`+
  835. `<w:pgMar w:top="%d" w:right="%d" w:bottom="%d" w:left="%d" w:header="720" w:footer="720"/>`,
  836. w, h, orientAttr,
  837. mmToTwips(mT), mmToTwips(mR), mmToTwips(mB), mmToTwips(mL))
  838. }
  839. func buildSectPr(pc *PageConf, headerRef, footerRef string, continuous, titlePg bool) string {
  840. typ := ""
  841. if continuous {
  842. // the columned body continues on the same page as the spanning
  843. // title section it follows
  844. typ = `<w:type w:val="continuous"/>`
  845. }
  846. cols := ""
  847. if pc != nil && pc.Columns > 1 {
  848. gap := pc.ColGap
  849. if gap <= 0 {
  850. gap = 8
  851. }
  852. cols = fmt.Sprintf(`<w:cols w:num="%d" w:space="%d"/>`, pc.Columns, mmToTwips(gap))
  853. }
  854. first := ""
  855. if titlePg {
  856. // "different first page" with no first-page reference: Word leaves
  857. // page 1's header and footer empty
  858. first = `<w:titlePg/>`
  859. }
  860. return `<w:sectPr>` + headerRef + footerRef + typ + pgGeometry(pc) + cols +
  861. first + `</w:sectPr>`
  862. }
  863. // buildHfPart renders a header (root "hdr") or footer ("ftr") part
  864. func buildHfPart(root, text string, pageNumbers bool) string {
  865. var runs strings.Builder
  866. if strings.TrimSpace(text) != "" {
  867. runs.WriteString(`<w:r><w:t xml:space="preserve">` + xmlEscape(text) + `</w:t></w:r>`)
  868. }
  869. if pageNumbers {
  870. if runs.Len() > 0 {
  871. runs.WriteString(`<w:r><w:t xml:space="preserve"> - </w:t></w:r>`)
  872. }
  873. runs.WriteString(`<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
  874. `<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>` +
  875. `<w:r><w:fldChar w:fldCharType="end"/></w:r>`)
  876. }
  877. // no <w:jc>: the editor renders header/footer text left aligned, so the
  878. // export must not silently centre it
  879. return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n" +
  880. `<w:` + root + ` ` + docxNs + `><w:p>` +
  881. runs.String() + `</w:p></w:` + root + `>`
  882. }
  883. /* ---------- static parts ---------- */
  884. // The style sheet pins the EDITOR's typography explicitly (Arial 11pt,
  885. // 1.5 line height, zero paragraph spacing; headings 14pt/6pt margins at
  886. // 1.25) so Word cannot substitute its own Normal defaults - that
  887. // substitution is what made exported pages break earlier than the
  888. // editor's page preview. Spacing units: half-points for sz, twentieths
  889. // of a point for spacing, 240ths of a line for w:line (auto rule).
  890. const docxStyles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
  891. <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:cs="Arial"/><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:before="0" w:after="0" w:line="360" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:spacing w:before="0" w:after="0" w:line="360" w:lineRule="auto"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="280" w:after="120" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="52"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="280" w:after="120" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:b/><w:sz w:val="40"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="280" w:after="120" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="280" w:after="120" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="280" w:after="120" w:line="300" w:lineRule="auto"/></w:pPr><w:rPr><w:b/><w:i/><w:sz w:val="22"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading5"><w:name w:val="heading 5"/><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading6"><w:name w:val="heading 6"/><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="20"/></w:rPr></w:style></w:styles>`
  892. const docxNumbering = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
  893. <w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:lvlText w:val="◦"/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="2"><w:numFmt w:val="bullet"/><w:lvlText w:val="-"/><w:pPr><w:ind w:left="2160" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="3"><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:pPr><w:ind w:left="2880" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="lowerLetter"/><w:lvlText w:val="%2."/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="2"><w:numFmt w:val="lowerRoman"/><w:lvlText w:val="%3."/><w:pPr><w:ind w:left="2160" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="3"><w:numFmt w:val="decimal"/><w:lvlText w:val="%4."/><w:pPr><w:ind w:left="2880" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num><w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num></w:numbering>`