/*
Package office implements converters between the ArozOS Office suite
document models and common office file formats.
Currently implemented: PowerPoint (.pptx) import/export for the Slides
webapp (src/web/Office/slides). The JSON model mirrors the "body" schema
documented in src/web/Office/common/CONTRACT.md and slides.js:
{ "size": [960, 540], "theme": "clean",
"slides": [ { "id", "bg", "notes", "objects": [
{ "id", "type": "text|image|shape|line|table|chart",
"x","y","w","h","rot","z", "props": {...} } ] } ] }
This package is dependency-free (Go standard library only) and shared by
the "office" AGI library (src/mod/agi/agi.office.go). Word (.docx) and
Excel (.xlsx) converters for the Docs and Sheets webapps will live here
too, reusing the same OOXML plumbing.
Author: tobychui
*/
package office
import (
"encoding/base64"
"encoding/json"
"errors"
"regexp"
"strconv"
"strings"
)
// EMU (English Metric Units) per pixel at 96 DPI
const emuPerPx = 9525
// Slide coordinate space used by the Slides webapp
const (
slidePxW = 960
slidePxH = 540
)
// Presentation is the Slides document body
type Presentation struct {
Size []int `json:"size,omitempty"`
Theme string `json:"theme,omitempty"`
Slides []*Slide `json:"slides"`
}
// Slide is a single slide
type Slide struct {
ID string `json:"id,omitempty"`
Bg string `json:"bg,omitempty"` // "" = theme default background
Notes string `json:"notes,omitempty"`
Objects []*Object `json:"objects"`
}
// Object is one visual element on a slide
type Object struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
Rot float64 `json:"rot,omitempty"`
Z int `json:"z,omitempty"`
Props Props `json:"props"`
}
// Props carries the per-type properties. Fields for every object type are
// merged into one struct so the JSON stays schema-compatible with slides.js.
type Props struct {
// text
HTML string `json:"html,omitempty"`
FontSize float64 `json:"fontSize,omitempty"`
Color string `json:"color,omitempty"`
Align string `json:"align,omitempty"`
Bold bool `json:"bold,omitempty"`
Italic bool `json:"italic,omitempty"`
Underline bool `json:"underline,omitempty"`
// image
Src string `json:"src,omitempty"`
Fit string `json:"fit,omitempty"`
// shape
Kind string `json:"kind,omitempty"`
Fill string `json:"fill,omitempty"`
Stroke string `json:"stroke,omitempty"`
StrokeW float64 `json:"strokeW,omitempty"`
Text string `json:"text,omitempty"`
TextColor string `json:"textColor,omitempty"`
// line
Dash bool `json:"dash,omitempty"`
ArrowEnd bool `json:"arrowEnd,omitempty"`
// table
Rows [][]string `json:"rows,omitempty"`
HeaderRow bool `json:"headerRow,omitempty"`
ColW []float64 `json:"colW,omitempty"` // column widths, percent
RowH []float64 `json:"rowH,omitempty"` // row heights, percent
// chart (spec kept opaque; Png is a client-side raster for export)
Spec json.RawMessage `json:"spec,omitempty"`
Png string `json:"png,omitempty"`
}
// theme background approximations, mirroring THEMES in slides.js
// (gradients are approximated by their first color stop for pptx export)
var themeBg = map[string]string{
"clean": "FFFFFF",
"midnight": "232A36",
"ocean": "0F4C75",
"sunset": "C0392B",
"forest": "0F3D33",
"paper": "F6F1E5",
}
var themeText = map[string]string{
"clean": "202124",
"midnight": "E8EAED",
"ocean": "F4FAFF",
"sunset": "FDF2EC",
"forest": "EAFAF1",
"paper": "3D3A33",
}
// PresentationToJSON serializes a Presentation back to the Slides body JSON
func PresentationToJSON(p *Presentation) (string, error) {
if p == nil {
return "", errors.New("nil presentation")
}
out, err := json.Marshal(p)
if err != nil {
return "", err
}
return string(out), nil
}
// ParsePresentationJSON decodes the Slides body JSON into a Presentation
func ParsePresentationJSON(jsonStr string) (*Presentation, error) {
p := Presentation{}
if err := json.Unmarshal([]byte(jsonStr), &p); err != nil {
return nil, errors.New("invalid presentation JSON: " + err.Error())
}
if len(p.Slides) == 0 {
return nil, errors.New("presentation contains no slides")
}
return &p, nil
}
/* ---------- shared small helpers ---------- */
func pxToEmu(px float64) int64 {
return int64(px * emuPerPx)
}
func emuToPx(emu int64, scale float64) float64 {
return float64(emu) / emuPerPx * scale
}
// hexColor normalizes "#rrggbb" to uppercase "RRGGBB"; returns fallback when invalid
func hexColor(c string, fallback string) string {
c = strings.TrimPrefix(strings.TrimSpace(c), "#")
if len(c) == 3 {
c = string([]byte{c[0], c[0], c[1], c[1], c[2], c[2]})
}
ok, _ := regexp.MatchString("^[0-9a-fA-F]{6}$", c)
if !ok {
return fallback
}
return strings.ToUpper(c)
}
// xmlEscape escapes a string for use inside XML text nodes and attributes
func xmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&",
"<", "<",
">", ">",
"\"", """,
"'", "'",
)
return r.Replace(s)
}
var brRe = regexp.MustCompile(`(?i)
`)
var blockCloseRe = regexp.MustCompile(`(?i)(div|p|li|h[1-6])>`)
var blockOpenRe = regexp.MustCompile(`(?i)<(div|p|li|h[1-6])(\s[^>]*)?>`)
var tagRe = regexp.MustCompile(`<[^>]*>`)
var listTagRe = regexp.MustCompile(`(?i)<(/?)(ul|ol|li)(\s[^>]*)?>`)
// flattenListMarkers rewrites