d2/lib/pptx/pptx.go
2023-12-12 10:37:19 -08:00

498 lines
14 KiB
Go

// pptx is a package to create slide presentations in pptx (Microsoft Power Point) format.
// A `.pptx` file is just a bunch of zip compressed `.xml` files following the Office Open XML (OOXML) format.
// To see its content, you can just `unzip <path/to/file>.pptx -d <folder>`.
// With this package, it is possible to create a `Presentation` and add `Slide`s to it.
// Then, when saving the presentation, it will generate the required `.xml` files, compress them and write to the disk.
// Note that this isn't a full implementation of the OOXML format, but a wrapper around it.
// There's a base template with common files to the presentation and then when saving, the package generate only the slides and relationships.
// The base template and slide templates were generated using https://python-pptx.readthedocs.io/en/latest/
// For more information about OOXML, check http://officeopenxml.com/index.php
package pptx
import (
"archive/zip"
"bytes"
_ "embed"
"fmt"
"image/png"
"os"
"text/template"
"time"
)
type BoardTitle struct {
LinkID string
Name string
BoardID string
LinkToSlide int
}
type Presentation struct {
Title string
Description string
Subject string
Creator string
// D2Version can't have letters, only numbers (`[0-9]`) and `.`
// Otherwise, it may fail to open in PowerPoint
D2Version string
includeNav bool
Slides []*Slide
}
type Slide struct {
BoardTitle []BoardTitle
Links []*Link
Image []byte
ImageId string
ImageWidth int
ImageHeight int
ImageTop int
ImageLeft int
ImageScaleFactor float64
}
func (s *Slide) AddLink(link *Link) {
link.Index = len(s.Links)
s.Links = append(s.Links, link)
link.ID = fmt.Sprintf("link%d", len(s.Links))
link.Height *= int(s.ImageScaleFactor)
link.Width *= int(s.ImageScaleFactor)
link.Top = s.ImageTop + int(float64(link.Top)*s.ImageScaleFactor)
link.Left = s.ImageLeft + int(float64(link.Left)*s.ImageScaleFactor)
}
type Link struct {
ID string
Index int
Top int
Left int
Width int
Height int
SlideIndex int
ExternalUrl string
Tooltip string
}
func NewPresentation(title, description, subject, creator, d2Version string, includeNav bool) *Presentation {
return &Presentation{
Title: title,
Description: description,
Subject: subject,
Creator: creator,
D2Version: d2Version,
includeNav: includeNav,
}
}
func (p *Presentation) headerHeight() int {
if p.includeNav {
return HEADER_HEIGHT
}
return 0
}
func (p *Presentation) height() int {
return SLIDE_HEIGHT - p.headerHeight()
}
func (p *Presentation) aspectRatio() float64 {
return float64(IMAGE_WIDTH) / float64(p.height())
}
func (p *Presentation) AddSlide(pngContent []byte, titlePath []BoardTitle) (*Slide, error) {
src, err := png.Decode(bytes.NewReader(pngContent))
if err != nil {
return nil, fmt.Errorf("error decoding PNG image: %v", err)
}
var width, height int
srcSize := src.Bounds().Size()
srcWidth, srcHeight := float64(srcSize.X), float64(srcSize.Y)
// compute the size and position to fit the slide
// if the image is wider than taller and its aspect ratio is, at least, the same as the available image space aspect ratio
// then, set the image width to the available space and compute the height
// ┌──────────────────────────────────────────────────┐ ─┬─
// │ HEADER │ │
// ├──┬────────────────────────────────────────────┬──┤ │ ─┬─
// │ │ │ │ │ │
// │ │ │ │ SLIDE │
// │ │ │ │ HEIGHT │
// │ │ │ │ │ IMAGE
// │ │ │ │ │ HEIGHT
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ │ │ │ │ │
// └──┴────────────────────────────────────────────┴──┘ ─┴─ ─┴─
// ├────────────────────SLIDE WIDTH───────────────────┤
// ├─────────────────IMAGE WIDTH────────────────┤
if srcWidth/srcHeight >= p.aspectRatio() {
// here, the image aspect ratio is, at least, equal to the slide aspect ratio
// so, it makes sense to expand the image horizontally to use as much as space as possible
width = SLIDE_WIDTH
height = int(float64(width) * (srcHeight / srcWidth))
// first, try to make the image as wide as the slide
// but, if this results in a tall image, use only the
// image adjusted width to avoid overlapping with the header
if height > p.height() {
width = IMAGE_WIDTH
height = int(float64(width) * (srcHeight / srcWidth))
}
} else {
// here, the aspect ratio could be 4x3, in which the image is still wider than taller,
// but expanding horizontally would result in an overflow
// so, we expand to make it fit the available vertical space
height = p.height()
width = int(float64(height) * (srcWidth / srcHeight))
}
top := p.headerHeight() + ((p.height() - height) / 2)
left := (SLIDE_WIDTH - width) / 2
slide := &Slide{
BoardTitle: make([]BoardTitle, len(titlePath)),
ImageId: fmt.Sprintf("slide%dImage", len(p.Slides)+1),
Image: pngContent,
ImageWidth: width,
ImageHeight: height,
ImageTop: top,
ImageLeft: left,
ImageScaleFactor: float64(width) / srcWidth,
}
// it must copy the board path to avoid slice reference issues
for i := 0; i < len(titlePath); i++ {
titlePath[i].LinkID = fmt.Sprintf("navLink%d", i)
slide.BoardTitle[i] = titlePath[i]
}
p.Slides = append(p.Slides, slide)
return slide, nil
}
func (p *Presentation) SaveTo(filePath string) error {
f, err := os.Create(filePath)
if err != nil {
return err
}
defer f.Close()
zipWriter := zip.NewWriter(f)
defer zipWriter.Close()
if err = copyPptxTemplateTo(zipWriter); err != nil {
return err
}
var slideFileNames []string
for i, slide := range p.Slides {
imageID := fmt.Sprintf("slide%dImage", i+1)
slideFileName := fmt.Sprintf("slide%d", i+1)
slideFileNames = append(slideFileNames, slideFileName)
imageWriter, err := zipWriter.Create(fmt.Sprintf("ppt/media/%s.png", imageID))
if err != nil {
return err
}
_, err = imageWriter.Write(slide.Image)
if err != nil {
return err
}
err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/_rels/%s.xml.rels", slideFileName), RELS_SLIDE_XML, getSlideXmlRelsContent(imageID, slide))
if err != nil {
return err
}
err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/%s.xml", slideFileName), SLIDE_XML, p.getSlideXmlContent(imageID, slide))
if err != nil {
return err
}
}
err = addFileFromTemplate(zipWriter, "[Content_Types].xml", CONTENT_TYPES_XML, ContentTypesXmlContent{
FileNames: slideFileNames,
})
if err != nil {
return err
}
err = addFileFromTemplate(zipWriter, "ppt/_rels/presentation.xml.rels", RELS_PRESENTATION_XML, getRelsPresentationXmlContent(slideFileNames))
if err != nil {
return err
}
err = addFileFromTemplate(zipWriter, "ppt/presentation.xml", PRESENTATION_XML, getPresentationXmlContent(slideFileNames))
if err != nil {
return err
}
dateTime := time.Now().Format(time.RFC3339)
err = addFileFromTemplate(zipWriter, "docProps/core.xml", CORE_XML, CoreXmlContent{
Creator: p.Creator,
Subject: p.Subject,
Description: p.Description,
LastModifiedBy: p.Creator,
Title: p.Title,
Created: dateTime,
Modified: dateTime,
})
if err != nil {
return err
}
titles := make([]string, 0, len(p.Slides))
for _, slide := range p.Slides {
titles = append(titles, slide.BoardTitle[len(slide.BoardTitle)-1].BoardID)
}
err = addFileFromTemplate(zipWriter, "docProps/app.xml", APP_XML, AppXmlContent{
SlideCount: len(p.Slides),
TitlesOfPartsCount: len(p.Slides) + 3, // + 3 for fonts and theme
D2Version: p.D2Version,
Titles: titles,
})
if err != nil {
return err
}
return nil
}
// Measurements in OOXML are made in English Metric Units (EMUs) where 1 inch = 914,400 EMUs
// The intent is to have a measurement unit that doesn't require floating points when dealing with centimeters, inches, points (DPI).
// Office Open XML (OOXML) http://officeopenxml.com/prPresentation.php
// https://startbigthinksmall.wordpress.com/2010/01/04/points-inches-and-emus-measuring-units-in-office-open-xml/
const SLIDE_WIDTH = 9_144_000
const SLIDE_HEIGHT = 5_143_500
const HEADER_HEIGHT = 392_471
// keep the right aspect ratio: SLIDE_WIDTH / SLIDE_HEIGHT = IMAGE_WIDTH / IMAGE_HEIGHT
const IMAGE_WIDTH = 8_446_273
//go:embed template.pptx
var PPTX_TEMPLATE []byte
func copyPptxTemplateTo(w *zip.Writer) error {
reader := bytes.NewReader(PPTX_TEMPLATE)
zipReader, err := zip.NewReader(reader, reader.Size())
if err != nil {
fmt.Printf("error creating zip reader: %v", err)
}
for _, f := range zipReader.File {
if err := w.Copy(f); err != nil {
return fmt.Errorf("error copying %s: %v", f.Name, err)
}
}
return nil
}
//go:embed templates/slide.xml.rels
var RELS_SLIDE_XML string
type RelsSlideXmlLinkContent struct {
RelationshipID string
ExternalUrl string
SlideIndex int
}
type RelsSlideXmlContent struct {
FileName string
RelationshipID string
Links []RelsSlideXmlLinkContent
}
func getSlideXmlRelsContent(imageID string, slide *Slide) RelsSlideXmlContent {
content := RelsSlideXmlContent{
FileName: imageID,
RelationshipID: imageID,
}
for _, link := range slide.Links {
content.Links = append(content.Links, RelsSlideXmlLinkContent{
RelationshipID: link.ID,
ExternalUrl: link.ExternalUrl,
SlideIndex: link.SlideIndex,
})
}
for _, t := range slide.BoardTitle {
content.Links = append(content.Links, RelsSlideXmlLinkContent{
RelationshipID: t.LinkID,
SlideIndex: t.LinkToSlide,
})
}
return content
}
//go:embed templates/slide.xml
var SLIDE_XML string
type SlideLinkXmlContent struct {
ID int
RelationshipID string
Name string
Action string
Left int
Top int
Width int
Height int
}
type SlideXmlTitlePathContent struct {
Name string
RelationshipID string
}
type SlideXmlContent struct {
Title string
TitlePrefix []SlideXmlTitlePathContent
Description string
HeaderHeight int
ImageID string
ImageLeft int
ImageTop int
ImageWidth int
ImageHeight int
Links []SlideLinkXmlContent
}
func (p *Presentation) getSlideXmlContent(imageID string, slide *Slide) SlideXmlContent {
title := make([]SlideXmlTitlePathContent, len(slide.BoardTitle)-1)
for i := 0; i < len(slide.BoardTitle)-1; i++ {
t := slide.BoardTitle[i]
title[i] = SlideXmlTitlePathContent{
Name: t.Name,
RelationshipID: t.LinkID,
}
}
content := SlideXmlContent{
Description: slide.BoardTitle[len(slide.BoardTitle)-1].BoardID,
HeaderHeight: p.headerHeight(),
ImageID: imageID,
ImageLeft: slide.ImageLeft,
ImageTop: slide.ImageTop,
ImageWidth: slide.ImageWidth,
ImageHeight: slide.ImageHeight,
}
if p.includeNav {
content.Title = slide.BoardTitle[len(slide.BoardTitle)-1].Name
content.TitlePrefix = title
}
for _, link := range slide.Links {
var action string
if link.ExternalUrl == "" {
action = "ppaction://hlinksldjump"
}
content.Links = append(content.Links, SlideLinkXmlContent{
ID: link.Index,
RelationshipID: link.ID,
Name: link.Tooltip,
Action: action,
Left: link.Left,
Top: link.Top,
Width: link.Width,
Height: link.Height,
})
}
return content
}
//go:embed templates/rels_presentation.xml
var RELS_PRESENTATION_XML string
type RelsPresentationSlideXmlContent struct {
RelationshipID string
FileName string
}
type RelsPresentationXmlContent struct {
Slides []RelsPresentationSlideXmlContent
}
func getRelsPresentationXmlContent(slideFileNames []string) RelsPresentationXmlContent {
var content RelsPresentationXmlContent
for _, name := range slideFileNames {
content.Slides = append(content.Slides, RelsPresentationSlideXmlContent{
RelationshipID: name,
FileName: name,
})
}
return content
}
//go:embed templates/content_types.xml
var CONTENT_TYPES_XML string
type ContentTypesXmlContent struct {
FileNames []string
}
//go:embed templates/presentation.xml
var PRESENTATION_XML string
type PresentationSlideXmlContent struct {
ID int
RelationshipID string
}
type PresentationXmlContent struct {
SlideWidth int
SlideHeight int
Slides []PresentationSlideXmlContent
}
func getPresentationXmlContent(slideFileNames []string) PresentationXmlContent {
content := PresentationXmlContent{
SlideWidth: SLIDE_WIDTH,
SlideHeight: SLIDE_HEIGHT,
}
for i, name := range slideFileNames {
content.Slides = append(content.Slides, PresentationSlideXmlContent{
// in the exported presentation, the first slide ID was 256, so keeping it here for compatibility
ID: 256 + i,
RelationshipID: name,
})
}
return content
}
//go:embed templates/core.xml
var CORE_XML string
type CoreXmlContent struct {
Title string
Subject string
Creator string
Description string
LastModifiedBy string
Created string
Modified string
}
//go:embed templates/app.xml
var APP_XML string
type AppXmlContent struct {
SlideCount int
TitlesOfPartsCount int
Titles []string
D2Version string
}
func addFileFromTemplate(zipFile *zip.Writer, filePath, templateContent string, templateData interface{}) error {
w, err := zipFile.Create(filePath)
if err != nil {
return err
}
tmpl, err := template.New(filePath).Parse(templateContent)
if err != nil {
return err
}
return tmpl.Execute(w, templateData)
}