xlsx_charts.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. package office
  2. /*
  3. xlsx_charts.go - Native Excel chart support for the Sheets webapp.
  4. The webapp stores charts as JSON blobs on each sheet:
  5. { id, x, y, w, h, // px in grid space
  6. range: "A1:B5",
  7. opts: { type: "bar"|"line"|"pie", title,
  8. headerRow: bool, labelCol: bool, stacked: bool } }
  9. Writer: every chart becomes a real DrawingML chart part
  10. (xl/drawings/drawingN.xml + xl/charts/chartN.xml) anchored absolutely,
  11. so Excel / LibreOffice render it and recalculate it from the referenced
  12. cell range. Reader: chart parts (ours or Excel-authored bar/line/pie
  13. charts) are mapped back to the webapp JSON model by reconstructing the
  14. bounding range from the series formulas.
  15. */
  16. import (
  17. "encoding/json"
  18. "fmt"
  19. "strconv"
  20. "strings"
  21. )
  22. // grid defaults must match sheets.js (DEF_COLW / DEF_ROWH)
  23. const (
  24. xlsxDefColPx = 92.0
  25. xlsxDefRowPx = 24.0
  26. )
  27. type xlsxChartOpts struct {
  28. Type string `json:"type,omitempty"`
  29. Title string `json:"title,omitempty"`
  30. HeaderRow *bool `json:"headerRow,omitempty"` // absent = true
  31. LabelCol *bool `json:"labelCol,omitempty"` // absent = true
  32. Stacked bool `json:"stacked,omitempty"`
  33. }
  34. type xlsxChart struct {
  35. ID string `json:"id"`
  36. X float64 `json:"x"`
  37. Y float64 `json:"y"`
  38. W float64 `json:"w"`
  39. H float64 `json:"h"`
  40. Range string `json:"range"`
  41. Opts *xlsxChartOpts `json:"opts,omitempty"`
  42. }
  43. func (c *xlsxChart) headerRow() bool {
  44. return c.Opts == nil || c.Opts.HeaderRow == nil || *c.Opts.HeaderRow
  45. }
  46. func (c *xlsxChart) labelCol() bool {
  47. return c.Opts == nil || c.Opts.LabelCol == nil || *c.Opts.LabelCol
  48. }
  49. func (c *xlsxChart) chartType() string {
  50. if c.Opts != nil && (c.Opts.Type == "line" || c.Opts.Type == "pie") {
  51. return c.Opts.Type
  52. }
  53. return "bar"
  54. }
  55. // parseSheetCharts decodes the passthrough chart blob, dropping entries
  56. // whose range does not parse
  57. func parseSheetCharts(raw json.RawMessage) []*xlsxChart {
  58. if len(raw) == 0 {
  59. return nil
  60. }
  61. var list []*xlsxChart
  62. if err := json.Unmarshal(raw, &list); err != nil {
  63. return nil
  64. }
  65. out := make([]*xlsxChart, 0, len(list))
  66. for _, ch := range list {
  67. if ch == nil {
  68. continue
  69. }
  70. if _, _, _, _, ok := parseRangeRef(ch.Range); !ok {
  71. continue
  72. }
  73. out = append(out, ch)
  74. }
  75. return out
  76. }
  77. // parseRangeRef parses "A1:C5" (or a single "A1") into a normalized
  78. // 0-based bounding box
  79. func parseRangeRef(ref string) (c1, r1, c2, r2 int, ok bool) {
  80. ref = strings.ReplaceAll(strings.TrimSpace(ref), "$", "")
  81. parts := strings.Split(ref, ":")
  82. if len(parts) == 1 {
  83. parts = append(parts, parts[0])
  84. }
  85. if len(parts) != 2 {
  86. return 0, 0, 0, 0, false
  87. }
  88. c1, r1, ok1 := parseCellRef(parts[0])
  89. c2, r2, ok2 := parseCellRef(parts[1])
  90. if !ok1 || !ok2 {
  91. return 0, 0, 0, 0, false
  92. }
  93. if c2 < c1 {
  94. c1, c2 = c2, c1
  95. }
  96. if r2 < r1 {
  97. r1, r2 = r2, r1
  98. }
  99. return c1, r1, c2, r2, true
  100. }
  101. // sheetRefPrefix quotes a sheet name for use in a chart series formula
  102. func sheetRefPrefix(name string) string {
  103. return "'" + strings.ReplaceAll(name, "'", "''") + "'!"
  104. }
  105. /* ==================== writer ==================== */
  106. // cellText returns the literal display text of a cell ("" for formulas -
  107. // Excel refreshes chart caches from the sheet on load anyway)
  108. func chartCellText(ws *WorkSheet, col, row int) string {
  109. cell, ok := ws.Cells[cellRef(col, row)]
  110. if !ok || cell == nil {
  111. return ""
  112. }
  113. v := cell.V
  114. if strings.HasPrefix(v, "=") {
  115. return ""
  116. }
  117. return strings.TrimPrefix(v, "'")
  118. }
  119. // buildChartXML renders one c:chartSpace part for a webapp chart
  120. func buildChartXML(ws *WorkSheet, ch *xlsxChart, sheetName string) string {
  121. c1, r1, c2, r2, _ := parseRangeRef(ch.Range)
  122. dataC1, dataR1 := c1, r1
  123. if ch.labelCol() {
  124. dataC1++
  125. }
  126. if ch.headerRow() {
  127. dataR1++
  128. }
  129. if dataC1 > c2 {
  130. dataC1 = c2
  131. }
  132. if dataR1 > r2 {
  133. dataR1 = r2
  134. }
  135. pre := sheetRefPrefix(sheetName)
  136. nPts := r2 - dataR1 + 1
  137. // category (label) reference shared by every series
  138. catXML := ""
  139. if ch.labelCol() {
  140. var cache strings.Builder
  141. cache.WriteString(fmt.Sprintf(`<c:ptCount val="%d"/>`, nPts))
  142. for r := dataR1; r <= r2; r++ {
  143. cache.WriteString(fmt.Sprintf(`<c:pt idx="%d"><c:v>%s</c:v></c:pt>`,
  144. r-dataR1, xmlEscape(chartCellText(ws, c1, r))))
  145. }
  146. catXML = fmt.Sprintf(
  147. `<c:cat><c:strRef><c:f>%s$%s$%d:$%s$%d</c:f><c:strCache>%s</c:strCache></c:strRef></c:cat>`,
  148. xmlEscape(pre), colName(c1), dataR1+1, colName(c1), r2+1, cache.String())
  149. }
  150. var sers strings.Builder
  151. serCount := 0
  152. for c := dataC1; c <= c2; c++ {
  153. idx := serCount
  154. serCount++
  155. sers.WriteString(fmt.Sprintf(`<c:ser><c:idx val="%d"/><c:order val="%d"/>`, idx, idx))
  156. if ch.headerRow() {
  157. sers.WriteString(fmt.Sprintf(
  158. `<c:tx><c:strRef><c:f>%s$%s$%d</c:f><c:strCache><c:ptCount val="1"/>`+
  159. `<c:pt idx="0"><c:v>%s</c:v></c:pt></c:strCache></c:strRef></c:tx>`,
  160. xmlEscape(pre), colName(c), r1+1, xmlEscape(chartCellText(ws, c, r1))))
  161. }
  162. if ch.chartType() == "line" {
  163. sers.WriteString(`<c:marker><c:symbol val="none"/></c:marker>`)
  164. }
  165. sers.WriteString(catXML)
  166. var vals strings.Builder
  167. vals.WriteString(`<c:formatCode>General</c:formatCode>`)
  168. vals.WriteString(fmt.Sprintf(`<c:ptCount val="%d"/>`, nPts))
  169. for r := dataR1; r <= r2; r++ {
  170. t := chartCellText(ws, c, r)
  171. if !looksNumeric(t) {
  172. continue // Excel fills the cache back in from the sheet
  173. }
  174. vals.WriteString(fmt.Sprintf(`<c:pt idx="%d"><c:v>%s</c:v></c:pt>`,
  175. r-dataR1, xmlEscape(strings.TrimSpace(t))))
  176. }
  177. sers.WriteString(fmt.Sprintf(
  178. `<c:val><c:numRef><c:f>%s$%s$%d:$%s$%d</c:f><c:numCache>%s</c:numCache></c:numRef></c:val>`,
  179. xmlEscape(pre), colName(c), dataR1+1, colName(c), r2+1, vals.String()))
  180. sers.WriteString(`</c:ser>`)
  181. if ch.chartType() == "pie" {
  182. break // a pie plots a single series
  183. }
  184. }
  185. grouping := "clustered"
  186. lineGrouping := "standard"
  187. if ch.Opts != nil && ch.Opts.Stacked {
  188. grouping = "stacked"
  189. lineGrouping = "stacked"
  190. }
  191. const axCat, axVal = "111111111", "222222222"
  192. axesXML := `<c:axId val="` + axCat + `"/><c:axId val="` + axVal + `"/>`
  193. catValAxes := `<c:catAx><c:axId val="` + axCat + `"/><c:scaling><c:orientation val="minMax"/></c:scaling>` +
  194. `<c:delete val="0"/><c:axPos val="b"/><c:crossAx val="` + axVal + `"/></c:catAx>` +
  195. `<c:valAx><c:axId val="` + axVal + `"/><c:scaling><c:orientation val="minMax"/></c:scaling>` +
  196. `<c:delete val="0"/><c:axPos val="l"/><c:crossAx val="` + axCat + `"/></c:valAx>`
  197. var plot string
  198. switch ch.chartType() {
  199. case "line":
  200. plot = `<c:lineChart><c:grouping val="` + lineGrouping + `"/><c:varyColors val="0"/>` +
  201. sers.String() + `<c:marker val="1"/>` + axesXML + `</c:lineChart>` + catValAxes
  202. case "pie":
  203. plot = `<c:pieChart><c:varyColors val="1"/>` + sers.String() +
  204. `<c:firstSliceAng val="0"/></c:pieChart>`
  205. default:
  206. overlap := ""
  207. if grouping == "stacked" {
  208. overlap = `<c:overlap val="100"/>`
  209. }
  210. plot = `<c:barChart><c:barDir val="col"/><c:grouping val="` + grouping + `"/><c:varyColors val="0"/>` +
  211. sers.String() + `<c:gapWidth val="150"/>` + overlap + axesXML + `</c:barChart>` + catValAxes
  212. }
  213. titleXML := `<c:autoTitleDeleted val="1"/>`
  214. if ch.Opts != nil && strings.TrimSpace(ch.Opts.Title) != "" {
  215. titleXML = `<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>` +
  216. xmlEscape(ch.Opts.Title) + `</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title>` +
  217. `<c:autoTitleDeleted val="0"/>`
  218. }
  219. return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n" +
  220. `<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"` +
  221. ` xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"` +
  222. ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
  223. `<c:chart>` + titleXML +
  224. `<c:plotArea><c:layout/>` + plot + `</c:plotArea>` +
  225. `<c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend>` +
  226. `<c:plotVisOnly val="1"/><c:dispBlanksAs val="gap"/>` +
  227. `</c:chart></c:chartSpace>`
  228. }
  229. // buildDrawingXML renders the xl/drawings part for one sheet; chartRelIDs
  230. // pairs each chart with its relationship id in the drawing's rels
  231. func buildDrawingXML(charts []*xlsxChart, chartRelIDs []string) string {
  232. var sb strings.Builder
  233. sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n")
  234. sb.WriteString(`<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"` +
  235. ` xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"` +
  236. ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
  237. for i, ch := range charts {
  238. w := ch.W
  239. if w < 40 {
  240. w = 480
  241. }
  242. h := ch.H
  243. if h < 30 {
  244. h = 300
  245. }
  246. sb.WriteString(`<xdr:absoluteAnchor>`)
  247. sb.WriteString(fmt.Sprintf(`<xdr:pos x="%d" y="%d"/><xdr:ext cx="%d" cy="%d"/>`,
  248. pxToEmu(ch.X), pxToEmu(ch.Y), pxToEmu(w), pxToEmu(h)))
  249. sb.WriteString(`<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr>` +
  250. fmt.Sprintf(`<xdr:cNvPr id="%d" name="Chart %d"/>`, i+2, i+1) +
  251. `<xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>` +
  252. `<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>` +
  253. `<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">` +
  254. `<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"` +
  255. ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"` +
  256. ` r:id="` + chartRelIDs[i] + `"/></a:graphicData></a:graphic></xdr:graphicFrame>`)
  257. sb.WriteString(`<xdr:clientData/></xdr:absoluteAnchor>`)
  258. }
  259. sb.WriteString(`</xdr:wsDr>`)
  260. return sb.String()
  261. }
  262. /* ==================== reader ==================== */
  263. // chartAnchorPx resolves a drawing anchor to grid-space pixels, using the
  264. // sheet's column/row sizes (webapp defaults for the rest)
  265. func chartAnchorPx(anchor *xnode, ws *WorkSheet) (x, y, w, h float64) {
  266. colX := func(col int, off int64) float64 {
  267. px := 0.0
  268. for i := 0; i < col; i++ {
  269. if cw, ok := ws.ColW[strconv.Itoa(i)]; ok && cw > 0 {
  270. px += cw
  271. } else {
  272. px += xlsxDefColPx
  273. }
  274. }
  275. return px + float64(off)/emuPerPx
  276. }
  277. rowY := func(row int, off int64) float64 {
  278. px := 0.0
  279. for i := 0; i < row; i++ {
  280. if rh, ok := ws.RowH[strconv.Itoa(i)]; ok && rh > 0 {
  281. px += rh
  282. } else {
  283. px += xlsxDefRowPx
  284. }
  285. }
  286. return px + float64(off)/emuPerPx
  287. }
  288. markerPx := func(m *xnode) (float64, float64) {
  289. col, _ := strconv.Atoi(strings.TrimSpace(m.first("col").Text))
  290. colOff, _ := strconv.ParseInt(strings.TrimSpace(m.first("colOff").Text), 10, 64)
  291. row, _ := strconv.Atoi(strings.TrimSpace(m.first("row").Text))
  292. rowOff, _ := strconv.ParseInt(strings.TrimSpace(m.first("rowOff").Text), 10, 64)
  293. return colX(col, colOff), rowY(row, rowOff)
  294. }
  295. x, y, w, h = 40, 40, 480, 300
  296. switch anchor.XMLName.Local {
  297. case "absoluteAnchor":
  298. if pos := anchor.first("pos"); pos != nil {
  299. px, _ := strconv.ParseInt(pos.attr("x"), 10, 64)
  300. py, _ := strconv.ParseInt(pos.attr("y"), 10, 64)
  301. x, y = float64(px)/emuPerPx, float64(py)/emuPerPx
  302. }
  303. if ext := anchor.first("ext"); ext != nil {
  304. cx, _ := strconv.ParseInt(ext.attr("cx"), 10, 64)
  305. cy, _ := strconv.ParseInt(ext.attr("cy"), 10, 64)
  306. w, h = float64(cx)/emuPerPx, float64(cy)/emuPerPx
  307. }
  308. case "oneCellAnchor":
  309. if from := anchor.first("from"); from != nil && from.first("col") != nil {
  310. x, y = markerPx(from)
  311. }
  312. if ext := anchor.first("ext"); ext != nil {
  313. cx, _ := strconv.ParseInt(ext.attr("cx"), 10, 64)
  314. cy, _ := strconv.ParseInt(ext.attr("cy"), 10, 64)
  315. w, h = float64(cx)/emuPerPx, float64(cy)/emuPerPx
  316. }
  317. case "twoCellAnchor":
  318. from, to := anchor.first("from"), anchor.first("to")
  319. if from != nil && from.first("col") != nil {
  320. x, y = markerPx(from)
  321. }
  322. if to != nil && to.first("col") != nil {
  323. x2, y2 := markerPx(to)
  324. if x2 > x {
  325. w = x2 - x
  326. }
  327. if y2 > y {
  328. h = y2 - y
  329. }
  330. }
  331. }
  332. if w < 60 {
  333. w = 480
  334. }
  335. if h < 40 {
  336. h = 300
  337. }
  338. return x, y, w, h
  339. }
  340. // parseChartFormulaRange strips the sheet prefix and $ from a series
  341. // formula reference like "'Sheet 1'!$B$2:$B$10"
  342. func parseChartFormulaRange(f string) (c1, r1, c2, r2 int, ok bool) {
  343. f = strings.TrimSpace(f)
  344. if i := strings.LastIndex(f, "!"); i >= 0 {
  345. f = f[i+1:]
  346. }
  347. return parseRangeRef(f)
  348. }
  349. // parseChartPart maps a c:chartSpace tree back to the webapp chart model
  350. // (bounding range reconstructed from the series formulas); returns nil for
  351. // chart types the webapp cannot represent
  352. func parseChartPart(tree *xnode, idSeq int) *xlsxChart {
  353. chart := tree.first("chart")
  354. if chart == nil {
  355. return nil
  356. }
  357. plotArea := chart.path("plotArea")
  358. if plotArea == nil {
  359. return nil
  360. }
  361. var plot *xnode
  362. chType := ""
  363. for _, cand := range []struct{ node, t string }{
  364. {"barChart", "bar"}, {"bar3DChart", "bar"},
  365. {"lineChart", "line"}, {"line3DChart", "line"},
  366. {"pieChart", "pie"}, {"pie3DChart", "pie"}, {"doughnutChart", "pie"},
  367. {"areaChart", "line"},
  368. } {
  369. if n := plotArea.first(cand.node); n != nil {
  370. plot, chType = n, cand.t
  371. break
  372. }
  373. }
  374. if plot == nil {
  375. return nil
  376. }
  377. opts := &xlsxChartOpts{Type: chType}
  378. if g := plot.first("grouping"); g != nil &&
  379. (g.attr("val") == "stacked" || g.attr("val") == "percentStacked") {
  380. opts.Stacked = true
  381. }
  382. if t := chart.first("title"); t != nil {
  383. var texts []string
  384. collectText(t, &texts)
  385. opts.Title = strings.TrimSpace(strings.Join(texts, ""))
  386. }
  387. // union the series references back into one bounding range
  388. haveRange := false
  389. uc1, ur1, uc2, ur2 := 0, 0, 0, 0
  390. extend := func(c1, r1, c2, r2 int) {
  391. if !haveRange {
  392. uc1, ur1, uc2, ur2 = c1, r1, c2, r2
  393. haveRange = true
  394. return
  395. }
  396. if c1 < uc1 {
  397. uc1 = c1
  398. }
  399. if r1 < ur1 {
  400. ur1 = r1
  401. }
  402. if c2 > uc2 {
  403. uc2 = c2
  404. }
  405. if r2 > ur2 {
  406. ur2 = r2
  407. }
  408. }
  409. refOf := func(n *xnode) (int, int, int, int, bool) {
  410. if n == nil {
  411. return 0, 0, 0, 0, false
  412. }
  413. for _, holder := range []string{"strRef", "numRef", "multiLvlStrRef"} {
  414. if ref := n.first(holder); ref != nil {
  415. if fn := ref.first("f"); fn != nil {
  416. return parseChartFormulaRange(fn.Text)
  417. }
  418. }
  419. }
  420. return 0, 0, 0, 0, false
  421. }
  422. headerRow, labelCol := false, false
  423. for _, ser := range plot.all("ser") {
  424. if c1, r1, c2, r2, ok := refOf(ser.first("tx")); ok {
  425. headerRow = true
  426. extend(c1, r1, c2, r2)
  427. }
  428. if c1, r1, c2, r2, ok := refOf(ser.first("cat")); ok {
  429. labelCol = true
  430. extend(c1, r1, c2, r2)
  431. }
  432. if c1, r1, c2, r2, ok := refOf(ser.first("val")); ok {
  433. extend(c1, r1, c2, r2)
  434. }
  435. }
  436. if !haveRange {
  437. return nil
  438. }
  439. hr, lc := headerRow, labelCol
  440. opts.HeaderRow = &hr
  441. opts.LabelCol = &lc
  442. return &xlsxChart{
  443. ID: fmt.Sprintf("ch-xlsx-%d", idSeq),
  444. Range: cellRef(uc1, ur1) + ":" + cellRef(uc2, ur2),
  445. Opts: opts,
  446. }
  447. }
  448. // parseSheetDrawing extracts every chart anchored on a worksheet drawing
  449. // part and stores them on the WorkSheet in webapp JSON form
  450. func parseSheetDrawing(files map[string][]byte, sheetPart string, sheetTree *xnode, ws *WorkSheet, idSeq *int) {
  451. dn := sheetTree.first("drawing")
  452. if dn == nil {
  453. return
  454. }
  455. rid := ""
  456. for _, a := range dn.Attrs {
  457. if a.Name.Local == "id" {
  458. rid = a.Value
  459. }
  460. }
  461. sheetDir := pathDir(sheetPart)
  462. sheetRels := parseRels(files[sheetDir+"/_rels/"+pathBase(sheetPart)+".rels"])
  463. target, ok := sheetRels[rid]
  464. if !ok {
  465. return
  466. }
  467. drawingPart := resolvePartPath(sheetDir, target)
  468. tree, err := parseXMLTree(files[drawingPart])
  469. if err != nil {
  470. return
  471. }
  472. drawDir := pathDir(drawingPart)
  473. drawRels := parseRels(files[drawDir+"/_rels/"+pathBase(drawingPart)+".rels"])
  474. var charts []*xlsxChart
  475. for i := range tree.Nodes {
  476. anchor := &tree.Nodes[i]
  477. switch anchor.XMLName.Local {
  478. case "absoluteAnchor", "oneCellAnchor", "twoCellAnchor":
  479. default:
  480. continue
  481. }
  482. chartRef := anchor.path("graphicFrame", "graphic", "graphicData", "chart")
  483. if chartRef == nil {
  484. continue
  485. }
  486. crid := ""
  487. for _, a := range chartRef.Attrs {
  488. if a.Name.Local == "id" {
  489. crid = a.Value
  490. }
  491. }
  492. chartTarget, ok := drawRels[crid]
  493. if !ok {
  494. continue
  495. }
  496. chartTree, err := parseXMLTree(files[resolvePartPath(drawDir, chartTarget)])
  497. if err != nil {
  498. continue
  499. }
  500. *idSeq++
  501. ch := parseChartPart(chartTree, *idSeq)
  502. if ch == nil {
  503. continue
  504. }
  505. ch.X, ch.Y, ch.W, ch.H = chartAnchorPx(anchor, ws)
  506. charts = append(charts, ch)
  507. }
  508. if len(charts) > 0 {
  509. if b, err := json.Marshal(charts); err == nil {
  510. ws.Charts = b
  511. }
  512. }
  513. }
  514. func pathDir(p string) string {
  515. if i := strings.LastIndex(p, "/"); i >= 0 {
  516. return p[:i]
  517. }
  518. return "."
  519. }
  520. func pathBase(p string) string {
  521. if i := strings.LastIndex(p, "/"); i >= 0 {
  522. return p[i+1:]
  523. }
  524. return p
  525. }