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