package pptx import ( "archive/zip" "bytes" "io" "math" "strings" "testing" "imuslab.com/arozos/mod/office/internal/fonts" "imuslab.com/arozos/mod/office/internal/htmlutil" "imuslab.com/arozos/mod/office/internal/media" "imuslab.com/arozos/mod/office/internal/officetest" "imuslab.com/arozos/mod/office/internal/units" "imuslab.com/arozos/mod/office/internal/xmltree" "imuslab.com/arozos/mod/office/model" ) // replaceParts copies a package, replacing (or adding) the named parts func replaceParts(t *testing.T, data []byte, replace map[string]string) []byte { t.Helper() zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("zip read: %v", err) } var buf bytes.Buffer zw := zip.NewWriter(&buf) seen := map[string]bool{} for _, f := range zr.File { w, err := zw.Create(f.Name) if err != nil { t.Fatalf("zip create: %v", err) } if v, ok := replace[f.Name]; ok { seen[f.Name] = true w.Write([]byte(v)) continue } rc, err := f.Open() if err != nil { t.Fatalf("zip open: %v", err) } io.Copy(w, rc) rc.Close() } for name, v := range replace { if !seen[name] { w, _ := zw.Create(name) w.Write([]byte(v)) } } zw.Close() return buf.Bytes() } // onePres builds a one-slide package, applies the replacements and parses it func onePres(t *testing.T, parts pptxParts, replace map[string]string) *model.Presentation { t.Helper() data := buildTestPptx(t, parts) if len(replace) > 0 { data = replaceParts(t, data, replace) } p, err := Parse(data) if err != nil { t.Fatalf("ParsePptx: %v", err) } return p } // a slide part with a background of its own func slideWithBg(bg, tree string) string { return `` + bg + `` + tree + `` } func closeTo(a, b float64) bool { return math.Abs(a-b) < 0.02 } // A 4:3 deck keeps its shape: 960x720, and a square stays square func TestPptxKeepsSlideShape(t *testing.T) { square := `` + `` + `` data := buildTestPptx(t, pptxParts{slides: []string{square}}) zr, _ := zip.NewReader(bytes.NewReader(data), int64(len(data))) var pres string for _, f := range zr.File { if f.Name == "ppt/presentation.xml" { rc, _ := f.Open() b, _ := io.ReadAll(rc) rc.Close() pres = strings.Replace(string(b), `cy="5143500"`, `cy="6858000"`, 1) } } p, err := Parse(replaceParts(t, data, map[string]string{"ppt/presentation.xml": pres})) if err != nil { t.Fatalf("ParsePptx: %v", err) } if len(p.Size) != 2 || p.Size[0] != 960 || p.Size[1] != 720 { t.Fatalf("size = %v, want [960 720]", p.Size) } o := p.Slides[0].Objects[0] if !closeTo(o.X, 480) || !closeTo(o.Y, 360) || !closeTo(o.W, 96) || !closeTo(o.H, 96) { t.Errorf("square = %v,%v %vx%v, want 480,360 96x96", o.X, o.Y, o.W, o.H) } // and the writer states the shape it was given out, err := Build(p) if err != nil { t.Fatalf("BuildPptx: %v", err) } if !strings.Contains(packagePart(t, out, "ppt/presentation.xml"), ``) { t.Errorf("written deck lost its 4:3 size") } } /* ---------------- colours and fills ---------------- */ func TestColorAlphaAndWithAlpha(t *testing.T) { tests := []struct { name string hex string alpha float64 want string }{ {"opaque stays six digits", "#ff0000", 1, "#ff0000"}, {"half transparent", "#ff0000", 0.5, "#ff000080"}, {"92.5% white", "#ffffff", 0.92549, "#ffffffec"}, {"not a hex colour", "red", 0.5, "red"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := withAlpha(tc.hex, tc.alpha); got != tc.want { t.Errorf("withAlpha = %q, want %q", got, tc.want) } }) } n, _ := xmltree.Parse([]byte(``)) if a := colorAlpha(n); !closeTo(a, 0.92549) { t.Errorf("colorAlpha = %v", a) } if a := htmlutil.HexAlpha("#ffffff80"); !closeTo(a, 128.0/255) { t.Errorf("hexAlpha = %v", a) } if got := srgbClrXML("#ff000080", "000000"); got != `` { t.Errorf("srgbClrXML = %s", got) } } func TestShapeFillKeepsAlpha(t *testing.T) { objs := parseOneSlide(t, pptxParts{slides: []string{ `` + `` + `` + ``, }}) if len(objs) != 1 || objs[0].Props.Fill != "#ffffff80" { t.Fatalf("fill = %+v, want #ffffff80", objs) } } func TestGradientOf(t *testing.T) { cc := colorCtx{scheme: map[string]string{"accent1": "4285F4"}} tests := []struct { name string xml string kind string angle float64 cx float64 first string }{ {"linear, top to bottom", `` + ``, "linear", 180, 0, "#ffffff"}, {"linear, left to right", `` + ``, "linear", 90, 0, "#4285f4"}, {"radial from the centre", `` + `` + ``, "radial", 0, 0.5, "#b1ddff"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { n, err := xmltree.Parse([]byte(strings.Replace(tc.xml, "", ``, 1))) if err != nil { t.Fatalf("xml: %v", err) } g := cc.gradientOf(n) if g == nil { t.Fatal("no gradient") } if g.Kind != tc.kind || !closeTo(g.Angle, tc.angle) || !closeTo(g.CX, tc.cx) || g.Stops[0].Color != tc.first { t.Errorf("gradient = %+v", g) } if g.Stops[0].Pos > g.Stops[len(g.Stops)-1].Pos { t.Errorf("stops are not in order: %+v", g.Stops) } }) } } /* ---------------- backgrounds ---------------- */ func TestPptxSlideBackgrounds(t *testing.T) { png := officetest.PngBytes(t) tests := []struct { name string bg string check func(t *testing.T, s *model.Slide) }{ {"picture stretched past the edges", ``, func(t *testing.T, s *model.Slide) { if s.BgImage == nil || !strings.HasPrefix(s.BgImage.Src, "data:image/png") { t.Fatalf("bgImage = %+v", s.BgImage) } if !closeTo(s.BgImage.X, -96) || !closeTo(s.BgImage.W, 1152) || !closeTo(s.BgImage.H, 540) { t.Errorf("placed at %+v, want x -96 w 1152 h 540", s.BgImage) } }}, {"gradient", `` + ``, func(t *testing.T, s *model.Slide) { if s.BgGrad == nil || len(s.BgGrad.Stops) != 2 || s.Bg != "#5e9eff" { t.Errorf("bg = %q, grad = %+v", s.Bg, s.BgGrad) } }}, {"theme style through bgRef", ``, func(t *testing.T, s *model.Slide) { if s.Bg != "#4285f4" { t.Errorf("bg = %q, want the reference's colour", s.Bg) } }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { p := onePres(t, pptxParts{ slides: []string{""}, slideRels: map[string]string{"rId9": "../media/bg.png"}, media: map[string][]byte{"ppt/media/bg.png": png}, }, map[string]string{"ppt/slides/slide1.xml": slideWithBg(tc.bg, "")}) tc.check(t, p.Slides[0]) }) } } /* ---------------- symbol fonts ---------------- */ func TestSymbolBulletAndText(t *testing.T) { tests := []struct { font, marker, want string ok bool }{ {"Wingdings", "l", "●", true}, {"Wingdings", "", "●", true}, {"Wingdings 2", "n", "■", true}, {"Symbol", "", "•", true}, {"Arial", "l", "", false}, {"Wingdings", "lo", "", false}, } for _, tc := range tests { got, _, ok := fonts.SymbolBullet(tc.font, tc.marker) if ok != tc.ok || got != tc.want { t.Errorf("symbolBullet(%q, %q) = %q, %v", tc.font, tc.marker, got, ok) } } if got := fonts.SymbolText("a = b  2"); got != "α = β ≥ 2" { t.Errorf("symbolText = %q", got) } if !fonts.IsSymbolFont("Wingdings 3") || fonts.IsSymbolFont("Calibri") { t.Error("isSymbolFont") } } func TestPptxBulletsAndNumbers(t *testing.T) { para := func(ppr, text string) string { return `` + ppr + `` + text + `` } tests := []struct { name string paras string want []string absent []string }{ {"a Wingdings bullet comes in as the shape it draws", para(``, "one"), []string{"●"}, []string{">l<", "Wingdings"}}, {"an empty paragraph has no bullet", ``, nil, []string{"•"}}, {"a geometric bullet falls back to Arial's twin, not the CJK face", para(``, "one"), []string{"●", "font-family:'Open Sans',Arimo,"}, nil}, {"a Wingdings bullet skips the deck's East Asian font", `` + `one`, []string{"font-family:Arial,Arimo,"}, nil}, {"a number is never set in a symbol font", para(``, "one"), []string{"1."}, []string{"Wingdings"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { objs := parseOneSlide(t, pptxParts{slides: []string{textBox(0, 0, 4000000, 2000000, tc.paras)}}) if len(objs) == 0 { t.Fatal("no objects") } html := objs[0].Props.HTML for _, w := range tc.want { if !strings.Contains(html, w) { t.Errorf("want %q in %s", w, html) } } for _, a := range tc.absent { if strings.Contains(html, a) { t.Errorf("did not want %q in %s", a, html) } } }) } } /* ---------------- text layout ---------------- */ func TestPptxTextLayoutRules(t *testing.T) { tests := []struct { name string shape string want []string absent []string }{ {"autofit sizes are whole points", `` + `` + `` + `fit`, // 20pt at 92.5% is 18.5pt, drawn at 19pt = 25.33px []string{"font-size:25.33px"}, []string{"font-size:24.67px"}}, {"space before the first paragraph with spcFirstLastPara", `` + `` + `` + `a`, []string{"margin-top:8px"}, nil}, {"Google Slides draws no space before the first paragraph", `` + `` + `` + `a` + `b`, // the second paragraph keeps its 6pt = 8px; the first has none []string{"margin-top:8px"}, []string{`
a`+ `b`), // 20% of a 20pt paragraph's 24pt line is 4.8pt = 6.4px []string{"margin-top:6.4px"}, nil}, {"a lowered run does not make its line taller", textBox(0, 0, 4000000, 2000000, `Pt`), []string{"vertical-align:sub;line-height:0"}, nil}, {"a tab goes to an inch stop", textBox(0, 0, 4000000, 2000000, `a b`), []string{"tab-size:96px"}, nil}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { objs := parseOneSlide(t, pptxParts{slides: []string{tc.shape}}) if len(objs) == 0 { t.Fatal("no objects") } html := objs[0].Props.HTML for _, w := range tc.want { if !strings.Contains(html, w) { t.Errorf("want %q in %s", w, html) } } for _, a := range tc.absent { if strings.Contains(html, a) { t.Errorf("did not want %q in %s", a, html) } } }) } } // A group's scale moves and sizes its children but leaves type alone func TestPptxGroupDoesNotScaleText(t *testing.T) { grp := `` + `` + `` + textBox(0, 0, 4000000, 2000000, `big`) + `` objs := parseOneSlide(t, pptxParts{slides: []string{grp}}) if len(objs) != 1 { t.Fatalf("objects = %d", len(objs)) } if !strings.Contains(objs[0].Props.HTML, "font-size:26.67px") { t.Errorf("20pt text in a half-size group = %s, want 26.67px", objs[0].Props.HTML) } if !closeTo(objs[0].W, 2000000.0/units.EmuPerPx) { t.Errorf("the box itself should still be scaled: w = %v", objs[0].W) } } /* ---------------- shapes ---------------- */ func TestPptxShapeKinds(t *testing.T) { sp := func(spPr string) string { return `` + spPr + `` } tests := []struct { name string shape string check func(t *testing.T, o *model.Object) }{ {"a zero-wide line shape is a line", sp(`` + ``), func(t *testing.T, o *model.Object) { if o.Type != "line" || !closeTo(o.W, 0) || !closeTo(o.H, 96) { t.Errorf("got %s %vx%v", o.Type, o.W, o.H) } }}, {"a preset keeps the adjustments it states", sp(`` + ``), func(t *testing.T, o *model.Object) { if o.Props.Kind != "chevron" || o.Props.Adj["adj"] != 30000 { t.Errorf("kind %q adj %v", o.Props.Kind, o.Props.Adj) } }}, {"a gradient fill", sp(`` + ``), func(t *testing.T, o *model.Object) { if o.Props.FillGrad == nil || o.Props.Fill != "#ff0000" { t.Errorf("fill %q grad %+v", o.Props.Fill, o.Props.FillGrad) } }}, {"custom geometry", sp(`` + `` + `` + ``), func(t *testing.T, o *model.Object) { if o.Props.Kind != "custom" || o.Props.Geom == nil || o.Props.Geom.Paths[0].D != "M0 0L100 0L50 100Z" { t.Errorf("kind %q geom %+v", o.Props.Kind, o.Props.Geom) } }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { objs := parseOneSlide(t, pptxParts{slides: []string{tc.shape}}) if len(objs) != 1 { t.Fatalf("objects = %d", len(objs)) } tc.check(t, objs[0]) }) } } func TestEvalGuideAndArcs(t *testing.T) { g := map[string]float64{"w": 1000, "h": 500, "adj": 25000} tests := []struct { fmla string want float64 }{ {"val 7", 7}, {"*/ w adj 100000", 250}, {"+- w h 100", 1400}, {"pin 0 adj 10000", 10000}, {"max w h", 1000}, {"?: -1 w h", 500}, {"sin 100 5400000", 100}, {"cos 100 0", 100}, } for _, tc := range tests { if got := evalGuide(tc.fmla, g); !closeTo(got, tc.want) { t.Errorf("evalGuide(%q) = %v, want %v", tc.fmla, got, tc.want) } } // a quarter arc from the top of a circle round to its right side n, _ := xmltree.Parse([]byte(`` + ``)) d := pathD(n, geomGuides(&xmltree.Node{}, 200, 200)) if !strings.HasPrefix(d, "M100 0C") || !strings.HasSuffix(d, " 200 100") { t.Errorf("arc path = %q", d) } } func TestPictureFilledShape(t *testing.T) { png := officetest.PngBytes(t) objs := parseOneSlide(t, pptxParts{ slides: []string{`` + `` + `` + ` `}, slideRels: map[string]string{"rId9": "../media/p.png"}, media: map[string][]byte{"ppt/media/p.png": png}, }) if len(objs) != 1 || objs[0].Type != "image" { t.Fatalf("objects = %+v", objs) } p := objs[0].Props if p.Mask != "ellipse" || len(p.Crop) != 4 || !closeTo(p.Crop[0], 0.1667) || !closeTo(p.Crop[2], 0.1667) { t.Errorf("mask %q crop %v, want ellipse and a sixth off each side", p.Mask, p.Crop) } } /* ---------------- tables ---------------- */ const testTableStyles = `` + `` + `` + `` + `` + `` + `` func testTableFrame(tblPr, rows string) string { return `` + `` + `` + tblPr + `` + rows + `` } func testCell(attrs, text string) string { return `` + text + `` } func TestPptxTableStyleAndMerges(t *testing.T) { rows := `` + testCell(` gridSpan="2"`, "head") + testCell(` hMerge="1"`, "") + testCell("", "x") + `` + `` + testCell(` rowSpan="2"`, "tall") + testCell("", "a") + testCell("", "b") + `` + `` + testCell(` vMerge="1"`, "") + testCell("", "c") + testCell("", "d") + `` objs := parseOneSlide(t, pptxParts{ slides: []string{testTableFrame(``, rows)}, extraParts: map[string]string{"ppt/tableStyles.xml": testTableStyles}, }) if len(objs) != 1 || objs[0].Type != "table" { t.Fatalf("objects = %+v", objs) } p := objs[0].Props if !p.Styled || p.Stroke != "#ffffff" || !closeTo(p.StrokeW, 12700.0/units.EmuPerPx) { t.Errorf("styled %v rule %q %v", p.Styled, p.Stroke, p.StrokeW) } if p.CellFill[0][0] != "#ff8800" || p.CellFill[1][1] != "#cccccc" || p.CellFill[2][1] != "#eeeeee" { t.Errorf("fills = %v (heading, band 1, band 2)", p.CellFill) } if !strings.Contains(p.Rows[0][0], "font-weight:700") || !strings.Contains(p.Rows[0][0], "#ffffff") { t.Errorf("heading text = %s, want bold white", p.Rows[0][0]) } want := [][]int{{0, 0, 1, 2}, {1, 0, 2, 1}} if len(p.Merges) != 2 || p.Merges[0][3] != 2 || p.Merges[1][2] != 2 { t.Fatalf("merges = %v, want %v", p.Merges, want) } // the writer states the merges back out, err := Build(&model.Presentation{Slides: []*model.Slide{{Objects: objs}}}) if err != nil { t.Fatalf("BuildPptx: %v", err) } slide := packagePart(t, out, "ppt/slides/slide1.xml") for _, w := range []string{`gridSpan="2"`, `hMerge="1"`, `rowSpan="2"`, `vMerge="1"`} { if !strings.Contains(slide, w) { t.Errorf("written table lacks %s", w) } } } func TestTableStylePartsOrder(t *testing.T) { style, _ := xmltree.Parse([]byte(strings.Replace(testTableStyles, `def="{S}">`, `def="{S}">`, 1))) st := style.First("tblStyle") names := func(parts []*xmltree.Node) string { var out []string for _, p := range parts { out = append(out, p.XMLName.Local) } return strings.Join(out, ",") } f := tblFlags{firstRow: true, bandRow: true} if got := names(styleParts(st, f, 0, 0, 3, 3)); got != "wholeTbl,firstRow" { t.Errorf("heading cell parts = %s", got) } if got := names(styleParts(st, f, 1, 0, 3, 3)); got != "wholeTbl,band1H" { t.Errorf("first body row parts = %s", got) } if got := names(styleParts(st, f, 2, 0, 3, 3)); got != "wholeTbl" { t.Errorf("second body row parts = %s (the style has no band2H)", got) } } /* ---------------- Office math ---------------- */ func TestChoiceReadable(t *testing.T) { const m = `xmlns:mc="mc" xmlns:a14="a14" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"` tests := []struct { name string xml string want bool }{ {"a symbol set as math", `≥`, true}, {"a fraction", ``, false}, {"no math at all", ``, false}, {"another requirement", ``, false}, } for _, tc := range tests { n, err := xmltree.Parse([]byte(tc.xml)) if err != nil { t.Fatalf("%s: %v", tc.name, err) } if got := choiceReadable(n); got != tc.want { t.Errorf("%s: choiceReadable = %v", tc.name, got) } } } func packagePart(t *testing.T, data []byte, name string) string { t.Helper() zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { t.Fatalf("zip: %v", err) } for _, f := range zr.File { if f.Name == name { rc, _ := f.Open() b, _ := io.ReadAll(rc) rc.Close() return string(b) } } t.Fatalf("no part %s", name) return "" } /* ---------------- writing ---------------- */ func TestPptxWriterKeepsNewLooks(t *testing.T) { pres := &model.Presentation{Size: []int{960, 540}, Slides: []*model.Slide{{Objects: []*model.Object{ {Type: "shape", W: 100, H: 100, Props: model.Props{Kind: "rect", Fill: "#ff000080"}}, {Type: "shape", W: 100, H: 100, Props: model.Props{Kind: "chevron", Fill: "#00ff00", Adj: map[string]float64{"adj": 30000}}}, {Type: "shape", W: 100, H: 100, Props: model.Props{Kind: "custom", Fill: "#0000ff", Geom: &model.CustomGeom{Paths: []model.CustomPath{{W: 100, H: 100, D: "M0 0L100 0L50 100Z"}}}}}, }}}} out, err := Build(pres) if err != nil { t.Fatalf("BuildPptx: %v", err) } slide := packagePart(t, out, "ppt/slides/slide1.xml") for _, w := range []string{ ``, ``, ``, ``, ``, } { if !strings.Contains(slide, w) { t.Errorf("written slide lacks %s", w) } } } // Backgrounds and gradient fills go out as PowerPoint states them, and come // back the same func TestPptxBackgroundsRoundTrip(t *testing.T) { grad := &model.Gradient{Kind: "linear", Angle: 180, Stops: []model.GradientStop{{Pos: 0, Color: "#5e9eff"}, {Pos: 1, Color: "#ffffff"}}} radial := &model.Gradient{Kind: "radial", CX: 0.5, CY: 0.5, Stops: []model.GradientStop{{Pos: 0, Color: "#b1ddff"}, {Pos: 1, Color: "#cbe8fe"}}} pic := media.EncodeDataURL(officetest.PngBytes(t), "png") pres := &model.Presentation{Size: []int{960, 720}, Slides: []*model.Slide{ {BgGrad: grad, Objects: []*model.Object{{Type: "shape", X: 10, Y: 10, W: 100, H: 100, Props: model.Props{Kind: "rect", Fill: "#b1ddff", FillGrad: radial}}}}, {BgImage: &model.BgImage{Src: pic, X: -96, Y: 0, W: 1152, H: 720}, Objects: []*model.Object{}}, }} out, err := Build(pres) if err != nil { t.Fatalf("BuildPptx: %v", err) } back, err := Parse(out) if err != nil { t.Fatalf("ParsePptx: %v", err) } s1, s2 := back.Slides[0], back.Slides[1] if s1.BgGrad == nil || !closeTo(s1.BgGrad.Angle, 180) || s1.BgGrad.Stops[0].Color != "#5e9eff" { t.Errorf("gradient background = %+v", s1.BgGrad) } var shape *model.Object for _, o := range s1.Objects { if o.Type == "shape" { shape = o } } if shape == nil || shape.Props.FillGrad == nil || shape.Props.FillGrad.Kind != "radial" || !closeTo(shape.Props.FillGrad.CX, 0.5) { t.Errorf("gradient fill = %+v", shape) } if s2.BgImage == nil || !closeTo(s2.BgImage.X, -96) || !closeTo(s2.BgImage.W, 1152) { t.Errorf("picture background = %+v", s2.BgImage) } } // A save keeps what the editor draws: a picture's frame fill showing through // its transparent pixels, and a first paragraph's space before func TestPptxWriterKeepsFrameFillAndFirstSpacing(t *testing.T) { grad := &model.Gradient{Kind: "linear", Angle: 180, Stops: []model.GradientStop{{Pos: 0, Color: "#263b86"}, {Pos: 1, Color: "#5e9eff"}}} pic := media.EncodeDataURL(officetest.PngBytes(t), "png") html := `
first
second
` pres := &model.Presentation{Size: []int{960, 540}, Slides: []*model.Slide{{Objects: []*model.Object{ {Type: "image", X: 10, Y: 10, W: 200, H: 50, Props: model.Props{Src: pic, Fill: "#263b86", FillGrad: grad}}, {Type: "image", X: 10, Y: 80, W: 200, H: 50, Props: model.Props{Src: pic, Fill: "#ffffffec"}}, {Type: "text", X: 10, Y: 150, W: 400, H: 100, Props: model.Props{HTML: html, FontSize: 18}}, }}}} out, err := Build(pres) if err != nil { t.Fatalf("BuildPptx: %v", err) } back, err := Parse(out) if err != nil { t.Fatalf("ParsePptx: %v", err) } var imgs []*model.Object var text *model.Object for _, o := range back.Slides[0].Objects { switch o.Type { case "image": imgs = append(imgs, o) case "text": text = o } } tests := []struct { name string ok bool }{ {"gradient frame fill", len(imgs) == 2 && imgs[0].Props.FillGrad != nil && imgs[0].Props.FillGrad.Stops[0].Color == "#263b86"}, {"translucent frame fill", len(imgs) == 2 && imgs[1].Props.Fill == "#ffffffec"}, {"first paragraph keeps its space before", text != nil && strings.HasPrefix(text.Props.HTML, `")], "margin-top:8px")}, } for _, tc := range tests { if !tc.ok { t.Errorf("%s lost: images %+v, text %+v", tc.name, imgs, text) } } } // A table that came with a style keeps its own look through a save: it names // the style that adds nothing, and its rules come back from the cells func TestPptxStyledTableRoundTrip(t *testing.T) { tests := []struct { name, stroke string strokeW float64 }{ {"white rules", "#ffffff", 1.33}, {"no rules", "none", 0}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { pres := &model.Presentation{Size: []int{960, 540}, Slides: []*model.Slide{{Objects: []*model.Object{ {Type: "table", X: 10, Y: 10, W: 300, H: 100, Props: model.Props{ Rows: [][]string{{"a", "b"}, {"c", "d"}}, HeaderRow: true, Styled: true, Stroke: tc.stroke, StrokeW: tc.strokeW, CellFill: [][]string{{"#6aa84f", "#6aa84f"}, {"", ""}}, }}, }}}} out, err := Build(pres) if err != nil { t.Fatalf("BuildPptx: %v", err) } if !strings.Contains(packagePart(t, out, "ppt/slides/slide1.xml"), noTableStyleID) { t.Errorf("styled table does not name the no-look style") } back, err := Parse(out) if err != nil { t.Fatalf("ParsePptx: %v", err) } var tbl *model.Object for _, o := range back.Slides[0].Objects { if o.Type == "table" { tbl = o } } if tbl == nil { t.Fatalf("table lost") } p := tbl.Props if !p.Styled || p.Stroke != tc.stroke || !closeTo(p.StrokeW, tc.strokeW) { t.Errorf("styled = %v, stroke = %q %v; want true, %q %v", p.Styled, p.Stroke, p.StrokeW, tc.stroke, tc.strokeW) } }) } }