render_test.go 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. package pptx
  2. import (
  3. "archive/zip"
  4. "bytes"
  5. "io"
  6. "math"
  7. "strings"
  8. "testing"
  9. "imuslab.com/arozos/mod/office/internal/fonts"
  10. "imuslab.com/arozos/mod/office/internal/htmlutil"
  11. "imuslab.com/arozos/mod/office/internal/media"
  12. "imuslab.com/arozos/mod/office/internal/officetest"
  13. "imuslab.com/arozos/mod/office/internal/units"
  14. "imuslab.com/arozos/mod/office/internal/xmltree"
  15. "imuslab.com/arozos/mod/office/model"
  16. )
  17. // replaceParts copies a package, replacing (or adding) the named parts
  18. func replaceParts(t *testing.T, data []byte, replace map[string]string) []byte {
  19. t.Helper()
  20. zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
  21. if err != nil {
  22. t.Fatalf("zip read: %v", err)
  23. }
  24. var buf bytes.Buffer
  25. zw := zip.NewWriter(&buf)
  26. seen := map[string]bool{}
  27. for _, f := range zr.File {
  28. w, err := zw.Create(f.Name)
  29. if err != nil {
  30. t.Fatalf("zip create: %v", err)
  31. }
  32. if v, ok := replace[f.Name]; ok {
  33. seen[f.Name] = true
  34. w.Write([]byte(v))
  35. continue
  36. }
  37. rc, err := f.Open()
  38. if err != nil {
  39. t.Fatalf("zip open: %v", err)
  40. }
  41. io.Copy(w, rc)
  42. rc.Close()
  43. }
  44. for name, v := range replace {
  45. if !seen[name] {
  46. w, _ := zw.Create(name)
  47. w.Write([]byte(v))
  48. }
  49. }
  50. zw.Close()
  51. return buf.Bytes()
  52. }
  53. // onePres builds a one-slide package, applies the replacements and parses it
  54. func onePres(t *testing.T, parts pptxParts, replace map[string]string) *model.Presentation {
  55. t.Helper()
  56. data := buildTestPptx(t, parts)
  57. if len(replace) > 0 {
  58. data = replaceParts(t, data, replace)
  59. }
  60. p, err := Parse(data)
  61. if err != nil {
  62. t.Fatalf("ParsePptx: %v", err)
  63. }
  64. return p
  65. }
  66. // a slide part with a background of its own
  67. func slideWithBg(bg, tree string) string {
  68. return `<p:sld ` + testNS + `><p:cSld>` + bg + `<p:spTree>` + tree + `</p:spTree></p:cSld></p:sld>`
  69. }
  70. func closeTo(a, b float64) bool { return math.Abs(a-b) < 0.02 }
  71. // A 4:3 deck keeps its shape: 960x720, and a square stays square
  72. func TestPptxKeepsSlideShape(t *testing.T) {
  73. square := `<p:sp><p:nvSpPr><p:cNvPr id="2" name="s"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
  74. `<p:spPr><a:xfrm><a:off x="4572000" y="3429000"/><a:ext cx="914400" cy="914400"/></a:xfrm>` +
  75. `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></p:spPr></p:sp>`
  76. data := buildTestPptx(t, pptxParts{slides: []string{square}})
  77. zr, _ := zip.NewReader(bytes.NewReader(data), int64(len(data)))
  78. var pres string
  79. for _, f := range zr.File {
  80. if f.Name == "ppt/presentation.xml" {
  81. rc, _ := f.Open()
  82. b, _ := io.ReadAll(rc)
  83. rc.Close()
  84. pres = strings.Replace(string(b), `cy="5143500"`, `cy="6858000"`, 1)
  85. }
  86. }
  87. p, err := Parse(replaceParts(t, data, map[string]string{"ppt/presentation.xml": pres}))
  88. if err != nil {
  89. t.Fatalf("ParsePptx: %v", err)
  90. }
  91. if len(p.Size) != 2 || p.Size[0] != 960 || p.Size[1] != 720 {
  92. t.Fatalf("size = %v, want [960 720]", p.Size)
  93. }
  94. o := p.Slides[0].Objects[0]
  95. if !closeTo(o.X, 480) || !closeTo(o.Y, 360) || !closeTo(o.W, 96) || !closeTo(o.H, 96) {
  96. t.Errorf("square = %v,%v %vx%v, want 480,360 96x96", o.X, o.Y, o.W, o.H)
  97. }
  98. // and the writer states the shape it was given
  99. out, err := Build(p)
  100. if err != nil {
  101. t.Fatalf("BuildPptx: %v", err)
  102. }
  103. if !strings.Contains(packagePart(t, out, "ppt/presentation.xml"), `<p:sldSz cx="9144000" cy="6858000"/>`) {
  104. t.Errorf("written deck lost its 4:3 size")
  105. }
  106. }
  107. /* ---------------- colours and fills ---------------- */
  108. func TestColorAlphaAndWithAlpha(t *testing.T) {
  109. tests := []struct {
  110. name string
  111. hex string
  112. alpha float64
  113. want string
  114. }{
  115. {"opaque stays six digits", "#ff0000", 1, "#ff0000"},
  116. {"half transparent", "#ff0000", 0.5, "#ff000080"},
  117. {"92.5% white", "#ffffff", 0.92549, "#ffffffec"},
  118. {"not a hex colour", "red", 0.5, "red"},
  119. }
  120. for _, tc := range tests {
  121. t.Run(tc.name, func(t *testing.T) {
  122. if got := withAlpha(tc.hex, tc.alpha); got != tc.want {
  123. t.Errorf("withAlpha = %q, want %q", got, tc.want)
  124. }
  125. })
  126. }
  127. n, _ := xmltree.Parse([]byte(`<a:srgbClr xmlns:a="a" val="FFFFFF"><a:alpha val="92549"/></a:srgbClr>`))
  128. if a := colorAlpha(n); !closeTo(a, 0.92549) {
  129. t.Errorf("colorAlpha = %v", a)
  130. }
  131. if a := htmlutil.HexAlpha("#ffffff80"); !closeTo(a, 128.0/255) {
  132. t.Errorf("hexAlpha = %v", a)
  133. }
  134. if got := srgbClrXML("#ff000080", "000000"); got != `<a:srgbClr val="FF0000"><a:alpha val="50196"/></a:srgbClr>` {
  135. t.Errorf("srgbClrXML = %s", got)
  136. }
  137. }
  138. func TestShapeFillKeepsAlpha(t *testing.T) {
  139. objs := parseOneSlide(t, pptxParts{slides: []string{
  140. `<p:sp><p:nvSpPr><p:cNvPr id="2" name="s"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
  141. `<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm>` +
  142. `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` +
  143. `<a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="50000"/></a:srgbClr></a:solidFill></p:spPr></p:sp>`,
  144. }})
  145. if len(objs) != 1 || objs[0].Props.Fill != "#ffffff80" {
  146. t.Fatalf("fill = %+v, want #ffffff80", objs)
  147. }
  148. }
  149. func TestGradientOf(t *testing.T) {
  150. cc := colorCtx{scheme: map[string]string{"accent1": "4285F4"}}
  151. tests := []struct {
  152. name string
  153. xml string
  154. kind string
  155. angle float64
  156. cx float64
  157. first string
  158. }{
  159. {"linear, top to bottom", `<a:gradFill><a:gsLst><a:gs pos="100000"><a:srgbClr val="000000"/></a:gs>` +
  160. `<a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="5400000"/></a:gradFill>`,
  161. "linear", 180, 0, "#ffffff"},
  162. {"linear, left to right", `<a:gradFill><a:gsLst><a:gs pos="0"><a:schemeClr val="accent1"/></a:gs>` +
  163. `<a:gs pos="100000"><a:srgbClr val="000000"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>`,
  164. "linear", 90, 0, "#4285f4"},
  165. {"radial from the centre", `<a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="B1DDFF"/></a:gs>` +
  166. `<a:gs pos="100000"><a:srgbClr val="CBE8FE"/></a:gs></a:gsLst><a:path path="circle">` +
  167. `<a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>`,
  168. "radial", 0, 0.5, "#b1ddff"},
  169. }
  170. for _, tc := range tests {
  171. t.Run(tc.name, func(t *testing.T) {
  172. n, err := xmltree.Parse([]byte(strings.Replace(tc.xml, "<a:gradFill>", `<a:gradFill xmlns:a="a">`, 1)))
  173. if err != nil {
  174. t.Fatalf("xml: %v", err)
  175. }
  176. g := cc.gradientOf(n)
  177. if g == nil {
  178. t.Fatal("no gradient")
  179. }
  180. if g.Kind != tc.kind || !closeTo(g.Angle, tc.angle) || !closeTo(g.CX, tc.cx) || g.Stops[0].Color != tc.first {
  181. t.Errorf("gradient = %+v", g)
  182. }
  183. if g.Stops[0].Pos > g.Stops[len(g.Stops)-1].Pos {
  184. t.Errorf("stops are not in order: %+v", g.Stops)
  185. }
  186. })
  187. }
  188. }
  189. /* ---------------- backgrounds ---------------- */
  190. func TestPptxSlideBackgrounds(t *testing.T) {
  191. png := officetest.PngBytes(t)
  192. tests := []struct {
  193. name string
  194. bg string
  195. check func(t *testing.T, s *model.Slide)
  196. }{
  197. {"picture stretched past the edges",
  198. `<p:bg><p:bgPr><a:blipFill><a:blip r:embed="rId9"/><a:stretch><a:fillRect l="-10000" r="-10000"/></a:stretch></a:blipFill></p:bgPr></p:bg>`,
  199. func(t *testing.T, s *model.Slide) {
  200. if s.BgImage == nil || !strings.HasPrefix(s.BgImage.Src, "data:image/png") {
  201. t.Fatalf("bgImage = %+v", s.BgImage)
  202. }
  203. if !closeTo(s.BgImage.X, -96) || !closeTo(s.BgImage.W, 1152) || !closeTo(s.BgImage.H, 540) {
  204. t.Errorf("placed at %+v, want x -96 w 1152 h 540", s.BgImage)
  205. }
  206. }},
  207. {"gradient",
  208. `<p:bg><p:bgPr><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="5E9EFF"/></a:gs>` +
  209. `<a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="5400000"/></a:gradFill></p:bgPr></p:bg>`,
  210. func(t *testing.T, s *model.Slide) {
  211. if s.BgGrad == nil || len(s.BgGrad.Stops) != 2 || s.Bg != "#5e9eff" {
  212. t.Errorf("bg = %q, grad = %+v", s.Bg, s.BgGrad)
  213. }
  214. }},
  215. {"theme style through bgRef",
  216. `<p:bg><p:bgRef idx="1001"><a:schemeClr val="accent1"/></p:bgRef></p:bg>`,
  217. func(t *testing.T, s *model.Slide) {
  218. if s.Bg != "#4285f4" {
  219. t.Errorf("bg = %q, want the reference's colour", s.Bg)
  220. }
  221. }},
  222. }
  223. for _, tc := range tests {
  224. t.Run(tc.name, func(t *testing.T) {
  225. p := onePres(t, pptxParts{
  226. slides: []string{""},
  227. slideRels: map[string]string{"rId9": "../media/bg.png"},
  228. media: map[string][]byte{"ppt/media/bg.png": png},
  229. }, map[string]string{"ppt/slides/slide1.xml": slideWithBg(tc.bg, "")})
  230. tc.check(t, p.Slides[0])
  231. })
  232. }
  233. }
  234. /* ---------------- symbol fonts ---------------- */
  235. func TestSymbolBulletAndText(t *testing.T) {
  236. tests := []struct {
  237. font, marker, want string
  238. ok bool
  239. }{
  240. {"Wingdings", "l", "●", true},
  241. {"Wingdings", "", "●", true},
  242. {"Wingdings 2", "n", "■", true},
  243. {"Symbol", "", "•", true},
  244. {"Arial", "l", "", false},
  245. {"Wingdings", "lo", "", false},
  246. }
  247. for _, tc := range tests {
  248. got, _, ok := fonts.SymbolBullet(tc.font, tc.marker)
  249. if ok != tc.ok || got != tc.want {
  250. t.Errorf("symbolBullet(%q, %q) = %q, %v", tc.font, tc.marker, got, ok)
  251. }
  252. }
  253. if got := fonts.SymbolText("a = b  2"); got != "α = β ≥ 2" {
  254. t.Errorf("symbolText = %q", got)
  255. }
  256. if !fonts.IsSymbolFont("Wingdings 3") || fonts.IsSymbolFont("Calibri") {
  257. t.Error("isSymbolFont")
  258. }
  259. }
  260. func TestPptxBulletsAndNumbers(t *testing.T) {
  261. para := func(ppr, text string) string {
  262. return `<a:p><a:pPr marL="342900" indent="-342900">` + ppr + `</a:pPr><a:r><a:rPr sz="2000"/><a:t>` + text + `</a:t></a:r></a:p>`
  263. }
  264. tests := []struct {
  265. name string
  266. paras string
  267. want []string
  268. absent []string
  269. }{
  270. {"a Wingdings bullet comes in as the shape it draws",
  271. para(`<a:buFont typeface="Wingdings"/><a:buChar char="l"/>`, "one"),
  272. []string{"●"}, []string{">l<", "Wingdings"}},
  273. {"an empty paragraph has no bullet",
  274. `<a:p><a:pPr marL="342900" indent="-342900"><a:buChar char="&#8226;"/></a:pPr><a:endParaRPr sz="2000"/></a:p>`,
  275. nil, []string{"•"}},
  276. {"a geometric bullet falls back to Arial's twin, not the CJK face",
  277. para(`<a:buFont typeface="Open Sans"/><a:buChar char="●"/>`, "one"),
  278. []string{"●", "font-family:'Open Sans',Arimo,"}, nil},
  279. {"a Wingdings bullet skips the deck's East Asian font",
  280. `<a:p><a:pPr marL="342900" indent="-342900"><a:buFont typeface="Wingdings"/><a:buChar char="l"/></a:pPr>` +
  281. `<a:r><a:rPr sz="2000"><a:latin typeface="Arial"/><a:ea typeface="Microsoft JhengHei"/></a:rPr><a:t>one</a:t></a:r></a:p>`,
  282. []string{"font-family:Arial,Arimo,"}, nil},
  283. {"a number is never set in a symbol font",
  284. para(`<a:buFont typeface="Wingdings"/><a:buAutoNum type="arabicPeriod"/>`, "one"),
  285. []string{"1."}, []string{"Wingdings"}},
  286. }
  287. for _, tc := range tests {
  288. t.Run(tc.name, func(t *testing.T) {
  289. objs := parseOneSlide(t, pptxParts{slides: []string{textBox(0, 0, 4000000, 2000000, tc.paras)}})
  290. if len(objs) == 0 {
  291. t.Fatal("no objects")
  292. }
  293. html := objs[0].Props.HTML
  294. for _, w := range tc.want {
  295. if !strings.Contains(html, w) {
  296. t.Errorf("want %q in %s", w, html)
  297. }
  298. }
  299. for _, a := range tc.absent {
  300. if strings.Contains(html, a) {
  301. t.Errorf("did not want %q in %s", a, html)
  302. }
  303. }
  304. })
  305. }
  306. }
  307. /* ---------------- text layout ---------------- */
  308. func TestPptxTextLayoutRules(t *testing.T) {
  309. tests := []struct {
  310. name string
  311. shape string
  312. want []string
  313. absent []string
  314. }{
  315. {"autofit sizes are whole points",
  316. `<p:sp><p:nvSpPr><p:cNvPr id="2" name="t"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>` +
  317. `<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="4000000" cy="2000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>` +
  318. `<p:txBody><a:bodyPr><a:normAutofit fontScale="92500"/></a:bodyPr><a:lstStyle/>` +
  319. `<a:p><a:r><a:rPr sz="2000"/><a:t>fit</a:t></a:r></a:p></p:txBody></p:sp>`,
  320. // 20pt at 92.5% is 18.5pt, drawn at 19pt = 25.33px
  321. []string{"font-size:25.33px"}, []string{"font-size:24.67px"}},
  322. {"space before the first paragraph with spcFirstLastPara",
  323. `<p:sp><p:nvSpPr><p:cNvPr id="2" name="t"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>` +
  324. `<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="4000000" cy="2000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>` +
  325. `<p:txBody><a:bodyPr spcFirstLastPara="1"/><a:lstStyle/>` +
  326. `<a:p><a:pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:pPr><a:r><a:rPr sz="1400"/><a:t>a</a:t></a:r></a:p></p:txBody></p:sp>`,
  327. []string{"margin-top:8px"}, nil},
  328. {"Google Slides draws no space before the first paragraph",
  329. `<p:sp><p:nvSpPr><p:cNvPr id="56" name="Google Shape;56;p13"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>` +
  330. `<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="4000000" cy="2000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>` +
  331. `<p:txBody><a:bodyPr spcFirstLastPara="1"/><a:lstStyle/>` +
  332. `<a:p><a:pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:pPr><a:r><a:rPr sz="1400"/><a:t>a</a:t></a:r></a:p>` +
  333. `<a:p><a:pPr><a:spcBef><a:spcPts val="600"/></a:spcBef></a:pPr><a:r><a:rPr sz="1400"/><a:t>b</a:t></a:r></a:p></p:txBody></p:sp>`,
  334. // the second paragraph keeps its 6pt = 8px; the first has none
  335. []string{"margin-top:8px"}, []string{`<div style="text-align:left;line-height:1.2;margin-top:8px`}},
  336. {"a percentage space is of the line",
  337. textBox(0, 0, 4000000, 2000000, `<a:p><a:r><a:rPr sz="2000"/><a:t>a</a:t></a:r></a:p>`+
  338. `<a:p><a:pPr><a:spcBef><a:spcPct val="20000"/></a:spcBef></a:pPr><a:r><a:rPr sz="2000"/><a:t>b</a:t></a:r></a:p>`),
  339. // 20% of a 20pt paragraph's 24pt line is 4.8pt = 6.4px
  340. []string{"margin-top:6.4px"}, nil},
  341. {"a lowered run does not make its line taller",
  342. textBox(0, 0, 4000000, 2000000, `<a:p><a:r><a:rPr sz="2000"/><a:t>P</a:t></a:r><a:r><a:rPr sz="2000" baseline="-25000"/><a:t>t</a:t></a:r></a:p>`),
  343. []string{"vertical-align:sub;line-height:0"}, nil},
  344. {"a tab goes to an inch stop",
  345. textBox(0, 0, 4000000, 2000000, `<a:p><a:r><a:rPr sz="2000"/><a:t>a&#9;b</a:t></a:r></a:p>`),
  346. []string{"tab-size:96px"}, nil},
  347. }
  348. for _, tc := range tests {
  349. t.Run(tc.name, func(t *testing.T) {
  350. objs := parseOneSlide(t, pptxParts{slides: []string{tc.shape}})
  351. if len(objs) == 0 {
  352. t.Fatal("no objects")
  353. }
  354. html := objs[0].Props.HTML
  355. for _, w := range tc.want {
  356. if !strings.Contains(html, w) {
  357. t.Errorf("want %q in %s", w, html)
  358. }
  359. }
  360. for _, a := range tc.absent {
  361. if strings.Contains(html, a) {
  362. t.Errorf("did not want %q in %s", a, html)
  363. }
  364. }
  365. })
  366. }
  367. }
  368. // A group's scale moves and sizes its children but leaves type alone
  369. func TestPptxGroupDoesNotScaleText(t *testing.T) {
  370. grp := `<p:grpSp><p:nvGrpSpPr><p:cNvPr id="5" name="g"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>` +
  371. `<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2000000" cy="1000000"/>` +
  372. `<a:chOff x="0" y="0"/><a:chExt cx="4000000" cy="2000000"/></a:xfrm></p:grpSpPr>` +
  373. textBox(0, 0, 4000000, 2000000, `<a:p><a:r><a:rPr sz="2000"/><a:t>big</a:t></a:r></a:p>`) +
  374. `</p:grpSp>`
  375. objs := parseOneSlide(t, pptxParts{slides: []string{grp}})
  376. if len(objs) != 1 {
  377. t.Fatalf("objects = %d", len(objs))
  378. }
  379. if !strings.Contains(objs[0].Props.HTML, "font-size:26.67px") {
  380. t.Errorf("20pt text in a half-size group = %s, want 26.67px", objs[0].Props.HTML)
  381. }
  382. if !closeTo(objs[0].W, 2000000.0/units.EmuPerPx) {
  383. t.Errorf("the box itself should still be scaled: w = %v", objs[0].W)
  384. }
  385. }
  386. /* ---------------- shapes ---------------- */
  387. func TestPptxShapeKinds(t *testing.T) {
  388. sp := func(spPr string) string {
  389. return `<p:sp><p:nvSpPr><p:cNvPr id="2" name="s"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>` + spPr + `</p:spPr></p:sp>`
  390. }
  391. tests := []struct {
  392. name string
  393. shape string
  394. check func(t *testing.T, o *model.Object)
  395. }{
  396. {"a zero-wide line shape is a line",
  397. sp(`<a:xfrm><a:off x="914400" y="0"/><a:ext cx="0" cy="914400"/></a:xfrm><a:prstGeom prst="line"><a:avLst/></a:prstGeom>` +
  398. `<a:ln w="9525"><a:solidFill><a:srgbClr val="000000"/></a:solidFill></a:ln>`),
  399. func(t *testing.T, o *model.Object) {
  400. if o.Type != "line" || !closeTo(o.W, 0) || !closeTo(o.H, 96) {
  401. t.Errorf("got %s %vx%v", o.Type, o.W, o.H)
  402. }
  403. }},
  404. {"a preset keeps the adjustments it states",
  405. sp(`<a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="457200"/></a:xfrm><a:prstGeom prst="chevron"><a:avLst><a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstGeom>` +
  406. `<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>`),
  407. func(t *testing.T, o *model.Object) {
  408. if o.Props.Kind != "chevron" || o.Props.Adj["adj"] != 30000 {
  409. t.Errorf("kind %q adj %v", o.Props.Kind, o.Props.Adj)
  410. }
  411. }},
  412. {"a gradient fill",
  413. sp(`<a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` +
  414. `<a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FF0000"/></a:gs><a:gs pos="100000"><a:srgbClr val="0000FF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>`),
  415. func(t *testing.T, o *model.Object) {
  416. if o.Props.FillGrad == nil || o.Props.Fill != "#ff0000" {
  417. t.Errorf("fill %q grad %+v", o.Props.Fill, o.Props.FillGrad)
  418. }
  419. }},
  420. {"custom geometry",
  421. sp(`<a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:custGeom><a:avLst/><a:gdLst/><a:pathLst>` +
  422. `<a:path w="100" h="100"><a:moveTo><a:pt x="0" y="0"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo>` +
  423. `<a:lnTo><a:pt x="50" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>` +
  424. `<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>`),
  425. func(t *testing.T, o *model.Object) {
  426. if o.Props.Kind != "custom" || o.Props.Geom == nil || o.Props.Geom.Paths[0].D != "M0 0L100 0L50 100Z" {
  427. t.Errorf("kind %q geom %+v", o.Props.Kind, o.Props.Geom)
  428. }
  429. }},
  430. }
  431. for _, tc := range tests {
  432. t.Run(tc.name, func(t *testing.T) {
  433. objs := parseOneSlide(t, pptxParts{slides: []string{tc.shape}})
  434. if len(objs) != 1 {
  435. t.Fatalf("objects = %d", len(objs))
  436. }
  437. tc.check(t, objs[0])
  438. })
  439. }
  440. }
  441. func TestEvalGuideAndArcs(t *testing.T) {
  442. g := map[string]float64{"w": 1000, "h": 500, "adj": 25000}
  443. tests := []struct {
  444. fmla string
  445. want float64
  446. }{
  447. {"val 7", 7},
  448. {"*/ w adj 100000", 250},
  449. {"+- w h 100", 1400},
  450. {"pin 0 adj 10000", 10000},
  451. {"max w h", 1000},
  452. {"?: -1 w h", 500},
  453. {"sin 100 5400000", 100},
  454. {"cos 100 0", 100},
  455. }
  456. for _, tc := range tests {
  457. if got := evalGuide(tc.fmla, g); !closeTo(got, tc.want) {
  458. t.Errorf("evalGuide(%q) = %v, want %v", tc.fmla, got, tc.want)
  459. }
  460. }
  461. // a quarter arc from the top of a circle round to its right side
  462. n, _ := xmltree.Parse([]byte(`<a:path xmlns:a="a" w="200" h="200"><a:moveTo><a:pt x="100" y="0"/></a:moveTo>` +
  463. `<a:arcTo wR="100" hR="100" stAng="16200000" swAng="5400000"/></a:path>`))
  464. d := pathD(n, geomGuides(&xmltree.Node{}, 200, 200))
  465. if !strings.HasPrefix(d, "M100 0C") || !strings.HasSuffix(d, " 200 100") {
  466. t.Errorf("arc path = %q", d)
  467. }
  468. }
  469. func TestPictureFilledShape(t *testing.T) {
  470. png := officetest.PngBytes(t)
  471. objs := parseOneSlide(t, pptxParts{
  472. slides: []string{`<p:sp><p:nvSpPr><p:cNvPr id="2" name="s"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>` +
  473. `<a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom>` +
  474. `<a:blipFill><a:blip r:embed="rId9"/><a:stretch><a:fillRect l="-25000" r="-25000"/></a:stretch></a:blipFill></p:spPr>` +
  475. `<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr><a:noFill/></a:rPr><a:t> </a:t></a:r></a:p></p:txBody></p:sp>`},
  476. slideRels: map[string]string{"rId9": "../media/p.png"},
  477. media: map[string][]byte{"ppt/media/p.png": png},
  478. })
  479. if len(objs) != 1 || objs[0].Type != "image" {
  480. t.Fatalf("objects = %+v", objs)
  481. }
  482. p := objs[0].Props
  483. if p.Mask != "ellipse" || len(p.Crop) != 4 || !closeTo(p.Crop[0], 0.1667) || !closeTo(p.Crop[2], 0.1667) {
  484. t.Errorf("mask %q crop %v, want ellipse and a sixth off each side", p.Mask, p.Crop)
  485. }
  486. }
  487. /* ---------------- tables ---------------- */
  488. const testTableStyles = `<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" def="{S}">` +
  489. `<a:tblStyle styleId="{S}" styleName="t"><a:wholeTbl><a:tcTxStyle><a:schemeClr val="dk1"/></a:tcTxStyle>` +
  490. `<a:tcStyle><a:tcBdr><a:insideH><a:ln w="12700"><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:ln></a:insideH></a:tcBdr>` +
  491. `<a:fill><a:solidFill><a:srgbClr val="EEEEEE"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl>` +
  492. `<a:band1H><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="CCCCCC"/></a:solidFill></a:fill></a:tcStyle></a:band1H>` +
  493. `<a:firstRow><a:tcTxStyle b="on"><a:srgbClr val="FFFFFF"/></a:tcTxStyle><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="FF8800"/></a:solidFill></a:fill></a:tcStyle></a:firstRow>` +
  494. `</a:tblStyle></a:tblStyleLst>`
  495. func testTableFrame(tblPr, rows string) string {
  496. return `<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="4" name="t"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr>` +
  497. `<p:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="914400"/></p:xfrm><a:graphic>` +
  498. `<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl>` + tblPr +
  499. `<a:tblGrid><a:gridCol w="914400"/><a:gridCol w="914400"/><a:gridCol w="914400"/></a:tblGrid>` + rows +
  500. `</a:tbl></a:graphicData></a:graphic></p:graphicFrame>`
  501. }
  502. func testCell(attrs, text string) string {
  503. return `<a:tc` + attrs + `><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr sz="1400"/><a:t>` + text +
  504. `</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc>`
  505. }
  506. func TestPptxTableStyleAndMerges(t *testing.T) {
  507. rows := `<a:tr h="304800">` + testCell(` gridSpan="2"`, "head") + testCell(` hMerge="1"`, "") + testCell("", "x") + `</a:tr>` +
  508. `<a:tr h="304800">` + testCell(` rowSpan="2"`, "tall") + testCell("", "a") + testCell("", "b") + `</a:tr>` +
  509. `<a:tr h="304800">` + testCell(` vMerge="1"`, "") + testCell("", "c") + testCell("", "d") + `</a:tr>`
  510. objs := parseOneSlide(t, pptxParts{
  511. slides: []string{testTableFrame(`<a:tblPr firstRow="1" bandRow="1"/>`, rows)},
  512. extraParts: map[string]string{"ppt/tableStyles.xml": testTableStyles},
  513. })
  514. if len(objs) != 1 || objs[0].Type != "table" {
  515. t.Fatalf("objects = %+v", objs)
  516. }
  517. p := objs[0].Props
  518. if !p.Styled || p.Stroke != "#ffffff" || !closeTo(p.StrokeW, 12700.0/units.EmuPerPx) {
  519. t.Errorf("styled %v rule %q %v", p.Styled, p.Stroke, p.StrokeW)
  520. }
  521. if p.CellFill[0][0] != "#ff8800" || p.CellFill[1][1] != "#cccccc" || p.CellFill[2][1] != "#eeeeee" {
  522. t.Errorf("fills = %v (heading, band 1, band 2)", p.CellFill)
  523. }
  524. if !strings.Contains(p.Rows[0][0], "font-weight:700") || !strings.Contains(p.Rows[0][0], "#ffffff") {
  525. t.Errorf("heading text = %s, want bold white", p.Rows[0][0])
  526. }
  527. want := [][]int{{0, 0, 1, 2}, {1, 0, 2, 1}}
  528. if len(p.Merges) != 2 || p.Merges[0][3] != 2 || p.Merges[1][2] != 2 {
  529. t.Fatalf("merges = %v, want %v", p.Merges, want)
  530. }
  531. // the writer states the merges back
  532. out, err := Build(&model.Presentation{Slides: []*model.Slide{{Objects: objs}}})
  533. if err != nil {
  534. t.Fatalf("BuildPptx: %v", err)
  535. }
  536. slide := packagePart(t, out, "ppt/slides/slide1.xml")
  537. for _, w := range []string{`gridSpan="2"`, `hMerge="1"`, `rowSpan="2"`, `vMerge="1"`} {
  538. if !strings.Contains(slide, w) {
  539. t.Errorf("written table lacks %s", w)
  540. }
  541. }
  542. }
  543. func TestTableStylePartsOrder(t *testing.T) {
  544. style, _ := xmltree.Parse([]byte(strings.Replace(testTableStyles, `def="{S}">`, `def="{S}">`, 1)))
  545. st := style.First("tblStyle")
  546. names := func(parts []*xmltree.Node) string {
  547. var out []string
  548. for _, p := range parts {
  549. out = append(out, p.XMLName.Local)
  550. }
  551. return strings.Join(out, ",")
  552. }
  553. f := tblFlags{firstRow: true, bandRow: true}
  554. if got := names(styleParts(st, f, 0, 0, 3, 3)); got != "wholeTbl,firstRow" {
  555. t.Errorf("heading cell parts = %s", got)
  556. }
  557. if got := names(styleParts(st, f, 1, 0, 3, 3)); got != "wholeTbl,band1H" {
  558. t.Errorf("first body row parts = %s", got)
  559. }
  560. if got := names(styleParts(st, f, 2, 0, 3, 3)); got != "wholeTbl" {
  561. t.Errorf("second body row parts = %s (the style has no band2H)", got)
  562. }
  563. }
  564. /* ---------------- Office math ---------------- */
  565. func TestChoiceReadable(t *testing.T) {
  566. const m = `xmlns:mc="mc" xmlns:a14="a14" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"`
  567. tests := []struct {
  568. name string
  569. xml string
  570. want bool
  571. }{
  572. {"a symbol set as math", `<mc:Choice ` + m + ` Requires="a14"><a14:m><m:oMath><m:r><m:t>≥</m:t></m:r></m:oMath></a14:m></mc:Choice>`, true},
  573. {"a fraction", `<mc:Choice ` + m + ` Requires="a14"><a14:m><m:oMath><m:f><m:num/><m:den/></m:f></m:oMath></a14:m></mc:Choice>`, false},
  574. {"no math at all", `<mc:Choice ` + m + ` Requires="a14"><x/></mc:Choice>`, false},
  575. {"another requirement", `<mc:Choice ` + m + ` Requires="p14"><a14:m><m:oMath/></a14:m></mc:Choice>`, false},
  576. }
  577. for _, tc := range tests {
  578. n, err := xmltree.Parse([]byte(tc.xml))
  579. if err != nil {
  580. t.Fatalf("%s: %v", tc.name, err)
  581. }
  582. if got := choiceReadable(n); got != tc.want {
  583. t.Errorf("%s: choiceReadable = %v", tc.name, got)
  584. }
  585. }
  586. }
  587. func packagePart(t *testing.T, data []byte, name string) string {
  588. t.Helper()
  589. zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
  590. if err != nil {
  591. t.Fatalf("zip: %v", err)
  592. }
  593. for _, f := range zr.File {
  594. if f.Name == name {
  595. rc, _ := f.Open()
  596. b, _ := io.ReadAll(rc)
  597. rc.Close()
  598. return string(b)
  599. }
  600. }
  601. t.Fatalf("no part %s", name)
  602. return ""
  603. }
  604. /* ---------------- writing ---------------- */
  605. func TestPptxWriterKeepsNewLooks(t *testing.T) {
  606. pres := &model.Presentation{Size: []int{960, 540}, Slides: []*model.Slide{{Objects: []*model.Object{
  607. {Type: "shape", W: 100, H: 100, Props: model.Props{Kind: "rect", Fill: "#ff000080"}},
  608. {Type: "shape", W: 100, H: 100, Props: model.Props{Kind: "chevron", Fill: "#00ff00", Adj: map[string]float64{"adj": 30000}}},
  609. {Type: "shape", W: 100, H: 100, Props: model.Props{Kind: "custom", Fill: "#0000ff",
  610. Geom: &model.CustomGeom{Paths: []model.CustomPath{{W: 100, H: 100, D: "M0 0L100 0L50 100Z"}}}}},
  611. }}}}
  612. out, err := Build(pres)
  613. if err != nil {
  614. t.Fatalf("BuildPptx: %v", err)
  615. }
  616. slide := packagePart(t, out, "ppt/slides/slide1.xml")
  617. for _, w := range []string{
  618. `<a:srgbClr val="FF0000"><a:alpha val="50196"/></a:srgbClr>`,
  619. `<a:gd name="adj" fmla="val 30000"/>`,
  620. `<a:custGeom>`, `<a:lnTo><a:pt x="100" y="0"/></a:lnTo>`, `<a:close/>`,
  621. } {
  622. if !strings.Contains(slide, w) {
  623. t.Errorf("written slide lacks %s", w)
  624. }
  625. }
  626. }
  627. // Backgrounds and gradient fills go out as PowerPoint states them, and come
  628. // back the same
  629. func TestPptxBackgroundsRoundTrip(t *testing.T) {
  630. grad := &model.Gradient{Kind: "linear", Angle: 180, Stops: []model.GradientStop{{Pos: 0, Color: "#5e9eff"}, {Pos: 1, Color: "#ffffff"}}}
  631. radial := &model.Gradient{Kind: "radial", CX: 0.5, CY: 0.5, Stops: []model.GradientStop{{Pos: 0, Color: "#b1ddff"}, {Pos: 1, Color: "#cbe8fe"}}}
  632. pic := media.EncodeDataURL(officetest.PngBytes(t), "png")
  633. pres := &model.Presentation{Size: []int{960, 720}, Slides: []*model.Slide{
  634. {BgGrad: grad, Objects: []*model.Object{{Type: "shape", X: 10, Y: 10, W: 100, H: 100,
  635. Props: model.Props{Kind: "rect", Fill: "#b1ddff", FillGrad: radial}}}},
  636. {BgImage: &model.BgImage{Src: pic, X: -96, Y: 0, W: 1152, H: 720}, Objects: []*model.Object{}},
  637. }}
  638. out, err := Build(pres)
  639. if err != nil {
  640. t.Fatalf("BuildPptx: %v", err)
  641. }
  642. back, err := Parse(out)
  643. if err != nil {
  644. t.Fatalf("ParsePptx: %v", err)
  645. }
  646. s1, s2 := back.Slides[0], back.Slides[1]
  647. if s1.BgGrad == nil || !closeTo(s1.BgGrad.Angle, 180) || s1.BgGrad.Stops[0].Color != "#5e9eff" {
  648. t.Errorf("gradient background = %+v", s1.BgGrad)
  649. }
  650. var shape *model.Object
  651. for _, o := range s1.Objects {
  652. if o.Type == "shape" {
  653. shape = o
  654. }
  655. }
  656. if shape == nil || shape.Props.FillGrad == nil || shape.Props.FillGrad.Kind != "radial" || !closeTo(shape.Props.FillGrad.CX, 0.5) {
  657. t.Errorf("gradient fill = %+v", shape)
  658. }
  659. if s2.BgImage == nil || !closeTo(s2.BgImage.X, -96) || !closeTo(s2.BgImage.W, 1152) {
  660. t.Errorf("picture background = %+v", s2.BgImage)
  661. }
  662. }
  663. // A save keeps what the editor draws: a picture's frame fill showing through
  664. // its transparent pixels, and a first paragraph's space before
  665. func TestPptxWriterKeepsFrameFillAndFirstSpacing(t *testing.T) {
  666. grad := &model.Gradient{Kind: "linear", Angle: 180, Stops: []model.GradientStop{{Pos: 0, Color: "#263b86"}, {Pos: 1, Color: "#5e9eff"}}}
  667. pic := media.EncodeDataURL(officetest.PngBytes(t), "png")
  668. html := `<div style="margin-top:8px;margin-bottom:6px;">first</div><div style="margin-top:8px;">second</div>`
  669. pres := &model.Presentation{Size: []int{960, 540}, Slides: []*model.Slide{{Objects: []*model.Object{
  670. {Type: "image", X: 10, Y: 10, W: 200, H: 50, Props: model.Props{Src: pic, Fill: "#263b86", FillGrad: grad}},
  671. {Type: "image", X: 10, Y: 80, W: 200, H: 50, Props: model.Props{Src: pic, Fill: "#ffffffec"}},
  672. {Type: "text", X: 10, Y: 150, W: 400, H: 100, Props: model.Props{HTML: html, FontSize: 18}},
  673. }}}}
  674. out, err := Build(pres)
  675. if err != nil {
  676. t.Fatalf("BuildPptx: %v", err)
  677. }
  678. back, err := Parse(out)
  679. if err != nil {
  680. t.Fatalf("ParsePptx: %v", err)
  681. }
  682. var imgs []*model.Object
  683. var text *model.Object
  684. for _, o := range back.Slides[0].Objects {
  685. switch o.Type {
  686. case "image":
  687. imgs = append(imgs, o)
  688. case "text":
  689. text = o
  690. }
  691. }
  692. tests := []struct {
  693. name string
  694. ok bool
  695. }{
  696. {"gradient frame fill", len(imgs) == 2 && imgs[0].Props.FillGrad != nil && imgs[0].Props.FillGrad.Stops[0].Color == "#263b86"},
  697. {"translucent frame fill", len(imgs) == 2 && imgs[1].Props.Fill == "#ffffffec"},
  698. {"first paragraph keeps its space before", text != nil && strings.HasPrefix(text.Props.HTML, `<div`) &&
  699. strings.Contains(text.Props.HTML[:strings.Index(text.Props.HTML, ">")], "margin-top:8px")},
  700. }
  701. for _, tc := range tests {
  702. if !tc.ok {
  703. t.Errorf("%s lost: images %+v, text %+v", tc.name, imgs, text)
  704. }
  705. }
  706. }
  707. // A table that came with a style keeps its own look through a save: it names
  708. // the style that adds nothing, and its rules come back from the cells
  709. func TestPptxStyledTableRoundTrip(t *testing.T) {
  710. tests := []struct {
  711. name, stroke string
  712. strokeW float64
  713. }{
  714. {"white rules", "#ffffff", 1.33},
  715. {"no rules", "none", 0},
  716. }
  717. for _, tc := range tests {
  718. t.Run(tc.name, func(t *testing.T) {
  719. pres := &model.Presentation{Size: []int{960, 540}, Slides: []*model.Slide{{Objects: []*model.Object{
  720. {Type: "table", X: 10, Y: 10, W: 300, H: 100, Props: model.Props{
  721. Rows: [][]string{{"a", "b"}, {"c", "d"}}, HeaderRow: true, Styled: true,
  722. Stroke: tc.stroke, StrokeW: tc.strokeW,
  723. CellFill: [][]string{{"#6aa84f", "#6aa84f"}, {"", ""}},
  724. }},
  725. }}}}
  726. out, err := Build(pres)
  727. if err != nil {
  728. t.Fatalf("BuildPptx: %v", err)
  729. }
  730. if !strings.Contains(packagePart(t, out, "ppt/slides/slide1.xml"), noTableStyleID) {
  731. t.Errorf("styled table does not name the no-look style")
  732. }
  733. back, err := Parse(out)
  734. if err != nil {
  735. t.Fatalf("ParsePptx: %v", err)
  736. }
  737. var tbl *model.Object
  738. for _, o := range back.Slides[0].Objects {
  739. if o.Type == "table" {
  740. tbl = o
  741. }
  742. }
  743. if tbl == nil {
  744. t.Fatalf("table lost")
  745. }
  746. p := tbl.Props
  747. if !p.Styled || p.Stroke != tc.stroke || !closeTo(p.StrokeW, tc.strokeW) {
  748. t.Errorf("styled = %v, stroke = %q %v; want true, %q %v", p.Styled, p.Stroke, p.StrokeW, tc.stroke, tc.strokeW)
  749. }
  750. })
  751. }
  752. }