Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Compatibility is **measured, not asserted** (PLAN.md §5): this file tracks
what the shim implements, what intentionally diverges, and what throws a
clear "not yet supported" exception. Phase numbers refer to PLAN.md §13.

Baselined against **phpoffice/phpspreadsheet 5.9.0** (the surface the CI gate
in `php/.compat-surface.json` diffs against).

## Supported (Phase 1)

| Area | API | Notes |
Expand All @@ -20,7 +23,9 @@ clear "not yet supported" exception. Phase numbers refer to PLAN.md §13.
| Writer\Csv | `set/getDelimiter`, `setEnclosure` (only `"`), `set/getLineEnding`, `set/getUseBOM`, `set/getSheetIndex`, `save` (paths, stream-wrapper URLs, open resources) | plus `setSanitizeFormulas()` (easy-excel extra, opt-in OWASP guard) |
| Writer\Html | `save`, `generateHtmlAll`, `generateHTMLHeader`, `generateStyles`, `generateNavigation`, `generateSheetData`, `generateHTMLFooter`, `set/getSheetIndex`, `writeAllSheets`, `set/getGenerateSheetNavigationBlock`, `set/getUseInlineCss`, `set/getEmbedImages`, `set/getImagesRoot`, `set/getLineEnding`, `getOrientation`, `setEditHtmlCallback`, plus the table/conditional/boolean knobs | **pure PHP** (works with or without the extension); renders formatted cell values into sheet tables with merged-cell row/colspans. Fine-grained per-cell styling and image embedding are not rendered — a single shared stylesheet is emitted |
| Reader\Xlsx | `load` (paths and stream-wrapper URLs), `setReadDataOnly`, `canRead` | wrapper sources are staged through a local temp file before the native open |
| Reader\Csv | `load`, `setDelimiter`, `setEnclosure`, `setSheetIndex`, `canRead` | streams in 1k-row chunks |
| Reader\Csv | `load`, `set/getDelimiter`, `setEnclosure`, `set/getEscapeCharacter`, `set/getTestAutoDetect`, `setSheetIndex`, `canRead`, `listWorksheetInfo`, `listWorksheetNames` | streams in 1k-row chunks; implements `Reader\IReader2` |
| Reader\CsvNoEscape | inherits `Reader\Csv`; escape character pinned to `''` and auto-detect to `false` (both re-enable attempts throw) | strict RFC-4180 parsing — backslashes are treated literally |
| Reader\IReader2 | `load`, `listWorksheetInfo`, `listWorksheetNames` | PhpSpreadsheet 5.8+ listing contract; the Csv reader implements it |
| Value binding | DefaultValueBinder semantics: numeric strings → numbers (leading-zero strings preserved), `=…` → formula, `DateTimeInterface` → Excel serial | |

## Supported (Phase 2 — formatting & structure)
Expand All @@ -46,6 +51,7 @@ clear "not yet supported" exception. Phase numbers refer to PLAN.md §13.
| Images | `Worksheet\Drawing`: `setName/setDescription/setPath/setCoordinates/setOffsetX/Y/setWidth/setHeight/setWorksheet` | width/height scale from the decoded PNG/JPEG/GIF dimensions; aspect kept when only one side is set |
| Sheet protection | `getProtection()->setSheet/setPassword` + all action-lock flags | applied at save; workbook encryption is not supported |
| Charts | **native API only**: `Worksheet::addNativeChart($cell, $spec)` / `Native::addChart` with a declarative spec (type, series, title, legend, size); types: area/bar/barStacked/col/colStacked/doughnut/line/pie/radar/scatter | PhpSpreadsheet's `Chart` object model is **not** mapped — see "Not yet supported" |
| Sparklines | **easy-excel extra** (no PhpSpreadsheet equivalent): `Worksheet::addSparkline(Sparkline\|array)` / `Native::addSparkline`; `Worksheet\Sparkline` builder — types line/column/win_loss, high/low/first/last/negative/markers/axis/reverse toggles, style + per-marker colors | PhpSpreadsheet cannot read or write Excel sparklines at all; backed by excelize `AddSparkline` |
| Auto-filter | `setAutoFilter` on streamed sheets | now injected into the saved container (no degrade); see divergence 16 |

## Supported (Phase 4.1 — compat completion, wave 1)
Expand Down Expand Up @@ -86,7 +92,7 @@ clear "not yet supported" exception. Phase numbers refer to PLAN.md §13.
|---|---|---|
| Rich text cells | `new RichText`, `createText/createTextRun`, `Run::getFont()` (bold/italic/size/name/underline/color…), `setCellValue($coord, $richText)` | a plain placeholder keeps dimensions correct; the formatted runs apply at save (divergence 22) |
| Memory drawings | `Worksheet\MemoryDrawing` (GD resource → PNG/JPEG/GIF, `setImageResource`, `setRenderingFunction`, size/offset, `setWorksheet`) | rendered in PHP, sent to the extension as base64 bytes; requires ext-gd |
| Charts | the PhpSpreadsheet `Chart\*` object model: `Chart`, `DataSeries` (bar/column ±stacked, line, area, pie, doughnut, scatter, radar; bar/col direction), `DataSeriesValues`, `PlotArea`, `Legend`, `Title`, X/Y axis labels; `Worksheet::addChart` | mapped onto the native chart spec; series data sources are excelize formula strings |
| Charts | the PhpSpreadsheet `Chart\*` object model: `Chart`, `DataSeries` (bar/column ±stacked, line, area, pie, doughnut, scatter, radar; bar/col direction), `DataSeriesValues`, `PlotArea`, `Legend`, `Title`, `DataTable` (`PlotArea::setDataTable`, 5.9), X/Y axis labels; `Worksheet::addChart` | mapped onto the native chart spec; series data sources are excelize formula strings. Data tables render under area/bar/col/line plots (Excel/excelize limit); the show-keys toggle is honoured, and the H/V border flags collapse onto excelize's single outline |
| Auto-filter rules | `getAutoFilter()->getColumn($col)->createRule()->setRule($op, $value)`, AND/OR join | column rules force the model path (FilterColumn XML); excelize doesn't hide rows automatically (divergence 23) |

## Documented divergences
Expand Down Expand Up @@ -228,7 +234,7 @@ missing. Run it against a frozen baseline so a *new* gap (e.g. a PhpSpreadsheet
version bump adding constants) fails CI instead of surfacing at runtime:

```
composer require --dev phpoffice/phpspreadsheet
composer require --dev "phpoffice/phpspreadsheet:^5.9"
php tools/compat-surface-diff.php --members # full report
php tools/compat-surface-diff.php --baseline=.compat-surface.json # gate (exit 1 on new gaps)
php tools/compat-surface-diff.php --update-baseline=.compat-surface.json # bump deliberately
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ RUN install-php-extensions gd zip intl xsl mbstring
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
ENV EASY_EXCEL_ALIAS=off COMPOSER_ALLOW_SUPERUSER=1
COPY php/ php/
RUN composer --working-dir=php require --no-interaction --no-progress --no-audit phpoffice/phpspreadsheet
RUN composer --working-dir=php require --no-interaction --no-progress --no-audit "phpoffice/phpspreadsheet:^5.9"

FROM compat-surface-deps AS compat-surface
RUN php php/tools/compat-surface-diff.php --members --baseline=php/.compat-surface.json
Expand Down
10 changes: 10 additions & 0 deletions NATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ easy_excel_add_chart(int $handle, string $sheet, string $cell, string $chartJson
// "legend":{"position":"bottom"},"width":600,"height":300}
// types: area, bar, barStacked, col, colStacked, doughnut, line, pie,
// radar, scatter

easy_excel_add_sparkline(int $handle, string $sheet, string $sparklineJson): ?string
// Tiny in-cell charts (no PhpSpreadsheet equivalent). One location cell per
// data range; the two arrays are paired positionally.
// {"type":"column",
// "location":["G2","G3"],"dataRange":["A2:F2","A3:F3"],
// "style":13,"high":true,"low":true,"markers":true,
// "seriesColor":"638EC6","markersColor":"FF0000"}
// types: line, column, win_loss
// toggles: high, low, first, last, negative, markers, axis, reverse
```

## Saving
Expand Down
2 changes: 1 addition & 1 deletion bench/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "Phase-0 benchmark rig: identical workloads across easy-excel and the PHP spreadsheet ecosystem (PLAN.md §10).",
"require": {
"php": ">=8.3",
"phpoffice/phpspreadsheet": "^5.0 || ^4.0",
"phpoffice/phpspreadsheet": "^5.9",
"openspout/openspout": "^4.24",
"avadim/fast-excel-writer": "^6.0",
"rap2hpoutre/fast-excel": "^5.6"
Expand Down
16 changes: 13 additions & 3 deletions extension/compat/chart.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type chartSpec struct {
YAxisTitle string `json:"yAxisTitle"`
Width uint `json:"width"`
Height uint `json:"height"`
DataTable struct {
Show bool `json:"show"`
ShowKeys bool `json:"showKeys"`
} `json:"dataTable"`
}

var chartTypes = map[string]excelize.ChartType{
Expand Down Expand Up @@ -62,7 +66,7 @@ func TranslateChart(jsonSpec string) (*excelize.Chart, error) {
})
}
if spec.Title != "" {
chart.Title = []excelize.RichTextRun{{Text: spec.Title}}
chart.Title = excelize.ChartTitle{Paragraph: []excelize.RichTextRun{{Text: spec.Title}}}
}
switch spec.Legend.Position {
case "":
Expand All @@ -72,16 +76,22 @@ func TranslateChart(jsonSpec string) (*excelize.Chart, error) {
return nil, fmt.Errorf("easy-excel: unsupported legend position %q", spec.Legend.Position)
}
if spec.XAxisTitle != "" {
chart.XAxis.Title = []excelize.RichTextRun{{Text: spec.XAxisTitle}}
chart.XAxis.Title = excelize.ChartTitle{Paragraph: []excelize.RichTextRun{{Text: spec.XAxisTitle}}}
}
if spec.YAxisTitle != "" {
chart.YAxis.Title = []excelize.RichTextRun{{Text: spec.YAxisTitle}}
chart.YAxis.Title = excelize.ChartTitle{Paragraph: []excelize.RichTextRun{{Text: spec.YAxisTitle}}}
}
if spec.Width > 0 {
chart.Dimension.Width = spec.Width
}
if spec.Height > 0 {
chart.Dimension.Height = spec.Height
}
if spec.DataTable.Show {
// excelize only renders a data table for area/bar/col/line groupings;
// the compat layer already gates on plot type, so we set it directly.
chart.PlotArea.ShowDataTable = true
chart.PlotArea.ShowDataTableKeys = spec.DataTable.ShowKeys
}
return chart, nil
}
51 changes: 51 additions & 0 deletions extension/compat/chart_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package compat

import "testing"

func TestTranslateChartDataTable(t *testing.T) {
chart, err := TranslateChart(`{
"type": "col",
"title": "Sales",
"series": [{"name": "Sheet1!$B$1", "categories": "Sheet1!$A$2:$A$4", "values": "Sheet1!$B$2:$B$4"}],
"dataTable": {"show": true, "showKeys": true}
}`)
if err != nil {
t.Fatal(err)
}
if !chart.PlotArea.ShowDataTable {
t.Error("ShowDataTable should be enabled")
}
if !chart.PlotArea.ShowDataTableKeys {
t.Error("ShowDataTableKeys should follow showKeys")
}
}

func TestTranslateChartDataTableKeysOff(t *testing.T) {
chart, err := TranslateChart(`{
"type": "bar",
"series": [{"name": "n", "categories": "c", "values": "v"}],
"dataTable": {"show": true, "showKeys": false}
}`)
if err != nil {
t.Fatal(err)
}
if !chart.PlotArea.ShowDataTable {
t.Error("ShowDataTable should be enabled")
}
if chart.PlotArea.ShowDataTableKeys {
t.Error("ShowDataTableKeys should be false")
}
}

func TestTranslateChartNoDataTableByDefault(t *testing.T) {
chart, err := TranslateChart(`{
"type": "line",
"series": [{"name": "n", "categories": "c", "values": "v"}]
}`)
if err != nil {
t.Fatal(err)
}
if chart.PlotArea.ShowDataTable {
t.Error("ShowDataTable should default to false")
}
}
85 changes: 85 additions & 0 deletions extension/compat/sparkline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package compat

import (
"encoding/json"
"fmt"

"github.com/xuri/excelize/v2"
)

// sparklineSpec is the JSON form of easy-excel's native sparkline API. Excel
// sparklines are tiny in-cell charts (one per location cell) driven by a data
// range; PhpSpreadsheet has no sparkline object model, so this is an
// easy-excel-native declarative feature mapped onto excelize.AddSparkline.
type sparklineSpec struct {
Location []string `json:"location"`
DataR []string `json:"dataRange"`
Type string `json:"type"` // line | column | win_loss
Style int `json:"style"`
Weight float64 `json:"weight"`

High bool `json:"high"`
Low bool `json:"low"`
First bool `json:"first"`
Last bool `json:"last"`
Negative bool `json:"negative"`
Markers bool `json:"markers"`
Axis bool `json:"axis"`
Reverse bool `json:"reverse"`

SeriesColor string `json:"seriesColor"`
NegativeColor string `json:"negativeColor"`
MarkersColor string `json:"markersColor"`
FirstColor string `json:"firstColor"`
LastColor string `json:"lastColor"`
HighColor string `json:"highColor"`
LowColor string `json:"lowColor"`
}

// valid sparkline types (excelize also accepts these; we validate up front so a
// bad spec fails at queue time with a clear message rather than deep in save).
var sparklineTypes = map[string]bool{"line": true, "column": true, "win_loss": true}

// TranslateSparkline builds excelize.SparklineOptions from the JSON spec.
func TranslateSparkline(jsonSpec string) (*excelize.SparklineOptions, error) {
var spec sparklineSpec
if err := json.Unmarshal([]byte(jsonSpec), &spec); err != nil {
return nil, fmt.Errorf("easy-excel: invalid sparkline spec: %w", err)
}
if len(spec.Location) == 0 {
return nil, fmt.Errorf("easy-excel: sparkline needs at least one location cell")
}
if len(spec.DataR) == 0 {
return nil, fmt.Errorf("easy-excel: sparkline needs at least one data range")
}
if len(spec.Location) != len(spec.DataR) {
return nil, fmt.Errorf("easy-excel: sparkline location/dataRange count mismatch (%d vs %d)",
len(spec.Location), len(spec.DataR))
}
if spec.Type != "" && !sparklineTypes[spec.Type] {
return nil, fmt.Errorf("easy-excel: unsupported sparkline type %q (want line|column|win_loss)", spec.Type)
}

return &excelize.SparklineOptions{
Location: spec.Location,
Range: spec.DataR,
Type: spec.Type,
Style: spec.Style,
Weight: spec.Weight,
High: spec.High,
Low: spec.Low,
First: spec.First,
Last: spec.Last,
Negative: spec.Negative,
Markers: spec.Markers,
Axis: spec.Axis,
Reverse: spec.Reverse,
SeriesColor: spec.SeriesColor,
NegativeColor: spec.NegativeColor,
MarkersColor: spec.MarkersColor,
FirstColor: spec.FirstColor,
LastColor: spec.LastColor,
HightColor: spec.HighColor, // excelize field is spelled "HightColor"
LowColor: spec.LowColor,
}, nil
}
60 changes: 60 additions & 0 deletions extension/compat/sparkline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package compat

import "testing"

func TestTranslateSparklineFullSpec(t *testing.T) {
opts, err := TranslateSparkline(`{
"type": "column",
"location": ["G2", "G3"],
"dataRange": ["A2:F2", "A3:F3"],
"style": 13,
"high": true, "low": true, "markers": true,
"seriesColor": "FF0000", "markersColor": "00FF00"
}`)
if err != nil {
t.Fatal(err)
}
if opts.Type != "column" {
t.Errorf("type: got %q", opts.Type)
}
if len(opts.Location) != 2 || opts.Location[1] != "G3" {
t.Errorf("location: %+v", opts.Location)
}
if len(opts.Range) != 2 || opts.Range[0] != "A2:F2" {
t.Errorf("range: %+v", opts.Range)
}
if opts.Style != 13 || !opts.High || !opts.Low || !opts.Markers {
t.Errorf("flags/style wrong: %+v", opts)
}
if opts.SeriesColor != "FF0000" || opts.MarkersColor != "00FF00" {
t.Errorf("colors wrong: %+v", opts)
}
}

func TestTranslateSparklineDefaults(t *testing.T) {
opts, err := TranslateSparkline(`{"location": ["B1"], "dataRange": ["C1:H1"]}`)
if err != nil {
t.Fatal(err)
}
if opts.Type != "" { // excelize defaults empty -> "line"
t.Errorf("expected empty type default, got %q", opts.Type)
}
if opts.High || opts.Low || opts.Markers {
t.Errorf("toggles should default false: %+v", opts)
}
}

func TestTranslateSparklineErrors(t *testing.T) {
cases := map[string]string{
"no location": `{"dataRange": ["A1:F1"]}`,
"no dataRange": `{"location": ["G1"]}`,
"count mismatch": `{"location": ["G1", "G2"], "dataRange": ["A1:F1"]}`,
"bad type": `{"location": ["G1"], "dataRange": ["A1:F1"], "type": "pie"}`,
"invalid json": `{not json`,
}
for name, spec := range cases {
if _, err := TranslateSparkline(spec); err == nil {
t.Errorf("%s: expected error, got nil", name)
}
}
}
15 changes: 15 additions & 0 deletions extension/core/phase3.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ func (w *Workbook) AddChart(sheet, cell, jsonSpec string) error {
return w.queueOp(sheet, pendingOp{kind: opChart, ref: cell, s1: jsonSpec})
}

// AddSparkline queues a sparkline group (easy-excel native JSON spec). Unlike a
// chart it is sheet-scoped (locations/ranges live in the spec), so ref is empty.
func (w *Workbook) AddSparkline(sheet, jsonSpec string) error {
if _, err := compat.TranslateSparkline(jsonSpec); err != nil {
return err
}
return w.queueOp(sheet, pendingOp{kind: opSparkline, ref: "", s1: jsonSpec})
}

// applyOpPhase3 executes the queued Phase-3 ops in random-access mode.
func (w *Workbook) applyOpPhase3(sheet string, op pendingOp) error {
switch op.kind {
Expand Down Expand Up @@ -178,6 +187,12 @@ func (w *Workbook) applyOpPhase3(sheet string, op pendingOp) error {
return err
}
return w.f.AddChart(sheet, op.ref, chart)
case opSparkline:
opts, err := compat.TranslateSparkline(op.s1)
if err != nil {
return err
}
return w.f.AddSparkline(sheet, opts)
case opUnmerge:
tl, br, err := splitRange(op.ref)
if err != nil {
Expand Down
36 changes: 36 additions & 0 deletions extension/core/phase3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,42 @@ func TestAddChartSaves(t *testing.T) {
}
}

func TestAddSparklineSaves(t *testing.T) {
w, err := New(testEnv())
if err != nil {
t.Fatal(err)
}
defer w.Close()
fillRows(t, w, "Worksheet", 1, 5)
spec := `{"type":"column","location":["G1","G2","G3","G4","G5"],
"dataRange":["A1:F1","A2:F2","A3:F3","A4:F4","A5:F5"],
"high":true,"low":true,"style":13}`
if err := w.AddSparkline("Worksheet", spec); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "sparkline.xlsx")
if err := w.SaveXlsx(path, ""); err != nil {
t.Fatal(err)
}
// data must survive the sparkline overlay
f := reopen(t, path)
if v, _ := f.GetCellValue("Worksheet", "B3"); v != "3" {
t.Errorf("data corrupted by sparkline: B3=%q", v)
}
}

func TestAddSparklineRejectsBadSpec(t *testing.T) {
w, err := New(testEnv())
if err != nil {
t.Fatal(err)
}
defer w.Close()
// type/dataRange mismatch is caught at queue time, before save
if err := w.AddSparkline("Worksheet", `{"location":["G1"]}`); err == nil {
t.Error("expected error for missing dataRange")
}
}

func TestCalculatedReadRows(t *testing.T) {
w, err := New(testEnv())
if err != nil {
Expand Down
Loading
Loading