Chart and table data: numbers, formats, datasets and series mapping
Status: RR-54 (release readiness). Additive schema plus one behaviour fix (strict chart numbers). Ships in the next lockstep release: core first, then renderer and PPTX, then editor. This page is the contract the four packages implement; change it here first.
Decisions (vetoable)
Recorded 2026-10-05 for the owner, who asked for all five recommendations of the chart/table data review at once:
- One strict chart number rule in core. The preview read
"12%"as a gap and"1e6"as 1000000; the exporter stripped every non-numeric character ("1e6"became 16,"(5)"became 5,"Q1"became 1). Both engines now call corechartNumber, which follows the existing data-import rule. A string that is not a strict decimal number is a gap in both engines and achart-value-not-numericwarning, never a guessed value. Decks whose chart cells held"12%"or"$5"export a gap where they used to export 12 or 5. ChartDataSourceis removed from the schema. No engine resolved it (opf#240, descoped), so the format-audit program (FA-07, 2026-10-06) deleted it, with itschart-data-source-unresolvedwarning. A future data-resolver design re-adds it when it is built. Inline data records where it came from withdata.source, which engines never read.- Number formats use the variables syntax. One pattern language for
NumberVariable.format, column formats and cell formats. Core converts it to an Excel format code for the PPTX export and back on import. - Datasets are top-level and shared. A chart or a table references one with
{ "dataset": "<id>" }. Core inlines references before composition, so the engines keep plotting and laying out inline data. - Series mapping is by column name.
chart.mappingpicks the category, X and series columns. Absent keeps today's positional rule exactly.
A document that uses none of the new fields validates, previews and exports byte-for-byte as before, except for decision 1 on non-numeric strings.
Schema
Number formats
NumberFormat is a string with the NumberVariable.format syntax: an optional literal prefix, a numeric part made of #, 0, , (grouping) and . (decimals), and an optional literal suffix. A % in the prefix or suffix multiplies the value by 100. Examples: #,##0, 0.0%, $#,##0.00, #,##0 units. An invalid pattern is a number-format-invalid error. A format applies only to number values; strings, booleans, null and rich runs display unchanged.
Two spellings display differently in Excel, so prefer the alternatives in charts that are exported: optional decimals only (0.##) show a whole number as 5 in core and the preview but as 5. (with a trailing point) in Excel, and an all-# integer part (#, #,###) shows zero as 0 in core but as nothing in Excel. Use 0.0#-style decimals with at least one 0, or a whole-number format, and an integer part that ends in 0.
Columns
DataColumn is { "name": string, "format"?: NumberFormat }. Anywhere a chart or dataset column is a string, it may be a DataColumn; the string form is { "name": value } with no format.
"chart": {
"type": "column",
"data": {
"columns": ["Quarter", { "name": "Revenue", "format": "$#,##0.0" }, { "name": "Margin", "format": "0%" }],
"rows": [["Q1", 12.4, 0.31], ["Q2", 18.1, 0.34]]
}
}A table column header may also be a DataColumn. A StyledTableCell gains an optional format:
- on a body cell, it formats that cell's number value;
- on a header cell (in
columns), it is the column's format, the same as aDataColumnheader.
A body cell's own format wins over the column's.
"table": {
"columns": ["Region", { "name": "Revenue", "format": "$#,##0.0" }, { "value": "Growth", "style": { "align": "right" }, "format": "0%" }],
"rows": [["EMEA", 8.2, 0.4], ["APAC", 6.1, { "value": 0.52, "format": "0.0%" }]]
}Data source provenance
DataSourceRef is { "src": string, "sheet"?, "range"?, "fields"?: string[], "retrieved"?: string (ISO date or date-time), "description"?: string }. It records where inline data or a dataset came from. Engines never read, fetch or refresh it; they keep it through editing, export and re-import. It may appear as ChartData.source and Dataset.source.
There is no chart data source by file or asset (chart.data with src, sheet, range, columns): that form is removed (opf#240, descoped) and is a schema error. Import the data inline with columns/rows and record its origin in source.
Datasets
Top-level datasets maps ids (the assets id pattern) to a Dataset:
{
"datasets": {
"revenue": {
"title": "Revenue by quarter",
"columns": ["Quarter", { "name": "Revenue", "format": "$#,##0.0" }, "Costs"],
"rows": [["Q1", 12, 8], ["Q2", 18, 11], ["Q3", 24, 15]],
"source": { "src": "./data/revenue.csv", "retrieved": "2026-10-05" }
}
}
}Rows hold ChartDataCell scalars (string, number, boolean, null). A DatasetRef is { "dataset": "<id>", "fields"?: string[] }; fields selects and orders columns by name, each at most once (a repeated name is a schema error).
- Chart:
chart.datamay be aDatasetRef. It is the secondoneOfbranch besideChartData. - Table: a table is either inline (
rowsrequired, optionalcolumns) or dataset-backed (datasetrequired, optionalfields, and norowsorcolumns). Dataset tables take their headers and column formats from the dataset; per-cell styles need an inline table.
Validation:
- an unknown dataset id is a
dataset-unknownerror; - an unknown field is a
dataset-field-unknownerror; - duplicate column names in a dataset, or in chart data that a
mappingorfieldsaddresses, are adata-column-duplicateerror; - a dataset nothing references is the warning
opf/unused-dataset(categoryreferences).
Series mapping
chart.mapping is { "category"?: string, "x"?: string, "series"?: string[] }, naming columns after any fields selection:
category: the label column. Default: the first column.x: the X column of an XY (scatter) chart. Default, today's rule: the second column (the first whencategorynames the second), and only when the chart has three or more columns. With two columns there is no X column: the second column is the one series, plotted against row numbers. Anxthat leaves no series (two columns, or every series dropped) is achart-mapping-adaptedwarning and that column is plotted as the series. A chart is XY when its type resolves (deprecated ids through their replacement) to the scatter construct ofchartOptionTarget. Anxequal to the category is achart-mapping-adaptedwarning and the default is used. On a chart type without an X axis it is dropped with achart-mapping-adaptedwarning.series: the plotted columns, in order. Default: every column that is notcategoryorx.
An unknown name is a chart-mapping-unknown-column error. A series that repeats the category or X column is a chart-mapping-adapted warning and is dropped. Mapping only reorders and selects columns; pie, doughnut and single-series constructs still plot one series (series-dropped, as today).
Core runtime API
Exported from @openpresentation/opf and @openpresentation/opf/data (module src/chart-data.ts; the resolvers are also on @openpresentation/opf/composition). Inputs are typed unknown so engines can pass any parsed chart, table or document; the result types are exported (ResolvedChartData, ResolvedTableData, DataDiagnostic, DataColumn, DataSourceRef, Dataset, DatasetRef, ChartMapping, DataCellValue, DataTableCell, DataTableHeader, DataStyledCell).
/** Strict chart number: finite numbers, and strings in strict decimal syntax (trimmed). Everything else is null (a gap), including "+5", ".5", "5.", "007" and plain digit strings beyond the safe integer range (usually identifiers that would lose digits); a decimal or exponent form such as "1e20" is a number. */
export function chartNumber(value: unknown): number | null;
/** Format a number with a NumberFormat; an absent or invalid format prints the General form (String(value)). */
export function formatDataNumber(value: number, format?: string): string;
/** Why a NumberFormat is invalid (the number-format-invalid message), or undefined when it is valid or absent. */
export function numberFormatError(format: unknown): string | undefined;
/** NumberFormat -> Excel format code ("General" when absent or invalid). Literal prefix/suffix text is quoted as one run ("0.0 m/s" -> '0.0 "m/s"'), so '/' (Excel's fraction bar), 'E+', '@', '*', '_' and '?' are never bare (native check opf#387); placeholders are written '#' before '0' ("0#" -> "#0"). */
export function toExcelNumberFormat(format?: string): string;
/** Excel format code -> NumberFormat, or undefined when the code has no exact NumberFormat equivalent (General, sections, scaling commas, and placeholder orders Excel reads by position such as "0#" or "0.#0"). */
export function fromExcelNumberFormat(code: string): string | undefined;
/** Pure: a copy of the document where every chart and table DatasetRef is replaced by inline data (columns as DataColumn when a format applies, rows copied; a chart also takes the dataset's `source`). `datasets` stays in place. Unknown ids, and references naming an unknown field, are left as they are. */
export function inlineDatasets<T>(document: T): T;
/** One table or chart in inline form (a dataset reference copied from document.datasets); anything else is returned as is. */
export function inlineTableData<T>(table: T, document?: unknown): T;
export function inlineChartData<T>(chart: T, document?: unknown): T;
/**
* Resolve a chart's data to the canonical positional table: [category, (x,) ...series]. hasX is true when columns[1] is the X
* column: an XY chart with three or more resolved columns (with two, the second column is the series against row numbers).
* Category cells are kept as authored; X and series cells pass through chartNumber. Data with a single column has no
* category: that column is the chart's values and passes through chartNumber too (with chart-value-not-numeric per cell).
* Inline ChartData, a DatasetRef (needs the document) and mapping are resolved here.
* options.path is the chart's JSON Pointer, the base of diagnostic paths (dataset cells report at /datasets/<id>/...).
*/
export function resolveChartData(chart: unknown, document?: unknown, options?: { path?: string }):
| { ok: true; columns: string[]; hasX: boolean; formats: (string | undefined)[]; rows: (string | number | boolean | null)[][]; source?: DataSourceRef; dataset?: string; diagnostics: DataDiagnostic[] }
| { ok: false; reason: "dataset-unknown" | "no-rows" | "no-columns"; message: string; diagnostics: DataDiagnostic[] };
/** Resolve a table (inline or dataset-backed) to headers, rows and per-column formats. */
export function resolveTableData(table: unknown, document?: unknown, options?: { path?: string }):
{ columns?: DataTableHeader[]; rows: DataTableCell[][]; formats: (string | undefined)[]; dataset?: string; diagnostics: DataDiagnostic[] };
/** Display value of a table cell: a number with a valid format (cell, else column) becomes formatted text (a styled cell keeps its style with the text as value); anything else is returned unchanged. */
export function tableCellDisplayValue(cell: DataTableCell, columnFormat?: string): DataTableCell;
/** Migration help (see below): the patch that stores one chart value column's text as numbers with the format that shows the same text, or undefined. */
export function suggestChartNumberFix(chart: unknown, document?: unknown, options?: { path?: string; column?: number | string }):
{ patches: { op: "replace"; path: string; value: unknown }[]; column: number; name: string; format: string } | undefined;
/** Helpers: dataset-level diagnostics (duplicate names, invalid formats), unreferenced dataset ids, the XY test, the DatasetRef test. */
export function datasetDiagnostics(document: unknown): DataDiagnostic[];
export function unusedDatasets(document: unknown): string[];
export function isXYChartType(type: unknown): boolean;
export function isDatasetRef(value: unknown): value is DatasetRef;DataDiagnostic is { code, severity: "error" | "warning", path, message }, with the codes above plus chart-value-not-numeric. chartNumber runs after variables are filled. Before filling, the validator does not warn on a var:<id> cell whose variable is a number. chart-value-not-numeric is reported for any value cell that chartNumber rejects (strings and booleans), never for null or "". An unknown fields entry is a dataset-field-unknown error and resolveChartData/resolveTableData leave that column out. number-format-invalid applies to DataColumn.format and StyledTableCell.format; NumberVariable.format keeps its existing check (variable-format where it is used), so no existing document gains an error.
validate reports the codes as opf/<code> rule ids: the errors are format findings, opf/chart-value-not-numeric and opf/chart-mapping-adapted are content warnings, opf/chart-data-source-unresolved is a references warning, and references adds opf/unused-dataset. The raw schema issue of a finding is in its validation field (validation.params.code).
Migration help
Decks written before the strict rule often hold display text in chart cells. When every text cell of a value column (an X or series column, or a lone column) is written in one display style that a NumberFormat reproduces exactly, suggestChartNumberFix returns the fix, and validate attaches it to each of the column's opf/chart-value-not-numeric warnings as fixes: [{ id: "store-chart-numbers", title, kind: "patch", safe: false, patch }] (a finding fix; core never applies it):
"12%","8.5%"become 0.12 and 0.085 with the column format0.#%;"$1,234","$56"become 1234 and 56 with$#,##0(also€,£,¥);"1,234.5","999"become 1234.5 (the plain number stays) with#,##0.##.
The patch replaces each text cell with its number and the column with a DataColumn that has the format; a dataset column is fixed in the dataset, so every chart and table that uses it changes, and its tables show the same text. A style is one optional leading minus, one optional currency symbol, digits (grouped in threes or plain), optional decimals and an optional %. There is no fix when the column already has a format, when styles are mixed ("12%" beside "$5" or beside the number 0.5), for accounting negatives "(5)", "$-5", a decimal comma ("1.234,5"), units ("5 units"), ".5" or "007", for mixed grouping ("1,234" beside "5678"), or when one format cannot show every value as written ("$5" beside "$5.50"). Every fixed value is checked: formatDataNumber(value, format) equals the text as written.
Core composition, table layout and pagination accept dataset-backed tables and charts by inlining first. Table layout measures the formatted text. Markdown conversion, diff, merge, patch, validate and format keep the new fields. In detail:
composeSlideinlines a dataset-backedtableorchartfromoptions.presentation.datasets, so the composed item'svalue(andpayload) is the inline copy; the itempathstill points at the authored field.layoutTable(value, box, { presentation })does the same, and each body cell'sTableCellLayout.valueis its display value (tableCellDisplayValue);inputstays the authored cell. A DataColumn header shows itsname(pathends in.name).paginateSlide/paginatevalidate a slide with the deck'sdatasets(andreferences). A dataset table that has to be split is written to the continuation slides as inline tables (the slide that fits is returned unchanged, with its reference).convertContentkeeps a dataset reference between chart and table (fieldstoo;mappingis reported as lost) and needsoptions.presentationto convert a dataset table to any other kind. Inline conversions keep DataColumn formats and report lost number formats andsource. Slide-level conversions validate with the document's datasets; slide-only structure edits skip the dataset checks they cannot make.- Markdown writes the new fields in its embedded YAML form (lossless);
datasetsgo to the front matter.formatordersdatasets,mapping,sourceandDataColumnkeys by the schema.diffreports changes underdatasetsin their own category. The chart rules ofvalidateread resolved chart data (DataColumn names, dataset charts). - Variables:
resolveVariableswalksdatasetslike any other content, so{{id}}tokens and wholevar:<id>cells in dataset rows are filled before engines inline the reference. opf ingest --dataset <id>(CLI) writes the imported columns and rows intodatasets.<id>and references it. Re-importing into an existing dataset keeps its title, description and the format of each column whose name is unchanged;source.srcnames the imported file relative to the deck file's folder with/separators (stdin: no source), andsource.retrievedis set only by--date YYYY-MM-DD; the command never reads the clock.- The generated
Tabletype now hasrows?(a dataset table has none) anddataset?/fields?; TypeScript callers that readtable.rowshandle the dataset form (or callresolveTableData).
Engines
All three engines read core's functions when they exist and fall back to their previous behaviour on a core without RR-54, so each can ship before or after core. A document that uses none of the new fields renders, exports and edits byte for byte as before (renderer: all 805 slides of the core examples; PPTX: 21 golden digests; editor: identical patches).
Preview (opf-render, opf-render#127)
- Data: every chart path reads
resolveChartData: the catalog charts, the chartex constructs, scatter, pie and doughnut, single-column charts, and the legacy sketch. Its ownchartNumberdelegates to core's, so"0x10"is now a gap too. Data that does not resolve keeps the "No chart data" placeholder. A document with an unknown dataset or mapping column fails validation (invalid-opf) like any other invalid document. - Formats:
- Data labels use their series' format, through
formatDataNumber. - Value-axis ticks use the first plotted series' format, as Excel does for a source-linked axis.
- The scatter X axis and X labels use the X column's format.
- Percent axes (100% stacked, pareto) and pie percent labels stay percent.
- A histogram of a lone column plots counts and takes no format.
- Data labels use their series' format, through
- Tables: cells draw core layout's display text, so a body cell's own
formatwins over its column's. - Tracing: the editor relies on each drawn part's authored path.
- Every part of a dataset-backed chart or table traces to its authored
slides.N.chartorslides.N.tablepath, because it has no rows of its own. - A chart with
mappingtraces each part to the authored column index.
- Every part of a dataset-backed chart or table traces to its authored
PPTX export (opf-pptx, opf-pptx#171)
- Numbers: the export resolves data through
resolveChartData, and the lenientparsedNumber/numericValueare removed. Non-numeric strings export as gaps, with onechart-value-not-numericdiagnostic per chart that carries acount. - Classic charts:
toExcelNumberFormatcodes are written as:- the series
c:numCache/c:formatCode(added where PptxGenJS writes none, such as pie and doughnut); - the series and chart-group data-label
c:numFmt sourceLinked="0"; - the value-axis
c:numFmt. The first plotted series' format is used, a scatter X axis takes the X column's, and a 100% stacked axis keeps0%. - Labels that show percentages keep PowerPoint's percent form.
- the series
- Chartex:
cx:lvl formatCodeand the value-label and value-axiscx:numFmt, except on binned constructs. - Embedded workbook: custom
numFmtentries from id 164, with cell styles on every value cell, so Edit Data shows the formats. - Tables: tables export core layout's display text.
- Datasets and mapping: a chart that uses them exports exactly like its inline equivalent.
- Provenance: in
fullmode,OPF_DATASETS_V1holds thedatasetsmap.OPF_DATA_V1sits on each chart or table frame that uses a new field and holds the authoreddata,mappingand table form (and, FA-14, a chart'shighlight, which then also records its inlinedata; see chart-options.md), plus a hash of the cached names, values and format codes.references-onlymode andprovenance: falsewrite neither tag.
PPTX import (opf-pptx)
- With provenance:
datasetsis restored before document provenance validates. A frame's dataset reference,mapping, formats andsourceare restored only while the cache hash still matches. If PowerPoint (or a person) changed the values, the native values import and a diagnostic says why, such aschart-data-provenance-changedortable-dataset-unavailable. The hash compares format codes in core's canonical form (fromExcelNumberFormat), so PowerPoint's re-spellings on save (\$#,##0.0for$#,##0.0,#,##0\ "units"for#,##0 "units", native check opf#385) still match; a General code counts as no code. - Without provenance: a cache
formatCodethatfromExcelNumberFormatmaps back becomes that column's{ name, format }, for both classic and chartex charts. Core returns the canonical spelling, such as#,##0 unitsfor#,##0 "units". Other codes are reported aschart-number-format-adaptedand never invented. - Cached values: these follow the strict rule as well. XML decimal forms (
+5,.5,007) are numbers; anything else is a gap with a diagnostic, never a stripped number or 0.
Editor (opf-editor, opf-editor#92)
- DataColumn headers: the data grid shows a
DataColumnheader'sname. Rename, insert, delete, move and paste keep each column's format. - Chart cells: they follow
chartNumber. Text that is not a number is refused with the reason, and a stored string read as a gap is flagged. - Shared datasets: a dataset-backed chart or table is edited at
/datasets/<id>throughfields.- Hidden columns keep their data.
- A column added while
fieldsis set goes to both the dataset andfields. - A rename updates every
fieldsandmappingthat names the column, in the same patch. - Deleting a column another item uses is refused with
dataset-column-in-use. - The status line says how many items share the dataset, and "Use a copy of the data" (
detachGridDataset) inlines one item's data.
- Format and mapping:
- A per-column format field (
setGridColumnFormat) is checked withnumberFormatError. - A "Chart columns" panel (
setChartMapping) sets the category, the X column (XY charts only) and the series, and writes only what differs from the default.
- A per-column format field (
- Import: "Store as a shared dataset" (
prepareDatasetImport) imports intodatasets.<id>, asopf ingest --datasetdoes. - Other panels: the table style panel and
table-optionsrefuse a dataset table withtable-dataset-backed. Find and replace also searches dataset text. - Undo: each edit is one undoable patch.
Native PowerPoint checks
Wanted before release:
- formatted data labels and value axes;
- Edit Data shows the workbook cell formats with no repair prompt;
- no repair prompt for the frame
custDataLst; - after a plain save in PowerPoint, the data tags and cache hash survive;
- the two spellings above (
0.##on a whole number,#on zero) show as described.
Sample decks are written by the PPTX branch under artifacts/rr-54-native/.
Not in this change
- Resolving a data source by file or asset, or refreshing
source(opf#240). - Date axes and date formats.
- Per-series colours. (Columns with line series, optionally on a secondary axis, are the
combochart type: chart-options.md.) - Formulas.
- Rich text in dataset cells.