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)
}
})
}
}