office.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. /*
  2. Package office implements converters between the ArozOS Office suite
  3. document models and common office file formats.
  4. Currently implemented: PowerPoint (.pptx) import/export for the Slides
  5. webapp (src/web/Office/slides). The JSON model mirrors the "body" schema
  6. documented in src/web/Office/common/CONTRACT.md and slides.js:
  7. { "size": [960, 540], "theme": "clean",
  8. "slides": [ { "id", "bg", "notes", "objects": [
  9. { "id", "type": "text|image|shape|line|table|chart",
  10. "x","y","w","h","rot","z", "props": {...} } ] } ] }
  11. This package is dependency-free (Go standard library only) and shared by
  12. the "office" AGI library (src/mod/agi/agi.office.go). Word (.docx) and
  13. Excel (.xlsx) converters for the Docs and Sheets webapps will live here
  14. too, reusing the same OOXML plumbing.
  15. Author: tobychui
  16. */
  17. package office
  18. import (
  19. "encoding/base64"
  20. "encoding/json"
  21. "errors"
  22. "regexp"
  23. "strconv"
  24. "strings"
  25. )
  26. // EMU (English Metric Units) per pixel at 96 DPI
  27. const emuPerPx = 9525
  28. // Slide coordinate space used by the Slides webapp
  29. const (
  30. slidePxW = 960
  31. slidePxH = 540
  32. )
  33. // Presentation is the Slides document body
  34. type Presentation struct {
  35. Size []int `json:"size,omitempty"`
  36. Theme string `json:"theme,omitempty"`
  37. Slides []*Slide `json:"slides"`
  38. }
  39. // Slide is a single slide
  40. type Slide struct {
  41. ID string `json:"id,omitempty"`
  42. Bg string `json:"bg,omitempty"` // "" = theme default background
  43. Notes string `json:"notes,omitempty"`
  44. Objects []*Object `json:"objects"`
  45. }
  46. // Object is one visual element on a slide
  47. type Object struct {
  48. ID string `json:"id,omitempty"`
  49. Type string `json:"type"`
  50. X float64 `json:"x"`
  51. Y float64 `json:"y"`
  52. W float64 `json:"w"`
  53. H float64 `json:"h"`
  54. Rot float64 `json:"rot,omitempty"`
  55. Z int `json:"z,omitempty"`
  56. Props Props `json:"props"`
  57. }
  58. // Props carries the per-type properties. Fields for every object type are
  59. // merged into one struct so the JSON stays schema-compatible with slides.js.
  60. type Props struct {
  61. // text
  62. HTML string `json:"html,omitempty"`
  63. FontSize float64 `json:"fontSize,omitempty"`
  64. Color string `json:"color,omitempty"`
  65. Align string `json:"align,omitempty"`
  66. Bold bool `json:"bold,omitempty"`
  67. Italic bool `json:"italic,omitempty"`
  68. Underline bool `json:"underline,omitempty"`
  69. // image
  70. Src string `json:"src,omitempty"`
  71. Fit string `json:"fit,omitempty"`
  72. // shape
  73. Kind string `json:"kind,omitempty"`
  74. Fill string `json:"fill,omitempty"`
  75. Stroke string `json:"stroke,omitempty"`
  76. StrokeW float64 `json:"strokeW,omitempty"`
  77. Text string `json:"text,omitempty"`
  78. TextColor string `json:"textColor,omitempty"`
  79. // line
  80. Dash bool `json:"dash,omitempty"`
  81. ArrowEnd bool `json:"arrowEnd,omitempty"`
  82. // table
  83. Rows [][]string `json:"rows,omitempty"`
  84. HeaderRow bool `json:"headerRow,omitempty"`
  85. ColW []float64 `json:"colW,omitempty"` // column widths, percent
  86. RowH []float64 `json:"rowH,omitempty"` // row heights, percent
  87. // chart (spec kept opaque; Png is a client-side raster for export)
  88. Spec json.RawMessage `json:"spec,omitempty"`
  89. Png string `json:"png,omitempty"`
  90. }
  91. // theme background approximations, mirroring THEMES in slides.js
  92. // (gradients are approximated by their first color stop for pptx export)
  93. var themeBg = map[string]string{
  94. "clean": "FFFFFF",
  95. "midnight": "232A36",
  96. "ocean": "0F4C75",
  97. "sunset": "C0392B",
  98. "forest": "0F3D33",
  99. "paper": "F6F1E5",
  100. }
  101. var themeText = map[string]string{
  102. "clean": "202124",
  103. "midnight": "E8EAED",
  104. "ocean": "F4FAFF",
  105. "sunset": "FDF2EC",
  106. "forest": "EAFAF1",
  107. "paper": "3D3A33",
  108. }
  109. // PresentationToJSON serializes a Presentation back to the Slides body JSON
  110. func PresentationToJSON(p *Presentation) (string, error) {
  111. if p == nil {
  112. return "", errors.New("nil presentation")
  113. }
  114. out, err := json.Marshal(p)
  115. if err != nil {
  116. return "", err
  117. }
  118. return string(out), nil
  119. }
  120. // ParsePresentationJSON decodes the Slides body JSON into a Presentation
  121. func ParsePresentationJSON(jsonStr string) (*Presentation, error) {
  122. p := Presentation{}
  123. if err := json.Unmarshal([]byte(jsonStr), &p); err != nil {
  124. return nil, errors.New("invalid presentation JSON: " + err.Error())
  125. }
  126. if len(p.Slides) == 0 {
  127. return nil, errors.New("presentation contains no slides")
  128. }
  129. return &p, nil
  130. }
  131. /* ---------- shared small helpers ---------- */
  132. func pxToEmu(px float64) int64 {
  133. return int64(px * emuPerPx)
  134. }
  135. func emuToPx(emu int64, scale float64) float64 {
  136. return float64(emu) / emuPerPx * scale
  137. }
  138. // hexColor normalizes "#rrggbb" to uppercase "RRGGBB"; returns fallback when invalid
  139. func hexColor(c string, fallback string) string {
  140. c = strings.TrimPrefix(strings.TrimSpace(c), "#")
  141. if len(c) == 3 {
  142. c = string([]byte{c[0], c[0], c[1], c[1], c[2], c[2]})
  143. }
  144. ok, _ := regexp.MatchString("^[0-9a-fA-F]{6}$", c)
  145. if !ok {
  146. return fallback
  147. }
  148. return strings.ToUpper(c)
  149. }
  150. // xmlEscape escapes a string for use inside XML text nodes and attributes
  151. func xmlEscape(s string) string {
  152. r := strings.NewReplacer(
  153. "&", "&",
  154. "<", "&lt;",
  155. ">", "&gt;",
  156. "\"", "&quot;",
  157. "'", "&apos;",
  158. )
  159. return r.Replace(s)
  160. }
  161. var brRe = regexp.MustCompile(`(?i)<br\s*/?>`)
  162. var blockCloseRe = regexp.MustCompile(`(?i)</(div|p|li|h[1-6])>`)
  163. var blockOpenRe = regexp.MustCompile(`(?i)<(div|p|li|h[1-6])(\s[^>]*)?>`)
  164. var tagRe = regexp.MustCompile(`<[^>]*>`)
  165. var listTagRe = regexp.MustCompile(`(?i)<(/?)(ul|ol|li)(\s[^>]*)?>`)
  166. // flattenListMarkers rewrites <ul>/<ol>/<li> into text lines with visible
  167. // bullet / number prefixes so lists survive flattening (e.g. pptx export)
  168. func flattenListMarkers(s string) string {
  169. type listCtx struct {
  170. ordered bool
  171. counter int
  172. }
  173. var stack []listCtx
  174. var out strings.Builder
  175. last := 0
  176. for _, m := range listTagRe.FindAllStringSubmatchIndex(s, -1) {
  177. out.WriteString(s[last:m[0]])
  178. last = m[1]
  179. closing := m[2] != m[3] // group 1 non-empty = "/"
  180. tag := strings.ToLower(s[m[4]:m[5]])
  181. switch tag {
  182. case "ul", "ol":
  183. if closing {
  184. if len(stack) > 0 {
  185. stack = stack[:len(stack)-1]
  186. }
  187. } else {
  188. stack = append(stack, listCtx{ordered: tag == "ol"})
  189. out.WriteString("\n")
  190. }
  191. case "li":
  192. if closing {
  193. out.WriteString("\n")
  194. } else {
  195. indent := strings.Repeat(" ", maxInt(0, len(stack)-1))
  196. if len(stack) > 0 && stack[len(stack)-1].ordered {
  197. stack[len(stack)-1].counter++
  198. out.WriteString(indent + strconv.Itoa(stack[len(stack)-1].counter) + ". ")
  199. } else {
  200. out.WriteString(indent + "• ")
  201. }
  202. }
  203. }
  204. }
  205. out.WriteString(s[last:])
  206. // collapse the double newlines produced by adjacent list boundaries
  207. return strings.TrimPrefix(multiNlRe.ReplaceAllString(out.String(), "\n"), "\n")
  208. }
  209. var multiNlRe = regexp.MustCompile(`\n{2,}`)
  210. func maxInt(a, b int) int {
  211. if a > b {
  212. return a
  213. }
  214. return b
  215. }
  216. // htmlToLines flattens the limited contenteditable HTML stored in text
  217. // objects into plain-text lines (one per paragraph)
  218. func htmlToLines(h string) []string {
  219. if h == "" {
  220. return []string{""}
  221. }
  222. s := flattenListMarkers(h)
  223. s = brRe.ReplaceAllString(s, "\n")
  224. s = blockCloseRe.ReplaceAllString(s, "\n")
  225. s = blockOpenRe.ReplaceAllString(s, "")
  226. s = tagRe.ReplaceAllString(s, "")
  227. s = strings.ReplaceAll(s, "&nbsp;", " ")
  228. s = strings.ReplaceAll(s, "&lt;", "<")
  229. s = strings.ReplaceAll(s, "&gt;", ">")
  230. s = strings.ReplaceAll(s, "&quot;", "\"")
  231. s = strings.ReplaceAll(s, "&#39;", "'")
  232. s = strings.ReplaceAll(s, "&apos;", "'")
  233. s = strings.ReplaceAll(s, "&amp;", "&")
  234. lines := strings.Split(s, "\n")
  235. // drop a single trailing empty line produced by a closing block tag
  236. if len(lines) > 1 && strings.TrimSpace(lines[len(lines)-1]) == "" {
  237. lines = lines[:len(lines)-1]
  238. }
  239. return lines
  240. }
  241. // linesToHTML converts plain-text lines back to the storage HTML format
  242. func linesToHTML(lines []string) string {
  243. esc := make([]string, len(lines))
  244. for i, l := range lines {
  245. esc[i] = xmlEscape(l)
  246. }
  247. return strings.Join(esc, "<br>")
  248. }
  249. // dataURL decodes a data:image/...;base64,... URL. Returns bytes, file
  250. // extension (png/jpeg/gif) and ok.
  251. func decodeDataURL(durl string) ([]byte, string, bool) {
  252. if !strings.HasPrefix(durl, "data:image/") {
  253. return nil, "", false
  254. }
  255. comma := strings.Index(durl, ",")
  256. if comma < 0 {
  257. return nil, "", false
  258. }
  259. header := durl[:comma]
  260. if !strings.Contains(header, ";base64") {
  261. return nil, "", false
  262. }
  263. ext := "png"
  264. if strings.HasPrefix(header, "data:image/jpeg") || strings.HasPrefix(header, "data:image/jpg") {
  265. ext = "jpeg"
  266. } else if strings.HasPrefix(header, "data:image/gif") {
  267. ext = "gif"
  268. } else if !strings.HasPrefix(header, "data:image/png") {
  269. return nil, "", false
  270. }
  271. raw, err := base64.StdEncoding.DecodeString(durl[comma+1:])
  272. if err != nil {
  273. return nil, "", false
  274. }
  275. return raw, ext, true
  276. }
  277. // encodeDataURL builds a data URL from raw image bytes
  278. func encodeDataURL(data []byte, ext string) string {
  279. mime := "image/png"
  280. switch strings.ToLower(ext) {
  281. case "jpg", "jpeg":
  282. mime = "image/jpeg"
  283. case "gif":
  284. mime = "image/gif"
  285. case "bmp":
  286. mime = "image/bmp"
  287. case "svg":
  288. mime = "image/svg+xml"
  289. }
  290. return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
  291. }