odp_writer.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package office
  2. /*
  3. odp_writer.go - Build an OpenDocument Presentation (.odp) from a
  4. Presentation.
  5. Covers the pptx writer's subset: text boxes (line-flattened rich text,
  6. size/color/bold/italic/underline/align), images (data URLs), basic
  7. shapes (rect / rounded rect -> draw:rect, ellipse -> draw:ellipse,
  8. other kinds approximated as rectangles), lines, tables and charts via
  9. their client-side PNG raster (props.png). Speaker notes are kept.
  10. Video/audio objects are dropped by the client before export, exactly
  11. like the pptx path.
  12. */
  13. import (
  14. "fmt"
  15. "sort"
  16. "strings"
  17. )
  18. // BuildOdp serializes a Presentation into a complete .odp file
  19. func BuildOdp(p *Presentation) ([]byte, error) {
  20. b := &odpBuilder{}
  21. var body strings.Builder
  22. for si, slide := range p.Slides {
  23. pageAttr := ""
  24. bg := slide.Bg
  25. if bg == "" {
  26. if c, ok := themeBg[p.Theme]; ok {
  27. bg = "#" + c
  28. }
  29. }
  30. if strings.HasPrefix(bg, "#") {
  31. ps := b.newStyle("drawing-page",
  32. `<style:drawing-page-properties draw:fill="solid" draw:fill-color="`+bg+`"/>`)
  33. pageAttr = ` draw:style-name="` + ps + `"`
  34. }
  35. body.WriteString(fmt.Sprintf(`<draw:page draw:name="page%d"%s>`, si+1, pageAttr))
  36. objs := append([]*Object(nil), slide.Objects...)
  37. sort.SliceStable(objs, func(a, bIdx int) bool { return objs[a].Z < objs[bIdx].Z })
  38. for _, o := range objs {
  39. b.emitObject(&body, o, p)
  40. }
  41. if strings.TrimSpace(slide.Notes) != "" {
  42. body.WriteString(`<presentation:notes><draw:frame presentation:class="notes" ` +
  43. `svg:x="2cm" svg:y="16cm" svg:width="21cm" svg:height="10cm"><draw:text-box>`)
  44. for _, ln := range strings.Split(slide.Notes, "\n") {
  45. body.WriteString(`<text:p>` + xmlEscape(ln) + `</text:p>`)
  46. }
  47. body.WriteString(`</draw:text-box></draw:frame></presentation:notes>`)
  48. }
  49. body.WriteString(`</draw:page>`)
  50. }
  51. content := `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
  52. `<office:document-content ` + odfNs + `>` +
  53. `<office:automatic-styles>` + b.styles.String() + `</office:automatic-styles>` +
  54. `<office:body><office:presentation>` + body.String() + `</office:presentation></office:body>` +
  55. `</office:document-content>`
  56. // 960x540 px slide = 25.4 x 14.288 cm
  57. stylesXML := `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
  58. `<office:document-styles ` + odfNs + `>` +
  59. `<office:automatic-styles><style:page-layout style:name="PL1">` +
  60. `<style:page-layout-properties fo:page-width="25.4cm" fo:page-height="14.288cm" ` +
  61. `fo:margin-top="0cm" fo:margin-right="0cm" fo:margin-bottom="0cm" fo:margin-left="0cm" ` +
  62. `style:print-orientation="landscape"/></style:page-layout></office:automatic-styles>` +
  63. `<office:master-styles><style:master-page style:name="Default" style:page-layout-name="PL1"/>` +
  64. `</office:master-styles></office:document-styles>`
  65. return buildOdfZip(odpMime, map[string]string{
  66. "content.xml": content,
  67. "styles.xml": stylesXML,
  68. "meta.xml": odfMeta(),
  69. }, b.media)
  70. }
  71. type odpBuilder struct {
  72. styles strings.Builder
  73. media []mediaEntry
  74. styleSeq int
  75. }
  76. func (b *odpBuilder) newStyle(family, props string) string {
  77. b.styleSeq++
  78. name := fmt.Sprintf("S%d", b.styleSeq)
  79. b.styles.WriteString(`<style:style style:name="` + name + `" style:family="` + family + `">` +
  80. props + `</style:style>`)
  81. return name
  82. }
  83. func odpGeom(o *Object) string {
  84. return fmt.Sprintf(` svg:x="%s" svg:y="%s" svg:width="%s" svg:height="%s"`,
  85. pxToCm(o.X), pxToCm(o.Y), pxToCm(o.W), pxToCm(o.H))
  86. }
  87. func (b *odpBuilder) emitObject(body *strings.Builder, o *Object, p *Presentation) {
  88. switch o.Type {
  89. case "text":
  90. b.emitText(body, o, p)
  91. case "image":
  92. b.emitImage(body, o, o.Props.Src)
  93. case "chart":
  94. if o.Props.Png != "" {
  95. b.emitImage(body, o, o.Props.Png)
  96. }
  97. case "shape":
  98. b.emitShape(body, o)
  99. case "line":
  100. b.emitLine(body, o)
  101. case "table":
  102. b.emitTable(body, o)
  103. }
  104. }
  105. func (b *odpBuilder) textStyleFor(o *Object, p *Presentation) string {
  106. props := ""
  107. if o.Props.Bold {
  108. props += ` fo:font-weight="bold"`
  109. }
  110. if o.Props.Italic {
  111. props += ` fo:font-style="italic"`
  112. }
  113. if o.Props.Underline {
  114. props += ` style:text-underline-style="solid"`
  115. }
  116. color := o.Props.Color
  117. if color == "" {
  118. if c, ok := themeText[p.Theme]; ok {
  119. color = "#" + c
  120. }
  121. }
  122. if strings.HasPrefix(color, "#") {
  123. props += ` fo:color="` + color + `"`
  124. }
  125. size := o.Props.FontSize
  126. if size <= 0 {
  127. size = 24
  128. }
  129. props += fmt.Sprintf(` fo:font-size="%.1fpt"`, size*72.0/96.0)
  130. align := ""
  131. switch o.Props.Align {
  132. case "center":
  133. align = `<style:paragraph-properties fo:text-align="center"/>`
  134. case "right":
  135. align = `<style:paragraph-properties fo:text-align="end"/>`
  136. }
  137. return b.newStyle("paragraph", align+`<style:text-properties`+props+`/>`)
  138. }
  139. func (b *odpBuilder) emitText(body *strings.Builder, o *Object, p *Presentation) {
  140. ps := b.textStyleFor(o, p)
  141. body.WriteString(`<draw:frame` + odpGeom(o) + `><draw:text-box>`)
  142. for _, ln := range htmlToLines(o.Props.HTML) {
  143. body.WriteString(`<text:p text:style-name="` + ps + `">` + xmlEscape(ln) + `</text:p>`)
  144. }
  145. body.WriteString(`</draw:text-box></draw:frame>`)
  146. }
  147. func (b *odpBuilder) emitImage(body *strings.Builder, o *Object, src string) {
  148. pic := odfPicture(src, &b.media)
  149. if pic == "" {
  150. return
  151. }
  152. body.WriteString(`<draw:frame` + odpGeom(o) + `>` +
  153. `<draw:image xlink:href="` + pic + `" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/>` +
  154. `</draw:frame>`)
  155. }
  156. func (b *odpBuilder) emitShape(body *strings.Builder, o *Object) {
  157. props := ""
  158. if strings.HasPrefix(o.Props.Fill, "#") {
  159. props += ` draw:fill="solid" draw:fill-color="` + o.Props.Fill + `"`
  160. }
  161. if strings.HasPrefix(o.Props.Stroke, "#") && o.Props.StrokeW > 0 {
  162. props += fmt.Sprintf(` draw:stroke="solid" svg:stroke-color="%s" svg:stroke-width="%s"`,
  163. o.Props.Stroke, pxToCm(o.Props.StrokeW))
  164. } else {
  165. props += ` draw:stroke="none"`
  166. }
  167. gs := b.newStyle("graphic", `<style:graphic-properties`+props+`/>`)
  168. tag := "draw:rect"
  169. extra := ""
  170. switch o.Props.Kind {
  171. case "ellipse":
  172. tag = "draw:ellipse"
  173. case "round":
  174. extra = ` draw:corner-radius="0.3cm"`
  175. }
  176. body.WriteString(`<` + tag + ` draw:style-name="` + gs + `"` + odpGeom(o) + extra + `>`)
  177. if strings.TrimSpace(o.Props.Text) != "" {
  178. body.WriteString(`<text:p>` + xmlEscape(o.Props.Text) + `</text:p>`)
  179. }
  180. body.WriteString(`</` + strings.Split(tag, " ")[0] + `>`)
  181. }
  182. func (b *odpBuilder) emitLine(body *strings.Builder, o *Object) {
  183. stroke := o.Props.Stroke
  184. if !strings.HasPrefix(stroke, "#") {
  185. stroke = "#333333"
  186. }
  187. sw := o.Props.StrokeW
  188. if sw <= 0 {
  189. sw = 2
  190. }
  191. gs := b.newStyle("graphic", fmt.Sprintf(
  192. `<style:graphic-properties draw:stroke="solid" svg:stroke-color="%s" svg:stroke-width="%s"/>`,
  193. stroke, pxToCm(sw)))
  194. body.WriteString(fmt.Sprintf(
  195. `<draw:line draw:style-name="%s" svg:x1="%s" svg:y1="%s" svg:x2="%s" svg:y2="%s"/>`,
  196. gs, pxToCm(o.X), pxToCm(o.Y), pxToCm(o.X+o.W), pxToCm(o.Y+o.H)))
  197. }
  198. func (b *odpBuilder) emitTable(body *strings.Builder, o *Object) {
  199. rows := o.Props.Rows
  200. if len(rows) == 0 {
  201. return
  202. }
  203. cols := len(rows[0])
  204. if cols == 0 {
  205. cols = 1
  206. }
  207. body.WriteString(`<draw:frame` + odpGeom(o) + `><table:table>`)
  208. for c := 0; c < cols; c++ {
  209. pct := 100.0 / float64(cols)
  210. if c < len(o.Props.ColW) && o.Props.ColW[c] > 0 {
  211. pct = o.Props.ColW[c]
  212. }
  213. cs := b.newStyle("table-column",
  214. `<style:table-column-properties style:column-width="`+pxToCm(o.W*pct/100)+`"/>`)
  215. body.WriteString(`<table:table-column table:style-name="` + cs + `"/>`)
  216. }
  217. for ri, row := range rows {
  218. body.WriteString(`<table:table-row>`)
  219. for ci := 0; ci < cols; ci++ {
  220. cell := ""
  221. if ci < len(row) {
  222. cell = strings.Join(htmlToLines(row[ci]), " ")
  223. }
  224. bold := o.Props.HeaderRow && ri == 0
  225. inner := xmlEscape(cell)
  226. if bold && inner != "" {
  227. ts := b.newStyle("text", `<style:text-properties fo:font-weight="bold"/>`)
  228. inner = `<text:span text:style-name="` + ts + `">` + inner + `</text:span>`
  229. }
  230. body.WriteString(`<table:table-cell><text:p>` + inner + `</text:p></table:table-cell>`)
  231. }
  232. body.WriteString(`</table:table-row>`)
  233. }
  234. body.WriteString(`</table:table></draw:frame>`)
  235. }