xlsx_charts_test.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. package office
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "encoding/json"
  6. "io"
  7. "strings"
  8. "testing"
  9. )
  10. func chartTestWorkbook(t *testing.T, chartsJSON string) *Workbook {
  11. t.Helper()
  12. wb := &Workbook{Sheets: []*WorkSheet{{
  13. Name: "Data",
  14. Cells: map[string]*WorkCell{
  15. "A1": {V: "Month"}, "B1": {V: "Sales"}, "C1": {V: "Cost"},
  16. "A2": {V: "Jan"}, "B2": {V: "10"}, "C2": {V: "4"},
  17. "A3": {V: "Feb"}, "B3": {V: "20"}, "C3": {V: "8"},
  18. "A4": {V: "Mar"}, "B4": {V: "=B2+B3"}, "C4": {V: "12"},
  19. },
  20. }}}
  21. if chartsJSON != "" {
  22. wb.Sheets[0].Charts = json.RawMessage(chartsJSON)
  23. }
  24. return wb
  25. }
  26. func zipPart(t *testing.T, data []byte, name string) []byte {
  27. t.Helper()
  28. zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
  29. if err != nil {
  30. t.Fatalf("output is not a zip: %v", err)
  31. }
  32. for _, f := range zr.File {
  33. if f.Name == name {
  34. rc, err := f.Open()
  35. if err != nil {
  36. t.Fatalf("open %s: %v", name, err)
  37. }
  38. defer rc.Close()
  39. b, err := io.ReadAll(rc)
  40. if err != nil {
  41. t.Fatalf("read %s: %v", name, err)
  42. }
  43. return b
  44. }
  45. }
  46. return nil
  47. }
  48. func TestBuildXlsxWritesChartParts(t *testing.T) {
  49. tests := []struct {
  50. name string
  51. chart string
  52. wantPlot string
  53. wantExtra string
  54. }{
  55. {
  56. name: "bar chart",
  57. chart: `[{"id":"ch-1","x":100,"y":50,"w":480,"h":300,"range":"A1:C4","opts":{"type":"bar","title":"Sales chart"}}]`,
  58. wantPlot: "<c:barChart>", wantExtra: "Sales chart",
  59. },
  60. {
  61. name: "stacked line chart",
  62. chart: `[{"id":"ch-2","x":0,"y":0,"w":400,"h":200,"range":"A1:B4","opts":{"type":"line","stacked":true}}]`,
  63. wantPlot: "<c:lineChart>", wantExtra: `<c:grouping val="stacked"/>`,
  64. },
  65. {
  66. name: "pie chart",
  67. chart: `[{"id":"ch-3","x":0,"y":0,"w":300,"h":300,"range":"A1:B4","opts":{"type":"pie"}}]`,
  68. wantPlot: "<c:pieChart>", wantExtra: "",
  69. },
  70. }
  71. for _, tc := range tests {
  72. t.Run(tc.name, func(t *testing.T) {
  73. data, err := BuildXlsx(chartTestWorkbook(t, tc.chart))
  74. if err != nil {
  75. t.Fatalf("BuildXlsx: %v", err)
  76. }
  77. chartXML := string(zipPart(t, data, "xl/charts/chart1.xml"))
  78. if chartXML == "" {
  79. t.Fatal("xl/charts/chart1.xml missing from output")
  80. }
  81. if !strings.Contains(chartXML, tc.wantPlot) {
  82. t.Errorf("chart1.xml missing %s", tc.wantPlot)
  83. }
  84. if tc.wantExtra != "" && !strings.Contains(chartXML, tc.wantExtra) {
  85. t.Errorf("chart1.xml missing %q", tc.wantExtra)
  86. }
  87. // the ' around the sheet name is XML-escaped in the part
  88. if !strings.Contains(chartXML, "&apos;Data&apos;!$B$2:$B$4") {
  89. t.Errorf("chart1.xml missing series value reference, got: %s", chartXML)
  90. }
  91. drawing := string(zipPart(t, data, "xl/drawings/drawing1.xml"))
  92. if !strings.Contains(drawing, "<xdr:absoluteAnchor>") {
  93. t.Error("drawing1.xml missing absolute anchor")
  94. }
  95. sheet := string(zipPart(t, data, "xl/worksheets/sheet1.xml"))
  96. if !strings.Contains(sheet, `<drawing r:id="rId1"/>`) {
  97. t.Error("sheet1.xml missing drawing reference")
  98. }
  99. ctypes := string(zipPart(t, data, "[Content_Types].xml"))
  100. if !strings.Contains(ctypes, "/xl/charts/chart1.xml") ||
  101. !strings.Contains(ctypes, "/xl/drawings/drawing1.xml") {
  102. t.Error("[Content_Types].xml missing chart/drawing overrides")
  103. }
  104. })
  105. }
  106. }
  107. func TestBuildXlsxNoChartsNoDrawing(t *testing.T) {
  108. data, err := BuildXlsx(chartTestWorkbook(t, ""))
  109. if err != nil {
  110. t.Fatalf("BuildXlsx: %v", err)
  111. }
  112. if zipPart(t, data, "xl/drawings/drawing1.xml") != nil {
  113. t.Error("drawing part written for a chartless workbook")
  114. }
  115. if strings.Contains(string(zipPart(t, data, "xl/worksheets/sheet1.xml")), "<drawing") {
  116. t.Error("sheet references a drawing that does not exist")
  117. }
  118. }
  119. func TestXlsxChartRoundTrip(t *testing.T) {
  120. src := `[{"id":"ch-1","x":120,"y":60,"w":500,"h":320,"range":"A1:C4",` +
  121. `"opts":{"type":"bar","title":"Quarterly","headerRow":true,"labelCol":true,"stacked":true}}]`
  122. data, err := BuildXlsx(chartTestWorkbook(t, src))
  123. if err != nil {
  124. t.Fatalf("BuildXlsx: %v", err)
  125. }
  126. wb2, err := ParseXlsx(data)
  127. if err != nil {
  128. t.Fatalf("ParseXlsx: %v", err)
  129. }
  130. var charts []*xlsxChart
  131. if err := json.Unmarshal(wb2.Sheets[0].Charts, &charts); err != nil {
  132. t.Fatalf("reimported charts blob invalid: %v (%s)", err, wb2.Sheets[0].Charts)
  133. }
  134. if len(charts) != 1 {
  135. t.Fatalf("expected 1 chart after round trip, got %d", len(charts))
  136. }
  137. ch := charts[0]
  138. if ch.Range != "A1:C4" {
  139. t.Errorf("range: got %s, want A1:C4", ch.Range)
  140. }
  141. if ch.chartType() != "bar" {
  142. t.Errorf("type: got %s, want bar", ch.chartType())
  143. }
  144. if ch.Opts == nil || !ch.Opts.Stacked {
  145. t.Error("stacked flag lost in round trip")
  146. }
  147. if ch.Opts.Title != "Quarterly" {
  148. t.Errorf("title: got %q, want Quarterly", ch.Opts.Title)
  149. }
  150. if !ch.headerRow() || !ch.labelCol() {
  151. t.Error("headerRow/labelCol lost in round trip")
  152. }
  153. // absolute anchor position survives (px in, px out)
  154. if ch.X < 119 || ch.X > 121 || ch.Y < 59 || ch.Y > 61 {
  155. t.Errorf("position drifted: got (%v, %v), want (~120, ~60)", ch.X, ch.Y)
  156. }
  157. if ch.W < 499 || ch.W > 501 || ch.H < 319 || ch.H > 321 {
  158. t.Errorf("size drifted: got (%v, %v), want (~500, ~320)", ch.W, ch.H)
  159. }
  160. }
  161. func TestXlsxChartRoundTripNoHeaderNoLabel(t *testing.T) {
  162. src := `[{"id":"ch-1","x":0,"y":0,"w":400,"h":300,"range":"B2:C4",` +
  163. `"opts":{"type":"line","headerRow":false,"labelCol":false}}]`
  164. data, err := BuildXlsx(chartTestWorkbook(t, src))
  165. if err != nil {
  166. t.Fatalf("BuildXlsx: %v", err)
  167. }
  168. wb2, err := ParseXlsx(data)
  169. if err != nil {
  170. t.Fatalf("ParseXlsx: %v", err)
  171. }
  172. var charts []*xlsxChart
  173. if err := json.Unmarshal(wb2.Sheets[0].Charts, &charts); err != nil || len(charts) != 1 {
  174. t.Fatalf("expected 1 chart, got %s", wb2.Sheets[0].Charts)
  175. }
  176. ch := charts[0]
  177. if ch.Range != "B2:C4" {
  178. t.Errorf("range: got %s, want B2:C4", ch.Range)
  179. }
  180. if ch.headerRow() || ch.labelCol() {
  181. t.Errorf("headerRow/labelCol should be false, got %v/%v", ch.headerRow(), ch.labelCol())
  182. }
  183. if ch.chartType() != "line" {
  184. t.Errorf("type: got %s, want line", ch.chartType())
  185. }
  186. }
  187. func TestParseRangeRef(t *testing.T) {
  188. tests := []struct {
  189. in string
  190. c1, r1, c2, r2 int
  191. ok bool
  192. }{
  193. {"A1:C5", 0, 0, 2, 4, true},
  194. {"$B$2:$D$9", 1, 1, 3, 8, true},
  195. {"C5:A1", 0, 0, 2, 4, true}, // normalized
  196. {"A1", 0, 0, 0, 0, true},
  197. {"nope", 0, 0, 0, 0, false},
  198. {"", 0, 0, 0, 0, false},
  199. }
  200. for _, tc := range tests {
  201. c1, r1, c2, r2, ok := parseRangeRef(tc.in)
  202. if ok != tc.ok {
  203. t.Errorf("parseRangeRef(%q) ok = %v, want %v", tc.in, ok, tc.ok)
  204. continue
  205. }
  206. if ok && (c1 != tc.c1 || r1 != tc.r1 || c2 != tc.c2 || r2 != tc.r2) {
  207. t.Errorf("parseRangeRef(%q) = (%d,%d,%d,%d), want (%d,%d,%d,%d)",
  208. tc.in, c1, r1, c2, r2, tc.c1, tc.r1, tc.c2, tc.r2)
  209. }
  210. }
  211. }