package office
/*
odp_writer.go - Build an OpenDocument Presentation (.odp) from a
Presentation.
Covers the pptx writer's subset: text boxes (line-flattened rich text,
size/color/bold/italic/underline/align), images (data URLs), basic
shapes (rect / rounded rect -> draw:rect, ellipse -> draw:ellipse,
other kinds approximated as rectangles), lines, tables and charts via
their client-side PNG raster (props.png). Speaker notes are kept.
Video/audio objects are dropped by the client before export, exactly
like the pptx path.
*/
import (
"fmt"
"sort"
"strings"
)
// BuildOdp serializes a Presentation into a complete .odp file
func BuildOdp(p *Presentation) ([]byte, error) {
b := &odpBuilder{}
var body strings.Builder
for si, slide := range p.Slides {
pageAttr := ""
bg := slide.Bg
if bg == "" {
if c, ok := themeBg[p.Theme]; ok {
bg = "#" + c
}
}
if strings.HasPrefix(bg, "#") {
ps := b.newStyle("drawing-page",
``)
pageAttr = ` draw:style-name="` + ps + `"`
}
body.WriteString(fmt.Sprintf(``, si+1, pageAttr))
objs := append([]*Object(nil), slide.Objects...)
sort.SliceStable(objs, func(a, bIdx int) bool { return objs[a].Z < objs[bIdx].Z })
for _, o := range objs {
b.emitObject(&body, o, p)
}
if strings.TrimSpace(slide.Notes) != "" {
body.WriteString(``)
for _, ln := range strings.Split(slide.Notes, "\n") {
body.WriteString(`` + xmlEscape(ln) + ``)
}
body.WriteString(``)
}
body.WriteString(``)
}
content := `` + "\n" +
`` +
`` + b.styles.String() + `` +
`` + body.String() + `` +
``
// 960x540 px slide = 25.4 x 14.288 cm
stylesXML := `` + "\n" +
`` +
`` +
`` +
`` +
``
return buildOdfZip(odpMime, map[string]string{
"content.xml": content,
"styles.xml": stylesXML,
"meta.xml": odfMeta(),
}, b.media)
}
type odpBuilder struct {
styles strings.Builder
media []mediaEntry
styleSeq int
}
func (b *odpBuilder) newStyle(family, props string) string {
b.styleSeq++
name := fmt.Sprintf("S%d", b.styleSeq)
b.styles.WriteString(`` +
props + ``)
return name
}
func odpGeom(o *Object) string {
return fmt.Sprintf(` svg:x="%s" svg:y="%s" svg:width="%s" svg:height="%s"`,
pxToCm(o.X), pxToCm(o.Y), pxToCm(o.W), pxToCm(o.H))
}
func (b *odpBuilder) emitObject(body *strings.Builder, o *Object, p *Presentation) {
switch o.Type {
case "text":
b.emitText(body, o, p)
case "image":
b.emitImage(body, o, o.Props.Src)
case "chart":
if o.Props.Png != "" {
b.emitImage(body, o, o.Props.Png)
}
case "shape":
b.emitShape(body, o)
case "line":
b.emitLine(body, o)
case "table":
b.emitTable(body, o)
}
}
func (b *odpBuilder) textStyleFor(o *Object, p *Presentation) string {
props := ""
if o.Props.Bold {
props += ` fo:font-weight="bold"`
}
if o.Props.Italic {
props += ` fo:font-style="italic"`
}
if o.Props.Underline {
props += ` style:text-underline-style="solid"`
}
color := o.Props.Color
if color == "" {
if c, ok := themeText[p.Theme]; ok {
color = "#" + c
}
}
if strings.HasPrefix(color, "#") {
props += ` fo:color="` + color + `"`
}
size := o.Props.FontSize
if size <= 0 {
size = 24
}
props += fmt.Sprintf(` fo:font-size="%.1fpt"`, size*72.0/96.0)
align := ""
switch o.Props.Align {
case "center":
align = ``
case "right":
align = ``
}
return b.newStyle("paragraph", align+``)
}
func (b *odpBuilder) emitText(body *strings.Builder, o *Object, p *Presentation) {
ps := b.textStyleFor(o, p)
body.WriteString(``)
for _, ln := range htmlToLines(o.Props.HTML) {
body.WriteString(`` + xmlEscape(ln) + ``)
}
body.WriteString(``)
}
func (b *odpBuilder) emitImage(body *strings.Builder, o *Object, src string) {
pic := odfPicture(src, &b.media)
if pic == "" {
return
}
body.WriteString(`` +
`` +
``)
}
func (b *odpBuilder) emitShape(body *strings.Builder, o *Object) {
props := ""
if strings.HasPrefix(o.Props.Fill, "#") {
props += ` draw:fill="solid" draw:fill-color="` + o.Props.Fill + `"`
}
if strings.HasPrefix(o.Props.Stroke, "#") && o.Props.StrokeW > 0 {
props += fmt.Sprintf(` draw:stroke="solid" svg:stroke-color="%s" svg:stroke-width="%s"`,
o.Props.Stroke, pxToCm(o.Props.StrokeW))
} else {
props += ` draw:stroke="none"`
}
gs := b.newStyle("graphic", ``)
tag := "draw:rect"
extra := ""
switch o.Props.Kind {
case "ellipse":
tag = "draw:ellipse"
case "round":
extra = ` draw:corner-radius="0.3cm"`
}
body.WriteString(`<` + tag + ` draw:style-name="` + gs + `"` + odpGeom(o) + extra + `>`)
if strings.TrimSpace(o.Props.Text) != "" {
body.WriteString(`` + xmlEscape(o.Props.Text) + ``)
}
body.WriteString(`` + strings.Split(tag, " ")[0] + `>`)
}
func (b *odpBuilder) emitLine(body *strings.Builder, o *Object) {
stroke := o.Props.Stroke
if !strings.HasPrefix(stroke, "#") {
stroke = "#333333"
}
sw := o.Props.StrokeW
if sw <= 0 {
sw = 2
}
gs := b.newStyle("graphic", fmt.Sprintf(
``,
stroke, pxToCm(sw)))
body.WriteString(fmt.Sprintf(
``,
gs, pxToCm(o.X), pxToCm(o.Y), pxToCm(o.X+o.W), pxToCm(o.Y+o.H)))
}
func (b *odpBuilder) emitTable(body *strings.Builder, o *Object) {
rows := o.Props.Rows
if len(rows) == 0 {
return
}
cols := len(rows[0])
if cols == 0 {
cols = 1
}
body.WriteString(``)
for c := 0; c < cols; c++ {
pct := 100.0 / float64(cols)
if c < len(o.Props.ColW) && o.Props.ColW[c] > 0 {
pct = o.Props.ColW[c]
}
cs := b.newStyle("table-column",
``)
body.WriteString(``)
}
for ri, row := range rows {
body.WriteString(``)
for ci := 0; ci < cols; ci++ {
cell := ""
if ci < len(row) {
cell = strings.Join(htmlToLines(row[ci]), " ")
}
bold := o.Props.HeaderRow && ri == 0
inner := xmlEscape(cell)
if bold && inner != "" {
ts := b.newStyle("text", ``)
inner = `` + inner + ``
}
body.WriteString(`` + inner + ``)
}
body.WriteString(``)
}
body.WriteString(``)
}