package officekit import ( "bytes" "encoding/xml" "fmt" "strings" ) // Slide is one slide's content, as the model supplies it to // create_presentation/edit_presentation: a title and a flat list of // bullet lines. Deliberately this simple — no layouts, no inherited // placeholders, no raw DrawingML — so the model never authors // PresentationML either; every slide is rendered as two independent, // absolutely-positioned text boxes (title, body) that don't need to // resolve against the slide layout/master's placeholder scheme. type Slide struct { Title string Bullets []string } // --- read side: typed XML for parsing a slide's shape tree back out // (ReadPptxSlides). Every struct tag below is a plain, unprefixed // local name with no namespace component — PresentationML genuinely // needs several simultaneous namespaces (p: for presentation elements, // a: for drawing elements), so unlike docx's single-namespace case we // can't lean on one default namespace to cover everything. Instead we // rely on a different, equally standard encoding/xml behavior: a field // tag with no namespace component matches an element by local name // only, regardless of which namespace it actually resolved to. So // "cSld" matches and "xfrm" matches alike, without // our Go types needing to know or care which prefix/namespace a given // element belongs to. (See the longer version of this note on // wDocument in docx.go — same underlying mechanism, applied here to // the harder, multi-namespace case.) --- type pSld struct { XMLName xml.Name `xml:"sld"` CSld pCSld `xml:"cSld"` } type pCSld struct { SpTree pSpTree `xml:"spTree"` } type pSpTree struct { Sp []pSp `xml:"sp"` } type pCNvPr struct { Id int `xml:"id,attr"` Name string `xml:"name,attr"` } type pSp struct { NvSpPr pNvSpPr `xml:"nvSpPr"` TxBody pTxBody `xml:"txBody"` } type pNvSpPr struct { CNvPr pCNvPr `xml:"cNvPr"` } type pTxBody struct { Paras []pTxPara `xml:"p"` } type pTxPara struct { Run pTxRun `xml:"r"` } type pTxRun struct { Text string `xml:"t"` } // --- write side: deliberately NOT built with xml.Marshal. PresentationML's // several simultaneous namespaces mean a struct-tag-driven Marshal would // need Go to auto-assign namespace prefixes, which is both unpredictable // and avoidable here — we fully control this template, so we write the // real p:/a: prefixes directly and escape every piece of user-supplied // text through encoding/xml's own escaper (xmlEscapeText), which is the // same function Marshal itself uses internally for chardata. That keeps // the "well-formed, correctly-escaped, never touched by the model" // property from the package doc comment without fighting Go's namespace // prefix allocation. --- // xmlEscapeText XML-escapes s for use as element chardata (title and // bullet text — the only user-supplied strings that end up in a slide). func xmlEscapeText(s string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(s)) return buf.String() } const titleShapeTemplate = ` %s ` const bodyParaTemplate = `%s` const bodyShapeTemplate = ` %s ` func titleShape(id int, text string) string { return fmt.Sprintf(titleShapeTemplate, id, xmlEscapeText(text)) } func bodyShape(id int, bullets []string) string { var paras strings.Builder for _, b := range bullets { fmt.Fprintf(¶s, bodyParaTemplate, xmlEscapeText(b)) } return fmt.Sprintf(bodyShapeTemplate, id, paras.String()) } func slideXML(s Slide) ([]byte, error) { var shapes strings.Builder if s.Title != "" { shapes.WriteString(titleShape(2, s.Title)) } if len(s.Bullets) > 0 { shapes.WriteString(bodyShape(3, s.Bullets)) } out := xml.Header + ` ` + shapes.String() + ` ` return []byte(out), nil } func slidePath(n int) string { return fmt.Sprintf("ppt/slides/slide%d.xml", n) } func slideRelsPath(n int) string { return fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", n) } const slideRelsBody = xml.Header + ` ` // BuildPptx renders slides into a complete, valid .pptx file. func BuildPptx(slides []Slide) ([]byte, error) { if len(slides) == 0 { slides = []Slide{{}} } parts := []zipPart{ {Name: "[Content_Types].xml", Body: pptxContentTypes(len(slides))}, {Name: "_rels/.rels", Body: pptxRootRels}, {Name: "ppt/presentation.xml", Body: pptxPresentationXML(len(slides))}, {Name: "ppt/_rels/presentation.xml.rels", Body: pptxPresentationRels(len(slides))}, {Name: "ppt/slideMasters/slideMaster1.xml", Body: pptxSlideMaster}, {Name: "ppt/slideMasters/_rels/slideMaster1.xml.rels", Body: pptxSlideMasterRels}, {Name: "ppt/slideLayouts/slideLayout1.xml", Body: pptxSlideLayout}, {Name: "ppt/slideLayouts/_rels/slideLayout1.xml.rels", Body: pptxSlideLayoutRels}, {Name: "ppt/theme/theme1.xml", Body: pptxTheme}, {Name: "docProps/core.xml", Body: officeCoreProps}, } for i, s := range slides { n := i + 1 sx, err := slideXML(s) if err != nil { return nil, err } parts = append(parts, zipPart{Name: slidePath(n), Body: string(sx)}, zipPart{Name: slideRelsPath(n), Body: slideRelsBody}, ) } return buildZip(parts) } // ReadPptxSlides returns every slide's title + bullets, in order, for // the read_presentation tool. It re-derives slide order and count from // ppt/presentation.xml's sldIdLst / presentation.xml.rels rather than // assuming a fixed count, so a file this package wrote (or edited) // round-trips exactly. func ReadPptxSlides(data []byte) ([]Slide, error) { n, err := countSlides(data) if err != nil { return nil, err } var out []Slide for i := 1; i <= n; i++ { raw, ok, err := readZipPart(data, slidePath(i)) if err != nil { return nil, err } if !ok { continue } var sld pSld if err := xml.Unmarshal(raw, &sld); err != nil { return nil, fmt.Errorf("officekit: parse %s: %w", slidePath(i), err) } s := Slide{} for _, sp := range sld.CSld.SpTree.Sp { switch sp.NvSpPr.CNvPr.Name { case "Title": if len(sp.TxBody.Paras) > 0 { s.Title = sp.TxBody.Paras[0].Run.Text } case "Body": for _, p := range sp.TxBody.Paras { s.Bullets = append(s.Bullets, p.Run.Text) } } } out = append(out, s) } return out, nil } // countSlides counts how many ppt/slides/slideN.xml parts a .pptx // actually contains by probing sequentially (simplest reliable // approach given this package writes slides as a dense 1..n sequence // and never deletes from the middle). func countSlides(data []byte) (int, error) { n := 0 for { _, ok, err := readZipPart(data, slidePath(n+1)) if err != nil { return 0, err } if !ok { break } n++ } return n, nil } // AppendPptxSlide adds one new slide at the end and returns the new // file bytes, keeping [Content_Types].xml, presentation.xml and // presentation.xml.rels in sync with the new slide count. func AppendPptxSlide(data []byte, s Slide) ([]byte, error) { n, err := countSlides(data) if err != nil { return nil, err } newN := n + 1 sx, err := slideXML(s) if err != nil { return nil, err } out, err := addZipParts(data, []zipPart{ {Name: slidePath(newN), Body: string(sx)}, {Name: slideRelsPath(newN), Body: slideRelsBody}, }) if err != nil { return nil, err } out, err = replaceZipPart(out, "[Content_Types].xml", []byte(pptxContentTypes(newN))) if err != nil { return nil, err } out, err = replaceZipPart(out, "ppt/presentation.xml", []byte(pptxPresentationXML(newN))) if err != nil { return nil, err } return replaceZipPart(out, "ppt/_rels/presentation.xml.rels", []byte(pptxPresentationRels(newN))) } // UpdatePptxSlide replaces one existing slide's content (1-based // index) in place. func UpdatePptxSlide(data []byte, index int, s Slide) ([]byte, error) { n, err := countSlides(data) if err != nil { return nil, err } if index < 1 || index > n { return nil, fmt.Errorf("officekit: slide %d out of range (presentation has %d slide(s))", index, n) } sx, err := slideXML(s) if err != nil { return nil, err } return replaceZipPart(data, slidePath(index), []byte(sx)) } func pptxContentTypes(n int) string { var overrides strings.Builder for i := 1; i <= n; i++ { fmt.Fprintf(&overrides, ``, slidePath(i)) } return xml.Header + ` ` + overrides.String() + `` } func pptxPresentationXML(n int) string { var ids strings.Builder for i := 1; i <= n; i++ { fmt.Fprintf(&ids, ``, 255+i, i+1) } return xml.Header + ` ` + ids.String() + ` ` } func pptxPresentationRels(n int) string { var rels strings.Builder for i := 1; i <= n; i++ { fmt.Fprintf(&rels, ``, i+1, i) } return xml.Header + ` ` + rels.String() + `` } const pptxRootRels = xml.Header + ` ` const pptxSlideMasterRels = xml.Header + ` ` const pptxSlideLayoutRels = xml.Header + ` ` const pptxSlideMaster = xml.Header + ` ` const pptxSlideLayout = xml.Header + ` ` const pptxTheme = xml.Header + ` `