diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index ea4bee246..67187a3fd 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -1,5 +1,7 @@ #### Features πŸš€ +- Export diagrams to `.pptx` (PowerPoint)[#1139](https://github.com/terrastruct/d2/pull/1139) + #### Improvements 🧹 #### Bugfixes ⛑️ diff --git a/d2cli/main.go b/d2cli/main.go index 395a162f1..06f4afb6f 100644 --- a/d2cli/main.go +++ b/d2cli/main.go @@ -7,6 +7,7 @@ import ( "io" "os" "os/exec" + "os/user" "path/filepath" "strconv" "strings" @@ -34,6 +35,7 @@ import ( "oss.terrastruct.com/d2/lib/pdf" pdflib "oss.terrastruct.com/d2/lib/pdf" "oss.terrastruct.com/d2/lib/png" + "oss.terrastruct.com/d2/lib/pptx" "oss.terrastruct.com/d2/lib/textmeasure" "oss.terrastruct.com/d2/lib/version" @@ -234,7 +236,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) { ms.Log.Debug.Printf("using layout plugin %s (%s)", *layoutFlag, plocation) var pw png.Playwright - if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" { + if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" || filepath.Ext(outputPath) == ".pptx" { if darkThemeFlag != nil { ms.Log.Warn.Printf("--dark-theme cannot be used while exporting to another format other than .svg") darkThemeFlag = nil @@ -347,7 +349,8 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende return nil, false, err } - if filepath.Ext(outputPath) == ".pdf" { + switch filepath.Ext(outputPath) { + case ".pdf": pageMap := pdf.BuildPDFPageMap(diagram, nil, nil) pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap) if err != nil { @@ -356,7 +359,27 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende dur := time.Since(start) ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur) return pdf, true, nil - } else { + case ".pptx": + var username string + if user, err := user.Current(); err == nil { + username = user.Username + } + description := "Presentation generated with D2 - https://d2lang.com/" + rootName := getFileName(outputPath) + // version must be only numbers to avoid issues with PowerPoint + p := pptx.NewPresentation(rootName, description, rootName, username, version.OnlyNumbers()) + svg, err := renderPPTX(ctx, ms, p, plugin, renderOpts, outputPath, page, diagram, nil) + if err != nil { + return nil, false, err + } + err = p.SaveTo(outputPath) + if err != nil { + return nil, false, err + } + dur := time.Since(start) + ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur) + return svg, true, nil + default: compileDur := time.Since(start) if animateInterval <= 0 { // Rename all the "root.layers.x" to the paths that the boards get output to @@ -651,11 +674,7 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt var currBoardPath []string // Root board doesn't have a name, so we use the output filename if diagram.Name == "" { - ext := filepath.Ext(outputPath) - trimmedPath := strings.TrimSuffix(outputPath, ext) - splitPath := strings.Split(trimmedPath, "/") - rootName := splitPath[len(splitPath)-1] - currBoardPath = append(boardPath, rootName) + currBoardPath = append(boardPath, getFileName(outputPath)) } else { currBoardPath = append(boardPath, diagram.Name) } @@ -738,6 +757,77 @@ func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opt return svg, nil } +func renderPPTX(ctx context.Context, ms *xmain.State, presentation *pptx.Presentation, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, page playwright.Page, diagram *d2target.Diagram, boardPath []string) ([]byte, error) { + var currBoardPath []string + // Root board doesn't have a name, so we use the output filename + if diagram.Name == "" { + currBoardPath = append(boardPath, getFileName(outputPath)) + } else { + currBoardPath = append(boardPath, diagram.Name) + } + + var svg []byte + if !diagram.IsFolderOnly { + // gofpdf will print the png img with a slight filter + // make the bg fill within the png transparent so that the pdf bg fill is the only bg color present + diagram.Root.Fill = "transparent" + + var err error + svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{ + Pad: opts.Pad, + Sketch: opts.Sketch, + Center: opts.Center, + SetDimensions: true, + }) + if err != nil { + return nil, err + } + + svg, err = plugin.PostProcess(ctx, svg) + if err != nil { + return nil, err + } + + svg, bundleErr := imgbundler.BundleLocal(ctx, ms, svg) + svg, bundleErr2 := imgbundler.BundleRemote(ctx, ms, svg) + bundleErr = multierr.Combine(bundleErr, bundleErr2) + if bundleErr != nil { + return nil, bundleErr + } + + pngImg, err := png.ConvertSVG(ms, page, svg) + if err != nil { + return nil, err + } + + err = presentation.AddSlide(pngImg, currBoardPath) + if err != nil { + return nil, err + } + } + + for _, dl := range diagram.Layers { + _, err := renderPPTX(ctx, ms, presentation, plugin, opts, "", page, dl, currBoardPath) + if err != nil { + return nil, err + } + } + for _, dl := range diagram.Scenarios { + _, err := renderPPTX(ctx, ms, presentation, plugin, opts, "", page, dl, currBoardPath) + if err != nil { + return nil, err + } + } + for _, dl := range diagram.Steps { + _, err := renderPPTX(ctx, ms, presentation, plugin, opts, "", page, dl, currBoardPath) + if err != nil { + return nil, err + } + } + + return svg, nil +} + // newExt must include leading . func renameExt(fp string, newExt string) string { ext := filepath.Ext(fp) @@ -748,6 +838,11 @@ func renameExt(fp string, newExt string) string { } } +func getFileName(path string) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(filepath.Base(path), ext) +} + // TODO: remove after removing slog func DiscardSlog(ctx context.Context) context.Context { return ctxlog.With(ctx, slog.Make(sloghuman.Sink(io.Discard))) diff --git a/e2etests-cli/main_test.go b/e2etests-cli/main_test.go index dcc85c9e4..3bf8d24a3 100644 --- a/e2etests-cli/main_test.go +++ b/e2etests-cli/main_test.go @@ -9,6 +9,7 @@ import ( "time" "oss.terrastruct.com/d2/d2cli" + "oss.terrastruct.com/d2/lib/pptx" "oss.terrastruct.com/util-go/assert" "oss.terrastruct.com/util-go/diff" "oss.terrastruct.com/util-go/xmain" @@ -244,6 +245,33 @@ layers: { testdataIgnoreDiff(t, ".pdf", pdf) }, }, + { + name: "how_to_solve_problems_pptx", + skipCI: true, + run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { + writeFile(t, dir, "in.d2", `how to solve a hard problem? +steps: { + 1: { + w: write down the problem + } + 2: { + w -> t + t: think really hard about it + } + 3: { + t -> w2 + w2: write down the solution + } +} +`) + err := runTestMain(t, ctx, dir, env, "in.d2", "how_to_solve_problems.pptx") + assert.Success(t, err) + + file := readFile(t, dir, "how_to_solve_problems.pptx") + err = pptx.Validate(file, 4) + assert.Success(t, err) + }, + }, { name: "stdin", run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) { diff --git a/lib/pptx/pptx.go b/lib/pptx/pptx.go new file mode 100644 index 000000000..0063e0764 --- /dev/null +++ b/lib/pptx/pptx.go @@ -0,0 +1,380 @@ +// 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" + "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 +// 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 + +const IMAGE_HEIGHT = SLIDE_HEIGHT - HEADER_HEIGHT + +// keep the right aspect ratio: SLIDE_WIDTH / SLIDE_HEIGHT = IMAGE_WIDTH / IMAGE_HEIGHT +const IMAGE_WIDTH = 8_446_273 +const IMAGE_ASPECT_RATIO = float64(IMAGE_WIDTH) / float64(IMAGE_HEIGHT) + +//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 RelsSlideXmlContent struct { + FileName string + RelationshipID string +} + +//go:embed templates/slide.xml +var SLIDE_XML string + +type SlideXmlContent struct { + Title string + TitlePrefix string + Description string + HeaderHeight int + ImageID string + ImageLeft int + ImageTop int + ImageWidth int + ImageHeight int +} + +func getSlideXmlContent(imageID string, slide *Slide) SlideXmlContent { + boardPath := slide.BoardPath + boardName := boardPath[len(boardPath)-1] + prefixPath := boardPath[:len(boardPath)-1] + var prefix string + if len(prefixPath) > 0 { + prefix = strings.Join(prefixPath, " / ") + " / " + } + return SlideXmlContent{ + Title: boardName, + TitlePrefix: prefix, + Description: strings.Join(boardPath, " / "), + HeaderHeight: HEADER_HEIGHT, + ImageID: imageID, + ImageLeft: slide.ImageLeft, + ImageTop: slide.ImageTop + HEADER_HEIGHT, + ImageWidth: slide.ImageWidth, + ImageHeight: slide.ImageHeight, + } +} + +//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) +} diff --git a/lib/pptx/template.pptx b/lib/pptx/template.pptx new file mode 100644 index 000000000..f87fcd9e2 Binary files /dev/null and b/lib/pptx/template.pptx differ diff --git a/lib/pptx/templates/app.xml b/lib/pptx/templates/app.xml new file mode 100644 index 000000000..ada0cf3db --- /dev/null +++ b/lib/pptx/templates/app.xml @@ -0,0 +1,51 @@ + + + 1 + 0 + D2 + On-screen Show (16:9) + 0 + {{.SlideCount}} + 0 + 0 + 0 + false + + + + Fonts + + + 2 + + + Theme + + + 1 + + + Slide Titles + + + {{.SlideCount}} + + + + + + Arial + Calibri + Office Theme + {{range .Titles}} + {{.}} + {{end}} + + + false + false + + false + {{.D2Version}} + \ No newline at end of file diff --git a/lib/pptx/templates/content_types.xml b/lib/pptx/templates/content_types.xml new file mode 100644 index 000000000..6d6106c76 --- /dev/null +++ b/lib/pptx/templates/content_types.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + {{range .FileNames}} + + {{end}} + + + + \ No newline at end of file diff --git a/lib/pptx/templates/core.xml b/lib/pptx/templates/core.xml new file mode 100644 index 000000000..2f6d784b0 --- /dev/null +++ b/lib/pptx/templates/core.xml @@ -0,0 +1,17 @@ + + + {{.Title}} + {{.Subject}} + {{.Creator}} + + {{.Description}} + {{.LastModifiedBy}} + 1 + {{.Created}} + {{.Modified}} + + \ No newline at end of file diff --git a/lib/pptx/templates/presentation.xml b/lib/pptx/templates/presentation.xml new file mode 100644 index 000000000..4f3b0e744 --- /dev/null +++ b/lib/pptx/templates/presentation.xml @@ -0,0 +1,136 @@ + + + + + + + {{range .Slides}} + + {{end}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/pptx/templates/rels_presentation.xml b/lib/pptx/templates/rels_presentation.xml new file mode 100644 index 000000000..f7010c2c5 --- /dev/null +++ b/lib/pptx/templates/rels_presentation.xml @@ -0,0 +1,21 @@ + + + + + + + + {{range .Slides}} + + {{end}} + \ No newline at end of file diff --git a/lib/pptx/templates/slide.xml b/lib/pptx/templates/slide.xml new file mode 100644 index 000000000..2721068a1 --- /dev/null +++ b/lib/pptx/templates/slide.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{if .TitlePrefix}} + + {{.TitlePrefix}} + + {{end}} + + + {{.Title}} + + + + + + + + + + \ No newline at end of file diff --git a/lib/pptx/templates/slide.xml.rels b/lib/pptx/templates/slide.xml.rels new file mode 100644 index 000000000..92475c305 --- /dev/null +++ b/lib/pptx/templates/slide.xml.rels @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/lib/pptx/validate.go b/lib/pptx/validate.go new file mode 100644 index 000000000..2d1dcf272 --- /dev/null +++ b/lib/pptx/validate.go @@ -0,0 +1,82 @@ +package pptx + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" +) + +func Validate(pptxContent []byte, nSlides int) error { + reader := bytes.NewReader(pptxContent) + zipReader, err := zip.NewReader(reader, reader.Size()) + if err != nil { + fmt.Printf("error reading pptx content: %v", err) + } + + expectedCount := getExpectedPptxFileCount(nSlides) + if len(zipReader.File) != expectedCount { + return fmt.Errorf("expected %d files, got %d", expectedCount, len(zipReader.File)) + } + + for i := 0; i < nSlides; i++ { + if err := checkFile(zipReader, fmt.Sprintf("ppt/slides/slide%d.xml", i+1)); err != nil { + return err + } + if err := checkFile(zipReader, fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", i+1)); err != nil { + return err + } + if err := checkFile(zipReader, fmt.Sprintf("ppt/media/slide%dImage.png", i+1)); err != nil { + return err + } + } + + for _, file := range zipReader.File { + if !strings.Contains(file.Name, ".xml") { + continue + } + // checks if the XML content is valid + f, err := file.Open() + if err != nil { + return fmt.Errorf("error opening %s: %v", file.Name, err) + } + decoder := xml.NewDecoder(f) + for { + if err := decoder.Decode(new(interface{})); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("error parsing xml content in %s: %v", file.Name, err) + } + } + defer f.Close() + } + + return nil +} + +func checkFile(reader *zip.Reader, fname string) error { + f, err := reader.Open(fname) + if err != nil { + return fmt.Errorf("error opening file %s: %v", fname, err) + } + defer f.Close() + if _, err = f.Stat(); err != nil { + return fmt.Errorf("error getting file info %s: %v", fname, err) + } + return nil +} + +func getExpectedPptxFileCount(nSlides int) int { + reader := bytes.NewReader(PPTX_TEMPLATE) + zipReader, err := zip.NewReader(reader, reader.Size()) + if err != nil { + return -1 + } + baseFiles := len(zipReader.File) + presentationFiles := 5 // presentation, rels, app, core, content types + slideFiles := 3 * nSlides // slides, rels, images + return baseFiles + presentationFiles + slideFiles +} diff --git a/lib/version/version.go b/lib/version/version.go index a305938c4..10cd4946d 100644 --- a/lib/version/version.go +++ b/lib/version/version.go @@ -1,4 +1,14 @@ package version +import "regexp" + // Pre-built binaries will have version set correctly during build time. var Version = "v0.4.0-HEAD" + +func OnlyNumbers() string { + re, err := regexp.Compile("[0-9]+.[0-9]+.[0-9]+") + if err != nil { + return "" + } + return re.FindString(Version) +}