docx_fidelity_test.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. package office
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "fmt"
  6. "image"
  7. "image/color"
  8. "image/png"
  9. "math"
  10. "strings"
  11. "testing"
  12. )
  13. // tiny real PNG with a known non-4:3 aspect (100x25)
  14. func makePngDataURL(t *testing.T, w, h int) string {
  15. t.Helper()
  16. img := image.NewRGBA(image.Rect(0, 0, w, h))
  17. for x := 0; x < w; x++ {
  18. for y := 0; y < h; y++ {
  19. img.Set(x, y, color.RGBA{R: 200, G: 30, B: 30, A: 255})
  20. }
  21. }
  22. var buf bytes.Buffer
  23. if err := png.Encode(&buf, img); err != nil {
  24. t.Fatalf("png encode: %v", err)
  25. }
  26. return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
  27. }
  28. func TestDocxImageKeepsNaturalAspect(t *testing.T) {
  29. // width given, no height: docx must derive height from the 100x25
  30. // natural size (aspect 4:1), not the old 4:3 guess
  31. src := makePngDataURL(t, 100, 25)
  32. doc := &Document{HTML: `<p><img src="` + src + `" width="400"></p>`}
  33. data, err := BuildDocx(doc)
  34. if err != nil {
  35. t.Fatalf("BuildDocx: %v", err)
  36. }
  37. body := string(zipPart(t, data, "word/document.xml"))
  38. wantCx := pxToEmu(400)
  39. wantCy := pxToEmu(100) // 400 * 25/100
  40. want := fmt.Sprintf(`<wp:extent cx="%d" cy="%d"/>`, wantCx, wantCy)
  41. if !strings.Contains(body, want) {
  42. t.Errorf("expected %s in document.xml, got: %s", want, snippetAround(body, "wp:extent"))
  43. }
  44. }
  45. func TestDocxImageNoSizeUsesNatural(t *testing.T) {
  46. src := makePngDataURL(t, 120, 90)
  47. doc := &Document{HTML: `<p><img src="` + src + `"></p>`}
  48. data, err := BuildDocx(doc)
  49. if err != nil {
  50. t.Fatalf("BuildDocx: %v", err)
  51. }
  52. body := string(zipPart(t, data, "word/document.xml"))
  53. want := fmt.Sprintf(`<wp:extent cx="%d" cy="%d"/>`, pxToEmu(120), pxToEmu(90))
  54. if !strings.Contains(body, want) {
  55. t.Errorf("expected %s, got: %s", want, snippetAround(body, "wp:extent"))
  56. }
  57. }
  58. func TestDocxImageCappedToTextWidth(t *testing.T) {
  59. src := makePngDataURL(t, 100, 50)
  60. textW := textWidthPt(nil) // A4, 25.4mm margins
  61. emu := func(pt float64) int64 { return int64(math.Round(pt * 12700)) }
  62. tests := []struct {
  63. name, img string
  64. cx, cy int64
  65. }{
  66. // no stated height: the editor scales it with the width
  67. {"automatic height", `<img src="` + src + `" width="1240">`, emu(textW), emu(textW / 2)},
  68. // a stated height stays as the editor shows it (max-width only)
  69. {"stated height", `<img src="` + src + `" style="width:600pt;height:200pt;">`, emu(textW), emu(200)},
  70. {"narrow picture", `<img src="` + src + `" style="width:300pt;height:150pt;">`, emu(300), emu(150)},
  71. }
  72. for _, tc := range tests {
  73. data, err := BuildDocx(&Document{HTML: `<p>` + tc.img + `</p>`})
  74. if err != nil {
  75. t.Fatalf("%s: BuildDocx: %v", tc.name, err)
  76. }
  77. body := string(zipPart(t, data, "word/document.xml"))
  78. want := fmt.Sprintf(`<wp:extent cx="%d" cy="%d"/>`, tc.cx, tc.cy)
  79. if !strings.Contains(body, want) {
  80. t.Errorf("%s: expected %s, got: %s", tc.name, want, snippetAround(body, "wp:extent"))
  81. }
  82. }
  83. }
  84. func TestDocxTableWidthAndShading(t *testing.T) {
  85. doc := &Document{HTML: `<table class="of-table">` +
  86. `<colgroup><col style="width:60%"><col style="width:40%"></colgroup>` +
  87. `<tbody><tr>` +
  88. `<td style="background-color: rgb(60, 64, 67); font-weight: 700;">A</td>` +
  89. `<td>B</td></tr></tbody></table>`}
  90. data, err := BuildDocx(doc)
  91. if err != nil {
  92. t.Fatalf("BuildDocx: %v", err)
  93. }
  94. body := string(zipPart(t, data, "word/document.xml"))
  95. // full text width (A4, 25.4mm margins: 9026 twips), fixed layout
  96. if !strings.Contains(body, `<w:tblW w:w="9026" w:type="dxa"/>`) {
  97. t.Errorf("table is not full width: %s", snippetAround(body, "tblW"))
  98. }
  99. if !strings.Contains(body, `<w:tblLayout w:type="fixed"/>`) {
  100. t.Error("table layout is not fixed")
  101. }
  102. // column proportions from the colgroup: 60% and 40% of 9026 twips
  103. if !strings.Contains(body, `<w:gridCol w:w="5416"/>`) ||
  104. !strings.Contains(body, `<w:gridCol w:w="3610"/>`) {
  105. t.Errorf("grid columns do not follow the colgroup: %s", snippetAround(body, "tblGrid"))
  106. }
  107. if !strings.Contains(body, `<w:tcW w:w="5416" w:type="dxa"/>`) ||
  108. !strings.Contains(body, `<w:tcW w:w="3610" w:type="dxa"/>`) {
  109. t.Errorf("cell widths not proportional: %s", snippetAround(body, "tcW"))
  110. }
  111. // theme shading + bold survive
  112. if !strings.Contains(body, `<w:shd w:val="clear" w:color="auto" w:fill="3C4043"/>`) {
  113. t.Errorf("cell shading lost: %s", snippetAround(body, "shd"))
  114. }
  115. // ... on the cell alone: the runs inside do not shade themselves again
  116. if n := strings.Count(body, `w:fill="3C4043"`); n != 1 {
  117. t.Errorf("cell shading written %d times, want once (tcPr): %s", n, body)
  118. }
  119. }
  120. func TestDocxTableWidthRoundTrip(t *testing.T) {
  121. // a resized table: 372px of the 620px text column (60%), px colgroup
  122. doc := &Document{HTML: `<table class="of-table" style="width: 372px; table-layout: fixed;">` +
  123. `<colgroup><col style="width:186px"><col style="width:93px"><col style="width:93px"></colgroup>` +
  124. `<tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>`}
  125. data, err := BuildDocx(doc)
  126. if err != nil {
  127. t.Fatalf("BuildDocx: %v", err)
  128. }
  129. body := string(zipPart(t, data, "word/document.xml"))
  130. // 372px = 279pt = 5580 twips
  131. if !strings.Contains(body, `<w:tblW w:w="5580" w:type="dxa"/>`) {
  132. t.Errorf("table width not 279pt: %s", snippetAround(body, "tblW"))
  133. }
  134. // px colgroup ratios (50/25/25) scaled into the grid
  135. if !strings.Contains(body, `<w:tcW w:w="2790" w:type="dxa"/>`) ||
  136. !strings.Contains(body, `<w:tcW w:w="1395" w:type="dxa"/>`) {
  137. t.Errorf("cell widths not 50/25/25: %s", snippetAround(body, "tcW"))
  138. }
  139. back, err := ParseDocx(data)
  140. if err != nil {
  141. t.Fatalf("ParseDocx: %v", err)
  142. }
  143. if !strings.Contains(back.HTML, `class="of-table"`) {
  144. t.Errorf("imported table lost the of-table class: %s", back.HTML)
  145. }
  146. if !strings.Contains(back.HTML, "width:279pt") {
  147. t.Errorf("imported table lost its width: %s", back.HTML)
  148. }
  149. if !strings.Contains(back.HTML, `<colgroup><col style="width:139.5pt"><col style="width:69.75pt"><col style="width:69.75pt"></colgroup>`) {
  150. t.Errorf("imported table lost column proportions: %s", back.HTML)
  151. }
  152. }
  153. func TestDocxTableShadingRoundTrip(t *testing.T) {
  154. doc := &Document{HTML: `<table class="of-table"><tbody><tr>` +
  155. `<td style="background-color: rgb(60, 64, 67);">H</td><td>x</td></tr></tbody></table>`}
  156. data, err := BuildDocx(doc)
  157. if err != nil {
  158. t.Fatalf("BuildDocx: %v", err)
  159. }
  160. back, err := ParseDocx(data)
  161. if err != nil {
  162. t.Fatalf("ParseDocx: %v", err)
  163. }
  164. if !strings.Contains(back.HTML, "background-color:#3c4043") {
  165. t.Errorf("cell shading lost on import: %s", back.HTML)
  166. }
  167. }
  168. func snippetAround(s, needle string) string {
  169. i := strings.Index(s, needle)
  170. if i < 0 {
  171. return "(needle absent) " + s[:minInt(300, len(s))]
  172. }
  173. start := i - 80
  174. if start < 0 {
  175. start = 0
  176. }
  177. end := i + 220
  178. if end > len(s) {
  179. end = len(s)
  180. }
  181. return s[start:end]
  182. }
  183. func minInt(a, b int) int {
  184. if a < b {
  185. return a
  186. }
  187. return b
  188. }