docx_rich_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. package office
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "image"
  6. "image/color"
  7. "image/png"
  8. "strings"
  9. "testing"
  10. )
  11. const wNS = `xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ` +
  12. `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ` +
  13. `xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ` +
  14. `xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ` +
  15. `xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"`
  16. // testDocx zips a minimal package: the body XML plus any extra parts
  17. func testDocx(t *testing.T, body string, parts map[string]string) []byte {
  18. t.Helper()
  19. var buf bytes.Buffer
  20. zw := zip.NewWriter(&buf)
  21. all := map[string]string{
  22. "word/document.xml": `<?xml version="1.0" encoding="UTF-8"?><w:document ` + wNS + `><w:body>` + body +
  23. `<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720"/></w:sectPr></w:body></w:document>`,
  24. }
  25. for k, v := range parts {
  26. all[k] = v
  27. }
  28. for name, content := range all {
  29. f, err := zw.Create(name)
  30. if err != nil {
  31. t.Fatalf("zip create: %v", err)
  32. }
  33. if _, err := f.Write([]byte(content)); err != nil {
  34. t.Fatalf("zip write: %v", err)
  35. }
  36. }
  37. if err := zw.Close(); err != nil {
  38. t.Fatalf("zip close: %v", err)
  39. }
  40. return buf.Bytes()
  41. }
  42. func parseTestDocx(t *testing.T, body string, parts map[string]string) *Document {
  43. t.Helper()
  44. doc, err := ParseDocx(testDocx(t, body, parts))
  45. if err != nil {
  46. t.Fatalf("ParseDocx: %v", err)
  47. }
  48. return doc
  49. }
  50. func TestOnOff(t *testing.T) {
  51. tests := []struct {
  52. xml string
  53. set, on bool
  54. }{
  55. {`<w:b/>`, true, true},
  56. {`<w:b w:val="1"/>`, true, true},
  57. {`<w:b w:val="true"/>`, true, true},
  58. {`<w:b w:val="0"/>`, true, false},
  59. {`<w:b w:val="false"/>`, true, false},
  60. {`<w:b w:val="off"/>`, true, false},
  61. }
  62. for _, tc := range tests {
  63. n, err := parseXMLTree([]byte(`<w:rPr ` + wNS + `>` + tc.xml + `</w:rPr>`))
  64. if err != nil {
  65. t.Fatalf("%s: %v", tc.xml, err)
  66. }
  67. got := onOff(n.first("b"))
  68. if got.set != tc.set || got.v != tc.on {
  69. t.Errorf("onOff(%s) = %+v, want set=%v on=%v", tc.xml, got, tc.set, tc.on)
  70. }
  71. }
  72. if got := onOff(nil); got.set {
  73. t.Errorf("onOff(nil) = %+v, want unset", got)
  74. }
  75. }
  76. func TestFormatListNumber(t *testing.T) {
  77. tests := []struct {
  78. v int
  79. fmt string
  80. want string
  81. }{
  82. {3, "decimal", "3"},
  83. {1, "lowerLetter", "a"},
  84. {28, "lowerLetter", "bb"},
  85. {2, "upperLetter", "B"},
  86. {4, "lowerRoman", "iv"},
  87. {1994, "upperRoman", "MCMXCIV"},
  88. {7, "decimalZero", "07"},
  89. {12, "decimalZero", "12"},
  90. {5, "bullet", ""},
  91. }
  92. for _, tc := range tests {
  93. if got := formatListNumber(tc.v, tc.fmt); got != tc.want {
  94. t.Errorf("formatListNumber(%d, %s) = %q, want %q", tc.v, tc.fmt, got, tc.want)
  95. }
  96. }
  97. }
  98. func TestPictureTurns(t *testing.T) {
  99. tests := []struct {
  100. rot float64
  101. turns int
  102. ok bool
  103. }{
  104. {0, 0, true},
  105. {5400000, 1, true},
  106. {10800000, 2, true},
  107. {16200000, 3, true},
  108. {-5400000, 3, true},
  109. {21600000, 0, true},
  110. {5430000, 1, true}, // within a degree
  111. {2700000, 0, false},
  112. }
  113. for _, tc := range tests {
  114. turns, ok := pictureTurns(tc.rot)
  115. if ok != tc.ok || (ok && turns != tc.turns) {
  116. t.Errorf("pictureTurns(%v) = %d,%v want %d,%v", tc.rot, turns, ok, tc.turns, tc.ok)
  117. }
  118. }
  119. }
  120. func TestOrientInsets(t *testing.T) {
  121. in := [4]float64{1, 2, 3, 4} // top right bottom left
  122. tests := []struct {
  123. name string
  124. turns int
  125. flipH, flipV bool
  126. want [4]float64
  127. }{
  128. {"none", 0, false, false, [4]float64{1, 2, 3, 4}},
  129. {"quarter", 1, false, false, [4]float64{4, 1, 2, 3}},
  130. {"half", 2, false, false, [4]float64{3, 4, 1, 2}},
  131. {"three quarters", 3, false, false, [4]float64{2, 3, 4, 1}},
  132. {"mirror", 0, true, false, [4]float64{1, 4, 3, 2}},
  133. {"flip", 0, false, true, [4]float64{3, 2, 1, 4}},
  134. }
  135. for _, tc := range tests {
  136. if got := orientInsets(in, tc.turns, tc.flipH, tc.flipV); got != tc.want {
  137. t.Errorf("%s: orientInsets = %v, want %v", tc.name, got, tc.want)
  138. }
  139. }
  140. }
  141. func TestOrientPixels(t *testing.T) {
  142. // a 3x2 picture with a red top-left pixel
  143. src := image.NewNRGBA(image.Rect(0, 0, 3, 2))
  144. red := color.NRGBA{R: 255, A: 255}
  145. src.SetNRGBA(0, 0, red)
  146. tests := []struct {
  147. name string
  148. turns int
  149. flipH, flipV bool
  150. w, h, rx, ry int
  151. }{
  152. {"quarter turn", 1, false, false, 2, 3, 1, 0},
  153. {"half turn", 2, false, false, 3, 2, 2, 1},
  154. {"three quarters", 3, false, false, 2, 3, 0, 2},
  155. {"mirror", 0, true, false, 3, 2, 2, 0},
  156. {"flip", 0, false, true, 3, 2, 0, 1},
  157. }
  158. for _, tc := range tests {
  159. out := orientPixels(src, tc.turns, tc.flipH, tc.flipV)
  160. if out.Bounds().Dx() != tc.w || out.Bounds().Dy() != tc.h {
  161. t.Errorf("%s: size %v, want %dx%d", tc.name, out.Bounds().Size(), tc.w, tc.h)
  162. continue
  163. }
  164. if out.NRGBAAt(tc.rx, tc.ry) != red {
  165. t.Errorf("%s: red pixel not at (%d,%d)", tc.name, tc.rx, tc.ry)
  166. }
  167. }
  168. }
  169. func TestDocxReaderRunAndParagraphRules(t *testing.T) {
  170. tests := []struct {
  171. name, body string
  172. want []string
  173. dont []string
  174. }{
  175. {
  176. name: "explicit off toggle",
  177. body: `<w:p><w:r><w:rPr><w:b w:val="0"/></w:rPr><w:t>plain</w:t></w:r></w:p>`,
  178. want: []string{`>plain</p>`},
  179. dont: []string{"font-weight:700"},
  180. },
  181. {
  182. name: "spacing before and after",
  183. body: `<w:p><w:pPr><w:spacing w:before="240" w:after="120"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p>`,
  184. want: []string{"padding-top:12pt;margin-bottom:6pt;"},
  185. },
  186. {
  187. name: "exact line height",
  188. body: `<w:p><w:pPr><w:spacing w:line="360" w:lineRule="exact"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p>`,
  189. want: []string{`data-lsexact="18pt"`},
  190. },
  191. {
  192. name: "a leading space inside the text collapses as usual",
  193. body: `<w:p><w:r><w:t xml:space="preserve">one</w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve"> two</w:t></w:r></w:p>`,
  194. dont: []string{"pre-wrap"},
  195. },
  196. {
  197. name: "a leading space at the start is kept",
  198. body: `<w:p><w:r><w:t xml:space="preserve"> lead</w:t></w:r></w:p>`,
  199. want: []string{"white-space:pre-wrap"},
  200. },
  201. {
  202. name: "right tab stop with leader",
  203. body: `<w:p><w:pPr><w:tabs><w:tab w:val="right" w:leader="dot" w:pos="9026"/></w:tabs></w:pPr><w:r><w:t>a</w:t></w:r><w:r><w:tab/><w:t>1</w:t></w:r></w:p>`,
  204. want: []string{`data-tabs="right:451.3:dot"`, `<span class="doc-tab">` + "\t" + `</span>1`},
  205. },
  206. {
  207. name: "page break splits the paragraph",
  208. body: `<w:p><w:r><w:t>before</w:t></w:r><w:r><w:br w:type="page"/></w:r><w:r><w:t>after</w:t></w:r></w:p>`,
  209. want: []string{`>before</p><div class="doc-pagebreak"`, `>after</p>`},
  210. },
  211. {
  212. name: "non-breaking and soft hyphens",
  213. body: `<w:p><w:r><w:t>a</w:t><w:noBreakHyphen/><w:t>b</w:t><w:softHyphen/><w:t>c</w:t></w:r></w:p>`,
  214. want: []string{"a\u2011b\u00adc"},
  215. },
  216. }
  217. for _, tc := range tests {
  218. doc := parseTestDocx(t, tc.body, nil)
  219. for _, w := range tc.want {
  220. if !strings.Contains(doc.HTML, w) {
  221. t.Errorf("%s: want %q in %s", tc.name, w, doc.HTML)
  222. }
  223. }
  224. for _, d := range tc.dont {
  225. if strings.Contains(doc.HTML, d) {
  226. t.Errorf("%s: did not want %q in %s", tc.name, d, doc.HTML)
  227. }
  228. }
  229. }
  230. }
  231. func TestDocxReaderFootnotes(t *testing.T) {
  232. body := `<w:p><w:r><w:t>text</w:t></w:r><w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:footnoteReference w:id="7"/></w:r></w:p>`
  233. notes := `<?xml version="1.0" encoding="UTF-8"?><w:footnotes ` + wNS + `>` +
  234. `<w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote>` +
  235. `<w:footnote w:id="7"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> the note</w:t></w:r></w:p></w:footnote></w:footnotes>`
  236. doc := parseTestDocx(t, body, map[string]string{"word/footnotes.xml": notes})
  237. if !strings.Contains(doc.HTML, `<sup class="doc-fnref" data-fn="7" contenteditable="false">1</sup>`) {
  238. t.Errorf("footnote reference not imported: %s", doc.HTML)
  239. }
  240. if strings.Count(doc.HTML, "<sup") != 1 {
  241. t.Errorf("footnote reference nested in another superscript: %s", doc.HTML)
  242. }
  243. if len(doc.Footnotes) != 1 || doc.Footnotes[0].ID != "7" || !strings.Contains(doc.Footnotes[0].HTML, "the note") {
  244. t.Errorf("footnotes = %+v", doc.Footnotes)
  245. }
  246. }
  247. func TestDocxReaderRotatedPicture(t *testing.T) {
  248. img := image.NewNRGBA(image.Rect(0, 0, 4, 2))
  249. var pngBuf bytes.Buffer
  250. if err := png.Encode(&pngBuf, img); err != nil {
  251. t.Fatalf("png: %v", err)
  252. }
  253. rels := `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
  254. `<Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/p.png"/></Relationships>`
  255. picture := func(rot string) string {
  256. return `<w:p><w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="254000"/>` +
  257. `<a:graphic><a:graphicData><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/>` +
  258. `<a:srcRect t="10000"/></pic:blipFill><pic:spPr><a:xfrm ` + rot + `><a:ext cx="508000" cy="254000"/></a:xfrm></pic:spPr>` +
  259. `</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`
  260. }
  261. tests := []struct {
  262. name, rot, want string
  263. }{
  264. {"upright", ``, "width:40pt;height:20pt;object-fit:fill;object-view-box:inset(10% 0% 0% 0%)"},
  265. {"quarter turn", `rot="5400000"`, "width:20pt;height:40pt;object-fit:fill;object-view-box:inset(0% 10% 0% 0%)"},
  266. }
  267. for _, tc := range tests {
  268. zipData := testDocx(t, picture(tc.rot), map[string]string{
  269. "word/_rels/document.xml.rels": rels,
  270. })
  271. // the media part is binary: add it by rebuilding with the bytes
  272. doc := parseDocxWithMedia(t, zipData, "word/media/p.png", pngBuf.Bytes())
  273. if !strings.Contains(doc.HTML, tc.want) {
  274. t.Errorf("%s: want %q in %.400s", tc.name, tc.want, doc.HTML)
  275. }
  276. }
  277. }
  278. // parseDocxWithMedia adds one binary part to a test package and parses it
  279. func parseDocxWithMedia(t *testing.T, zipData []byte, name string, data []byte) *Document {
  280. t.Helper()
  281. zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData)))
  282. if err != nil {
  283. t.Fatalf("zip: %v", err)
  284. }
  285. var buf bytes.Buffer
  286. zw := zip.NewWriter(&buf)
  287. for _, f := range zr.File {
  288. rc, err := f.Open()
  289. if err != nil {
  290. t.Fatalf("zip open: %v", err)
  291. }
  292. w, _ := zw.Create(f.Name)
  293. var b bytes.Buffer
  294. if _, err := b.ReadFrom(rc); err != nil {
  295. t.Fatalf("zip read: %v", err)
  296. }
  297. rc.Close()
  298. w.Write(b.Bytes())
  299. }
  300. w, _ := zw.Create(name)
  301. w.Write(data)
  302. if err := zw.Close(); err != nil {
  303. t.Fatalf("zip close: %v", err)
  304. }
  305. doc, err := ParseDocx(buf.Bytes())
  306. if err != nil {
  307. t.Fatalf("ParseDocx: %v", err)
  308. }
  309. return doc
  310. }
  311. func TestDocxRichRoundTrip(t *testing.T) {
  312. // the editor's own model survives docx -> model unchanged
  313. tests := []struct {
  314. name, html string
  315. }{
  316. {"spacing and indents", `<p style="padding-top:6pt;margin-bottom:10pt;margin-left:36pt;text-indent:-18pt;">x</p>`},
  317. {"exact line height", `<p data-lsexact="14pt">x</p>`},
  318. {"line spacing", `<p data-ls="1.5">x</p>`},
  319. {"keep with next", `<p data-keep-next="1">x</p>`},
  320. {"tab stops", `<p data-tabs="right:451.3:dot">a<span class="doc-tab">` + "\t" + `</span>1</p>`},
  321. {"footnote reference", `<p>a<sup class="doc-fnref" data-fn="1" contenteditable="false">1</sup></p>`},
  322. {"page field", `<p><span class="doc-field" data-field="PAGE">1</span></p>`},
  323. {"shaded paragraph", `<p style="background-color:#ffe599;">x</p>`},
  324. {"trailing line break", `<p>x<br><br></p>`},
  325. {"horizontal rule", `<p>a</p><hr><p>b</p>`},
  326. {"bordered paragraph", `<p style="margin-top:6pt;margin-left:10pt;border-left:1.5pt solid #ff0000;padding-left:4pt;">x</p>`},
  327. }
  328. for _, tc := range tests {
  329. src := &Document{HTML: tc.html, Footnotes: []Footnote{{ID: "1", HTML: "<p>note</p>"}}}
  330. data, err := BuildDocx(src)
  331. if err != nil {
  332. t.Fatalf("%s: BuildDocx: %v", tc.name, err)
  333. }
  334. back, err := ParseDocx(data)
  335. if err != nil {
  336. t.Fatalf("%s: ParseDocx: %v", tc.name, err)
  337. }
  338. if !strings.Contains(back.HTML, tc.html) {
  339. t.Errorf("%s: want %s, got %s", tc.name, tc.html, back.HTML)
  340. }
  341. }
  342. }
  343. func TestDocxWriterEditorBoxes(t *testing.T) {
  344. tests := []struct {
  345. name, html string
  346. want []string
  347. }{
  348. {
  349. // docs.css: 3px rule, 12px padding, 8pt + 2pt either side
  350. name: "blockquote",
  351. html: `<blockquote>quoted</blockquote>`,
  352. want: []string{`<w:left w:val="single" w:sz="18" w:space="9" w:color="C3C7CC"/>`,
  353. `<w:spacing w:before="200" w:after="200"`, `<w:ind w:left="225"`, `<w:color w:val="5F6368"/>`},
  354. },
  355. {
  356. // a 1px frame with 10px 12px inside: the half point w:space
  357. // cannot hold goes to the spacing outside
  358. name: "code block",
  359. html: `<pre>code</pre>`,
  360. want: []string{`<w:top w:val="single" w:sz="6" w:space="8" w:color="E2E5E9"/>`,
  361. `<w:left w:val="single" w:sz="6" w:space="9" w:color="E2E5E9"/>`,
  362. `<w:spacing w:before="150" w:after="150"`, `<w:ind w:left="195" w:right="195"`,
  363. `<w:shd w:val="clear" w:color="auto" w:fill="F1F3F4"/>`, `w:ascii="Consolas"`},
  364. },
  365. {
  366. name: "an indent blockquote from the browser keeps no rule",
  367. html: `<blockquote style="margin: 0 0 0 40px; border: none; padding: 0px;"><p>x</p></blockquote>`,
  368. want: []string{`<w:ind w:left="600"`},
  369. },
  370. {
  371. name: "header cell is centred",
  372. html: `<table class="of-table"><tbody><tr><th>H</th></tr></tbody></table>`,
  373. want: []string{`<w:tblStyle w:val="ArozEditorTable"/>`, `<w:jc w:val="center"/>`},
  374. },
  375. {
  376. name: "imported table stays a Word table",
  377. html: `<table class="of-table" data-docx="1"><tbody><tr><td>c</td></tr></tbody></table>`,
  378. want: []string{`<w:tblStyle w:val="TableGrid"/>`},
  379. },
  380. {
  381. name: "rule",
  382. html: `<hr>`,
  383. want: []string{`<w:pStyle w:val="ArozHorizontalRule"/>`, `w:line="20" w:lineRule="exact"`},
  384. },
  385. }
  386. for _, tc := range tests {
  387. data, err := BuildDocx(&Document{HTML: tc.html})
  388. if err != nil {
  389. t.Fatalf("%s: BuildDocx: %v", tc.name, err)
  390. }
  391. body := string(zipPart(t, data, "word/document.xml"))
  392. for _, w := range tc.want {
  393. if !strings.Contains(body, w) {
  394. t.Errorf("%s: want %s in %s", tc.name, w, body)
  395. }
  396. }
  397. }
  398. // the indent blockquote draws no rule
  399. data, _ := BuildDocx(&Document{HTML: tests[2].html})
  400. if body := string(zipPart(t, data, "word/document.xml")); strings.Contains(body, "<w:pBdr>") {
  401. t.Errorf("border:none blockquote got a rule: %s", body)
  402. }
  403. }
  404. func TestDocxPlainHeaderStaysPlain(t *testing.T) {
  405. data, err := BuildDocx(&Document{HTML: "<p>x</p>", Header: "Top & tail", Footer: "Bottom"})
  406. if err != nil {
  407. t.Fatalf("BuildDocx: %v", err)
  408. }
  409. back, err := ParseDocx(data)
  410. if err != nil {
  411. t.Fatalf("ParseDocx: %v", err)
  412. }
  413. if back.Header != "Top & tail" || back.HeaderHTML != "" {
  414. t.Errorf("header = %q / %q, want plain text", back.Header, back.HeaderHTML)
  415. }
  416. if back.Footer != "Bottom" || back.FooterHTML != "" || back.PageNumbers {
  417. t.Errorf("footer = %q / %q pn=%v, want plain text", back.Footer, back.FooterHTML, back.PageNumbers)
  418. }
  419. }