Files
enkly-ai/pkg/officekit/pptx.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

377 lines
16 KiB
Go

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 <p:cSld> and "xfrm" matches <a:xfrm> 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 = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Title"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="457200" y="274638"/><a:ext cx="8229600" cy="1143000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="3200" b="1"/><a:t>%s</a:t></a:r></a:p></p:txBody>
</p:sp>`
const bodyParaTemplate = `<a:p><a:pPr><a:buChar char="&#8226;"/></a:pPr><a:r><a:rPr lang="en-US" sz="2000"/><a:t>%s</a:t></a:r></a:p>`
const bodyShapeTemplate = `<p:sp><p:nvSpPr><p:cNvPr id="%d" name="Body"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="457200" y="1600200"/><a:ext cx="8229600" cy="4525963"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/>%s</p:txBody>
</p:sp>`
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(&paras, 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 + `<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>` + shapes.String() + `</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>`
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 + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`
// 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, `<Override PartName="/%s" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`, slidePath(i))
}
return xml.Header + `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
` + overrides.String() + `</Types>`
}
func pptxPresentationXML(n int) string {
var ids strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&ids, `<p:sldId id="%d" r:id="rId%d"/>`, 255+i, i+1)
}
return xml.Header + `<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst>` + ids.String() + `</p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>`
}
func pptxPresentationRels(n int) string {
var rels strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&rels, `<Relationship Id="rId%d" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide%d.xml"/>`, i+1, i)
}
return xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
` + rels.String() + `</Relationships>`
}
const pptxRootRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
</Relationships>`
const pptxSlideMasterRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>`
const pptxSlideLayoutRels = xml.Header + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`
const pptxSlideMaster = xml.Header + `<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`
const pptxSlideLayout = xml.Header + `<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank" preserve="1">
<p:cSld name="Blank">
<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sldLayout>`
const pptxTheme = xml.Header + `<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="enkly">
<a:themeElements>
<a:clrScheme name="enkly">
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme>
<a:fontScheme name="enkly">
<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
</a:fontScheme>
<a:fmtScheme name="enkly">
<a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
<a:lnStyleLst><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst>
</a:fmtScheme>
</a:themeElements>
</a:theme>`