package office
/*
xlsx_charts.go - Native Excel chart support for the Sheets webapp.
The webapp stores charts as JSON blobs on each sheet:
{ id, x, y, w, h, // px in grid space
range: "A1:B5",
opts: { type: "bar"|"line"|"pie", title,
headerRow: bool, labelCol: bool, stacked: bool } }
Writer: every chart becomes a real DrawingML chart part
(xl/drawings/drawingN.xml + xl/charts/chartN.xml) anchored absolutely,
so Excel / LibreOffice render it and recalculate it from the referenced
cell range. Reader: chart parts (ours or Excel-authored bar/line/pie
charts) are mapped back to the webapp JSON model by reconstructing the
bounding range from the series formulas.
*/
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// grid defaults must match sheets.js (DEF_COLW / DEF_ROWH)
const (
xlsxDefColPx = 92.0
xlsxDefRowPx = 24.0
)
type xlsxChartOpts struct {
Type string `json:"type,omitempty"`
Title string `json:"title,omitempty"`
HeaderRow *bool `json:"headerRow,omitempty"` // absent = true
LabelCol *bool `json:"labelCol,omitempty"` // absent = true
Stacked bool `json:"stacked,omitempty"`
}
type xlsxChart struct {
ID string `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
W float64 `json:"w"`
H float64 `json:"h"`
Range string `json:"range"`
Opts *xlsxChartOpts `json:"opts,omitempty"`
}
func (c *xlsxChart) headerRow() bool {
return c.Opts == nil || c.Opts.HeaderRow == nil || *c.Opts.HeaderRow
}
func (c *xlsxChart) labelCol() bool {
return c.Opts == nil || c.Opts.LabelCol == nil || *c.Opts.LabelCol
}
func (c *xlsxChart) chartType() string {
if c.Opts != nil && (c.Opts.Type == "line" || c.Opts.Type == "pie") {
return c.Opts.Type
}
return "bar"
}
// parseSheetCharts decodes the passthrough chart blob, dropping entries
// whose range does not parse
func parseSheetCharts(raw json.RawMessage) []*xlsxChart {
if len(raw) == 0 {
return nil
}
var list []*xlsxChart
if err := json.Unmarshal(raw, &list); err != nil {
return nil
}
out := make([]*xlsxChart, 0, len(list))
for _, ch := range list {
if ch == nil {
continue
}
if _, _, _, _, ok := parseRangeRef(ch.Range); !ok {
continue
}
out = append(out, ch)
}
return out
}
// parseRangeRef parses "A1:C5" (or a single "A1") into a normalized
// 0-based bounding box
func parseRangeRef(ref string) (c1, r1, c2, r2 int, ok bool) {
ref = strings.ReplaceAll(strings.TrimSpace(ref), "$", "")
parts := strings.Split(ref, ":")
if len(parts) == 1 {
parts = append(parts, parts[0])
}
if len(parts) != 2 {
return 0, 0, 0, 0, false
}
c1, r1, ok1 := parseCellRef(parts[0])
c2, r2, ok2 := parseCellRef(parts[1])
if !ok1 || !ok2 {
return 0, 0, 0, 0, false
}
if c2 < c1 {
c1, c2 = c2, c1
}
if r2 < r1 {
r1, r2 = r2, r1
}
return c1, r1, c2, r2, true
}
// sheetRefPrefix quotes a sheet name for use in a chart series formula
func sheetRefPrefix(name string) string {
return "'" + strings.ReplaceAll(name, "'", "''") + "'!"
}
/* ==================== writer ==================== */
// cellText returns the literal display text of a cell ("" for formulas -
// Excel refreshes chart caches from the sheet on load anyway)
func chartCellText(ws *WorkSheet, col, row int) string {
cell, ok := ws.Cells[cellRef(col, row)]
if !ok || cell == nil {
return ""
}
v := cell.V
if strings.HasPrefix(v, "=") {
return ""
}
return strings.TrimPrefix(v, "'")
}
// buildChartXML renders one c:chartSpace part for a webapp chart
func buildChartXML(ws *WorkSheet, ch *xlsxChart, sheetName string) string {
c1, r1, c2, r2, _ := parseRangeRef(ch.Range)
dataC1, dataR1 := c1, r1
if ch.labelCol() {
dataC1++
}
if ch.headerRow() {
dataR1++
}
if dataC1 > c2 {
dataC1 = c2
}
if dataR1 > r2 {
dataR1 = r2
}
pre := sheetRefPrefix(sheetName)
nPts := r2 - dataR1 + 1
// category (label) reference shared by every series
catXML := ""
if ch.labelCol() {
var cache strings.Builder
cache.WriteString(fmt.Sprintf(``, nPts))
for r := dataR1; r <= r2; r++ {
cache.WriteString(fmt.Sprintf(`%s`,
r-dataR1, xmlEscape(chartCellText(ws, c1, r))))
}
catXML = fmt.Sprintf(
`%s$%s$%d:$%s$%d%s`,
xmlEscape(pre), colName(c1), dataR1+1, colName(c1), r2+1, cache.String())
}
var sers strings.Builder
serCount := 0
for c := dataC1; c <= c2; c++ {
idx := serCount
serCount++
sers.WriteString(fmt.Sprintf(``, idx, idx))
if ch.headerRow() {
sers.WriteString(fmt.Sprintf(
`%s$%s$%d`+
`%s`,
xmlEscape(pre), colName(c), r1+1, xmlEscape(chartCellText(ws, c, r1))))
}
if ch.chartType() == "line" {
sers.WriteString(``)
}
sers.WriteString(catXML)
var vals strings.Builder
vals.WriteString(`General`)
vals.WriteString(fmt.Sprintf(``, nPts))
for r := dataR1; r <= r2; r++ {
t := chartCellText(ws, c, r)
if !looksNumeric(t) {
continue // Excel fills the cache back in from the sheet
}
vals.WriteString(fmt.Sprintf(`%s`,
r-dataR1, xmlEscape(strings.TrimSpace(t))))
}
sers.WriteString(fmt.Sprintf(
`%s$%s$%d:$%s$%d%s`,
xmlEscape(pre), colName(c), dataR1+1, colName(c), r2+1, vals.String()))
sers.WriteString(``)
if ch.chartType() == "pie" {
break // a pie plots a single series
}
}
grouping := "clustered"
lineGrouping := "standard"
if ch.Opts != nil && ch.Opts.Stacked {
grouping = "stacked"
lineGrouping = "stacked"
}
const axCat, axVal = "111111111", "222222222"
axesXML := ``
catValAxes := `` +
`` +
`` +
``
var plot string
switch ch.chartType() {
case "line":
plot = `` +
sers.String() + `` + axesXML + `` + catValAxes
case "pie":
plot = `` + sers.String() +
``
default:
overlap := ""
if grouping == "stacked" {
overlap = ``
}
plot = `` +
sers.String() + `` + overlap + axesXML + `` + catValAxes
}
titleXML := ``
if ch.Opts != nil && strings.TrimSpace(ch.Opts.Title) != "" {
titleXML = `` +
xmlEscape(ch.Opts.Title) + `` +
``
}
return `` + "\n" +
`` +
`` + titleXML +
`` + plot + `` +
`` +
`` +
``
}
// buildDrawingXML renders the xl/drawings part for one sheet; chartRelIDs
// pairs each chart with its relationship id in the drawing's rels
func buildDrawingXML(charts []*xlsxChart, chartRelIDs []string) string {
var sb strings.Builder
sb.WriteString(`` + "\n")
sb.WriteString(``)
for i, ch := range charts {
w := ch.W
if w < 40 {
w = 480
}
h := ch.H
if h < 30 {
h = 300
}
sb.WriteString(``)
sb.WriteString(fmt.Sprintf(``,
pxToEmu(ch.X), pxToEmu(ch.Y), pxToEmu(w), pxToEmu(h)))
sb.WriteString(`` +
fmt.Sprintf(``, i+2, i+1) +
`` +
`` +
`` +
``)
sb.WriteString(``)
}
sb.WriteString(``)
return sb.String()
}
/* ==================== reader ==================== */
// chartAnchorPx resolves a drawing anchor to grid-space pixels, using the
// sheet's column/row sizes (webapp defaults for the rest)
func chartAnchorPx(anchor *xnode, ws *WorkSheet) (x, y, w, h float64) {
colX := func(col int, off int64) float64 {
px := 0.0
for i := 0; i < col; i++ {
if cw, ok := ws.ColW[strconv.Itoa(i)]; ok && cw > 0 {
px += cw
} else {
px += xlsxDefColPx
}
}
return px + float64(off)/emuPerPx
}
rowY := func(row int, off int64) float64 {
px := 0.0
for i := 0; i < row; i++ {
if rh, ok := ws.RowH[strconv.Itoa(i)]; ok && rh > 0 {
px += rh
} else {
px += xlsxDefRowPx
}
}
return px + float64(off)/emuPerPx
}
markerPx := func(m *xnode) (float64, float64) {
col, _ := strconv.Atoi(strings.TrimSpace(m.first("col").Text))
colOff, _ := strconv.ParseInt(strings.TrimSpace(m.first("colOff").Text), 10, 64)
row, _ := strconv.Atoi(strings.TrimSpace(m.first("row").Text))
rowOff, _ := strconv.ParseInt(strings.TrimSpace(m.first("rowOff").Text), 10, 64)
return colX(col, colOff), rowY(row, rowOff)
}
x, y, w, h = 40, 40, 480, 300
switch anchor.XMLName.Local {
case "absoluteAnchor":
if pos := anchor.first("pos"); pos != nil {
px, _ := strconv.ParseInt(pos.attr("x"), 10, 64)
py, _ := strconv.ParseInt(pos.attr("y"), 10, 64)
x, y = float64(px)/emuPerPx, float64(py)/emuPerPx
}
if ext := anchor.first("ext"); ext != nil {
cx, _ := strconv.ParseInt(ext.attr("cx"), 10, 64)
cy, _ := strconv.ParseInt(ext.attr("cy"), 10, 64)
w, h = float64(cx)/emuPerPx, float64(cy)/emuPerPx
}
case "oneCellAnchor":
if from := anchor.first("from"); from != nil && from.first("col") != nil {
x, y = markerPx(from)
}
if ext := anchor.first("ext"); ext != nil {
cx, _ := strconv.ParseInt(ext.attr("cx"), 10, 64)
cy, _ := strconv.ParseInt(ext.attr("cy"), 10, 64)
w, h = float64(cx)/emuPerPx, float64(cy)/emuPerPx
}
case "twoCellAnchor":
from, to := anchor.first("from"), anchor.first("to")
if from != nil && from.first("col") != nil {
x, y = markerPx(from)
}
if to != nil && to.first("col") != nil {
x2, y2 := markerPx(to)
if x2 > x {
w = x2 - x
}
if y2 > y {
h = y2 - y
}
}
}
if w < 60 {
w = 480
}
if h < 40 {
h = 300
}
return x, y, w, h
}
// parseChartFormulaRange strips the sheet prefix and $ from a series
// formula reference like "'Sheet 1'!$B$2:$B$10"
func parseChartFormulaRange(f string) (c1, r1, c2, r2 int, ok bool) {
f = strings.TrimSpace(f)
if i := strings.LastIndex(f, "!"); i >= 0 {
f = f[i+1:]
}
return parseRangeRef(f)
}
// parseChartPart maps a c:chartSpace tree back to the webapp chart model
// (bounding range reconstructed from the series formulas); returns nil for
// chart types the webapp cannot represent
func parseChartPart(tree *xnode, idSeq int) *xlsxChart {
chart := tree.first("chart")
if chart == nil {
return nil
}
plotArea := chart.path("plotArea")
if plotArea == nil {
return nil
}
var plot *xnode
chType := ""
for _, cand := range []struct{ node, t string }{
{"barChart", "bar"}, {"bar3DChart", "bar"},
{"lineChart", "line"}, {"line3DChart", "line"},
{"pieChart", "pie"}, {"pie3DChart", "pie"}, {"doughnutChart", "pie"},
{"areaChart", "line"},
} {
if n := plotArea.first(cand.node); n != nil {
plot, chType = n, cand.t
break
}
}
if plot == nil {
return nil
}
opts := &xlsxChartOpts{Type: chType}
if g := plot.first("grouping"); g != nil &&
(g.attr("val") == "stacked" || g.attr("val") == "percentStacked") {
opts.Stacked = true
}
if t := chart.first("title"); t != nil {
var texts []string
collectText(t, &texts)
opts.Title = strings.TrimSpace(strings.Join(texts, ""))
}
// union the series references back into one bounding range
haveRange := false
uc1, ur1, uc2, ur2 := 0, 0, 0, 0
extend := func(c1, r1, c2, r2 int) {
if !haveRange {
uc1, ur1, uc2, ur2 = c1, r1, c2, r2
haveRange = true
return
}
if c1 < uc1 {
uc1 = c1
}
if r1 < ur1 {
ur1 = r1
}
if c2 > uc2 {
uc2 = c2
}
if r2 > ur2 {
ur2 = r2
}
}
refOf := func(n *xnode) (int, int, int, int, bool) {
if n == nil {
return 0, 0, 0, 0, false
}
for _, holder := range []string{"strRef", "numRef", "multiLvlStrRef"} {
if ref := n.first(holder); ref != nil {
if fn := ref.first("f"); fn != nil {
return parseChartFormulaRange(fn.Text)
}
}
}
return 0, 0, 0, 0, false
}
headerRow, labelCol := false, false
for _, ser := range plot.all("ser") {
if c1, r1, c2, r2, ok := refOf(ser.first("tx")); ok {
headerRow = true
extend(c1, r1, c2, r2)
}
if c1, r1, c2, r2, ok := refOf(ser.first("cat")); ok {
labelCol = true
extend(c1, r1, c2, r2)
}
if c1, r1, c2, r2, ok := refOf(ser.first("val")); ok {
extend(c1, r1, c2, r2)
}
}
if !haveRange {
return nil
}
hr, lc := headerRow, labelCol
opts.HeaderRow = &hr
opts.LabelCol = &lc
return &xlsxChart{
ID: fmt.Sprintf("ch-xlsx-%d", idSeq),
Range: cellRef(uc1, ur1) + ":" + cellRef(uc2, ur2),
Opts: opts,
}
}
// parseSheetDrawing extracts every chart anchored on a worksheet drawing
// part and stores them on the WorkSheet in webapp JSON form
func parseSheetDrawing(files map[string][]byte, sheetPart string, sheetTree *xnode, ws *WorkSheet, idSeq *int) {
dn := sheetTree.first("drawing")
if dn == nil {
return
}
rid := ""
for _, a := range dn.Attrs {
if a.Name.Local == "id" {
rid = a.Value
}
}
sheetDir := pathDir(sheetPart)
sheetRels := parseRels(files[sheetDir+"/_rels/"+pathBase(sheetPart)+".rels"])
target, ok := sheetRels[rid]
if !ok {
return
}
drawingPart := resolvePartPath(sheetDir, target)
tree, err := parseXMLTree(files[drawingPart])
if err != nil {
return
}
drawDir := pathDir(drawingPart)
drawRels := parseRels(files[drawDir+"/_rels/"+pathBase(drawingPart)+".rels"])
var charts []*xlsxChart
for i := range tree.Nodes {
anchor := &tree.Nodes[i]
switch anchor.XMLName.Local {
case "absoluteAnchor", "oneCellAnchor", "twoCellAnchor":
default:
continue
}
chartRef := anchor.path("graphicFrame", "graphic", "graphicData", "chart")
if chartRef == nil {
continue
}
crid := ""
for _, a := range chartRef.Attrs {
if a.Name.Local == "id" {
crid = a.Value
}
}
chartTarget, ok := drawRels[crid]
if !ok {
continue
}
chartTree, err := parseXMLTree(files[resolvePartPath(drawDir, chartTarget)])
if err != nil {
continue
}
*idSeq++
ch := parseChartPart(chartTree, *idSeq)
if ch == nil {
continue
}
ch.X, ch.Y, ch.W, ch.H = chartAnchorPx(anchor, ws)
charts = append(charts, ch)
}
if len(charts) > 0 {
if b, err := json.Marshal(charts); err == nil {
ws.Charts = b
}
}
}
func pathDir(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[:i]
}
return "."
}
func pathBase(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
}
return p
}