diff --git a/lib/pptx/pptx.go b/lib/pptx/pptx.go index 897720e75..0063e0764 100644 --- a/lib/pptx/pptx.go +++ b/lib/pptx/pptx.go @@ -1,3 +1,12 @@ +// 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 .pptx -d `. +// 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 ( @@ -5,10 +14,199 @@ import ( "bytes" _ "embed" "fmt" + "image/png" + "os" "strings" "text/template" + "time" ) +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 + + Slides []*Slide +} + +type Slide struct { + BoardPath []string + Image []byte + ImageWidth int + ImageHeight int + ImageTop int + ImageLeft int +} + +func NewPresentation(title, description, subject, creator, d2Version string) *Presentation { + return &Presentation{ + Title: title, + Description: description, + Subject: subject, + Creator: creator, + D2Version: d2Version, + } +} + +func (p *Presentation) AddSlide(pngContent []byte, boardPath []string) error { + src, err := png.Decode(bytes.NewReader(pngContent)) + if err != nil { + return 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 >= IMAGE_ASPECT_RATIO { + // 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 > IMAGE_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 = IMAGE_HEIGHT + width = int(float64(height) * (srcWidth / srcHeight)) + } + top := (IMAGE_HEIGHT - height) / 2 + left := (SLIDE_WIDTH - width) / 2 + + slide := &Slide{ + BoardPath: make([]string, len(boardPath)), + Image: pngContent, + ImageWidth: width, + ImageHeight: height, + ImageTop: top, + ImageLeft: left, + } + // it must copy the board path to avoid slice reference issues + copy(slide.BoardPath, boardPath) + + p.Slides = append(p.Slides, slide) + return 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, RelsSlideXmlContent{ + FileName: imageID, + RelationshipID: imageID, + }) + if err != nil { + return err + } + + err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/%s.xml", slideFileName), SLIDE_XML, 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, strings.Join(slide.BoardPath, "/")) + } + err = addFileFromTemplate(zipWriter, "docProps/app.xml", APP_XML, AppXmlContent{ + SlideCount: len(p.Slides), + TitlesOfPartsCount: len(p.Slides) + 3, + 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 diff --git a/lib/pptx/presentation.go b/lib/pptx/presentation.go deleted file mode 100644 index 43f3522da..000000000 --- a/lib/pptx/presentation.go +++ /dev/null @@ -1,207 +0,0 @@ -// 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 .pptx -d `. -// 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" - "strings" - "time" -) - -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 - - Slides []*Slide -} - -type Slide struct { - BoardPath []string - Image []byte - ImageWidth int - ImageHeight int - ImageTop int - ImageLeft int -} - -func NewPresentation(title, description, subject, creator, d2Version string) *Presentation { - return &Presentation{ - Title: title, - Description: description, - Subject: subject, - Creator: creator, - D2Version: d2Version, - } -} - -func (p *Presentation) AddSlide(pngContent []byte, boardPath []string) error { - src, err := png.Decode(bytes.NewReader(pngContent)) - if err != nil { - return 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 >= IMAGE_ASPECT_RATIO { - // 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 > IMAGE_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 = IMAGE_HEIGHT - width = int(float64(height) * (srcWidth / srcHeight)) - } - top := (IMAGE_HEIGHT - height) / 2 - left := (SLIDE_WIDTH - width) / 2 - - slide := &Slide{ - BoardPath: make([]string, len(boardPath)), - Image: pngContent, - ImageWidth: width, - ImageHeight: height, - ImageTop: top, - ImageLeft: left, - } - // it must copy the board path to avoid slice reference issues - copy(slide.BoardPath, boardPath) - - p.Slides = append(p.Slides, slide) - return 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, RelsSlideXmlContent{ - FileName: imageID, - RelationshipID: imageID, - }) - if err != nil { - return err - } - - err = addFileFromTemplate(zipWriter, fmt.Sprintf("ppt/slides/%s.xml", slideFileName), SLIDE_XML, 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, strings.Join(slide.BoardPath, "/")) - } - err = addFileFromTemplate(zipWriter, "docProps/app.xml", APP_XML, AppXmlContent{ - SlideCount: len(p.Slides), - TitlesOfPartsCount: len(p.Slides) + 3, - D2Version: p.D2Version, - Titles: titles, - }) - if err != nil { - return err - } - - return nil -}