@openpresentation/opf 0.11.4 → 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.
- package/README.md +16 -0
- package/dist/audit.d.ts +156 -0
- package/dist/audit.js +7 -0
- package/dist/{catalogs-CUClB3nl.d.ts → catalogs-BnI7JKcn.d.ts} +3 -3
- package/dist/catalogs.d.ts +2 -2
- package/dist/catalogs.js +2 -1
- package/dist/{chunk-KJKASEPE.js → chunk-4LAM3N6S.js} +753 -42
- package/dist/chunk-6LZK2IZB.js +1690 -0
- package/dist/{chunk-NSVVQ52O.js → chunk-AUM5MXBR.js} +8 -6
- package/dist/{chunk-AEUSVEUY.js → chunk-E34DJ5LL.js} +50 -14
- package/dist/chunk-FYLJVF7X.js +18448 -0
- package/dist/{chunk-HJL64ETN.js → chunk-GJKJL4YY.js} +14 -0
- package/dist/{chunk-3H2U7FDR.js → chunk-HOBSL7AJ.js} +1867 -247
- package/dist/chunk-JKVKYVWL.js +1332 -0
- package/dist/{chunk-3OJN7QAW.js → chunk-MWPMJE6P.js} +18 -4
- package/dist/chunk-RUC7L5DJ.js +261 -0
- package/dist/chunk-S5W34SIJ.js +1 -0
- package/dist/chunk-V2537ENL.js +198 -0
- package/dist/{chunk-RXNFDGPC.js → chunk-WXSPETQ6.js} +311 -40
- package/dist/{composition-Dl-d9FRj.d.ts → composition-DWbCiMwF.d.ts} +559 -10
- package/dist/composition.d.ts +1 -1
- package/dist/composition.js +2 -1
- package/dist/convert.d.ts +261 -0
- package/dist/convert.js +1107 -0
- package/dist/diff.d.ts +145 -0
- package/dist/diff.js +604 -0
- package/dist/docs.js +72 -18
- package/dist/examples.d.ts +1 -1
- package/dist/examples.js +112 -112
- package/dist/font-policy.d.ts +15 -0
- package/dist/font-policy.js +1 -1
- package/dist/format.d.ts +20 -0
- package/dist/format.js +83 -0
- package/dist/index.d.ts +173 -24
- package/dist/index.js +926 -262
- package/dist/lint.d.ts +4 -4
- package/dist/lint.js +6 -5
- package/dist/markdown.d.ts +74 -0
- package/dist/markdown.js +1941 -0
- package/dist/pagination.d.ts +1 -1
- package/dist/pagination.js +6 -5
- package/dist/patch.d.ts +99 -0
- package/dist/patch.js +6 -0
- package/dist/{presentation-j5PVrqfV.d.ts → presentation-DcF-MZcz.d.ts} +452 -17
- package/dist/repo-readme.js +1 -1
- package/dist/{schemas-X_NniU4A.d.ts → schemas-BSjlG8-R.d.ts} +28 -0
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +1 -1
- package/dist/spec/catalogs/audiences/customers.json +5 -1
- package/dist/spec/catalogs/audiences/executives.json +5 -1
- package/dist/spec/catalogs/audiences/index.json +20 -8
- package/dist/spec/catalogs/audiences/investors.json +5 -1
- package/dist/spec/catalogs/audiences/marketing-team.json +5 -1
- package/dist/spec/catalogs/audiences/regulators.json +5 -1
- package/dist/spec/catalogs/audiences/sales-team.json +5 -1
- package/dist/spec/catalogs/manifest.json +2 -2
- package/dist/spec/reference/engine-defaults.json +1 -1
- package/dist/spec/reference/font-policy.json +311 -40
- package/dist/spec/reference/font-policy.schema.json +40 -0
- package/dist/spec/reference/symbol-font-encodings.json +18354 -0
- package/dist/spec/reference/symbol-font-encodings.schema.json +156 -0
- package/dist/spec/schemas/audience.schema.json +7 -7
- package/dist/spec/schemas/narrative.schema.json +1 -1
- package/dist/spec/schemas/opf.schema.json +597 -28
- package/dist/spec-files.d.ts +1 -1
- package/dist/spec-files.js +1 -1
- package/dist/symbol-font-encodings.d.ts +128 -0
- package/dist/symbol-font-encodings.js +1 -0
- package/dist/types.d.ts +4 -4
- package/dist/validator-DfG9yxba.d.ts +172 -0
- package/dist/validator.d.ts +4 -36
- package/dist/validator.js +5 -4
- package/package.json +32 -3
- package/dist/chunk-FDCWQHWO.js +0 -1366
package/dist/convert.js
ADDED
|
@@ -0,0 +1,1107 @@
|
|
|
1
|
+
import { paginatePresentation, OPFPaginationError } from './chunk-AUM5MXBR.js';
|
|
2
|
+
import { CONTENT_KEYS, isRecord, contentKeysOf, refuse, clone, Loss, isSlideLike, assertValidOwner, report, OPFConversionError, plainOf, assertValidOutput, same, splitRuns, runsOf, lineIsPlain, linePlain, concatText, parseQuoteLines, parseCodeFence, splitWhen, parseListLine, levelsFromIndents, lineValue, joinLines, hasFormatting, fenceCode } from './chunk-V2537ENL.js';
|
|
3
|
+
export { OPFConversionError } from './chunk-V2537ENL.js';
|
|
4
|
+
import './chunk-JKVKYVWL.js';
|
|
5
|
+
import { promotedRegionKeys, visitContentPayloads } from './chunk-HOBSL7AJ.js';
|
|
6
|
+
import './chunk-S5W34SIJ.js';
|
|
7
|
+
import './chunk-E34DJ5LL.js';
|
|
8
|
+
import './chunk-4LAM3N6S.js';
|
|
9
|
+
|
|
10
|
+
// src/convert/tables.ts
|
|
11
|
+
function cellOf(raw) {
|
|
12
|
+
if (isRecord(raw) && Object.hasOwn(raw, "value")) {
|
|
13
|
+
return { value: raw.value, styled: isRecord(raw.style) && Object.keys(raw.style).length > 0, merged: (raw.colSpan ?? 1) > 1 || (raw.rowSpan ?? 1) > 1 };
|
|
14
|
+
}
|
|
15
|
+
return { value: raw, styled: false, merged: false };
|
|
16
|
+
}
|
|
17
|
+
function valueText(value) {
|
|
18
|
+
if (Array.isArray(value)) return value;
|
|
19
|
+
if (value === null || value === void 0) return "";
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
var valuePlain = (value) => Array.isArray(value) ? plainOf(value) : value === null || value === void 0 ? "" : String(value);
|
|
23
|
+
var valueEmpty = (value) => valuePlain(value).trim() === "";
|
|
24
|
+
function readCells(table, loss, purpose, allowMerged = false) {
|
|
25
|
+
const rows = table?.rows;
|
|
26
|
+
if (!Array.isArray(rows) || rows.some((row) => !Array.isArray(row))) throw refuse("This table has no rows to convert.");
|
|
27
|
+
const all = [...Array.isArray(table.columns) ? [table.columns] : [], ...rows];
|
|
28
|
+
for (const row of all) {
|
|
29
|
+
for (const raw of row) {
|
|
30
|
+
const cell = cellOf(raw);
|
|
31
|
+
if (cell.merged) {
|
|
32
|
+
if (!allowMerged) throw refuse(`This table has merged cells, which ${purpose} cannot carry. Split the merged cells first.`);
|
|
33
|
+
loss.note("merged cells");
|
|
34
|
+
}
|
|
35
|
+
if (cell.styled) loss.note("cell styles");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { columns: Array.isArray(table.columns) ? table.columns.map((cell) => cellOf(cell).value) : void 0, rows: rows.map((row) => row.map((cell) => cellOf(cell).value)) };
|
|
39
|
+
}
|
|
40
|
+
function listToTable(items, loss) {
|
|
41
|
+
const entries = items.map((item) => {
|
|
42
|
+
const object = isRecord(item);
|
|
43
|
+
if (object && (item.level ?? 0) > 0) loss.note("list nesting levels");
|
|
44
|
+
const text = object ? item.text : item;
|
|
45
|
+
return { text: valueText(text), description: object && item.description !== void 0 && plainOf(item.description) !== "" ? valueText(item.description) : void 0 };
|
|
46
|
+
});
|
|
47
|
+
if (!entries.length) throw refuse("A table needs at least one row, and this list has no items.");
|
|
48
|
+
const described = entries.some((entry) => entry.description !== void 0);
|
|
49
|
+
return { rows: entries.map((entry) => described ? [entry.text, entry.description ?? null] : [entry.text]) };
|
|
50
|
+
}
|
|
51
|
+
function tableToList(table, loss) {
|
|
52
|
+
const { columns, rows } = readCells(table, loss, "a list");
|
|
53
|
+
if (!rows.length) throw refuse("A list needs at least one item, and this table has no rows.");
|
|
54
|
+
if (columns) loss.note("column headings");
|
|
55
|
+
const width = Math.max(columns?.length ?? 0, ...rows.map((row) => row.length));
|
|
56
|
+
if (width > 2) loss.note("table columns beyond the second (joined into the description)");
|
|
57
|
+
return rows.map((row) => {
|
|
58
|
+
const text = valueText(row[0]);
|
|
59
|
+
const rest = row.slice(1).filter((value) => !valueEmpty(value));
|
|
60
|
+
if (!rest.length) return text;
|
|
61
|
+
let description;
|
|
62
|
+
if (rest.length === 1) description = valueText(rest[0]);
|
|
63
|
+
else if (rest.some(Array.isArray)) description = rest.flatMap((value, index) => [...index ? [", "] : [], ...runsOf(valueText(value))]);
|
|
64
|
+
else description = rest.map(valuePlain).join(", ");
|
|
65
|
+
return { text, description };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function timelineToTable(timeline, options, loss) {
|
|
69
|
+
const events = Array.isArray(timeline) ? timeline : timeline.events;
|
|
70
|
+
if (!Array.isArray(timeline)) {
|
|
71
|
+
if (timeline.name) loss.note("timeline name");
|
|
72
|
+
if (timeline.description) loss.note("timeline description");
|
|
73
|
+
}
|
|
74
|
+
const hasWhen = events.some((event) => event.when);
|
|
75
|
+
const hasDescription = events.some((event) => event.description);
|
|
76
|
+
const columns = [...hasWhen ? ["When"] : [], "What", ...hasDescription ? ["Description"] : []];
|
|
77
|
+
const rows = events.map((event) => [...hasWhen ? [event.when ?? null] : [], event.what, ...hasDescription ? [event.description ?? null] : []]);
|
|
78
|
+
return { ...options.headings === false ? {} : { columns }, rows };
|
|
79
|
+
}
|
|
80
|
+
var WHEN_HEADINGS = ["when", "date", "dates", "time", "year", "month", "quarter", "period", "deadline", "due", "due date", "start", "timeline"];
|
|
81
|
+
var WHAT_HEADINGS = ["what", "event", "events", "milestone", "milestones", "title", "name", "step", "task", "activity", "initiative", "item", "phase", "stage", "deliverable"];
|
|
82
|
+
var DESCRIPTION_HEADINGS = ["description", "details", "detail", "notes", "note", "summary", "comment", "comments"];
|
|
83
|
+
var heading = (value) => valuePlain(value).trim().toLowerCase().replace(/:$/, "");
|
|
84
|
+
function rolesFrom(columns, names, explicit) {
|
|
85
|
+
const roles = {};
|
|
86
|
+
for (const [role, index] of Object.entries(explicit ?? {})) if (index !== void 0) roles[role] = index;
|
|
87
|
+
if (columns) {
|
|
88
|
+
columns.forEach((value, index) => {
|
|
89
|
+
const label = heading(value);
|
|
90
|
+
for (const [role, list] of Object.entries(names)) if (roles[role] === void 0 && list.includes(label) && !Object.values(roles).includes(index)) roles[role] = index;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return roles;
|
|
94
|
+
}
|
|
95
|
+
function noteRenamedHeadings(columns, roles, names, loss) {
|
|
96
|
+
if (!columns) return;
|
|
97
|
+
for (const [role, index] of Object.entries(roles)) {
|
|
98
|
+
const label = heading(columns[index]);
|
|
99
|
+
if (label !== "" && !(names[role] ?? []).includes(label)) loss.note(`column heading "${valuePlain(columns[index])}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function tableToTimeline(table, options, loss) {
|
|
103
|
+
const { columns, rows } = readCells(table, loss, "a timeline");
|
|
104
|
+
if (!columns && !options.columns) throw refuse('A timeline reads its columns by their headings. Add a heading row that names a "When", a "What" and optionally a "Description" column.');
|
|
105
|
+
const roles = rolesFrom(columns, { when: WHEN_HEADINGS, what: WHAT_HEADINGS, description: DESCRIPTION_HEADINGS }, options.columns);
|
|
106
|
+
const width = Math.max(columns?.length ?? 0, ...rows.map((row) => row.length));
|
|
107
|
+
if (roles.what === void 0) {
|
|
108
|
+
const free = Array.from({ length: width }, (_, index) => index).filter((index) => !Object.values(roles).includes(index));
|
|
109
|
+
if (free.length === 1) roles.what = free[0];
|
|
110
|
+
}
|
|
111
|
+
if (roles.what === void 0) throw refuse('No column holds the event text. Name one column "What" or "Event" (headings it understands: when, date, what, event, milestone, description, details).');
|
|
112
|
+
if (!rows.length) throw refuse("A timeline needs at least one event, and this table has no rows.");
|
|
113
|
+
const used = new Set(Object.values(roles));
|
|
114
|
+
columns?.forEach((value, index) => {
|
|
115
|
+
if (!used.has(index) && rows.some((row) => !valueEmpty(row[index]))) loss.note(`column "${valuePlain(value)}"`);
|
|
116
|
+
});
|
|
117
|
+
noteRenamedHeadings(columns, roles, { when: WHEN_HEADINGS, what: WHAT_HEADINGS, description: DESCRIPTION_HEADINGS }, loss);
|
|
118
|
+
const flat = (value) => {
|
|
119
|
+
if (hasFormatting(value)) loss.note("text formatting");
|
|
120
|
+
return valuePlain(value);
|
|
121
|
+
};
|
|
122
|
+
const events = rows.map((row) => {
|
|
123
|
+
const event = {};
|
|
124
|
+
if (roles.when !== void 0 && !valueEmpty(row[roles.when])) event.when = flat(row[roles.when]);
|
|
125
|
+
event.what = flat(row[roles.what]);
|
|
126
|
+
if (roles.description !== void 0 && !valueEmpty(row[roles.description])) event.description = flat(row[roles.description]);
|
|
127
|
+
return event;
|
|
128
|
+
});
|
|
129
|
+
return events;
|
|
130
|
+
}
|
|
131
|
+
var COLUMN_LABELS = { label: "Label", value: "Value", unit: "Unit", delta: "Delta", trend: "Trend", description: "Description" };
|
|
132
|
+
var METRIC_HEADINGS = {
|
|
133
|
+
label: ["label", "metric", "name", "kpi", "measure", "indicator"],
|
|
134
|
+
value: ["value", "amount", "number", "result", "actual", "current"],
|
|
135
|
+
unit: ["unit", "units"],
|
|
136
|
+
delta: ["delta", "change", "growth", "difference", "vs", "vs."],
|
|
137
|
+
trend: ["trend", "direction"],
|
|
138
|
+
description: ["description", "details", "detail", "notes", "note", "context"]
|
|
139
|
+
};
|
|
140
|
+
function metricsToTable(blocks, options, loss) {
|
|
141
|
+
if (!blocks.length) throw refuse("This group has no blocks.");
|
|
142
|
+
const metrics = blocks.map((block, index) => {
|
|
143
|
+
const keys = isRecord(block) ? Object.keys(block).filter((key) => key !== "id" && key !== "extensions" && key !== "type") : [];
|
|
144
|
+
if (keys.length !== 1 || keys[0] !== "metric") throw refuse(`Block ${index + 1} is not a metric. A group converts to a table only when every block in it is a metric.`, { block: index });
|
|
145
|
+
if (block.id !== void 0 || block.extensions !== void 0) loss.note("block ids and extensions");
|
|
146
|
+
return isRecord(block.metric) ? block.metric : { value: block.metric };
|
|
147
|
+
});
|
|
148
|
+
const present = (field) => field === "value" || metrics.some((metric) => metric[field] !== void 0 && metric[field] !== "");
|
|
149
|
+
const fields = Object.keys(COLUMN_LABELS).filter(present);
|
|
150
|
+
return {
|
|
151
|
+
...options.headings === false ? {} : { columns: fields.map((field) => COLUMN_LABELS[field]) },
|
|
152
|
+
rows: metrics.map((metric) => fields.map((field) => metric[field] === void 0 || metric[field] === "" ? null : metric[field]))
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function tableToMetrics(table, loss) {
|
|
156
|
+
const { columns, rows } = readCells(table, loss, "a set of metrics");
|
|
157
|
+
if (!columns) throw refuse('Metrics read their columns by their headings. Add a heading row that names a "Value" column, and optionally "Label", "Unit", "Delta", "Trend" and "Description".');
|
|
158
|
+
const roles = rolesFrom(columns, METRIC_HEADINGS, void 0);
|
|
159
|
+
if (roles.value === void 0) throw refuse('No column holds the metric value. Name one column "Value" (headings it understands: value, amount, number, result, label, unit, delta, change, trend, description).');
|
|
160
|
+
if (!rows.length) throw refuse("A set of metrics needs at least one row, and this table has no rows.");
|
|
161
|
+
const used = new Set(Object.values(roles));
|
|
162
|
+
columns.forEach((value, index) => {
|
|
163
|
+
if (!used.has(index) && rows.some((row) => !valueEmpty(row[index]))) loss.note(`column "${valuePlain(value)}"`);
|
|
164
|
+
});
|
|
165
|
+
noteRenamedHeadings(columns, roles, METRIC_HEADINGS, loss);
|
|
166
|
+
const text = (value) => {
|
|
167
|
+
if (hasFormatting(value)) loss.note("text formatting");
|
|
168
|
+
return valuePlain(value);
|
|
169
|
+
};
|
|
170
|
+
return rows.map((row, rowIndex) => {
|
|
171
|
+
const raw = row[roles.value];
|
|
172
|
+
if (valueEmpty(raw)) throw refuse(`Row ${rowIndex + 1} has no value, so it cannot be a metric.`, { row: rowIndex });
|
|
173
|
+
const metric = { value: typeof raw === "number" ? raw : text(raw) };
|
|
174
|
+
if (roles.label !== void 0 && !valueEmpty(row[roles.label])) metric.label = text(row[roles.label]);
|
|
175
|
+
if (roles.unit !== void 0 && !valueEmpty(row[roles.unit])) metric.unit = text(row[roles.unit]);
|
|
176
|
+
if (roles.delta !== void 0 && !valueEmpty(row[roles.delta])) metric.delta = typeof row[roles.delta] === "number" ? row[roles.delta] : text(row[roles.delta]);
|
|
177
|
+
if (roles.trend !== void 0 && !valueEmpty(row[roles.trend])) {
|
|
178
|
+
const trend = text(row[roles.trend]).trim().toLowerCase();
|
|
179
|
+
if (trend === "up" || trend === "down" || trend === "flat") metric.trend = trend;
|
|
180
|
+
else loss.note("trend values other than up, down or flat");
|
|
181
|
+
}
|
|
182
|
+
if (roles.description !== void 0 && !valueEmpty(row[roles.description])) metric.description = text(row[roles.description]);
|
|
183
|
+
return { metric };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
var escapePipe = (text) => text.replaceAll("\\", "\\\\").replaceAll("|", "\\|");
|
|
187
|
+
var SEPARATOR_ROW = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
|
188
|
+
function tableToTextLines(table, loss) {
|
|
189
|
+
const { columns, rows } = readCells(table, loss, "text", true);
|
|
190
|
+
if (!rows.length && !columns) throw refuse("This table is empty.");
|
|
191
|
+
const cell = (value) => {
|
|
192
|
+
if (hasFormatting(value)) loss.note("text formatting");
|
|
193
|
+
const text = valuePlain(value);
|
|
194
|
+
if (/[\r\n]/.test(text)) loss.note("line breaks inside cells");
|
|
195
|
+
return text.replace(/\s*(?:\r\n|\r|\n)\s*/g, " ");
|
|
196
|
+
};
|
|
197
|
+
if (columns) {
|
|
198
|
+
const width = Math.max(columns.length, ...rows.map((row) => row.length));
|
|
199
|
+
const fill = (row) => Array.from({ length: width }, (_, index) => cell(row[index]));
|
|
200
|
+
const line = (cells) => `| ${cells.map(escapePipe).join(" | ")} |`;
|
|
201
|
+
return [line(fill(columns)), line(Array.from({ length: width }, () => "---")), ...rows.map((row) => line(fill(row)))];
|
|
202
|
+
}
|
|
203
|
+
return rows.map((row) => {
|
|
204
|
+
const line = row.map((value) => {
|
|
205
|
+
const text = cell(value);
|
|
206
|
+
if (text.includes(" ")) throw refuse("A cell contains a tab, which cannot be written to tab-separated text.");
|
|
207
|
+
return text;
|
|
208
|
+
}).join(" ");
|
|
209
|
+
if (line.trim() === "" && !line.includes(" ")) loss.note("empty rows (a blank line is dropped when the text is read back)");
|
|
210
|
+
return line;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function splitPipes(line) {
|
|
214
|
+
const cells = [];
|
|
215
|
+
let current = "";
|
|
216
|
+
for (let index = 0; index < line.length; index++) {
|
|
217
|
+
const char = line[index];
|
|
218
|
+
if (char === "\\" && (line[index + 1] === "|" || line[index + 1] === "\\")) {
|
|
219
|
+
current += line[index + 1];
|
|
220
|
+
index++;
|
|
221
|
+
} else if (char === "|") {
|
|
222
|
+
cells.push(current);
|
|
223
|
+
current = "";
|
|
224
|
+
} else current += char;
|
|
225
|
+
}
|
|
226
|
+
cells.push(current);
|
|
227
|
+
if (cells[0].trim() === "") cells.shift();
|
|
228
|
+
if (cells.length && cells.at(-1).trim() === "") cells.pop();
|
|
229
|
+
return cells.map((cell) => cell.trim());
|
|
230
|
+
}
|
|
231
|
+
function textLinesToTable(lines, options) {
|
|
232
|
+
const separator = options.delimiter ?? " ";
|
|
233
|
+
const kept = lines.filter((line) => line.trim() !== "" || separator !== "" && line.includes(separator));
|
|
234
|
+
if (!kept.length) throw refuse("This text is empty.");
|
|
235
|
+
const rectangular = (rows, header) => {
|
|
236
|
+
const width = rows[0].length;
|
|
237
|
+
const bad = rows.findIndex((row) => row.length !== width);
|
|
238
|
+
if (bad >= 0) throw refuse(`Line ${bad + 1} has ${rows[bad].length} cells but the first line has ${width}. Every line needs the same number of cells.`, { line: bad });
|
|
239
|
+
if (header && rows.length < 1) throw refuse("A heading row needs at least one line.");
|
|
240
|
+
return header ? { columns: rows[0], rows: rows.slice(1) } : { rows };
|
|
241
|
+
};
|
|
242
|
+
if (!options.delimiter && kept.length >= 2 && SEPARATOR_ROW.test(kept[1]) && kept[0].includes("|")) {
|
|
243
|
+
const rows = [kept[0], ...kept.slice(2)].map(splitPipes);
|
|
244
|
+
const result = rectangular(rows, true);
|
|
245
|
+
if (splitPipes(kept[1]).length !== rows[0].length) throw refuse("The separator row of this Markdown table does not match its heading row.");
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const delimiter = options.delimiter ?? " ";
|
|
249
|
+
if (delimiter.length === 0 || /[\r\n]/.test(delimiter)) throw refuse("Choose a delimiter that is not empty and not a line break.");
|
|
250
|
+
if (kept.some((line) => !line.includes(delimiter))) throw refuse(`This text has no table structure. A table needs tab-separated lines, a Markdown pipe table, or a delimiter you name${options.delimiter ? ` (every line must contain "${options.delimiter}")` : ""}.`);
|
|
251
|
+
return rectangular(kept.map((line) => line.split(delimiter)), options.header === true);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/convert/content.ts
|
|
255
|
+
var CONTENT_KIND_LABELS = Object.freeze({
|
|
256
|
+
text: "Text",
|
|
257
|
+
list: "List",
|
|
258
|
+
quote: "Quote",
|
|
259
|
+
metric: "Metric",
|
|
260
|
+
code: "Code",
|
|
261
|
+
timeline: "Timeline",
|
|
262
|
+
chart: "Chart",
|
|
263
|
+
table: "Table",
|
|
264
|
+
image: "Image",
|
|
265
|
+
video: "Video",
|
|
266
|
+
group: "Group",
|
|
267
|
+
metrics: "Metric blocks"
|
|
268
|
+
});
|
|
269
|
+
var CONTENT_CONVERSIONS = Object.freeze({
|
|
270
|
+
text: ["list", "quote", "metric", "code", "timeline", "table"],
|
|
271
|
+
list: ["text", "timeline", "table"],
|
|
272
|
+
quote: ["text"],
|
|
273
|
+
metric: ["text"],
|
|
274
|
+
code: ["text"],
|
|
275
|
+
timeline: ["text", "list", "table"],
|
|
276
|
+
chart: ["table"],
|
|
277
|
+
table: ["chart", "list", "timeline", "text", "metrics"],
|
|
278
|
+
group: ["table"]
|
|
279
|
+
});
|
|
280
|
+
var KIND_OF_KEY = { items: "list", bullets: "list", blocks: "group" };
|
|
281
|
+
var TYPE_OF_KIND = { text: "text", list: "list", quote: "quote", metric: "metric", code: "code", timeline: "timeline", chart: "chart", table: "table", metrics: "group" };
|
|
282
|
+
function readContent(payload) {
|
|
283
|
+
if (!isRecord(payload)) return void 0;
|
|
284
|
+
if (Array.isArray(payload.blocks)) return contentKeysOf(payload).length ? void 0 : { key: "blocks", kind: "group", content: payload.blocks };
|
|
285
|
+
const keys = contentKeysOf(payload);
|
|
286
|
+
if (keys.length !== 1) return void 0;
|
|
287
|
+
const key = keys[0];
|
|
288
|
+
return { key, kind: KIND_OF_KEY[key] ?? key, content: payload[key] };
|
|
289
|
+
}
|
|
290
|
+
var MAX_METRIC_VALUE = 24;
|
|
291
|
+
var INDENT = " ";
|
|
292
|
+
var indentRuns = (runs, level) => level > 0 ? [INDENT.repeat(level), ...runs] : runs;
|
|
293
|
+
function toLines(kind, content, options, loss) {
|
|
294
|
+
switch (kind) {
|
|
295
|
+
case "text":
|
|
296
|
+
return splitRuns(runsOf(content));
|
|
297
|
+
case "list": {
|
|
298
|
+
const lines = [];
|
|
299
|
+
let previous = -1;
|
|
300
|
+
for (const item of content) {
|
|
301
|
+
const object = isRecord(item);
|
|
302
|
+
const level = object ? item.level ?? 0 : 0;
|
|
303
|
+
if (level > previous + 1) loss.note("list nesting levels (renumbered to follow the previous item)");
|
|
304
|
+
previous = level;
|
|
305
|
+
const itemLines = splitRuns(object ? runsOf(item.text) : runsOf(item));
|
|
306
|
+
for (const line of itemLines) lines.push(indentRuns(line, level));
|
|
307
|
+
if (object && item.description !== void 0 && plainOf(item.description) !== "") {
|
|
308
|
+
loss.note("list item descriptions (kept as indented lines)");
|
|
309
|
+
for (const line of splitRuns(runsOf(item.description))) lines.push(indentRuns(line, level + 1));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return lines;
|
|
313
|
+
}
|
|
314
|
+
case "quote": {
|
|
315
|
+
const quote = typeof content === "string" ? { text: content } : content;
|
|
316
|
+
const lines = splitRuns([quote.text]);
|
|
317
|
+
if (quote.attribution) lines.push([`\u2014 ${quote.attribution}`]);
|
|
318
|
+
if (quote.source) lines.push([quote.attribution ? `\u2014 ${quote.source}` : quote.source]);
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
321
|
+
case "metric": {
|
|
322
|
+
const metric = isRecord(content) ? content : { value: content };
|
|
323
|
+
const value = `${metric.value}${metric.unit ? (/^[A-Za-z]/.test(metric.unit) ? " " : "") + metric.unit : ""}`;
|
|
324
|
+
const lines = [[value]];
|
|
325
|
+
for (const part of [metric.label, metric.description, metric.delta === void 0 ? void 0 : String(metric.delta)]) if (part !== void 0 && part !== "") lines.push(...splitRuns([part]));
|
|
326
|
+
if (metric.trend) loss.note("metric trend");
|
|
327
|
+
return lines;
|
|
328
|
+
}
|
|
329
|
+
case "code": {
|
|
330
|
+
const code = typeof content === "string" ? { source: content } : content;
|
|
331
|
+
if ((code.language || code.filename) && options.fences !== "never") return fenceCode(code.source, code.language, code.filename).map((line) => [line]);
|
|
332
|
+
if (code.language) loss.note("code language");
|
|
333
|
+
if (code.filename) loss.note("code filename");
|
|
334
|
+
return splitRuns([code.source]);
|
|
335
|
+
}
|
|
336
|
+
case "timeline": {
|
|
337
|
+
const events = Array.isArray(content) ? content : content.events;
|
|
338
|
+
if (!Array.isArray(content)) {
|
|
339
|
+
if (content.name) loss.note("timeline name");
|
|
340
|
+
if (content.description) loss.note("timeline description");
|
|
341
|
+
}
|
|
342
|
+
const lines = [];
|
|
343
|
+
for (const event of events) {
|
|
344
|
+
lines.push([event.when ? `${event.when}: ${event.what}` : event.what]);
|
|
345
|
+
if (event.description) for (const line of String(event.description).split(/\r\n|\r|\n/)) lines.push([`${INDENT}${line}`]);
|
|
346
|
+
}
|
|
347
|
+
return lines;
|
|
348
|
+
}
|
|
349
|
+
default:
|
|
350
|
+
throw refuse(`${CONTENT_KIND_LABELS[kind] ?? kind} content has no text lines.`, { kind });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
var isBlank = (line) => linePlain(line).trim() === "";
|
|
354
|
+
function nonBlank(lines, loss) {
|
|
355
|
+
const kept = lines.filter((line) => !isBlank(line));
|
|
356
|
+
if (kept.length !== lines.length) loss.note("blank lines");
|
|
357
|
+
return kept;
|
|
358
|
+
}
|
|
359
|
+
function fromLines(kind, lines, options, loss) {
|
|
360
|
+
const flat = (line) => {
|
|
361
|
+
if (!lineIsPlain(line)) loss.note("text formatting");
|
|
362
|
+
return linePlain(line);
|
|
363
|
+
};
|
|
364
|
+
switch (kind) {
|
|
365
|
+
case "text":
|
|
366
|
+
return { key: "text", value: joinLines(lines) };
|
|
367
|
+
case "list": {
|
|
368
|
+
const kept = nonBlank(lines, loss);
|
|
369
|
+
const parsed = kept.map(parseListLine);
|
|
370
|
+
if (parsed.some((entry) => entry.numbered)) loss.note("list numbering");
|
|
371
|
+
const levels = levelsFromIndents(parsed.map((entry) => entry.indent));
|
|
372
|
+
return {
|
|
373
|
+
key: "items",
|
|
374
|
+
value: parsed.map((entry, index) => {
|
|
375
|
+
const value = lineValue(entry.line);
|
|
376
|
+
return levels[index] ? { text: value, level: levels[index] } : value;
|
|
377
|
+
})
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
case "timeline": {
|
|
381
|
+
const kept = nonBlank(lines, loss);
|
|
382
|
+
if (!kept.length) throw refuse("A timeline needs at least one event, and this block has no text.", { kind });
|
|
383
|
+
const events = [];
|
|
384
|
+
for (const line of kept) {
|
|
385
|
+
const text = flat(line);
|
|
386
|
+
if (events.length && /^[ \t]/.test(text)) {
|
|
387
|
+
const detail = text.trim();
|
|
388
|
+
events.at(-1).description = events.at(-1).description ? `${events.at(-1).description}
|
|
389
|
+
${detail}` : detail;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const split = splitWhen(text, options.looseWhen);
|
|
393
|
+
events.push(split ? { when: split.when, what: split.what } : { what: text });
|
|
394
|
+
}
|
|
395
|
+
return { key: "timeline", value: events };
|
|
396
|
+
}
|
|
397
|
+
case "code": {
|
|
398
|
+
const plain = lines.map(flat);
|
|
399
|
+
const fence = parseCodeFence(plain);
|
|
400
|
+
return { key: "code", value: fence ? { source: fence.source, ...fence.language ? { language: fence.language } : {}, ...fence.filename ? { filename: fence.filename } : {} } : { source: plain.join("\n") } };
|
|
401
|
+
}
|
|
402
|
+
case "quote": {
|
|
403
|
+
const plain = lines.map(flat);
|
|
404
|
+
const parsed = parseQuoteLines(plain);
|
|
405
|
+
let body = parsed.text;
|
|
406
|
+
while (body.length > 1 && body.at(-1).trim() === "" && parsed.attribution !== void 0) {
|
|
407
|
+
body = body.slice(0, -1);
|
|
408
|
+
loss.note("blank lines");
|
|
409
|
+
}
|
|
410
|
+
return { key: "quote", value: { text: body.join("\n"), ...parsed.attribution !== void 0 ? { attribution: parsed.attribution } : {}, ...parsed.source !== void 0 ? { source: parsed.source } : {} } };
|
|
411
|
+
}
|
|
412
|
+
case "metric": {
|
|
413
|
+
const kept = nonBlank(lines, loss);
|
|
414
|
+
if (!kept.length) throw refuse("A metric needs a value, and this block has no text.", { kind });
|
|
415
|
+
const first = flat(kept[0]).trim();
|
|
416
|
+
if (first.length > MAX_METRIC_VALUE) throw refuse(`The first line is longer than ${MAX_METRIC_VALUE} characters, which is too long to be a metric value. Shorten it or keep this block as text.`, { kind });
|
|
417
|
+
const metric = { value: /^-?(0|[1-9]\d*)(\.\d+)?$/.test(first) && String(Number(first)) === first ? Number(first) : first };
|
|
418
|
+
if (kept[1]) metric.label = flat(kept[1]);
|
|
419
|
+
if (kept.length > 2) metric.description = kept.slice(2).map(flat).join("\n");
|
|
420
|
+
return { key: "metric", value: metric };
|
|
421
|
+
}
|
|
422
|
+
default:
|
|
423
|
+
throw refuse(`Cannot convert text into ${CONTENT_KIND_LABELS[kind]?.toLowerCase() ?? kind}.`, { kind });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function listToTimeline(items, options, loss) {
|
|
427
|
+
if (!items.length) throw refuse("A timeline needs at least one event, and this list has no items.");
|
|
428
|
+
const events = items.map((item) => {
|
|
429
|
+
const object = isRecord(item);
|
|
430
|
+
if (object && (item.level ?? 0) > 0) loss.note("list nesting levels");
|
|
431
|
+
const text = object ? item.text : item;
|
|
432
|
+
if (hasFormatting(text) || object && hasFormatting(item.description)) loss.note("text formatting");
|
|
433
|
+
const plain = plainOf(text);
|
|
434
|
+
const split = splitWhen(plain, options.looseWhen);
|
|
435
|
+
const event = split ? { when: split.when, what: split.what } : { what: plain };
|
|
436
|
+
if (object && item.description !== void 0 && plainOf(item.description) !== "") event.description = plainOf(item.description);
|
|
437
|
+
return event;
|
|
438
|
+
});
|
|
439
|
+
return { key: "timeline", value: events };
|
|
440
|
+
}
|
|
441
|
+
function timelineToList(timeline, loss) {
|
|
442
|
+
const events = Array.isArray(timeline) ? timeline : timeline.events;
|
|
443
|
+
if (!Array.isArray(timeline)) {
|
|
444
|
+
if (timeline.name) loss.note("timeline name");
|
|
445
|
+
if (timeline.description) loss.note("timeline description");
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
key: "items",
|
|
449
|
+
value: events.map((event) => {
|
|
450
|
+
const text = event.when ? `${event.when}: ${event.what}` : event.what;
|
|
451
|
+
return event.description ? { text, description: event.description } : text;
|
|
452
|
+
})
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function chartToTable(chart, loss) {
|
|
456
|
+
const data = chart?.data;
|
|
457
|
+
if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) throw refuse("This chart reads external data. Only a chart with inline columns and rows converts to a table.");
|
|
458
|
+
if (chart.type) loss.note("chart type");
|
|
459
|
+
return { key: "table", value: { columns: clone(data.columns), rows: clone(data.rows) } };
|
|
460
|
+
}
|
|
461
|
+
function tableToChart(table) {
|
|
462
|
+
const { columns, rows } = table ?? {};
|
|
463
|
+
if (!Array.isArray(columns) || !columns.length || columns.some((label) => typeof label !== "string" || label === "")) throw refuse("A chart needs a plain text label for every column. Add column labels to the table first.");
|
|
464
|
+
const body = rows.map((row, rowIndex) => {
|
|
465
|
+
if (row.length !== columns.length) throw refuse(`Row ${rowIndex + 1} does not have ${columns.length} cells.`, { row: rowIndex });
|
|
466
|
+
return row.map((cell, columnIndex) => {
|
|
467
|
+
if (cell !== null && typeof cell === "object") throw refuse("This table has styled, merged or formatted cells. Chart data is plain values; clear them first.", { row: rowIndex, column: columnIndex });
|
|
468
|
+
if (columnIndex === 0) return cell;
|
|
469
|
+
if (cell === null || cell === "") return null;
|
|
470
|
+
if (typeof cell === "number") return cell;
|
|
471
|
+
if (typeof cell === "string" && cell.trim() !== "" && Number.isFinite(Number(cell)) && String(Number(cell)) === cell.trim()) return Number(cell);
|
|
472
|
+
throw refuse(`Row ${rowIndex + 1}, column ${columnIndex + 1} ("${String(cell)}") is not a number, so it cannot be a chart value.`, { row: rowIndex, column: columnIndex });
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
if (!body.length) throw refuse("A chart needs at least one row.");
|
|
476
|
+
return { key: "chart", value: { type: "column", data: { columns: [...columns], rows: body } } };
|
|
477
|
+
}
|
|
478
|
+
function convertField(from, to, content, options, loss) {
|
|
479
|
+
if (from === "chart") return chartToTable(content, loss);
|
|
480
|
+
if (from === "group") return { key: "table", value: metricsToTable(content, options, loss) };
|
|
481
|
+
if (from === "table") {
|
|
482
|
+
switch (to) {
|
|
483
|
+
case "chart":
|
|
484
|
+
return tableToChart(content);
|
|
485
|
+
case "list":
|
|
486
|
+
return { key: "items", value: tableToList(content, loss) };
|
|
487
|
+
case "timeline":
|
|
488
|
+
return { key: "timeline", value: tableToTimeline(content, options, loss) };
|
|
489
|
+
case "text":
|
|
490
|
+
return fromLines("text", tableToTextLines(content, loss).map((line) => [line]), options, loss);
|
|
491
|
+
case "metrics":
|
|
492
|
+
return { key: "blocks", value: tableToMetrics(content, loss) };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (from === "list" && to === "table") return { key: "table", value: listToTable(content, loss) };
|
|
496
|
+
if (from === "list" && to === "timeline") return listToTimeline(content, options, loss);
|
|
497
|
+
if (from === "timeline" && to === "table") return { key: "table", value: timelineToTable(content, options, loss) };
|
|
498
|
+
if (from === "timeline" && to === "list") return timelineToList(content, loss);
|
|
499
|
+
if (from === "text" && to === "table") {
|
|
500
|
+
const lines = splitRuns(runsOf(content));
|
|
501
|
+
const plain = lines.map((line) => {
|
|
502
|
+
if (!lineIsPlain(line)) loss.note("text formatting");
|
|
503
|
+
return linePlain(line);
|
|
504
|
+
});
|
|
505
|
+
return { key: "table", value: textLinesToTable(plain, options) };
|
|
506
|
+
}
|
|
507
|
+
return fromLines(to, toLines(from, content, options, loss), options, loss);
|
|
508
|
+
}
|
|
509
|
+
function convertContent(payload, to, options = {}) {
|
|
510
|
+
const info = readContent(payload);
|
|
511
|
+
if (!info) throw refuse("Choose a payload that holds one text, list, quote, metric, code, timeline, chart or table, or a group of metric blocks.");
|
|
512
|
+
const owner = payload;
|
|
513
|
+
if (info.kind === to) return { payload: clone(owner), from: info.kind, to, key: info.key, changed: false, lossless: true, loss: [] };
|
|
514
|
+
const targets = CONTENT_CONVERSIONS[info.kind] ?? [];
|
|
515
|
+
if (!targets.includes(to)) {
|
|
516
|
+
const label = CONTENT_KIND_LABELS[info.kind] ?? info.kind;
|
|
517
|
+
throw refuse(`${label} content cannot be converted to ${CONTENT_KIND_LABELS[to]?.toLowerCase() ?? to}. ${targets.length ? `It converts to: ${targets.map((target) => CONTENT_KIND_LABELS[target].toLowerCase()).join(", ")}.` : "It has no text to convert."}`, { from: info.kind, to });
|
|
518
|
+
}
|
|
519
|
+
const loss = new Loss();
|
|
520
|
+
const { key, value } = convertField(info.kind, to, info.content, options, loss);
|
|
521
|
+
const out = {};
|
|
522
|
+
for (const [name, entry] of Object.entries(owner)) {
|
|
523
|
+
if (CONTENT_KEYS.includes(name) || name === "type" || name === "blocks") continue;
|
|
524
|
+
if (name === "composition" && info.kind === "group" && !isSlideLike(owner)) {
|
|
525
|
+
loss.note("group arrangement (composition)");
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
out[name] = clone(entry);
|
|
529
|
+
}
|
|
530
|
+
if (owner.type !== void 0) out.type = TYPE_OF_KIND[to] ?? to;
|
|
531
|
+
out[key] = value;
|
|
532
|
+
assertValidOwner(out);
|
|
533
|
+
return { payload: out, from: info.kind, to, key, changed: true, ...report(loss.list) };
|
|
534
|
+
}
|
|
535
|
+
function contentConversionTargets(payload, options = {}) {
|
|
536
|
+
const info = readContent(payload);
|
|
537
|
+
if (!info) return [];
|
|
538
|
+
if (info.kind === "group" && !info.content.every((block) => isRecord(block) && block.metric !== void 0 && Object.keys(block).every((key) => key === "metric" || key === "id" || key === "extensions" || key === "type"))) return [];
|
|
539
|
+
return (CONTENT_CONVERSIONS[info.kind] ?? []).map((kind) => {
|
|
540
|
+
const label = CONTENT_KIND_LABELS[kind];
|
|
541
|
+
try {
|
|
542
|
+
const result = convertContent(payload, kind, options);
|
|
543
|
+
return { kind, label, available: true, lossless: result.lossless, loss: result.loss };
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (!(error instanceof OPFConversionError)) throw error;
|
|
546
|
+
return { kind, label, available: false, lossless: false, loss: [], reason: error.message };
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/convert/list.ts
|
|
552
|
+
var listKey = (payload) => {
|
|
553
|
+
const keys = ["items", "bullets"].filter((key) => Array.isArray(payload?.[key]));
|
|
554
|
+
if (!isRecord(payload) || keys.length !== 1) throw refuse("Choose a payload that holds one list (items or bullets).");
|
|
555
|
+
return keys[0];
|
|
556
|
+
};
|
|
557
|
+
var levelOf = (item) => isRecord(item) ? item.level ?? 0 : 0;
|
|
558
|
+
function withLevel(item, level) {
|
|
559
|
+
if (!isRecord(item)) return level > 0 ? { text: item, level } : item;
|
|
560
|
+
const next = { ...item };
|
|
561
|
+
if (level > 0) next.level = level;
|
|
562
|
+
else delete next.level;
|
|
563
|
+
return Object.keys(next).length === 1 && next.text !== void 0 ? next.text : next;
|
|
564
|
+
}
|
|
565
|
+
function shiftListLevels(payload, indices, delta, options = {}) {
|
|
566
|
+
const key = listKey(payload);
|
|
567
|
+
const items = payload[key];
|
|
568
|
+
if (!Number.isInteger(delta) || delta === 0) throw refuse("Choose a level change of one or more steps in or out.");
|
|
569
|
+
const selected = [...new Set(indices)].sort((a, b) => a - b);
|
|
570
|
+
if (!selected.length || selected.some((index) => !Number.isInteger(index) || index < 0 || index >= items.length)) throw refuse("Choose items that exist in the list.", { indices });
|
|
571
|
+
const original = items.map(levelOf);
|
|
572
|
+
const levels = [...original];
|
|
573
|
+
const moved = /* @__PURE__ */ new Set();
|
|
574
|
+
const withChildren = options.withChildren !== false;
|
|
575
|
+
for (const index of selected) {
|
|
576
|
+
if (moved.has(index)) continue;
|
|
577
|
+
const previous = index > 0 ? levels[index - 1] : -1;
|
|
578
|
+
const wanted = Math.max(0, original[index] + delta);
|
|
579
|
+
const level = Math.min(wanted, previous + 1);
|
|
580
|
+
const applied = level - levels[index];
|
|
581
|
+
moved.add(index);
|
|
582
|
+
if (applied === 0) continue;
|
|
583
|
+
levels[index] = level;
|
|
584
|
+
if (withChildren) {
|
|
585
|
+
for (let next2 = index + 1; next2 < items.length && original[next2] > original[index]; next2++) {
|
|
586
|
+
levels[next2] = Math.max(0, levels[next2] + applied);
|
|
587
|
+
moved.add(next2);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const changed = levels.some((level, index) => level !== original[index]);
|
|
592
|
+
if (!changed) {
|
|
593
|
+
const reason = delta > 0 ? selected[0] === 0 ? "The first item cannot be nested under another item." : "An item can be nested only one level below the item above it." : "These items are already at the top level.";
|
|
594
|
+
return { payload: clone(payload), changed: false, reason, levels, ...report([]) };
|
|
595
|
+
}
|
|
596
|
+
const next = clone(payload);
|
|
597
|
+
next[key] = items.map((item, index) => levels[index] === original[index] ? clone(item) : withLevel(clone(item), levels[index]));
|
|
598
|
+
assertValidOwner(next);
|
|
599
|
+
return { payload: next, changed: true, levels, ...report([]) };
|
|
600
|
+
}
|
|
601
|
+
var demoteListItems = (payload, indices, options) => shiftListLevels(payload, indices, 1, options);
|
|
602
|
+
var promoteListItems = (payload, indices, options) => shiftListLevels(payload, indices, -1, options);
|
|
603
|
+
function convertListForm(payload, to) {
|
|
604
|
+
const key = listKey(payload);
|
|
605
|
+
if (key === to) return { payload: clone(payload), changed: false, ...report([]) };
|
|
606
|
+
const loss = new Loss();
|
|
607
|
+
const items = payload[key].map((item) => {
|
|
608
|
+
if (to === "bullets" && isRecord(item) && item.description !== void 0 && plainOf(item.description) !== "") {
|
|
609
|
+
loss.note("list item descriptions");
|
|
610
|
+
const { description: _dropped, ...rest } = item;
|
|
611
|
+
return clone(withLevel(rest, rest.level ?? 0));
|
|
612
|
+
}
|
|
613
|
+
return clone(item);
|
|
614
|
+
});
|
|
615
|
+
const next = {};
|
|
616
|
+
for (const [name, value] of Object.entries(payload)) {
|
|
617
|
+
if (name === key) next[to] = items;
|
|
618
|
+
else next[name] = clone(value);
|
|
619
|
+
}
|
|
620
|
+
if (payload.type === "text" || payload.type === "list") next.type = to === "bullets" ? "text" : "list";
|
|
621
|
+
assertValidOwner(next);
|
|
622
|
+
return { payload: next, changed: true, ...report(loss.list) };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/convert/structure.ts
|
|
626
|
+
var REGION_KEYS = new Set(promotedRegionKeys);
|
|
627
|
+
var isRegionKey = (key) => REGION_KEYS.has(key);
|
|
628
|
+
var regionKeysOf = (slide) => Object.keys(slide).filter(isRegionKey);
|
|
629
|
+
function at(slide, path) {
|
|
630
|
+
let value = slide;
|
|
631
|
+
for (const part of path) value = value?.[part];
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
function explicitBlocks(owner) {
|
|
635
|
+
if (Array.isArray(owner.blocks)) return owner.blocks;
|
|
636
|
+
const keys = contentKeysOf(owner);
|
|
637
|
+
const blocks = keys.map((key) => ({ ...keys.length === 1 && owner.type ? { type: owner.type } : {}, [key]: owner[key] }));
|
|
638
|
+
for (const key of [...CONTENT_KEYS, "type"]) delete owner[key];
|
|
639
|
+
owner.blocks = blocks;
|
|
640
|
+
return blocks;
|
|
641
|
+
}
|
|
642
|
+
function ownerAt(slide, path, what) {
|
|
643
|
+
const owner = path.length ? at(slide, path) : slide;
|
|
644
|
+
if (!isRecord(owner)) throw refuse(`There is no ${what} at that location.`, { path });
|
|
645
|
+
return owner;
|
|
646
|
+
}
|
|
647
|
+
var finish = (slide, loss, path) => {
|
|
648
|
+
assertValidOutput(slide, "slide");
|
|
649
|
+
return { slide, changed: true, ...path ? { path } : {}, ...report(loss.list) };
|
|
650
|
+
};
|
|
651
|
+
function wrapBlocks(slide, indices, options = {}) {
|
|
652
|
+
const next = clone(slide);
|
|
653
|
+
const container = options.container ?? [];
|
|
654
|
+
const owner = ownerAt(next, container, "container");
|
|
655
|
+
if (!Array.isArray(owner.blocks) && container.length && contentKeysOf(owner).length < 2) throw refuse("This content is a single payload, not a list of blocks. There is nothing to group it with.");
|
|
656
|
+
const blocks = explicitBlocks(owner);
|
|
657
|
+
const selected = [...new Set(indices)].sort((a, b) => a - b);
|
|
658
|
+
if (!selected.length || selected.some((index) => !Number.isInteger(index) || index < 0 || index >= blocks.length)) throw refuse("Choose blocks that exist in this container.", { indices });
|
|
659
|
+
const group = { blocks: selected.map((index) => blocks[index]), ...options.composition ? { composition: clone(options.composition) } : {} };
|
|
660
|
+
const first = selected[0];
|
|
661
|
+
const rest = new Set(selected.slice(1));
|
|
662
|
+
owner.blocks = blocks.flatMap((block, index) => index === first ? [group] : rest.has(index) ? [] : [block]);
|
|
663
|
+
return finish(next, new Loss(), [...container, "blocks", first]);
|
|
664
|
+
}
|
|
665
|
+
function unwrapGroup(slide, path) {
|
|
666
|
+
const next = clone(slide);
|
|
667
|
+
const group = at(next, path);
|
|
668
|
+
if (!isRecord(group) || !Array.isArray(group.blocks)) throw refuse("Choose a group to unwrap.", { path });
|
|
669
|
+
const index = path.at(-1);
|
|
670
|
+
if (path.length < 2 || path.at(-2) !== "blocks" || typeof index !== "number") throw refuse("Only a group that sits inside a list of blocks can be unwrapped. Convert the regions to blocks first.", { path });
|
|
671
|
+
const parent = at(next, path.slice(0, -2));
|
|
672
|
+
const loss = new Loss();
|
|
673
|
+
if (group.composition) loss.note("group arrangement (composition)");
|
|
674
|
+
if (group.id !== void 0) loss.note("group id");
|
|
675
|
+
if (group.extensions !== void 0) loss.note("group extensions");
|
|
676
|
+
parent.blocks.splice(index, 1, ...group.blocks);
|
|
677
|
+
return finish(next, loss, [...path.slice(0, -1), index]);
|
|
678
|
+
}
|
|
679
|
+
var ROWS = ["top", "middle", "bottom"];
|
|
680
|
+
var COLUMNS = ["left", "center", "right"];
|
|
681
|
+
function cellsOf(key) {
|
|
682
|
+
const [first = "", second] = key.split(":");
|
|
683
|
+
const parts = (text) => text.split("+");
|
|
684
|
+
let rows = ROWS;
|
|
685
|
+
let columns = COLUMNS;
|
|
686
|
+
if (second !== void 0) {
|
|
687
|
+
rows = parts(first);
|
|
688
|
+
columns = parts(second);
|
|
689
|
+
} else if (ROWS.includes(parts(first)[0])) rows = parts(first);
|
|
690
|
+
else columns = parts(first);
|
|
691
|
+
return new Set(rows.flatMap((row) => columns.map((column) => `${row}:${column}`)));
|
|
692
|
+
}
|
|
693
|
+
var overlaps = (a, b) => {
|
|
694
|
+
const other = cellsOf(b);
|
|
695
|
+
return [...cellsOf(a)].some((cell) => other.has(cell));
|
|
696
|
+
};
|
|
697
|
+
function sortRegionKeys(keys) {
|
|
698
|
+
const rank = (key) => {
|
|
699
|
+
const cells = [...cellsOf(key)].map((cell) => cell.split(":"));
|
|
700
|
+
return [Math.min(...cells.map(([row]) => ROWS.indexOf(row))), Math.min(...cells.map(([, column]) => COLUMNS.indexOf(column)))];
|
|
701
|
+
};
|
|
702
|
+
return [...keys].sort((a, b) => {
|
|
703
|
+
const [ar, ac] = rank(a);
|
|
704
|
+
const [br, bc] = rank(b);
|
|
705
|
+
return ar - br || ac - bc || keys.indexOf(a) - keys.indexOf(b);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
function blocksToRegions(slide, regions) {
|
|
709
|
+
const next = clone(slide);
|
|
710
|
+
if (regionKeysOf(next).length) throw refuse("This slide already uses named regions.");
|
|
711
|
+
const blocks = explicitBlocks(next);
|
|
712
|
+
if (!blocks.length) throw refuse("This slide has no blocks to place.");
|
|
713
|
+
if (regions.length !== blocks.length) throw refuse(`Name a region for each of the ${blocks.length} blocks.`, { blocks: blocks.length, regions: regions.length });
|
|
714
|
+
regions.forEach((key, index) => {
|
|
715
|
+
if (!isRegionKey(key)) throw refuse(`"${key}" is not a region (for example left, right, top:left, center+right).`, { region: key });
|
|
716
|
+
const clash = regions.findIndex((other, otherIndex) => otherIndex < index && overlaps(other, key));
|
|
717
|
+
if (clash >= 0) throw refuse(`Region "${key}" overlaps region "${regions[clash]}". Each block needs its own part of the slide.`, { region: key });
|
|
718
|
+
});
|
|
719
|
+
delete next.blocks;
|
|
720
|
+
regions.forEach((key, index) => {
|
|
721
|
+
next[key] = blocks[index];
|
|
722
|
+
});
|
|
723
|
+
return finish(next, new Loss(), [regions[0]]);
|
|
724
|
+
}
|
|
725
|
+
function regionsToBlocks(slide) {
|
|
726
|
+
const next = clone(slide);
|
|
727
|
+
const keys = sortRegionKeys(regionKeysOf(next));
|
|
728
|
+
if (!keys.length) throw refuse("This slide has no named regions.");
|
|
729
|
+
const blocks = keys.map((key) => next[key]);
|
|
730
|
+
for (const key of keys) delete next[key];
|
|
731
|
+
next.blocks = blocks;
|
|
732
|
+
const loss = new Loss();
|
|
733
|
+
loss.note("region placement");
|
|
734
|
+
return finish(next, loss, ["blocks", 0]);
|
|
735
|
+
}
|
|
736
|
+
function moveRegion(slide, from, to, options = {}) {
|
|
737
|
+
const next = clone(slide);
|
|
738
|
+
if (!isRegionKey(from) || next[from] === void 0) throw refuse(`This slide has no "${from}" region.`, { region: from });
|
|
739
|
+
if (!isRegionKey(to)) throw refuse(`"${to}" is not a region (for example left, right, top:left, center+right).`, { region: to });
|
|
740
|
+
if (from === to) return { slide: next, changed: false, path: [from], ...report([]) };
|
|
741
|
+
const occupied = next[to] !== void 0;
|
|
742
|
+
if (occupied && !options.swap) throw refuse(`The "${to}" region already has content. Choose to swap the two regions instead.`, { region: to });
|
|
743
|
+
const others = regionKeysOf(next).filter((key) => key !== from && key !== (options.swap ? to : void 0));
|
|
744
|
+
const clash = others.find((key) => overlaps(key, to) && key !== to);
|
|
745
|
+
if (clash) throw refuse(`Region "${to}" overlaps region "${clash}".`, { region: to });
|
|
746
|
+
const entries = Object.entries(next).map(([key, value]) => key === from ? [to, value] : options.swap && key === to ? [from, value] : [key, value]);
|
|
747
|
+
const rebuilt = Object.fromEntries(entries);
|
|
748
|
+
if (!occupied) rebuilt[to] = next[from];
|
|
749
|
+
return finish(rebuilt, new Loss(), [to]);
|
|
750
|
+
}
|
|
751
|
+
var IMAGE_POSITIONS = ["background", "top", "bottom", "left", "right"];
|
|
752
|
+
function imageAsset(payload) {
|
|
753
|
+
if (!isRecord(payload)) throw refuse("Choose an image block.");
|
|
754
|
+
const keys = Object.keys(payload).filter((key) => key !== "id" && key !== "extensions" && key !== "type");
|
|
755
|
+
if (keys.length !== 1 || keys[0] !== "image") throw refuse("Choose a block that holds only an image.");
|
|
756
|
+
return isRecord(payload.image) ? payload.image : { src: payload.image };
|
|
757
|
+
}
|
|
758
|
+
function removePayload(slide, path) {
|
|
759
|
+
if (!path.length) {
|
|
760
|
+
for (const key of [...CONTENT_KEYS, "type"]) delete slide[key];
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const index = path.at(-1);
|
|
764
|
+
if (typeof index === "number" && path.at(-2) === "blocks") {
|
|
765
|
+
const owner = at(slide, path.slice(0, -2));
|
|
766
|
+
owner.blocks.splice(index, 1);
|
|
767
|
+
if (owner.blocks.length) return;
|
|
768
|
+
if (path.length === 2) delete slide.blocks;
|
|
769
|
+
else removePayload(slide, path.slice(0, -2));
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (path.length === 1 && isRegionKey(String(path[0]))) {
|
|
773
|
+
delete slide[path[0]];
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
throw refuse("Choose an image block of this slide.", { path });
|
|
777
|
+
}
|
|
778
|
+
function promoteImage(slide, path, to, options = {}) {
|
|
779
|
+
const next = clone(slide);
|
|
780
|
+
const holder = path.length ? at(next, path) : contentKeysOf(next).join() === "image" && !Array.isArray(next.blocks) ? { image: next.image } : void 0;
|
|
781
|
+
const asset = imageAsset(holder);
|
|
782
|
+
if (typeof asset.src !== "string" || asset.src === "") throw refuse("This image has no source.");
|
|
783
|
+
const design = isRecord(next.design) ? next.design : {};
|
|
784
|
+
if (design[to] !== void 0 && design[to] !== false && !options.replace) throw refuse(`This slide already sets its ${to === "slideImage" ? "slide image" : to}. Choose to replace it, or remove it first.`, { slot: to });
|
|
785
|
+
const loss = new Loss();
|
|
786
|
+
const extras = ["title", "description", "mediaType", "format"].filter((key) => asset[key] !== void 0);
|
|
787
|
+
const position = options.position ?? "right";
|
|
788
|
+
if (!IMAGE_POSITIONS.includes(position)) throw refuse(`Choose a position: ${IMAGE_POSITIONS.join(", ")}.`, { position });
|
|
789
|
+
const alt = typeof asset.alt === "string" && asset.alt !== "" ? asset.alt : void 0;
|
|
790
|
+
if (to === "slideImage") {
|
|
791
|
+
design.slideImage = { src: asset.src, position, ...alt ? { alt } : {} };
|
|
792
|
+
if (extras.length) loss.note(`image ${extras.join(", ")}`);
|
|
793
|
+
} else if (to === "background") {
|
|
794
|
+
design.background = { type: "image", image: { src: asset.src, fit: "cover" } };
|
|
795
|
+
if (alt) loss.note("image alt text");
|
|
796
|
+
if (extras.length) loss.note(`image ${extras.join(", ")}`);
|
|
797
|
+
} else {
|
|
798
|
+
const opacity = options.opacity ?? 0.1;
|
|
799
|
+
if (typeof opacity !== "number" || !(opacity >= 0 && opacity <= 1)) throw refuse("Watermark opacity must be between 0 and 1.", { opacity });
|
|
800
|
+
design.watermark = { src: asset.src, opacity };
|
|
801
|
+
if (alt) loss.note("image alt text");
|
|
802
|
+
if (extras.length) loss.note(`image ${extras.join(", ")}`);
|
|
803
|
+
}
|
|
804
|
+
if (path.length) {
|
|
805
|
+
const payload = at(next, path);
|
|
806
|
+
if (payload.id !== void 0) loss.note("block id");
|
|
807
|
+
if (payload.extensions !== void 0) loss.note("block extensions");
|
|
808
|
+
}
|
|
809
|
+
next.design = design;
|
|
810
|
+
removePayload(next, path);
|
|
811
|
+
return finish(next, loss);
|
|
812
|
+
}
|
|
813
|
+
function demoteImage(slide, from, options = {}) {
|
|
814
|
+
const next = clone(slide);
|
|
815
|
+
const design = next.design;
|
|
816
|
+
const value = isRecord(design) ? design[from] : void 0;
|
|
817
|
+
if (value === void 0 || value === false) throw refuse(`This slide does not set its own ${from === "slideImage" ? "slide image" : from}.`, { slot: from });
|
|
818
|
+
const loss = new Loss();
|
|
819
|
+
let asset;
|
|
820
|
+
if (from === "slideImage") {
|
|
821
|
+
if (typeof value === "string") asset = { src: value };
|
|
822
|
+
else if (isRecord(value) && "position" in value) {
|
|
823
|
+
if (typeof value.src !== "string") throw refuse("This slide image only configures placement; it has no image of its own.");
|
|
824
|
+
asset = { src: value.src, ...value.alt ? { alt: value.alt } : {} };
|
|
825
|
+
loss.note("image position and framing");
|
|
826
|
+
} else asset = clone(value);
|
|
827
|
+
} else if (from === "background") {
|
|
828
|
+
if (!isRecord(value) || value.type !== "image" || typeof value.image?.src !== "string") throw refuse("This slide's background is not an image.");
|
|
829
|
+
asset = { src: value.image.src };
|
|
830
|
+
if (value.opacity !== void 0 || value.image.fit !== void 0 && value.image.fit !== "cover") loss.note("background fit and opacity");
|
|
831
|
+
} else {
|
|
832
|
+
if (typeof value === "string") asset = { src: value };
|
|
833
|
+
else if (isRecord(value) && typeof value.src === "string") {
|
|
834
|
+
const { opacity, ...rest } = value;
|
|
835
|
+
asset = rest;
|
|
836
|
+
if (opacity !== void 0) loss.note("watermark opacity");
|
|
837
|
+
} else throw refuse("This watermark has no image of its own.");
|
|
838
|
+
}
|
|
839
|
+
const block = { image: Object.keys(asset).length === 1 ? asset.src : asset };
|
|
840
|
+
delete design[from];
|
|
841
|
+
if (!Object.keys(design).length) delete next.design;
|
|
842
|
+
const regions = regionKeysOf(next);
|
|
843
|
+
let path;
|
|
844
|
+
if (regions.length) {
|
|
845
|
+
if (!options.region || !isRegionKey(options.region) || regions.some((key) => overlaps(key, options.region))) throw refuse("This slide places its content in regions. Choose a free region for the image.", { region: options.region });
|
|
846
|
+
next[options.region] = block;
|
|
847
|
+
path = [options.region];
|
|
848
|
+
} else if (Array.isArray(next.blocks) || contentKeysOf(next).length) {
|
|
849
|
+
const blocks = explicitBlocks(next);
|
|
850
|
+
const index = options.index ?? blocks.length;
|
|
851
|
+
if (!Number.isInteger(index) || index < 0 || index > blocks.length) throw refuse("The position is outside the slide's blocks.", { index });
|
|
852
|
+
blocks.splice(index, 0, block);
|
|
853
|
+
path = ["blocks", index];
|
|
854
|
+
} else {
|
|
855
|
+
next.image = block.image;
|
|
856
|
+
path = [];
|
|
857
|
+
}
|
|
858
|
+
return finish(next, loss, path);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// src/convert/slides.ts
|
|
862
|
+
var HEADINGS = ["title", "subtitle", "tag"];
|
|
863
|
+
var BODY_KEYS = /* @__PURE__ */ new Set([...CONTENT_KEYS, "type", "blocks"]);
|
|
864
|
+
function slideAt(presentation, index) {
|
|
865
|
+
const slides = presentation?.slides;
|
|
866
|
+
if (!Array.isArray(slides) || !Number.isInteger(index) || index < 0 || index >= slides.length) throw refuse("Choose a slide that exists.", { slideIndex: index });
|
|
867
|
+
return slides[index];
|
|
868
|
+
}
|
|
869
|
+
var slideIds = (slide) => {
|
|
870
|
+
const ids = typeof slide.id === "string" ? [slide.id] : [];
|
|
871
|
+
visitContentPayloads(slide, "", (payload) => {
|
|
872
|
+
if (typeof payload.id === "string") ids.push(payload.id);
|
|
873
|
+
});
|
|
874
|
+
return ids;
|
|
875
|
+
};
|
|
876
|
+
var documentIds = (presentation) => new Set(presentation.slides.flatMap(slideIds));
|
|
877
|
+
function replaceSlides(presentation, start, deleteCount, slides) {
|
|
878
|
+
const next = clone(presentation);
|
|
879
|
+
next.slides.splice(start, deleteCount, ...clone(slides));
|
|
880
|
+
return next;
|
|
881
|
+
}
|
|
882
|
+
function splittableBlocks(slide, loss) {
|
|
883
|
+
const regions = sortRegionKeys(regionKeysOf(slide));
|
|
884
|
+
if (regions.length) {
|
|
885
|
+
loss.note("region placement");
|
|
886
|
+
return regions.map((key) => slide[key]);
|
|
887
|
+
}
|
|
888
|
+
if (Array.isArray(slide.blocks)) return slide.blocks;
|
|
889
|
+
const keys = contentKeysOf(slide);
|
|
890
|
+
return keys.map((key) => ({ ...keys.length === 1 && slide.type ? { type: slide.type } : {}, [key]: slide[key] }));
|
|
891
|
+
}
|
|
892
|
+
function splitSlide(presentation, slideIndex, options = {}) {
|
|
893
|
+
const source = slideAt(presentation, slideIndex);
|
|
894
|
+
const loss = new Loss();
|
|
895
|
+
const blocks = splittableBlocks(clone(source), loss);
|
|
896
|
+
if (blocks.length < 2) throw refuse("This slide has one block, so there is nothing to split. Add or move content first, or split it where it overflows.");
|
|
897
|
+
let starts;
|
|
898
|
+
if (options.each) starts = blocks.map((_, index) => index).slice(1);
|
|
899
|
+
else {
|
|
900
|
+
starts = [...new Set(options.at ?? [])].sort((a, b) => a - b);
|
|
901
|
+
if (!starts.length) throw refuse("Choose where to split: block indexes at which a new slide starts, or one block per slide.");
|
|
902
|
+
if (starts.some((index) => !Number.isInteger(index) || index < 1 || index >= blocks.length)) throw refuse(`Choose split points between blocks 1 and ${blocks.length - 1}.`, { at: options.at });
|
|
903
|
+
}
|
|
904
|
+
const bounds = [0, ...starts, blocks.length];
|
|
905
|
+
const base = {};
|
|
906
|
+
for (const [key, value] of Object.entries(source)) if (!BODY_KEYS.has(key) && !isRegionKey(key)) base[key] = clone(value);
|
|
907
|
+
const taken = documentIds(presentation);
|
|
908
|
+
const slides = bounds.slice(0, -1).map((from, part) => {
|
|
909
|
+
const slide = {};
|
|
910
|
+
for (const [key, value] of Object.entries(base)) {
|
|
911
|
+
if (part > 0 && (key === "notes" || options.repeatHeadings === false && HEADINGS.includes(key))) continue;
|
|
912
|
+
if (part > 0 && key === "id") continue;
|
|
913
|
+
slide[key] = clone(value);
|
|
914
|
+
}
|
|
915
|
+
if (part > 0 && typeof base.id === "string") {
|
|
916
|
+
let suffix = part + 1;
|
|
917
|
+
while (taken.has(`${base.id}--${suffix}`)) suffix++;
|
|
918
|
+
slide.id = `${base.id}--${suffix}`;
|
|
919
|
+
taken.add(slide.id);
|
|
920
|
+
}
|
|
921
|
+
slide.blocks = clone(blocks.slice(from, bounds[part + 1]));
|
|
922
|
+
return slide;
|
|
923
|
+
});
|
|
924
|
+
assertValidOutput(slides, "slides");
|
|
925
|
+
return { presentation: replaceSlides(presentation, slideIndex, 1, slides), slides, range: { start: slideIndex, deleteCount: 1 }, changed: true, ...report(loss.list) };
|
|
926
|
+
}
|
|
927
|
+
var remapPath = (path, from, to) => path.replace(new RegExp(`^slides\\.${from}(?=\\.|$)`), `slides.${to}`);
|
|
928
|
+
function splitSlideOnOverflow(presentation, slideIndex, options = {}) {
|
|
929
|
+
const source = slideAt(presentation, slideIndex);
|
|
930
|
+
const whole = presentation;
|
|
931
|
+
let result;
|
|
932
|
+
try {
|
|
933
|
+
result = paginatePresentation({ ...clone(whole), slides: [clone(source)] }, options);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
if (error instanceof OPFPaginationError) throw refuse(error.message, { diagnostics: error.diagnostics });
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
const paginated = result.presentation.slides;
|
|
939
|
+
if (paginated.length === 1) {
|
|
940
|
+
return { presentation: clone(whole), slides: [clone(source)], range: { start: slideIndex, deleteCount: 1 }, changed: false, pages: [], ...report([]) };
|
|
941
|
+
}
|
|
942
|
+
const others = new Set(whole.slides.flatMap((slide, index) => index === slideIndex ? [] : slideIds(slide)));
|
|
943
|
+
const used = new Set(others);
|
|
944
|
+
const rename = (node) => {
|
|
945
|
+
if (typeof node.id !== "string") return;
|
|
946
|
+
let id = node.id;
|
|
947
|
+
for (let suffix = 2; used.has(id); suffix++) id = `${node.id}-${suffix}`;
|
|
948
|
+
node.id = id;
|
|
949
|
+
used.add(id);
|
|
950
|
+
};
|
|
951
|
+
for (const slide of paginated) {
|
|
952
|
+
rename(slide);
|
|
953
|
+
visitContentPayloads(slide, "", (payload) => rename(payload));
|
|
954
|
+
}
|
|
955
|
+
const pages = result.pages.map((page) => ({
|
|
956
|
+
...page,
|
|
957
|
+
sourceSlideIndex: slideIndex,
|
|
958
|
+
slideIndex: slideIndex + page.slideIndex,
|
|
959
|
+
mappings: page.mappings.map((mapping) => ({ ...mapping, sourcePath: remapPath(mapping.sourcePath, 0, slideIndex), outputPath: remapPath(mapping.outputPath, 0, slideIndex + page.slideIndex) })),
|
|
960
|
+
...page.repeatedMappings ? { repeatedMappings: page.repeatedMappings.map((mapping) => ({ ...mapping, sourcePath: remapPath(mapping.sourcePath, 0, slideIndex), outputPath: remapPath(mapping.outputPath, 0, slideIndex + page.slideIndex) })) } : {}
|
|
961
|
+
}));
|
|
962
|
+
assertValidOutput(paginated, "slides");
|
|
963
|
+
return { presentation: replaceSlides(whole, slideIndex, 1, paginated), slides: paginated, range: { start: slideIndex, deleteCount: 1 }, changed: true, pages, ...report([]) };
|
|
964
|
+
}
|
|
965
|
+
function mergeSlides(presentation, start, count = 2) {
|
|
966
|
+
if (!Number.isInteger(count) || count < 2) throw refuse("Choose at least two slides to merge.");
|
|
967
|
+
const slides = [];
|
|
968
|
+
for (let offset = 0; offset < count; offset++) {
|
|
969
|
+
if (!isRecord(presentation?.slides?.[start + offset])) throw refuse("Choose consecutive slides that exist.", { start, count });
|
|
970
|
+
slides.push(clone(slideAt(presentation, start + offset)));
|
|
971
|
+
}
|
|
972
|
+
const loss = new Loss();
|
|
973
|
+
if (slides.some((slide) => regionKeysOf(slide).length)) throw refuse("Slides that place content in named regions cannot be merged. Convert the regions to blocks first.");
|
|
974
|
+
const [first, ...others] = slides;
|
|
975
|
+
const merged = {};
|
|
976
|
+
for (const [key, value] of Object.entries(first)) if (!BODY_KEYS.has(key) && key !== "notes") merged[key] = value;
|
|
977
|
+
others.forEach((slide, index) => {
|
|
978
|
+
const number = start + index + 2;
|
|
979
|
+
for (const [key, value] of Object.entries(slide)) {
|
|
980
|
+
if (BODY_KEYS.has(key) || key === "notes") continue;
|
|
981
|
+
if (key === "id") loss.note(`slide id "${String(value)}"`);
|
|
982
|
+
else if (!same(first[key], value)) loss.note(`${key} of slide ${number}`);
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
const blocks = slides.flatMap((slide) => splittableBlocks(slide, loss));
|
|
986
|
+
if (blocks.length) merged.blocks = blocks;
|
|
987
|
+
const notes = slides.map((slide) => slide.notes).filter((note) => typeof note === "string" && note !== "");
|
|
988
|
+
if (notes.length) merged.notes = notes.join("\n\n");
|
|
989
|
+
assertValidOutput(merged, "slide");
|
|
990
|
+
return { presentation: replaceSlides(presentation, start, count, [merged]), slides: [merged], range: { start, deleteCount: count }, changed: true, ...report(loss.list) };
|
|
991
|
+
}
|
|
992
|
+
var segments = (path) => path.split(".");
|
|
993
|
+
var valueAt = (root, path) => path.reduce((value, part) => value?.[part], root);
|
|
994
|
+
function joinSlices(field, slices) {
|
|
995
|
+
const [first] = slices;
|
|
996
|
+
if (slices.length === 1) return clone(first);
|
|
997
|
+
switch (field) {
|
|
998
|
+
case "text":
|
|
999
|
+
return slices.slice(1).reduce((joined, slice) => concatText(joined, slice), clone(first));
|
|
1000
|
+
case "items":
|
|
1001
|
+
case "bullets":
|
|
1002
|
+
return slices.flatMap((slice) => clone(slice));
|
|
1003
|
+
case "code":
|
|
1004
|
+
case "quote": {
|
|
1005
|
+
const key = field === "code" ? "source" : "text";
|
|
1006
|
+
const text = slices.map((slice) => typeof slice === "string" ? slice : slice[key]).join("");
|
|
1007
|
+
return typeof first === "string" ? text : { ...clone(first), [key]: text };
|
|
1008
|
+
}
|
|
1009
|
+
case "table":
|
|
1010
|
+
return { ...clone(first), rows: slices.flatMap((slice) => clone(slice.rows)) };
|
|
1011
|
+
case "timeline":
|
|
1012
|
+
return Array.isArray(first) ? slices.flatMap((slice) => clone(slice)) : { ...clone(first), events: slices.flatMap((slice) => clone(slice.events)) };
|
|
1013
|
+
default:
|
|
1014
|
+
return clone(first);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function unpaginate(presentation, pages, options = {}) {
|
|
1018
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1019
|
+
for (const page of pages) {
|
|
1020
|
+
if (options.sourceSlideIndex !== void 0 && page.sourceSlideIndex !== options.sourceSlideIndex) continue;
|
|
1021
|
+
groups.set(page.sourceSlideIndex, [...groups.get(page.sourceSlideIndex) ?? [], page]);
|
|
1022
|
+
}
|
|
1023
|
+
const todo = [...groups.values()].filter((group) => group.length > 1);
|
|
1024
|
+
if (!todo.length) throw refuse("Those slides were not split by pagination, or only one page was kept.");
|
|
1025
|
+
let current = clone(presentation);
|
|
1026
|
+
let first = Number.POSITIVE_INFINITY;
|
|
1027
|
+
let end = 0;
|
|
1028
|
+
let removed = 0;
|
|
1029
|
+
for (const group of todo.sort((a, b) => b[0].slideIndex - a[0].slideIndex)) {
|
|
1030
|
+
const indexes = group.map((page) => page.slideIndex);
|
|
1031
|
+
if (indexes.some((index, position) => index !== indexes[0] + position)) throw refuse("The pages of this slide are no longer next to each other.", { slides: indexes });
|
|
1032
|
+
const rebuilt = rebuild(current, group);
|
|
1033
|
+
current = replaceSlides(current, indexes[0], indexes.length, [rebuilt]);
|
|
1034
|
+
first = Math.min(first, indexes[0]);
|
|
1035
|
+
end = Math.max(end, indexes[0] + indexes.length);
|
|
1036
|
+
removed += indexes.length - 1;
|
|
1037
|
+
}
|
|
1038
|
+
assertValidOutput(current.slides, "slides");
|
|
1039
|
+
const deleteCount = end - first;
|
|
1040
|
+
return { presentation: current, slides: current.slides.slice(first, first + deleteCount - removed), range: { start: first, deleteCount }, changed: true, ...report([]) };
|
|
1041
|
+
}
|
|
1042
|
+
function rebuild(presentation, group) {
|
|
1043
|
+
const firstPage = group[0];
|
|
1044
|
+
const skeleton = clone(valueAt(presentation, ["slides", String(firstPage.slideIndex)]));
|
|
1045
|
+
const root = {};
|
|
1046
|
+
for (const [key, value] of Object.entries(skeleton)) if (!BODY_KEYS.has(key) && !isRegionKey(key)) root[key] = value;
|
|
1047
|
+
const leaves = /* @__PURE__ */ new Map();
|
|
1048
|
+
const order = [];
|
|
1049
|
+
group.forEach((page, pageNumber) => {
|
|
1050
|
+
for (const mapping of page.mappings) {
|
|
1051
|
+
const outputSegments = segments(mapping.outputPath).slice(2);
|
|
1052
|
+
const value = valueAt(presentation, ["slides", String(page.slideIndex), ...outputSegments]);
|
|
1053
|
+
if (value === void 0) throw refuse(`The slide no longer has the content at ${mapping.outputPath}.`, { path: mapping.outputPath });
|
|
1054
|
+
const key = segments(mapping.sourcePath).slice(2).join(".");
|
|
1055
|
+
if (!leaves.has(key)) {
|
|
1056
|
+
leaves.set(key, []);
|
|
1057
|
+
order.push(key);
|
|
1058
|
+
}
|
|
1059
|
+
leaves.get(key).push({ page: pageNumber, value, ...mapping.range ? { range: mapping.range } : {}, outputSegments });
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
const place = (sourceSegments, containerOf, value) => {
|
|
1063
|
+
let cursor = root;
|
|
1064
|
+
for (let depth = 0; depth < sourceSegments.length - 1; depth++) {
|
|
1065
|
+
const part = sourceSegments[depth];
|
|
1066
|
+
if (part === "blocks") {
|
|
1067
|
+
cursor.blocks ??= [];
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const slot = Array.isArray(cursor.blocks) && /^\d+$/.test(part) ? cursor.blocks : cursor;
|
|
1071
|
+
const key = Array.isArray(cursor.blocks) && /^\d+$/.test(part) ? Number(part) : part;
|
|
1072
|
+
if (!isRecord(slot[key])) {
|
|
1073
|
+
const model = containerOf(depth);
|
|
1074
|
+
const copy = {};
|
|
1075
|
+
for (const [name, entry] of Object.entries(model ?? {})) if (!BODY_KEYS.has(name) && !isRegionKey(name)) copy[name] = clone(entry);
|
|
1076
|
+
slot[key] = copy;
|
|
1077
|
+
}
|
|
1078
|
+
cursor = slot[key];
|
|
1079
|
+
}
|
|
1080
|
+
cursor[sourceSegments.at(-1)] = value;
|
|
1081
|
+
};
|
|
1082
|
+
for (const key of order) {
|
|
1083
|
+
const slices = leaves.get(key);
|
|
1084
|
+
for (let index = 1; index < slices.length; index++) {
|
|
1085
|
+
const previous = slices[index - 1].range;
|
|
1086
|
+
const next = slices[index].range;
|
|
1087
|
+
if (!previous || !next || previous.end !== next.start) throw refuse("The slices of this slide are not consecutive any more, so they cannot be put back together.", { path: key });
|
|
1088
|
+
}
|
|
1089
|
+
const sourceSegments = key.split(".");
|
|
1090
|
+
const field = sourceSegments.at(-1);
|
|
1091
|
+
const model = slices[0];
|
|
1092
|
+
const containerOf = (depth) => valueAt(presentation, ["slides", String(group[model.page].slideIndex), ...model.outputSegments.slice(0, depth + 1)]);
|
|
1093
|
+
place(sourceSegments, containerOf, joinSlices(field, slices.map((slice) => slice.value)));
|
|
1094
|
+
}
|
|
1095
|
+
const compact = (node) => {
|
|
1096
|
+
if (Array.isArray(node.blocks)) {
|
|
1097
|
+
node.blocks = node.blocks.filter((block) => block !== void 0);
|
|
1098
|
+
for (const block of node.blocks) compact(block);
|
|
1099
|
+
}
|
|
1100
|
+
for (const key of Object.keys(node)) if (isRegionKey(key) && isRecord(node[key])) compact(node[key]);
|
|
1101
|
+
};
|
|
1102
|
+
compact(root);
|
|
1103
|
+
if (skeleton.type !== void 0 && CONTENT_KEYS.some((key) => root[key] !== void 0)) root.type = skeleton.type;
|
|
1104
|
+
return root;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
export { CONTENT_CONVERSIONS, CONTENT_KIND_LABELS, blocksToRegions, contentConversionTargets, convertContent, convertListForm, demoteImage, demoteListItems, mergeSlides, moveRegion, promoteImage, promoteListItems, readContent, regionsToBlocks, shiftListLevels, splitSlide, splitSlideOnOverflow, unpaginate, unwrapGroup, wrapBlocks };
|