...

Source file src/oss.terrastruct.com/d2/lib/pptx/pptx.go

Documentation: oss.terrastruct.com/d2/lib/pptx

     1  // pptx is a package to create slide presentations in pptx (Microsoft Power Point) format.
     2  // A `.pptx` file is just a bunch of zip compressed `.xml` files following the Office Open XML (OOXML) format.
     3  // To see its content, you can just `unzip <path/to/file>.pptx -d <folder>`.
     4  // With this package, it is possible to create a `Presentation` and add `Slide`s to it.
     5  // Then, when saving the presentation, it will generate the required `.xml` files, compress them and write to the disk.
     6  // Note that this isn't a full implementation of the OOXML format, but a wrapper around it.
     7  // There's a base template with common files to the presentation and then when saving, the package generate only the slides and relationships.
     8  // The base template and slide templates were generated using https://python-pptx.readthedocs.io/en/latest/
     9  // For more information about OOXML, check http://officeopenxml.com/index.php
    10  package pptx
    11  
    12  import (
    13  	"archive/zip"
    14  	"bytes"
    15  	_ "embed"
    16  	"fmt"
    17  	"image/png"
    18  	"os"
    19  	"text/template"
    20  	"time"
    21  )
    22  
    23  type BoardTitle struct {
    24  	LinkID      string
    25  	Name        string
    26  	BoardID     string
    27  	LinkToSlide int
    28  }
    29  
    30  type Presentation struct {
    31  	Title       string
    32  	Description string
    33  	Subject     string
    34  	Creator     string
    35  	// D2Version can't have letters, only numbers (`[0-9]`) and `.`
    36  	// Otherwise, it may fail to open in PowerPoint
    37  	D2Version  string
    38  	includeNav bool
    39  
    40  	Slides []*Slide
    41  }
    42  
    43  type Slide struct {
    44  	BoardTitle       []BoardTitle
    45  	Links            []*Link
    46  	Image            []byte
    47  	ImageId          string
    48  	ImageWidth       int
    49  	ImageHeight      int
    50  	ImageTop         int
    51  	ImageLeft        int
    52  	ImageScaleFactor float64
    53  }
    54  
    55  func (s *Slide) AddLink(link *Link) {
    56  	link.Index = len(s.Links)
    57  	s.Links = append(s.Links, link)
    58  	link.ID = fmt.Sprintf("link%d", len(s.Links))
    59  	link.Height *= int(s.ImageScaleFactor)
    60  	link.Width *= int(s.ImageScaleFactor)
    61  	link.Top = s.ImageTop + int(float64(link.Top)*s.ImageScaleFactor)
    62  	link.Left = s.ImageLeft + int(float64(link.Left)*s.ImageScaleFactor)
    63  }
    64  
    65  type Link struct {
    66  	ID          string
    67  	Index       int
    68  	Top         int
    69  	Left        int
    70  	Width       int
    71  	Height      int
    72  	SlideIndex  int
    73  	ExternalUrl string
    74  	Tooltip     string
    75  }
    76  
    77  func NewPresentation(title, description, subject, creator, d2Version string, includeNav bool) *Presentation {
    78  	return &Presentation{
    79  		Title:       title,
    80  		Description: description,
    81  		Subject:     subject,
    82  		Creator:     creator,
    83  		D2Version:   d2Version,
    84  		includeNav:  includeNav,
    85  	}
    86  }
    87  
    88  func (p *Presentation) headerHeight() int {
    89  	if p.includeNav {
    90  		return HEADER_HEIGHT
    91  	}
    92  	return 0
    93  }
    94  
    95  func (p *Presentation) height() int {
    96  	return SLIDE_HEIGHT - p.headerHeight()
    97  }
    98  
    99  func (p *Presentation) aspectRatio() float64 {
   100  	return float64(IMAGE_WIDTH) / float64(p.height())
   101  }
   102  
   103  func (p *Presentation) AddSlide(pngContent []byte, titlePath []BoardTitle) (*Slide, error) {
   104  	src, err := png.Decode(bytes.NewReader(pngContent))
   105  	if err != nil {
   106  		return nil, fmt.Errorf("error decoding PNG image: %v", err)
   107  	}
   108  
   109  	var width, height int
   110  	srcSize := src.Bounds().Size()
   111  	srcWidth, srcHeight := float64(srcSize.X), float64(srcSize.Y)
   112  
   113  	// compute the size and position to fit the slide
   114  	// if the image is wider than taller and its aspect ratio is, at least, the same as the available image space aspect ratio
   115  	// then, set the image width to the available space and compute the height
   116  	// ┌──────────────────────────────────────────────────┐   ─┬─
   117  	// │  HEADER                                          │    │
   118  	// ├──┬────────────────────────────────────────────┬──┤    │         ─┬─
   119  	// │  │                                            │  │    │          │
   120  	// │  │                                            │  │  SLIDE        │
   121  	// │  │                                            │  │  HEIGHT       │
   122  	// │  │                                            │  │    │        IMAGE
   123  	// │  │                                            │  │    │        HEIGHT
   124  	// │  │                                            │  │    │          │
   125  	// │  │                                            │  │    │          │
   126  	// │  │                                            │  │    │          │
   127  	// │  │                                            │  │    │          │
   128  	// └──┴────────────────────────────────────────────┴──┘   ─┴─        ─┴─
   129  	// ├────────────────────SLIDE WIDTH───────────────────┤
   130  	//    ├─────────────────IMAGE WIDTH────────────────┤
   131  	if srcWidth/srcHeight >= p.aspectRatio() {
   132  		// here, the image aspect ratio is, at least, equal to the slide aspect ratio
   133  		// so, it makes sense to expand the image horizontally to use as much as space as possible
   134  		width = SLIDE_WIDTH
   135  		height = int(float64(width) * (srcHeight / srcWidth))
   136  		// first, try to make the image as wide as the slide
   137  		// but, if this results in a tall image, use only the
   138  		// image adjusted width to avoid overlapping with the header
   139  		if height > p.height() {
   140  			width = IMAGE_WIDTH
   141  			height = int(float64(width) * (srcHeight / srcWidth))
   142  		}
   143  	} else {
   144  		// here, the aspect ratio could be 4x3, in which the image is still wider than taller,
   145  		// but expanding horizontally would result in an overflow
   146  		// so, we expand to make it fit the available vertical space
   147  		height = p.height()
   148  		width = int(float64(height) * (srcWidth / srcHeight))
   149  	}
   150  	top := p.headerHeight() + ((p.height() - height) / 2)
   151  	left := (SLIDE_WIDTH - width) / 2
   152  
   153  	slide := &Slide{
   154  		BoardTitle:       make([]BoardTitle, len(titlePath)),
   155  		ImageId:          fmt.Sprintf("slide%dImage", len(p.Slides)+1),
   156  		Image:            pngContent,
   157  		ImageWidth:       width,
   158  		ImageHeight:      height,
   159  		ImageTop:         top,
   160  		ImageLeft:        left,
   161  		ImageScaleFactor: float64(width) / srcWidth,
   162  	}
   163  	// it must copy the board path to avoid slice reference issues
   164  	for i := 0; i < len(titlePath); i++ {
   165  		titlePath[i].LinkID = fmt.Sprintf("navLink%d", i)
   166  		slide.BoardTitle[i] = titlePath[i]
   167  	}
   168  
   169  	p.Slides = append(p.Slides, slide)
   170  	return slide, nil
   171  }
   172  
   173  func (p *Presentation) SaveTo(filePath string) error {
   174  	f, err := os.Create(filePath)
   175  	if err != nil {
   176  		return err
   177  	}
   178  	defer f.Close()
   179  	zipWriter := zip.NewWriter(f)
   180  	defer zipWriter.Close()
   181  
   182  	if err = copyPptxTemplateTo(zipWriter); err != nil {
   183  		return err
   184  	}
   185  
   186  	var slideFileNames []string
   187  	for i, slide := range p.Slides {
   188  		imageID := fmt.Sprintf("slide%dImage", i+1)
   189  		slideFileName := fmt.Sprintf("slide%d", i+1)
   190  		slideFileNames = append(slideFileNames, slideFileName)
   191  
   192  		imageWriter, err := zipWriter.Create(fmt.Sprintf("ppt/media/%s.png", imageID))
   193  		if err != nil {
   194  			return err
   195  		}
   196  		_, err = imageWriter.Write(slide.Image)
   197  		if err != nil {
   198  			return err
   199  		}
   200  
   201  		err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/_rels/%s.xml.rels", slideFileName), RELS_SLIDE_XML, getSlideXmlRelsContent(imageID, slide))
   202  		if err != nil {
   203  			return err
   204  		}
   205  
   206  		err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/%s.xml", slideFileName), SLIDE_XML, p.getSlideXmlContent(imageID, slide))
   207  		if err != nil {
   208  			return err
   209  		}
   210  	}
   211  
   212  	err = addFileFromTemplate(zipWriter, "[Content_Types].xml", CONTENT_TYPES_XML, ContentTypesXmlContent{
   213  		FileNames: slideFileNames,
   214  	})
   215  	if err != nil {
   216  		return err
   217  	}
   218  
   219  	err = addFileFromTemplate(zipWriter, "ppt/_rels/presentation.xml.rels", RELS_PRESENTATION_XML, getRelsPresentationXmlContent(slideFileNames))
   220  	if err != nil {
   221  		return err
   222  	}
   223  
   224  	err = addFileFromTemplate(zipWriter, "ppt/presentation.xml", PRESENTATION_XML, getPresentationXmlContent(slideFileNames))
   225  	if err != nil {
   226  		return err
   227  	}
   228  
   229  	dateTime := time.Now().Format(time.RFC3339)
   230  	err = addFileFromTemplate(zipWriter, "docProps/core.xml", CORE_XML, CoreXmlContent{
   231  		Creator:        p.Creator,
   232  		Subject:        p.Subject,
   233  		Description:    p.Description,
   234  		LastModifiedBy: p.Creator,
   235  		Title:          p.Title,
   236  		Created:        dateTime,
   237  		Modified:       dateTime,
   238  	})
   239  	if err != nil {
   240  		return err
   241  	}
   242  
   243  	titles := make([]string, 0, len(p.Slides))
   244  	for _, slide := range p.Slides {
   245  		titles = append(titles, slide.BoardTitle[len(slide.BoardTitle)-1].BoardID)
   246  	}
   247  	err = addFileFromTemplate(zipWriter, "docProps/app.xml", APP_XML, AppXmlContent{
   248  		SlideCount:         len(p.Slides),
   249  		TitlesOfPartsCount: len(p.Slides) + 3, // + 3 for fonts and theme
   250  		D2Version:          p.D2Version,
   251  		Titles:             titles,
   252  	})
   253  	if err != nil {
   254  		return err
   255  	}
   256  
   257  	return nil
   258  }
   259  
   260  // Measurements in OOXML are made in English Metric Units (EMUs) where 1 inch = 914,400 EMUs
   261  // The intent is to have a measurement unit that doesn't require floating points when dealing with centimeters, inches, points (DPI).
   262  // Office Open XML (OOXML) http://officeopenxml.com/prPresentation.php
   263  // https://startbigthinksmall.wordpress.com/2010/01/04/points-inches-and-emus-measuring-units-in-office-open-xml/
   264  const SLIDE_WIDTH = 9_144_000
   265  const SLIDE_HEIGHT = 5_143_500
   266  const HEADER_HEIGHT = 392_471
   267  
   268  // keep the right aspect ratio: SLIDE_WIDTH / SLIDE_HEIGHT = IMAGE_WIDTH / IMAGE_HEIGHT
   269  const IMAGE_WIDTH = 8_446_273
   270  
   271  //go:embed template.pptx
   272  var PPTX_TEMPLATE []byte
   273  
   274  func copyPptxTemplateTo(w *zip.Writer) error {
   275  	reader := bytes.NewReader(PPTX_TEMPLATE)
   276  	zipReader, err := zip.NewReader(reader, reader.Size())
   277  	if err != nil {
   278  		fmt.Printf("error creating zip reader: %v", err)
   279  	}
   280  
   281  	for _, f := range zipReader.File {
   282  		if err := w.Copy(f); err != nil {
   283  			return fmt.Errorf("error copying %s: %v", f.Name, err)
   284  		}
   285  	}
   286  	return nil
   287  }
   288  
   289  //go:embed templates/slide.xml.rels
   290  var RELS_SLIDE_XML string
   291  
   292  type RelsSlideXmlLinkContent struct {
   293  	RelationshipID string
   294  	ExternalUrl    string
   295  	SlideIndex     int
   296  }
   297  
   298  type RelsSlideXmlContent struct {
   299  	FileName       string
   300  	RelationshipID string
   301  	Links          []RelsSlideXmlLinkContent
   302  }
   303  
   304  func getSlideXmlRelsContent(imageID string, slide *Slide) RelsSlideXmlContent {
   305  	content := RelsSlideXmlContent{
   306  		FileName:       imageID,
   307  		RelationshipID: imageID,
   308  	}
   309  
   310  	for _, link := range slide.Links {
   311  		content.Links = append(content.Links, RelsSlideXmlLinkContent{
   312  			RelationshipID: link.ID,
   313  			ExternalUrl:    link.ExternalUrl,
   314  			SlideIndex:     link.SlideIndex,
   315  		})
   316  	}
   317  
   318  	for _, t := range slide.BoardTitle {
   319  		content.Links = append(content.Links, RelsSlideXmlLinkContent{
   320  			RelationshipID: t.LinkID,
   321  			SlideIndex:     t.LinkToSlide,
   322  		})
   323  	}
   324  
   325  	return content
   326  }
   327  
   328  //go:embed templates/slide.xml
   329  var SLIDE_XML string
   330  
   331  type SlideLinkXmlContent struct {
   332  	ID             int
   333  	RelationshipID string
   334  	Name           string
   335  	Action         string
   336  	Left           int
   337  	Top            int
   338  	Width          int
   339  	Height         int
   340  }
   341  
   342  type SlideXmlTitlePathContent struct {
   343  	Name           string
   344  	RelationshipID string
   345  }
   346  
   347  type SlideXmlContent struct {
   348  	Title        string
   349  	TitlePrefix  []SlideXmlTitlePathContent
   350  	Description  string
   351  	HeaderHeight int
   352  	ImageID      string
   353  	ImageLeft    int
   354  	ImageTop     int
   355  	ImageWidth   int
   356  	ImageHeight  int
   357  
   358  	Links []SlideLinkXmlContent
   359  }
   360  
   361  func (p *Presentation) getSlideXmlContent(imageID string, slide *Slide) SlideXmlContent {
   362  	title := make([]SlideXmlTitlePathContent, len(slide.BoardTitle)-1)
   363  	for i := 0; i < len(slide.BoardTitle)-1; i++ {
   364  		t := slide.BoardTitle[i]
   365  		title[i] = SlideXmlTitlePathContent{
   366  			Name:           t.Name,
   367  			RelationshipID: t.LinkID,
   368  		}
   369  	}
   370  	content := SlideXmlContent{
   371  		Description:  slide.BoardTitle[len(slide.BoardTitle)-1].BoardID,
   372  		HeaderHeight: p.headerHeight(),
   373  		ImageID:      imageID,
   374  		ImageLeft:    slide.ImageLeft,
   375  		ImageTop:     slide.ImageTop,
   376  		ImageWidth:   slide.ImageWidth,
   377  		ImageHeight:  slide.ImageHeight,
   378  	}
   379  	if p.includeNav {
   380  		content.Title = slide.BoardTitle[len(slide.BoardTitle)-1].Name
   381  		content.TitlePrefix = title
   382  	}
   383  
   384  	for _, link := range slide.Links {
   385  		var action string
   386  		if link.ExternalUrl == "" {
   387  			action = "ppaction://hlinksldjump"
   388  		}
   389  		content.Links = append(content.Links, SlideLinkXmlContent{
   390  			ID:             link.Index,
   391  			RelationshipID: link.ID,
   392  			Name:           link.Tooltip,
   393  			Action:         action,
   394  			Left:           link.Left,
   395  			Top:            link.Top,
   396  			Width:          link.Width,
   397  			Height:         link.Height,
   398  		})
   399  	}
   400  
   401  	return content
   402  }
   403  
   404  //go:embed templates/rels_presentation.xml
   405  var RELS_PRESENTATION_XML string
   406  
   407  type RelsPresentationSlideXmlContent struct {
   408  	RelationshipID string
   409  	FileName       string
   410  }
   411  
   412  type RelsPresentationXmlContent struct {
   413  	Slides []RelsPresentationSlideXmlContent
   414  }
   415  
   416  func getRelsPresentationXmlContent(slideFileNames []string) RelsPresentationXmlContent {
   417  	var content RelsPresentationXmlContent
   418  	for _, name := range slideFileNames {
   419  		content.Slides = append(content.Slides, RelsPresentationSlideXmlContent{
   420  			RelationshipID: name,
   421  			FileName:       name,
   422  		})
   423  	}
   424  
   425  	return content
   426  }
   427  
   428  //go:embed templates/content_types.xml
   429  var CONTENT_TYPES_XML string
   430  
   431  type ContentTypesXmlContent struct {
   432  	FileNames []string
   433  }
   434  
   435  //go:embed templates/presentation.xml
   436  var PRESENTATION_XML string
   437  
   438  type PresentationSlideXmlContent struct {
   439  	ID             int
   440  	RelationshipID string
   441  }
   442  
   443  type PresentationXmlContent struct {
   444  	SlideWidth  int
   445  	SlideHeight int
   446  	Slides      []PresentationSlideXmlContent
   447  }
   448  
   449  func getPresentationXmlContent(slideFileNames []string) PresentationXmlContent {
   450  	content := PresentationXmlContent{
   451  		SlideWidth:  SLIDE_WIDTH,
   452  		SlideHeight: SLIDE_HEIGHT,
   453  	}
   454  	for i, name := range slideFileNames {
   455  		content.Slides = append(content.Slides, PresentationSlideXmlContent{
   456  			// in the exported presentation, the first slide ID was 256, so keeping it here for compatibility
   457  			ID:             256 + i,
   458  			RelationshipID: name,
   459  		})
   460  	}
   461  	return content
   462  }
   463  
   464  //go:embed templates/core.xml
   465  var CORE_XML string
   466  
   467  type CoreXmlContent struct {
   468  	Title          string
   469  	Subject        string
   470  	Creator        string
   471  	Description    string
   472  	LastModifiedBy string
   473  	Created        string
   474  	Modified       string
   475  }
   476  
   477  //go:embed templates/app.xml
   478  var APP_XML string
   479  
   480  type AppXmlContent struct {
   481  	SlideCount         int
   482  	TitlesOfPartsCount int
   483  	Titles             []string
   484  	D2Version          string
   485  }
   486  
   487  func addFileFromTemplate(zipFile *zip.Writer, filePath, templateContent string, templateData interface{}) error {
   488  	w, err := zipFile.Create(filePath)
   489  	if err != nil {
   490  		return err
   491  	}
   492  
   493  	tmpl, err := template.New(filePath).Parse(templateContent)
   494  	if err != nil {
   495  		return err
   496  	}
   497  	return tmpl.Execute(w, templateData)
   498  }
   499  

View as plain text