@openpresentation/opf-editor 0.11.2 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,46 @@
1
1
  // The data model behind the data grid (RR-24): one table-shaped view of a chart's inline data and of a
2
2
  // table, and every edit to it as one validated, undoable patch.
3
3
  //
4
- // A chart is `{ type, data: { columns, rows } }`: `columns` are labels, `rows` are arrays aligned to them. The
5
- // first column holds the categories (the x axis, or the slice names) and every further column is one series,
6
- // named by its column label, in the order the renderer draws them. (A scatter chart reads its x values from the
7
- // second column when it has three or more; a chart type that needs one column only reads the first.) A table is
8
- // `{ columns?, rows }` whose cells are plain values, rich runs or styled cells with spans, as table-options.js
9
- // describes.
4
+ // A chart is `{ type, data: { columns, rows }, mapping? }`: `columns` are labels (a string, or a `DataColumn`
5
+ // `{ name, format? }`), `rows` are arrays aligned to them. By default the first column holds the categories (the x axis, or
6
+ // the slice names) and every further column is one series, named by its column label, in the order the renderer draws them.
7
+ // (A scatter chart reads its x values from the second column; a chart type that needs one column only reads the first.)
8
+ // `mapping` names the category, X and series columns instead. A table is `{ columns?, rows }` whose cells are plain values,
9
+ // rich runs or styled cells with spans, as table-options.js describes.
10
+ //
11
+ // RR-54: a chart (`data: { dataset, fields? }`) or table (`{ dataset, fields? }`) may take its data from a shared top-level
12
+ // dataset. The grid then edits `/datasets/<id>` through the `fields` selection: edits map back to the dataset's own column
13
+ // indices, a column added while `fields` is set goes to the dataset and to `fields`, and a deleted column leaves `fields` (the
14
+ // dataset keeps it). Renaming a column keeps every `fields` and `mapping` that names it in step.
10
15
  //
11
16
  // Both are read as "lines": the header line (when there is one) followed by the body rows. The operations below
12
17
  // change lines and columns, keep merged cells whole or refuse with a reason, and return a `{ document, patches,
13
18
  // changed }` description like table-options.js does. Nothing here touches a DOM.
19
+ import * as core from "@openpresentation/opf";
14
20
  import { getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
15
- import { checkedDocument, fail } from "./edit-helpers.js";
21
+ import { checkedDocument, fail, same } from "./edit-helpers.js";
22
+ import { datasetUsage, isDatasetRef, walkDatasetItems } from "./dataset-refs.js";
16
23
  import { richTextContent, updateRichTextInput } from "./rich-text.js";
17
24
  import { formatGridNumber, isCanonicalNumber, parseDelimited, parseGridNumber, resolveNumberFormat, toDelimited } from "./grid-text.js";
18
25
  import { applyTableStyleToTable, readTableStyleOfTable } from "./table-options.js";
19
26
 
20
27
  const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
21
28
  const isStyled = (cell) => isObject(cell) && Object.hasOwn(cell, "value");
22
- const valueOf = (cell) => (isStyled(cell) ? cell.value : cell);
29
+ // A header may be a DataColumn `{ name, format? }`: its text is `name`.
30
+ const isColumnObject = (cell) => isObject(cell) && Object.hasOwn(cell, "name") && !Object.hasOwn(cell, "value");
31
+ const valueOf = (cell) => (isStyled(cell) ? cell.value : isColumnObject(cell) ? cell.name : cell);
32
+ const withValue = (raw, value) => (isStyled(raw) ? { ...raw, value } : isColumnObject(raw) ? { ...raw, name: value === null || value === undefined ? "" : String(value) } : value);
33
+ const formatOf = (raw) => ((isStyled(raw) || isColumnObject(raw)) && typeof raw.format === "string" ? raw.format : undefined);
23
34
  const clone = (value) => structuredClone(value);
35
+ const columnNameOf = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : "");
36
+ // A body row remembers which dataset row it came from, so a row that moves, sorts or is deleted keeps the columns `fields` hides.
37
+ const LINE_ORIGIN = Symbol("opf.grid.row");
38
+ const cloneLines = (lines) =>
39
+ lines.map((line) => {
40
+ const copy = clone(line);
41
+ if (line[LINE_ORIGIN] !== undefined) copy[LINE_ORIGIN] = line[LINE_ORIGIN];
42
+ return copy;
43
+ });
24
44
  const spansOf = (raw) => ({ rs: isStyled(raw) ? (raw.rowSpan ?? 1) : 1, cs: isStyled(raw) ? (raw.colSpan ?? 1) : 1 });
25
45
 
26
46
  /** Spreadsheet column letters: 0 is A, 25 is Z, 26 is AA. */
@@ -38,10 +58,33 @@ const decimalOf = (options = {}) => (options.decimal === "," || options.decimal
38
58
 
39
59
  // --- locating ----------------------------------------------------------------------------------
40
60
 
61
+ const datasetsOf = (document) => (isObject(document) && isObject(document.datasets) ? document.datasets : {});
62
+
63
+ /** The dataset a chart or table reference points at, and the dataset columns its `fields` select (by index). */
64
+ function datasetOf(document, ref, path, refParts) {
65
+ const id = ref.dataset;
66
+ const datasets = datasetsOf(document);
67
+ const data = Object.hasOwn(datasets, id) ? datasets[id] : undefined;
68
+ if (!isObject(data) || !Array.isArray(data.columns) || !Array.isArray(data.rows))
69
+ throw fail("dataset-unavailable", `This item uses the shared dataset '${id}', which the document does not hold.`, { path, dataset: id });
70
+ const names = data.columns.map(columnNameOf);
71
+ const fields = Array.isArray(ref.fields) ? ref.fields : undefined;
72
+ let indices = names.map((_, index) => index);
73
+ if (fields) {
74
+ indices = fields.map((field) => names.indexOf(field));
75
+ const missing = fields.find((_, index) => indices[index] < 0);
76
+ if (missing !== undefined) throw fail("dataset-unavailable", `This item selects the column ${JSON.stringify(missing)}, which dataset '${id}' does not have.`, { path, dataset: id, field: missing });
77
+ }
78
+ return { data, dataset: { id, fields, indices, refParts, ref } };
79
+ }
80
+
41
81
  /**
42
82
  * Find the data a path points at. A chart path ends in `chart` (or `chart.data`) and a table path in `table`.
43
- * Throws `grid-target-not-found`, `table-not-found` or `chart-data-source` (a chart that reads its data from a source
44
- * has no inline data to edit).
83
+ * Throws `grid-target-not-found`, `table-not-found`, `chart-data-source` (a chart that reads its data from a source
84
+ * has no inline data to edit) or `dataset-unavailable` (the shared dataset or one of its `fields` is missing).
85
+ * A chart or table that takes its data from a dataset (RR-54) is located at the dataset: `data` is the dataset, `dataParts`
86
+ * is `["datasets", id]` and `dataset` is `{ id, fields, indices, refParts, ref }` (`indices` maps each shown column to the
87
+ * dataset's own column).
45
88
  */
46
89
  export function locateGridData(document, path) {
47
90
  let parts;
@@ -55,21 +98,32 @@ export function locateGridData(document, path) {
55
98
  if (parts.at(-1) === "chart") {
56
99
  if (!isObject(owner) || !isObject(owner.data)) throw fail("grid-target-not-found", "This chart has no data.", { path });
57
100
  const data = owner.data;
101
+ const chartType = typeof owner.type === "string" ? owner.type : undefined;
102
+ if (isDatasetRef(data)) {
103
+ const found = datasetOf(document, data, path, [...parts, "data"]);
104
+ return { kind: "chart", parts, dataParts: ["datasets", data.dataset], data: found.data, chartType, owner, dataset: found.dataset };
105
+ }
58
106
  if (!Array.isArray(data.columns) || !Array.isArray(data.rows))
59
107
  throw fail("chart-data-source", "This chart reads its data from a source (src), not from inline columns and rows. The data grid edits inline chart data.", { path });
60
- return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType: typeof owner.type === "string" ? owner.type : undefined };
108
+ return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType, owner };
61
109
  }
62
110
  if (parts.at(-1) === "table") {
111
+ if (isObject(owner) && isDatasetRef(owner) && !Array.isArray(owner.rows)) {
112
+ const found = datasetOf(document, owner, path, parts);
113
+ return { kind: "table", parts, dataParts: ["datasets", owner.dataset], data: found.data, owner, dataset: found.dataset };
114
+ }
63
115
  if (!isObject(owner) || !Array.isArray(owner.rows)) throw fail("table-not-found", "Choose a table (a path ending in .table).", { path });
64
- return { kind: "table", parts, dataParts: parts, data: owner };
116
+ return { kind: "table", parts, dataParts: parts, data: owner, owner };
65
117
  }
66
118
  throw fail("grid-target-not-found", "Choose a chart or a table (a path ending in .chart or .table).", { path });
67
119
  }
68
120
 
69
121
  /**
70
122
  * The chart or table a selection path belongs to: `{ kind, path }` for `…chart`, `…chart.data.rows.1.0`, `…table`,
71
- * `…table.rows.1.2.value`, and so on. A chart whose data comes from a source is `{ kind: "chart", path, editable: false,
72
- * reason }`. Returns undefined when the path is not inside a chart or table.
123
+ * `…table.rows.1.2.value`, and so on. A chart whose data comes from a source (or from a dataset the document lacks) is
124
+ * `{ kind: "chart", path, editable: false, reason }`. A chart or table that takes its data from a shared dataset also has
125
+ * `dataset: { id, fields, count, items }` (`items` are the paths of every chart and table that uses the dataset).
126
+ * Returns undefined when the path is not inside a chart or table.
73
127
  */
74
128
  export function resolveDataGridTarget(document, selectedPath) {
75
129
  let parts;
@@ -83,9 +137,14 @@ export function resolveDataGridTarget(document, selectedPath) {
83
137
  const head = parts.slice(0, index + 1);
84
138
  try {
85
139
  const found = locateGridData(document, head);
86
- return { kind: found.kind, path: head.join(".") };
140
+ const target = { kind: found.kind, path: head.join(".") };
141
+ if (found.dataset) {
142
+ const items = datasetUsage(document, found.dataset.id).map((entry) => entry.path);
143
+ target.dataset = { id: found.dataset.id, ...(found.dataset.fields ? { fields: [...found.dataset.fields] } : {}), count: items.length, items };
144
+ }
145
+ return target;
87
146
  } catch (error) {
88
- if (error.code === "chart-data-source") return { kind: "chart", path: head.join("."), editable: false, reason: error.message };
147
+ if (error.code === "chart-data-source" || error.code === "dataset-unavailable") return { kind: parts[index], path: head.join("."), editable: false, reason: error.message };
89
148
  }
90
149
  }
91
150
  return undefined;
@@ -94,14 +153,29 @@ export function resolveDataGridTarget(document, selectedPath) {
94
153
  // --- lines -------------------------------------------------------------------------------------
95
154
 
96
155
  function toModel(found) {
97
- const { kind, data } = found;
98
- const hasHeader = kind === "chart" ? true : Array.isArray(data.columns);
99
- const lines = [...(hasHeader ? [clone(data.columns)] : []), ...clone(data.rows).map((row) => (Array.isArray(row) ? row : [row]))];
100
- const model = { kind, hasHeader, lines, width: 0, chartType: found.chartType };
156
+ const { kind, data, dataset } = found;
157
+ const hasHeader = kind === "chart" || Boolean(dataset) ? true : Array.isArray(data.columns);
158
+ let lines;
159
+ if (dataset) {
160
+ // The dataset as the item shows it: only the columns `fields` selects, in that order. Each body row remembers its dataset row.
161
+ const pick = (row) => dataset.indices.map((index) => (Array.isArray(row) && index < row.length ? clone(row[index]) : null));
162
+ lines = [clone(dataset.indices.map((index) => data.columns[index])), ...data.rows.map((row, r) => Object.assign(pick(row), { [LINE_ORIGIN]: r }))];
163
+ } else lines = [...(hasHeader ? [clone(data.columns)] : []), ...clone(data.rows).map((row) => (Array.isArray(row) ? row : [row]))];
164
+ const mapping = kind === "chart" && isObject(found.owner?.mapping) ? clone(found.owner.mapping) : undefined;
165
+ // `datasetNames` are every column of the shared dataset, also the ones `fields` hides: a new column's name must differ from them.
166
+ const model = { kind, hasHeader, lines, width: 0, chartType: found.chartType, dataset, mapping, colOrigin: [], origNames: [], datasetNames: dataset ? data.columns.map(columnNameOf) : [] };
101
167
  model.width = widthOf(model);
168
+ // Where each column came from, so renames, deletions and moves can be followed after an operation (null: a new column).
169
+ model.colOrigin = Array.from({ length: model.width }, (_, index) => index);
170
+ model.origNames = namesOfModel(model);
102
171
  return model;
103
172
  }
104
173
 
174
+ /** The header texts of a model (the column names `fields` and `mapping` address). */
175
+ function namesOfModel(model) {
176
+ return model.hasHeader ? Array.from({ length: model.width }, (_, column) => String(valueOf(model.lines[0]?.[column]) ?? "")) : [];
177
+ }
178
+
105
179
  function fromModel(model, original) {
106
180
  const rest = Object.fromEntries(Object.entries(original).filter(([key]) => key !== "columns" && key !== "rows"));
107
181
  const out = {};
@@ -113,13 +187,25 @@ function fromModel(model, original) {
113
187
  const widthOf = (model) => model.lines.reduce((most, line) => Math.max(most, line.length), 0);
114
188
  const offset = (model) => (model.hasHeader ? 1 : 0);
115
189
  const bodyCount = (model) => model.lines.length - offset(model);
116
- const filler = (model, u) => (model.kind === "chart" && !(model.hasHeader && u === 0) ? null : "");
190
+ // Column names must be unique where a dataset, `fields` or a chart `mapping` addresses them, so a new column of such a
191
+ // target gets a distinct placeholder name instead of "".
192
+ const needsNames = (model) => Boolean(model.dataset) || model.mapping !== undefined;
193
+ function uniqueName(model, taken = []) {
194
+ const used = new Set([...namesOfModel(model), ...model.datasetNames, ...taken]);
195
+ let n = 1;
196
+ while (used.has(n === 1 ? "New column" : `New column ${n}`)) n += 1;
197
+ return n === 1 ? "New column" : `New column ${n}`;
198
+ }
199
+ // What a new, empty cell holds: a chart's and a dataset's cells are null (a gap), a table's are "".
200
+ const emptyCell = (model) => (model.kind === "chart" || model.dataset ? null : "");
201
+ const filler = (model, u) => (model.hasHeader && u === 0 ? (needsNames(model) ? uniqueName(model) : "") : emptyCell(model));
117
202
 
118
203
  function padLines(model) {
119
204
  model.width = widthOf(model);
120
205
  model.lines.forEach((line, u) => {
121
206
  while (line.length < model.width) line.push(filler(model, u));
122
207
  });
208
+ while (model.colOrigin.length < model.width) model.colOrigin.push(null);
123
209
  }
124
210
 
125
211
  const addressOf = (model, u) => (model.hasHeader && u === 0 ? { section: "header", row: 0 } : { section: "body", row: u - offset(model) });
@@ -208,17 +294,94 @@ export function cellText(raw, kind, decimal = ".") {
208
294
  return String(value);
209
295
  }
210
296
 
211
- const looksNumeric = (value) => typeof value === "number" ? Number.isFinite(value) : typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
212
297
  const isScatter = (type) => typeof type === "string" && /^(scatter|bubble)/.test(type);
213
298
 
214
- /** What each column of a chart is for: "category" (column A), "series", and for a scatter chart with three or more columns "label" and "x". */
215
- export function chartColumnRoles(columnCount, chartType) {
299
+ // RR-54: core's strict chart number and XY test. A core that predates them (the installed range still allows one) keeps the
300
+ // editor working on documents that use none of the new fields: numbers and scatter-like type ids read as the editor always read them.
301
+ const chartNumber = (value) =>
302
+ typeof core.chartNumber === "function" ? core.chartNumber(value) : typeof value === "number" ? (Number.isFinite(value) ? value : null) : typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) ? Number(value) : null;
303
+ /** Whether the installed core has the RR-54 chart and table data contract (number formats, datasets, `chartNumber`); an older core edits documents that use none of it. */
304
+ export const supportsChartTableData = ["chartNumber", "numberFormatError", "inlineChartData", "inlineTableData", "isXYChartType"].every((name) => typeof core[name] === "function");
305
+ const isXYType = (type) => (typeof core.isXYChartType === "function" ? core.isXYChartType(type) : isScatter(type));
306
+
307
+ /**
308
+ * A number as the slide draws it: the column's format (a table body cell's own format wins) through core's `formatDataNumber`, or undefined when
309
+ * the cell is not a number, has no valid format, or the format changes nothing. A chart cell is read by `chartNumber`, as the renderer does, and
310
+ * only in a column the chart plots as numbers; a table cell is formatted only when it holds a number. The cell's `text` (the raw value) is never
311
+ * replaced: editing and copying use it.
312
+ */
313
+ function displayText(model, raw, column, role, text) {
314
+ if (!supportsChartTableData || typeof core.formatDataNumber !== "function") return undefined;
315
+ const format = (model.kind === "table" ? formatOf(raw) : undefined) ?? (model.hasHeader ? formatOf(model.lines[0]?.[column]) : undefined);
316
+ if (format === undefined || core.numberFormatError(format) !== undefined) return undefined;
317
+ const value = valueOf(raw);
318
+ let number = null;
319
+ if (model.kind === "chart") number = isNumericRole(role) && (typeof value === "number" || typeof value === "string") ? chartNumber(value) : null;
320
+ else if (typeof value === "number" && Number.isFinite(value)) number = value;
321
+ if (number === null) return undefined;
322
+ const shown = core.formatDataNumber(number, format);
323
+ return shown === text ? undefined : shown;
324
+ }
325
+
326
+ /**
327
+ * The default X column of an XY chart (core's rule): the second column, or the first when the category is the second, and only
328
+ * with three or more columns. With two, the second column is the one series, plotted against row numbers.
329
+ */
330
+ const defaultX = (category, count) => (count > 2 ? (category === 1 ? 0 : 1) : undefined);
331
+
332
+ /**
333
+ * The columns a chart plots, by index: `{ category, x, series }`. `mapping` names them (`{ category?, x?, series? }`); a missing or
334
+ * unknown name falls back to core's positional default. `x` is only set for an XY chart.
335
+ */
336
+ function mappingColumns(count, xy, mapping, names) {
337
+ const known = (name) => (typeof name === "string" ? names.indexOf(name) : -1);
338
+ const map = isObject(mapping) ? mapping : {};
339
+ let category = known(map.category);
340
+ if (category < 0) category = 0;
341
+ let x;
342
+ if (xy) {
343
+ x = known(map.x);
344
+ if (x < 0 || x === category) x = defaultX(category, count);
345
+ if (x !== undefined && (x >= count || x === category)) x = undefined;
346
+ }
347
+ let series;
348
+ if (Array.isArray(map.series)) {
349
+ series = [];
350
+ for (const name of map.series) {
351
+ const index = known(name);
352
+ if (index >= 0 && index !== category && index !== x && !series.includes(index)) series.push(index);
353
+ }
354
+ } else series = Array.from({ length: count }, (_, index) => index).filter((index) => index !== category && index !== x);
355
+ // An X column needs a series beside it; otherwise core plots it as the series against row numbers.
356
+ if (x !== undefined && !series.length) {
357
+ series = [x];
358
+ x = undefined;
359
+ }
360
+ return { category, x, series };
361
+ }
362
+
363
+ /**
364
+ * What each column of a chart is for: "category" (column A), "series", and for a scatter chart with three or more columns
365
+ * "label" and "x". With a `mapping` (and the column `names` it addresses) the roles follow it: "category" (the label column of a
366
+ * chart with no X axis), "label" and "x" for an XY chart, "series" for a plotted column and "other" for a column nothing plots.
367
+ */
368
+ export function chartColumnRoles(columnCount, chartType, mapping, names) {
369
+ if (isObject(mapping) && Array.isArray(names) && columnCount > 0) {
370
+ const xy = isXYType(chartType);
371
+ const { category, x, series } = mappingColumns(columnCount, xy, mapping, names);
372
+ const roles = Array(columnCount).fill("other");
373
+ for (const index of series) roles[index] = "series";
374
+ roles[category] = xy ? "label" : "category";
375
+ if (x !== undefined) roles[x] = "x";
376
+ return roles;
377
+ }
216
378
  if (columnCount <= 1) return Array(columnCount).fill("series");
217
379
  if (isScatter(chartType) && columnCount >= 3) return ["label", "x", ...Array(columnCount - 2).fill("series")];
218
380
  return ["category", ...Array(columnCount - 1).fill("series")];
219
381
  }
220
382
 
221
- const isNumericColumn = (model, column) => model.kind === "chart" && !["category", "label"].includes(chartColumnRoles(model.width, model.chartType)[column] ?? "series");
383
+ const rolesOf = (model) => (model.kind === "chart" ? chartColumnRoles(model.width, model.chartType, model.mapping, namesOfModel(model)) : []);
384
+ const isNumericRole = (role) => role === undefined || role === "series" || role === "x";
222
385
 
223
386
  // --- describing --------------------------------------------------------------------------------
224
387
 
@@ -234,7 +397,7 @@ export function describeDataGrid(document, path, options = {}) {
234
397
  const model = toModel(found);
235
398
  padLines(model);
236
399
  const owners = ownersOf(model);
237
- const roles = model.kind === "chart" ? chartColumnRoles(model.width, model.chartType) : [];
400
+ const roles = rolesOf(model);
238
401
  const warnings = [];
239
402
  const names = new Map();
240
403
  const lines = model.lines.map((line, u) =>
@@ -256,21 +419,27 @@ export function describeDataGrid(document, path, options = {}) {
256
419
  };
257
420
  if (Array.isArray(value)) cell.runs = value;
258
421
  if (isStyled(raw) && raw.style) cell.style = raw.style;
422
+ if (!owner && address.section === "body") {
423
+ const display = displayText(model, raw, column, roles[column], cell.text);
424
+ if (display !== undefined) cell.display = display;
425
+ }
426
+ if (address.section === "header" && formatOf(raw) !== undefined) cell.format = formatOf(raw);
259
427
  if (owner) cell.owner = { line: owner.u, column: owner.c };
260
428
  if (model.kind === "chart") {
261
429
  let warning;
262
430
  if (u === 0) {
263
431
  const name = String(value ?? "");
264
- if (column > 0 || model.width === 1) {
432
+ if (roles[column] !== "category" && roles[column] !== "label") {
265
433
  if (name.trim() === "") warning = "This series has no name.";
266
434
  else if (names.has(name)) warning = `Another series is also named "${name}".`;
267
435
  names.set(name, column);
268
436
  }
269
437
  } else if (roles[column] === "category" || roles[column] === "label") {
270
438
  if (roles[column] === "category" && (value === null || value === undefined || value === "")) warning = "This row has no category label.";
439
+ } else if (roles[column] === "other") {
440
+ // A column the chart does not plot may hold anything.
271
441
  } else if (typeof value === "boolean") warning = "A yes/no value is not a number, so the chart draws a gap here.";
272
- else if (value !== null && value !== undefined && !(typeof value === "number" || looksNumeric(value)) && String(value).trim() !== "")
273
- warning = "This is not a number, so the chart draws a gap here.";
442
+ else if (value !== null && value !== undefined && chartNumber(value) === null && String(value).trim() !== "") warning = "This is not a number, so the chart draws a gap here.";
274
443
  if (warning) {
275
444
  cell.warning = warning;
276
445
  warnings.push({ section: address.section, row: address.row, column, message: `${describeAddress({ ...address, column })}: ${warning}` });
@@ -287,6 +456,10 @@ export function describeDataGrid(document, path, options = {}) {
287
456
  rowCount: bodyCount(model),
288
457
  columnCount: model.width,
289
458
  columnRoles: roles,
459
+ columnFormats: Array.from({ length: model.width }, (_, column) => (model.hasHeader ? formatOf(model.lines[0]?.[column]) : undefined)),
460
+ ...(model.hasHeader ? { columnNames: namesOfModel(model) } : {}),
461
+ ...(found.dataset ? { dataset: { id: found.dataset.id, ...(found.dataset.fields ? { fields: [...found.dataset.fields] } : {}), items: datasetUsage(document, found.dataset.id).map((entry) => entry.path) } } : {}),
462
+ ...(model.mapping ? { mapping: clone(model.mapping) } : {}),
290
463
  lines,
291
464
  warnings,
292
465
  merges: rectsOf(model).map((rect) => ({ ...addressOf(model, rect.u), column: rect.c, rowSpan: rect.rs, colSpan: rect.cs })),
@@ -336,24 +509,164 @@ export function gridPatches(parts, before, after) {
336
509
  return patches;
337
510
  }
338
511
 
512
+ /** Like {@link gridPatches}, and also for a value that is added (`before` is undefined) or removed (`after` is undefined). */
513
+ function valuePatches(parts, before, after) {
514
+ if (before === undefined && after === undefined) return [];
515
+ const pointer = opfPathToJsonPointer(parts);
516
+ if (before === undefined) return [{ op: "add", path: pointer, value: clone(after) }];
517
+ if (after === undefined) return [{ op: "test", path: pointer, value: clone(before) }, { op: "remove", path: pointer }];
518
+ return gridPatches(parts, before, after);
519
+ }
520
+
521
+ // --- following columns across an operation -------------------------------------------------------
522
+
523
+ /** The column renames an operation made: a Map from the old name to the new one, for the columns that survived with another name. */
524
+ function renamesOf(model) {
525
+ const names = namesOfModel(model);
526
+ const renames = new Map();
527
+ model.colOrigin.forEach((origin, column) => {
528
+ if (origin === null || origin === undefined) return;
529
+ const from = model.origNames[origin];
530
+ const to = names[column];
531
+ if (from !== undefined && to !== undefined && from !== to) renames.set(from, to);
532
+ });
533
+ return renames;
534
+ }
535
+
536
+ /**
537
+ * A chart `mapping` after an operation: a renamed column keeps its role, a column that no longer exists leaves the mapping
538
+ * (an emptied `series` is dropped, which restores "every other column"). A name the data never had is left alone. Returns
539
+ * undefined when nothing is left.
540
+ */
541
+ function reconcileMapping(mapping, renames, before, after) {
542
+ if (!isObject(mapping)) return mapping;
543
+ const keep = (name) => {
544
+ const next = renames.has(name) ? renames.get(name) : name;
545
+ if (after.includes(next)) return next;
546
+ return before.includes(name) ? undefined : name;
547
+ };
548
+ const out = {};
549
+ for (const key of ["category", "x"]) {
550
+ if (typeof mapping[key] !== "string") continue;
551
+ const name = keep(mapping[key]);
552
+ if (name !== undefined) out[key] = name;
553
+ }
554
+ if (Array.isArray(mapping.series)) {
555
+ const series = mapping.series.map(keep).filter((name) => name !== undefined);
556
+ if (series.length) out.series = series;
557
+ }
558
+ return Object.keys(out).length ? out : undefined;
559
+ }
560
+
561
+ /** The changes to an inline chart or table: its data object, and the chart's `mapping` when a rename or deletion touches it. */
562
+ function inlineChanges(found, model, style) {
563
+ const after = fromModel(model, found.data);
564
+ if (style && style.preset !== "custom") applyTableStyleToTable(after, style.preset);
565
+ const changes = [{ parts: found.dataParts, before: found.data, after }];
566
+ if (found.kind === "chart" && found.owner.mapping !== undefined) {
567
+ const next = reconcileMapping(found.owner.mapping, renamesOf(model), model.origNames, namesOfModel(model));
568
+ if (!same(found.owner.mapping, next)) changes.push({ parts: [...found.parts, "mapping"], before: found.owner.mapping, after: next });
569
+ }
570
+ return changes;
571
+ }
572
+
573
+ /**
574
+ * The changes to a chart or table that shows a dataset: the dataset itself (the shown columns mapped back to its own column
575
+ * indices, each row to the dataset row it came from, so columns `fields` hides keep their data), the item's `fields` and `mapping`,
576
+ * and the `fields` and `mapping` of every other item that shares the dataset when a column is renamed. A column that
577
+ * `fields` selects leaves only `fields` when deleted; without `fields` it leaves the dataset, which another item's `fields` or
578
+ * `mapping` that names it forbids.
579
+ */
580
+ function datasetChanges(document, found, model) {
581
+ const { dataset, data } = found;
582
+ padLines(model);
583
+ const header = model.lines[0];
584
+ const names = namesOfModel(model);
585
+ const body = model.lines.slice(1);
586
+ const origin = model.colOrigin;
587
+ let columns;
588
+ let rows;
589
+ if (!dataset.fields) {
590
+ columns = header.map((raw) => raw);
591
+ rows = body.map((line) => line.map((raw) => raw));
592
+ } else {
593
+ const survivors = new Map();
594
+ origin.forEach((o, column) => {
595
+ if (o !== null && o !== undefined) survivors.set(dataset.indices[o], column);
596
+ });
597
+ columns = data.columns.map((column, index) => (survivors.has(index) ? header[survivors.get(index)] : column));
598
+ const position = new Map();
599
+ origin.forEach((o, column) => {
600
+ if (o !== null && o !== undefined) position.set(column, dataset.indices[o]);
601
+ else {
602
+ position.set(column, columns.length);
603
+ columns.push(header[column]);
604
+ }
605
+ });
606
+ rows = body.map((line) => {
607
+ const source = line[LINE_ORIGIN] !== undefined && Array.isArray(data.rows[line[LINE_ORIGIN]]) ? [...data.rows[line[LINE_ORIGIN]]] : [];
608
+ while (source.length < columns.length) source.push(null);
609
+ line.forEach((raw, column) => {
610
+ source[position.get(column)] = raw;
611
+ });
612
+ return source;
613
+ });
614
+ }
615
+ const renames = renamesOf(model);
616
+ const others = [];
617
+ const ownPath = found.parts.join(".");
618
+ walkDatasetItems(document, (entry) => {
619
+ if (entry.id === dataset.id && entry.parts.join(".") !== ownPath) others.push(entry);
620
+ });
621
+ if (!dataset.fields) {
622
+ const gone = model.origNames.filter((_, index) => !origin.includes(index));
623
+ for (const name of gone) {
624
+ const users = others.filter((entry) => (Array.isArray(entry.ref.fields) && entry.ref.fields.includes(name)) || (isObject(entry.item.mapping) && [entry.item.mapping.category, entry.item.mapping.x, ...(entry.item.mapping.series ?? [])].includes(name)));
625
+ if (users.length)
626
+ throw fail("dataset-column-in-use", `The column ${JSON.stringify(name)} is used by ${users.length === 1 ? "another chart or table" : `${users.length} other charts and tables`} that share the dataset '${dataset.id}'. Remove it there first, or use a copy of the data here.`, { column: name, items: users.map((entry) => entry.parts.join(".")) });
627
+ }
628
+ }
629
+ const changes = [{ parts: found.dataParts, before: data, after: { ...data, columns, rows } }];
630
+ if (dataset.fields) changes.push({ parts: [...dataset.refParts, "fields"], before: dataset.ref.fields, after: names });
631
+ if (found.kind === "chart" && found.owner.mapping !== undefined) {
632
+ const next = reconcileMapping(found.owner.mapping, renames, model.origNames, names);
633
+ if (!same(found.owner.mapping, next)) changes.push({ parts: [...found.parts, "mapping"], before: found.owner.mapping, after: next });
634
+ }
635
+ if (renames.size) {
636
+ for (const entry of others) {
637
+ if (Array.isArray(entry.ref.fields) && entry.ref.fields.some((name) => renames.has(name)))
638
+ changes.push({ parts: [...entry.refParts, "fields"], before: entry.ref.fields, after: entry.ref.fields.map((name) => (renames.has(name) ? renames.get(name) : name)) });
639
+ if (entry.kind === "chart" && isObject(entry.item.mapping)) {
640
+ const mapping = entry.item.mapping;
641
+ const rename = (name) => (renames.has(name) ? renames.get(name) : name);
642
+ const next = { ...mapping };
643
+ if (typeof mapping.category === "string") next.category = rename(mapping.category);
644
+ if (typeof mapping.x === "string") next.x = rename(mapping.x);
645
+ if (Array.isArray(mapping.series)) next.series = mapping.series.map(rename);
646
+ if (!same(mapping, next)) changes.push({ parts: [...entry.parts, "mapping"], before: mapping, after: next });
647
+ }
648
+ }
649
+ }
650
+ return changes;
651
+ }
652
+
339
653
  function transact(document, path, action, mutate, extra = {}) {
340
654
  const found = locateGridData(document, path);
341
655
  if (extra.kind && extra.kind !== found.kind) throw fail("grid-wrong-kind", `This operation works on a ${extra.kind}, and the path points at a ${found.kind}.`, { path });
342
656
  const model = toModel(found);
343
657
  const problemsBefore = mergeProblems(model).length;
344
658
  const info = mutate(model, found) ?? {};
345
- const style = info.restyle && found.kind === "table" ? readTableStyleOfTable(found.data) : undefined;
659
+ const style = info.restyle && found.kind === "table" && !found.dataset ? readTableStyleOfTable(found.data) : undefined;
346
660
  if (info.touchesStructure) assertMerges(model, problemsBefore, info.what ?? "This change");
347
661
  if (style && style.preset !== "custom") padLines(model);
348
- const after = fromModel(model, found.data);
349
- if (style && style.preset !== "custom") applyTableStyleToTable(after, style.preset);
350
- const patches = gridPatches(found.dataParts, found.data, after);
662
+ const changes = found.dataset ? datasetChanges(document, found, model) : inlineChanges(found, model, style);
663
+ const patches = changes.flatMap((entry) => valuePatches(entry.parts, entry.before, entry.after));
351
664
  const changed = patches.length > 0;
352
665
  const before = validateOpfDocument(document);
353
666
  const result = changed ? checkedDocument(document, patches, before) : document;
354
667
  // `restyle`, `touchesStructure` and `what` steer this function only; the rest of what the operation reports is the caller's.
355
668
  const { restyle: _restyle, touchesStructure: _structure, what: _what, ...summary } = info;
356
- return { action, ...summary, kind: found.kind, path: found.parts.join("."), document: clone(result), patches, changed };
669
+ return { action, ...summary, kind: found.kind, path: found.parts.join("."), ...(found.dataset ? { dataset: found.dataset.id } : {}), document: clone(result), patches, changed };
357
670
  }
358
671
 
359
672
  // --- row and column structure ------------------------------------------------------------------
@@ -370,7 +683,7 @@ function insertRows(model, at, count) {
370
683
  const u = at + offset(model);
371
684
  const crossing = rectsOf(model).filter((rect) => rect.u < u && u < rect.u + rect.rs);
372
685
  const reference = u - 1 >= offset(model) ? model.lines[u - 1] : u < model.lines.length ? model.lines[u] : undefined;
373
- const fresh = Array.from({ length: count }, () => Array.from({ length: model.width }, (_, column) => (model.kind === "chart" ? null : referenceCell(model, reference, column))));
686
+ const fresh = Array.from({ length: count }, () => Array.from({ length: model.width }, (_, column) => (model.kind === "chart" || model.dataset ? null : referenceCell(model, reference, column))));
374
687
  model.lines.splice(u, 0, ...fresh);
375
688
  for (const rect of crossing) {
376
689
  model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs + count, rect.cs);
@@ -403,10 +716,14 @@ function insertColumns(model, at, count) {
403
716
  if (!Number.isInteger(at) || at < 0 || at > model.width) throw fail("grid-column-out-of-range", `A column can be inserted at positions 1 to ${model.width + 1}.`, { at });
404
717
  const crossing = rectsOf(model).filter((rect) => rect.c < at && at < rect.c + rect.cs);
405
718
  const reference = at - 1 >= 0 ? at - 1 : at < model.width ? at : -1;
719
+ // New columns of a dataset (or of a chart with a mapping) are named at once: names must stay unique.
720
+ const named = [];
721
+ if (model.hasHeader && needsNames(model)) for (let i = 0; i < count; i += 1) named.push(uniqueName(model, named));
406
722
  model.lines.forEach((line, u) => {
407
- const fresh = Array.from({ length: count }, () => (model.kind === "chart" || (model.hasHeader && u === 0) ? filler(model, u) : referenceCell(model, line, reference)));
723
+ const fresh = Array.from({ length: count }, (_, i) => (model.hasHeader && u === 0 && needsNames(model) ? named[i] : model.kind === "chart" || model.dataset || (model.hasHeader && u === 0) ? filler(model, u) : referenceCell(model, line, reference)));
408
724
  line.splice(at, 0, ...fresh);
409
725
  });
726
+ model.colOrigin.splice(at, 0, ...Array(count).fill(null));
410
727
  for (const rect of crossing) {
411
728
  model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs, rect.cs + count);
412
729
  for (let y = rect.u; y < rect.u + rect.rs; y += 1) for (let i = 0; i < count; i += 1) model.lines[y][at + i] = null;
@@ -422,6 +739,7 @@ function deleteColumn(model, x) {
422
739
  else model.lines[rect.u][x + 1] = withSpans(anchor, rect.rs, rect.cs - 1);
423
740
  }
424
741
  for (const line of model.lines) line.splice(x, 1);
742
+ model.colOrigin.splice(x, 1);
425
743
  model.width -= 1;
426
744
  }
427
745
 
@@ -453,6 +771,7 @@ function moveColumns(model, from, count, to) {
453
771
  throw fail("grid-column-out-of-range", "That move leaves the table.", { from, count, to });
454
772
  if (from === to) return;
455
773
  for (const line of model.lines) moveBlock(line, from, count, to);
774
+ moveBlock(model.colOrigin, from, count, to);
456
775
  }
457
776
 
458
777
  // --- sorting -----------------------------------------------------------------------------------
@@ -529,6 +848,7 @@ function sortRows(model, column, options) {
529
848
 
530
849
  function setHeader(model, enabled, options) {
531
850
  if (model.kind !== "table") throw fail("grid-wrong-kind", "Only a table has a header row to turn on or off. A chart's first line always names its series.", {});
851
+ if (model.dataset) throw fail("grid-dataset-shared", `A table that shows the dataset '${model.dataset.id}' always has a header row: the dataset's column names.`, { dataset: model.dataset.id });
532
852
  if (enabled === model.hasHeader) return {};
533
853
  padLines(model);
534
854
  if (enabled) {
@@ -547,17 +867,23 @@ function setHeader(model, enabled, options) {
547
867
  model.hasHeader = true;
548
868
  return { header: true, use, restyle: true, touchesStructure: true, what: "A header row" };
549
869
  }
870
+ // A DataColumn header (`{ name, format }`) is not a body cell: it becomes its name. Its column format has nowhere to go and is dropped.
871
+ const dropped = model.lines[0].filter((raw) => isColumnObject(raw) && formatOf(raw) !== undefined).length;
872
+ model.lines[0] = model.lines[0].map((raw) => (isColumnObject(raw) ? raw.name : raw));
550
873
  model.hasHeader = false;
551
- return { header: false, restyle: true };
874
+ return { header: false, restyle: true, ...(dropped ? { droppedFormats: dropped } : {}) };
552
875
  }
553
876
 
554
877
  // --- transpose ---------------------------------------------------------------------------------
555
878
 
556
879
  function transposeChart(model) {
557
880
  if (model.kind !== "chart") throw fail("grid-wrong-kind", "Only chart data can be transposed.", {});
881
+ if (model.dataset) throw fail("grid-dataset-shared", `Swapping categories and series would reshape the shared dataset '${model.dataset.id}' for every chart and table that uses it. Use a copy of the data first.`, { dataset: model.dataset.id });
558
882
  padLines(model);
559
883
  if (model.width < 2) throw fail("chart-transpose-empty", "Transposing needs at least one series column besides the categories.", {});
560
- const [names, ...rows] = model.lines;
884
+ const [headers, ...rows] = model.lines;
885
+ // A column's number format belongs to its numbers: the old series names become category labels, as text.
886
+ const names = headers.map((cell) => String(valueOf(cell) ?? ""));
561
887
  let relabelled = 0;
562
888
  const categories = rows.map((row) => {
563
889
  const category = row[0];
@@ -568,6 +894,8 @@ function transposeChart(model) {
568
894
  const lines = [[names[0], ...categories], ...names.slice(1).map((name, index) => [name, ...rows.map((row) => row[index + 1] ?? null)])];
569
895
  model.lines = lines;
570
896
  model.width = widthOf(model);
897
+ // Every column is new: a mapping that named the old columns no longer applies.
898
+ model.colOrigin = Array(model.width).fill(null);
571
899
  return { rowsBefore: rows.length, columnsBefore: names.length, relabelled };
572
900
  }
573
901
 
@@ -575,20 +903,29 @@ function transposeChart(model) {
575
903
 
576
904
  function growTo(model, lines, width) {
577
905
  padLines(model);
578
- while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => (model.kind === "chart" ? null : "")));
906
+ while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => emptyCell(model)));
579
907
  while (model.width < width) {
580
908
  model.lines.forEach((line, u) => line.push(filler(model, u)));
581
909
  model.width += 1;
910
+ model.colOrigin.push(null);
582
911
  }
583
912
  }
584
913
 
585
- function newRaw(model, raw, text, decimal, address) {
914
+ // The column names of a shared dataset (and of a chart with a mapping) are how `fields` and `mapping` address columns: never blank.
915
+ const blankNameError = (model, address, text) =>
916
+ address.section === "header" && model.hasHeader && needsNames(model) && String(text ?? "").trim() === ""
917
+ ? `A column name cannot be empty here: ${model.dataset ? "the shared dataset" : "the chart's column mapping"} addresses columns by name.`
918
+ : undefined;
919
+
920
+ function newRaw(model, raw, text, decimal, address, roles) {
586
921
  const value = valueOf(raw);
922
+ const blank = blankNameError(model, address, text);
923
+ if (blank) return { error: blank };
587
924
  // Text that is already what the cell shows is left alone: a number kept as text stays text, runs keep their styling.
588
925
  const shown = cellText(raw, model.kind, decimal);
589
926
  if (Array.isArray(value)) {
590
927
  if (text.replace(/\r\n?/g, "\n") === shown.replace(/\r\n?/g, "\n")) return { skip: true };
591
- return { raw: isStyled(raw) ? { ...raw, value: updateRichTextInput(value, text) } : updateRichTextInput(value, text) };
928
+ return { raw: withValue(raw, updateRichTextInput(value, text)) };
592
929
  }
593
930
  if (text === shown) return { skip: true };
594
931
  let next;
@@ -596,17 +933,21 @@ function newRaw(model, raw, text, decimal, address) {
596
933
  if (model.hasHeader && address.section === "header") next = text;
597
934
  else next = text === "" ? "" : isCanonicalNumber(text) ? Number(text) : text;
598
935
  } else if (address.section === "header") next = text;
599
- else if (isNumericColumn(model, address.column)) {
936
+ else if (isNumericRole(roles[address.column])) {
937
+ // A chart number is read in the grid's number format and kept as a number. Text that is not a number is refused with
938
+ // a reason; it is never stored as a guess (core's chartNumber would draw it as a gap).
600
939
  const parsed = parseGridNumber(text, { decimal });
601
940
  if (parsed.error) return { error: parsed.error };
602
941
  next = parsed.empty ? null : parsed.value;
603
942
  if (next === null && (value === null || value === undefined)) return { skip: true };
604
- } else next = text === "" ? null : text;
605
- return { raw: isStyled(raw) ? { ...raw, value: next } : next };
943
+ } else if (roles[address.column] === "other") next = text === "" ? null : isCanonicalNumber(text) ? Number(text) : text;
944
+ else next = text === "" ? null : text;
945
+ return { raw: withValue(raw, next) };
606
946
  }
607
947
 
608
948
  function applyEdits(model, edits, decimal, owners) {
609
949
  const issues = [];
950
+ const roles = rolesOf(model);
610
951
  for (const edit of edits) {
611
952
  const address = { section: edit.section, row: edit.row ?? 0, column: edit.column };
612
953
  try {
@@ -620,11 +961,14 @@ function applyEdits(model, edits, decimal, owners) {
620
961
  const value = edit.value;
621
962
  if (model.kind === "chart" && !(value === null || ["string", "number", "boolean"].includes(typeof value))) throw fail("invalid-grid-value", "A chart cell is text, a number, true or false, or empty.", { cell: address });
622
963
  if (typeof value === "number" && !Number.isFinite(value)) throw fail("invalid-grid-value", "A cell cannot hold Infinity or NaN.", { cell: address });
623
- model.lines[u][address.column] = isStyled(raw) ? { ...raw, value } : value;
964
+ if (model.dataset && address.section === "header" && typeof value !== "string") throw fail("invalid-grid-value", "A dataset column name is text.", { cell: address });
965
+ const blank = blankNameError(model, address, value);
966
+ if (blank && !(valueOf(raw) === "" && value === "")) throw fail("invalid-grid-value", blank, { cell: address });
967
+ model.lines[u][address.column] = withValue(raw, value);
624
968
  continue;
625
969
  }
626
970
  if (typeof edit.text !== "string") throw fail("invalid-grid-value", "Give the cell's new text.", { cell: address });
627
- const outcome = newRaw(model, raw, edit.text, decimal, address);
971
+ const outcome = newRaw(model, raw, edit.text, decimal, address, roles);
628
972
  if (outcome.error) issues.push({ ...address, message: `${describeAddress(address)}: ${outcome.error}` });
629
973
  else if (!outcome.skip) model.lines[u][address.column] = outcome.raw;
630
974
  } catch (error) {
@@ -651,7 +995,7 @@ function pasteRows(model, anchor, matrix, options) {
651
995
  if (!Array.isArray(matrix) || !matrix.length) throw fail("invalid-grid-value", "There is nothing to paste.", {});
652
996
  const first = decimalOf(options);
653
997
  const attempt = (decimal) => {
654
- const trial = { ...model, lines: clone(model.lines) };
998
+ const trial = { ...model, lines: cloneLines(model.lines), colOrigin: [...model.colOrigin] };
655
999
  const u0 = lineIndex(trial, anchor);
656
1000
  const width = matrix.reduce((most, row) => Math.max(most, row.length), 0);
657
1001
  growTo(trial, u0 + matrix.length, anchor.column + width);
@@ -678,6 +1022,7 @@ function pasteRows(model, anchor, matrix, options) {
678
1022
  if (result.issues.length) throw refuse(result.issues);
679
1023
  model.lines = result.trial.lines;
680
1024
  model.width = result.trial.width;
1025
+ model.colOrigin = result.trial.colOrigin;
681
1026
  return { cells: result.cells, decimal, rows: matrix.length, restyle: true, touchesStructure: model.kind === "table", what: "Pasting" };
682
1027
  }
683
1028
 
@@ -781,6 +1126,141 @@ export function prepareTranspose(document, path) {
781
1126
  return transact(document, path, "transpose", (model) => transposeChart(model), { kind: "chart" });
782
1127
  }
783
1128
 
1129
+ // --- number format of a column (RR-54) ---------------------------------------------------------
1130
+
1131
+ /** Why `format` is not a valid number format ("#,##0", "0.0%", "$#,##0.00"), or undefined when it is valid or empty (empty clears the format). */
1132
+ export function columnFormatError(format) {
1133
+ if (format === undefined || format === null || format === "") return undefined;
1134
+ if (!supportsChartTableData) return "This version of OPF has no number formats; update @openpresentation/opf.";
1135
+ return core.numberFormatError(format);
1136
+ }
1137
+
1138
+ /** The header `raw` with `format` set, or cleared (`undefined`). A string header becomes `{ name, format }` and goes back to a string when the format is cleared. */
1139
+ function withColumnFormat(raw, format) {
1140
+ if (isColumnObject(raw)) {
1141
+ const next = { ...raw };
1142
+ if (format === undefined) delete next.format;
1143
+ else next.format = format;
1144
+ return Object.keys(next).length === 1 ? next.name : next;
1145
+ }
1146
+ if (isStyled(raw)) {
1147
+ const next = { ...raw };
1148
+ if (format === undefined) delete next.format;
1149
+ else next.format = format;
1150
+ return Object.keys(next).length === 1 ? next.value : next;
1151
+ }
1152
+ if (format === undefined) return raw;
1153
+ if (Array.isArray(raw)) return { value: raw, format };
1154
+ return { name: raw === null || raw === undefined ? "" : String(raw), format };
1155
+ }
1156
+
1157
+ function setColumnFormat(model, column, format) {
1158
+ if (!model.hasHeader) throw fail("grid-no-header", "This table has no header row, so there is no column heading to hold a number format. Turn on the header row first.", {});
1159
+ padLines(model);
1160
+ if (!Number.isInteger(column) || column < 0 || column >= model.width) throw fail("grid-column-out-of-range", "Choose a column.", { column });
1161
+ if (format !== undefined && format !== null && typeof format !== "string") throw fail("number-format-invalid", 'A number format is text such as "#,##0" or "0.0%".', { format });
1162
+ const next = typeof format === "string" && format !== "" ? format : undefined;
1163
+ const error = columnFormatError(next);
1164
+ if (error) throw fail("number-format-invalid", error, { format: next });
1165
+ if (ownersOf(model).has(`0:${column}`)) throw fail("table-cell-covered", `${describeAddress({ section: "header", row: 0, column })} is covered by a merged cell. Choose the merged heading.`, { column });
1166
+ model.lines[0][column] = withColumnFormat(model.lines[0][column], next);
1167
+ return { column, format: next ?? null };
1168
+ }
1169
+
1170
+ /** Set or clear (`null` or "") the number format of column `column` (its header cell). A string header becomes `{ name, format }` and returns to a string when the format is cleared. Refused with the reason when the format is invalid or the table has no header row. */
1171
+ export function prepareGridColumnFormat(document, path, column, format, options = {}) {
1172
+ return transact(document, path, "set-column-format", (model) => setColumnFormat(model, column, format), kindOf(options));
1173
+ }
1174
+
1175
+ // --- chart mapping (RR-54) -----------------------------------------------------------------------
1176
+
1177
+ /**
1178
+ * The columns a chart plots and how they got that role: `{ path, xy, columns: [{ name, format?, role }], category, x?, series,
1179
+ * authored? }`. `category`, `x` and `series` are column names (the mapping's, or core's positional default); `xy` is true for a chart
1180
+ * with an X axis (scatter); `authored` is the chart's own `mapping`, when it has one. Roles are "category", "label", "x", "series"
1181
+ * and "other" (a column nothing plots).
1182
+ */
1183
+ export function describeChartMapping(document, path) {
1184
+ const found = locateGridData(document, path);
1185
+ if (found.kind !== "chart") throw fail("grid-wrong-kind", "Only a chart has a series mapping.", { path });
1186
+ const model = toModel(found);
1187
+ padLines(model);
1188
+ const names = namesOfModel(model);
1189
+ const xy = isXYType(found.chartType);
1190
+ const { category, x, series } = mappingColumns(names.length, xy, model.mapping, names);
1191
+ const roles = chartColumnRoles(names.length, found.chartType, { ...(model.mapping ?? {}) }, names);
1192
+ return {
1193
+ path: found.parts.join("."),
1194
+ xy,
1195
+ columns: names.map((name, index) => ({ name, ...(formatOf(model.lines[0][index]) ? { format: formatOf(model.lines[0][index]) } : {}), role: roles[index] })),
1196
+ category: names[category],
1197
+ ...(x === undefined ? {} : { x: names[x] }),
1198
+ series: series.map((index) => names[index]),
1199
+ ...(model.mapping ? { authored: clone(model.mapping) } : {}),
1200
+ };
1201
+ }
1202
+
1203
+ /**
1204
+ * Set a chart's category, X and series columns by name. `wanted` is `{ category?, x?, series? }`; what it leaves out keeps its
1205
+ * current value. A field that equals the default (the first column is the category, the second the X column, every other column a
1206
+ * series, in order) is not written, and `chart.mapping` is removed when nothing is left. One patch.
1207
+ */
1208
+ export function prepareChartMapping(document, path, wanted = {}) {
1209
+ const found = locateGridData(document, path);
1210
+ if (found.kind !== "chart") throw fail("grid-wrong-kind", "Only a chart has a series mapping.", { path });
1211
+ const model = toModel(found);
1212
+ padLines(model);
1213
+ const names = namesOfModel(model);
1214
+ const xy = isXYType(found.chartType);
1215
+ const current = mappingColumns(names.length, xy, model.mapping, names);
1216
+ const pick = (name, what) => {
1217
+ const index = typeof name === "string" ? names.indexOf(name) : -1;
1218
+ if (index < 0) throw fail("chart-mapping-unknown-column", `The ${what} column ${JSON.stringify(name)} is not a column of this chart's data. Choose ${names.map((entry) => JSON.stringify(entry)).join(", ")}.`, { column: name });
1219
+ return index;
1220
+ };
1221
+ if (wanted.x !== undefined && !xy) throw fail("chart-mapping-x-unsupported", `A '${found.chartType ?? "chart"}' chart has no X axis, so it has no X column. Only a scatter chart does.`, { x: wanted.x });
1222
+ const category = wanted.category !== undefined ? pick(wanted.category, "category") : current.category;
1223
+ let x;
1224
+ if (xy) x = wanted.x !== undefined ? pick(wanted.x, "X") : current.x === category || current.x === undefined ? defaultX(category, names.length) : current.x;
1225
+ if (xy && x === category) throw fail("chart-mapping-conflict", "The X column and the category column must be different columns.", { category: names[category] });
1226
+ if (xy && x !== undefined && x >= names.length) throw fail("chart-mapping-conflict", "An XY chart needs a column for X besides the category.", {});
1227
+ const defaultSeries = names.map((_, index) => index).filter((index) => index !== category && index !== x);
1228
+ // Without its own `series`, a chart plots every other column: that follows a new category or X column.
1229
+ let series = wanted.series !== undefined ? wanted.series.map((name) => pick(name, "series")) : Array.isArray(model.mapping?.series) ? current.series : defaultSeries;
1230
+ series = series.filter((index, position) => index !== category && index !== x && series.indexOf(index) === position);
1231
+ if (!series.length) throw fail("chart-mapping-no-series", "Choose at least one series column to plot.", {});
1232
+ const next = {};
1233
+ if (category !== 0) next.category = names[category];
1234
+ if (xy && x !== undefined && x !== defaultX(category, names.length)) next.x = names[x];
1235
+ if (series.length !== defaultSeries.length || series.some((index, position) => index !== defaultSeries[position])) next.series = series.map((index) => names[index]);
1236
+ const after = Object.keys(next).length ? next : undefined;
1237
+ const before = validateOpfDocument(document);
1238
+ const patches = valuePatches([...found.parts, "mapping"], found.owner.mapping, after);
1239
+ const changed = patches.length > 0;
1240
+ const result = changed ? checkedDocument(document, patches, before) : document;
1241
+ return { action: "set-mapping", kind: "chart", path: found.parts.join("."), mapping: after ? clone(after) : null, document: clone(result), patches, changed };
1242
+ }
1243
+
1244
+ // --- shared datasets (RR-54) -----------------------------------------------------------------------
1245
+
1246
+ /**
1247
+ * Replace a chart's or table's dataset reference with its own inline copy of the data (the columns `fields` selects, formats and
1248
+ * the dataset's `source` kept). The dataset stays in the document for the other items. One patch; after it the item's edits no longer
1249
+ * reach the shared dataset.
1250
+ */
1251
+ export function prepareDetachDataset(document, path) {
1252
+ const found = locateGridData(document, path);
1253
+ if (!found.dataset) throw fail("grid-not-dataset", "This chart or table does not use a shared dataset.", { path });
1254
+ if (!supportsChartTableData) throw fail("grid-core-too-old", "This version of OPF cannot copy a shared dataset; update @openpresentation/opf.", { path });
1255
+ const inline = found.kind === "chart" ? core.inlineChartData(found.owner, document).data : core.inlineTableData(found.owner, document);
1256
+ const pointer = opfPathToJsonPointer(found.dataset.refParts);
1257
+ const patches = [{ op: "test", path: pointer, value: clone(found.dataset.ref) }, { op: "replace", path: pointer, value: clone(inline) }];
1258
+ const result = checkedDocument(document, patches, validateOpfDocument(document));
1259
+ return { action: "detach-dataset", kind: found.kind, path: found.parts.join("."), dataset: found.dataset.id, document: clone(result), patches, changed: true };
1260
+ }
1261
+
1262
+ export { datasetUsage };
1263
+
784
1264
  // --- copy --------------------------------------------------------------------------------------
785
1265
 
786
1266
  /**
@@ -834,3 +1314,9 @@ export const moveGridColumns = (editor, path, from, to, count = 1, options = {})
834
1314
  export const sortGridRows = (editor, path, column, options = {}) => run(editor, (document) => prepareSortRows(document, path, column, options), options);
835
1315
  export const setGridHeader = (editor, path, enabled, options = {}) => run(editor, (document) => prepareSetHeader(document, path, enabled, options), options);
836
1316
  export const transposeGridData = (editor, path, options = {}) => run(editor, (document) => prepareTranspose(document, path), options);
1317
+ /** Set (or clear, with `null` or "") the number format of a column's header as one undoable transaction. Throws `number-format-invalid` with the reason. See {@link columnFormatError}. */
1318
+ export const setGridColumnFormat = (editor, path, column, format, options = {}) => run(editor, (document) => prepareGridColumnFormat(document, path, column, format, options), options);
1319
+ /** Set a chart's category, X and series columns as one undoable transaction. See {@link prepareChartMapping}. */
1320
+ export const setChartMapping = (editor, path, wanted, options = {}) => run(editor, (document) => prepareChartMapping(document, path, wanted), options);
1321
+ /** Give a chart or table its own copy of a shared dataset's data, as one undoable transaction. See {@link prepareDetachDataset}. */
1322
+ export const detachGridDataset = (editor, path, options = {}) => run(editor, (document) => prepareDetachDataset(document, path), options);