@openpresentation/opf 0.6.0 → 0.8.0

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,8 +1,45 @@
1
+ // src/table.ts
2
+ var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
3
+ function tableGrid(value, path = "table") {
4
+ const table = record(value), hasHeaders = Array.isArray(table.columns) && table.columns.length > 0;
5
+ const values = [...hasHeaders ? [table.columns] : [], ...Array.isArray(table.rows) ? table.rows : []];
6
+ const columnCount = values.reduce((count, row) => Math.max(count, Array.isArray(row) ? row.length : 0), 1), rowCount = values.length;
7
+ const owners = Array.from({ length: rowCount }, () => Array(columnCount));
8
+ const rows2 = Array.from({ length: rowCount }, () => []), issues = [];
9
+ const location = (r, c) => `${path}.${hasHeaders && r === 0 ? "columns" : `rows.${r - Number(hasHeaders)}`}.${c}`;
10
+ for (let r = 0; r < rowCount; r++) for (let c = 0; c < columnCount; c++) {
11
+ const input = values[r]?.[c], cellPath = location(r, c), existing = owners[r][c];
12
+ if (existing) {
13
+ if (input !== null) issues.push({ path: cellPath, message: "A position covered by a spanning cell must explicitly contain null; content cannot be hidden." });
14
+ continue;
15
+ }
16
+ const object = record(input), styled = Object.hasOwn(object, "value");
17
+ const rowSpan = styled ? object.rowSpan ?? 1 : 1, colSpan = styled ? object.colSpan ?? 1 : 1;
18
+ if (!Number.isSafeInteger(rowSpan) || !Number.isSafeInteger(colSpan) || rowSpan < 1 || colSpan < 1 || r + rowSpan > rowCount || c + colSpan > columnCount || hasHeaders && r === 0 && rowSpan > 1) {
19
+ issues.push({ path: cellPath, message: "Cell spans must be positive integers within the table grid and cannot cross from headers into body rows." });
20
+ continue;
21
+ }
22
+ const cell = { input, value: styled ? object.value : input, style: styled ? record(object.style) : {}, row: r, column: c, rowSpan, colSpan, header: hasHeaders && r === 0, path: cellPath, valuePath: styled ? cellPath + ".value" : cellPath };
23
+ rows2[r].push(cell);
24
+ for (let y = r; y < r + rowSpan; y++) for (let x = c; x < c + colSpan; x++) {
25
+ if (owners[y][x]) issues.push({ path: cellPath, message: "Cell spans cannot overlap another spanning cell." });
26
+ else owners[y][x] = cell;
27
+ }
28
+ }
29
+ return { rows: rows2, columnCount, rowCount, hasHeaders, owners, issues };
30
+ }
31
+ function tableRowBoundaries(value) {
32
+ const grid = tableGrid(value), offset = Number(grid.hasHeaders), count = grid.rowCount - offset;
33
+ const allowed = new Set(Array.from({ length: count + 1 }, (_, i) => i));
34
+ for (const row of grid.rows) for (const cell of row) if (!cell.header) for (let r = 1; r < cell.rowSpan; r++) allowed.delete(cell.row - offset + r);
35
+ return [...allowed];
36
+ }
37
+
1
38
  // src/composition.ts
2
39
  var MAX_COMPOSITION_DEPTH = 32;
3
40
  function resolveFontFamilies(input) {
4
- const scheme = record(input);
5
- const family = (value) => typeof value === "string" ? value : record(value).family;
41
+ const scheme = record2(input);
42
+ const family = (value) => typeof value === "string" ? value : record2(value).family;
6
43
  return {
7
44
  heading: family(scheme.heading) ?? scheme.major ?? scheme.minor ?? "Roboto",
8
45
  body: family(scheme.body) ?? scheme.minor ?? scheme.major ?? "Roboto",
@@ -21,19 +58,21 @@ function textWidthMeasurer(style, measurement) {
21
58
  };
22
59
  }
23
60
  var OPFCompositionError = class extends Error {
24
- constructor(diagnostics) {
61
+ constructor(diagnostics, explanation) {
25
62
  super("Slide content does not fit its composition.");
26
63
  this.diagnostics = diagnostics;
27
64
  this.name = "OPFCompositionError";
65
+ if (explanation) this.explanation = explanation;
28
66
  }
29
67
  diagnostics;
30
68
  code = "layout-overflow";
69
+ explanation;
31
70
  };
32
71
  var fields = ["text", "items", "bullets", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"];
33
72
  var headings = /* @__PURE__ */ new Set(["title", "subtitle", "tag"]);
34
73
  var rows = ["top", "middle", "bottom"];
35
74
  var columns = ["left", "center", "right"];
36
- var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
75
+ var record2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
37
76
  var kind = (field) => field === "items" || field === "bullets" ? "list" : field;
38
77
  var round = (value) => Math.round(value * 1e6) / 1e6 || value;
39
78
  var textSegments = new Intl.Segmenter("und", { granularity: "grapheme" });
@@ -83,6 +122,110 @@ function fitText(text, box, requestedSize = 25, minFontSize = 16, measure = meas
83
122
  }
84
123
  return { lines, fontSize, lineHeight: fontSize * 1.22, overflow: lines.length * fontSize * 1.22 > box.height + 0.01 || lines.some((line) => measure(line, fontSize) > box.width + 0.01) };
85
124
  }
125
+ function layoutQuote(value, box, options = {}) {
126
+ const shorthand = typeof value === "string";
127
+ const quote = shorthand ? { text: value } : value;
128
+ if (!quote || Array.isArray(quote) || typeof quote.text !== "string" || [quote.attribution, quote.source].some((field) => field !== void 0 && typeof field !== "string")) {
129
+ throw new TypeError("Quote content must be a string or a text object with optional string attribution/source.");
130
+ }
131
+ const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale;
132
+ if (![box.x, box.y, box.width, box.height, scale, minimum].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) {
133
+ throw new RangeError("Quote dimensions, scale and minimum font size must be finite and positive.");
134
+ }
135
+ if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid quote overflow policy.");
136
+ const path = options.path ?? "quote";
137
+ const diagnostics = [], parts = [];
138
+ const report = (reason, diagnosticPath, roles, message) => diagnostics.push({ code: "text-overflow", reason, path: diagnosticPath, parts: roles, message });
139
+ const source = (sourcePath, text, outputStart) => ({ path: sourcePath, start: 0, end: text.length, outputStart, outputEnd: outputStart + text.length });
140
+ const add = (role, text, sources, area, fontSize, fontFamily, fontWeight, partPath) => {
141
+ const requestedStyle = { fontFamily, fontWeight, italic: false, path: partPath };
142
+ const style = resolveTextStyle({ ...requestedStyle }, options.textMeasurement);
143
+ const requestedFontSize = Math.max(fontSize * scale, minimum);
144
+ const part = { role, path: partPath, text, sources, box: area, requestedFontSize, minFontSize: minimum, requestedStyle, style };
145
+ parts.push(part);
146
+ return part;
147
+ };
148
+ let footer = "";
149
+ const footerSources = [];
150
+ for (const field of ["attribution", "source"]) {
151
+ const text = quote[field];
152
+ if (!text) continue;
153
+ if (footer) footer += " - ";
154
+ footerSources.push(source(`${path}.${field}`, text, footer.length));
155
+ footer += text;
156
+ }
157
+ const bodyPath = shorthand ? path : `${path}.text`;
158
+ const body = add(
159
+ "body",
160
+ `"${quote.text}"`,
161
+ [source(bodyPath, quote.text, 1)],
162
+ { x: box.x + 18, y: box.y + 18, width: box.width - 36, height: box.height - (footer ? 94 : 36) },
163
+ 28,
164
+ options.fonts?.heading ?? "sans-serif",
165
+ 600,
166
+ bodyPath
167
+ );
168
+ const attribution = footer ? add(
169
+ "footer",
170
+ footer,
171
+ footerSources,
172
+ { x: box.x + 18, y: box.y + box.height - 58, width: box.width - 36, height: 40 },
173
+ 17,
174
+ options.fonts?.body ?? "sans-serif",
175
+ 500,
176
+ path
177
+ ) : void 0;
178
+ const usable = (area) => [area.x, area.y, area.width, area.height].every(Number.isFinite) && area.width > 0 && area.height > 0;
179
+ const fit = (part, area, size = part.requestedFontSize, floor = minimum) => usable(area) ? fitText(part.text, area, size, floor, textWidthMeasurer(part.style, options.textMeasurement)) : void 0;
180
+ const inner = { x: box.x + 18, y: box.y + 18, width: box.width - 36, height: box.height - 36 };
181
+ if (!attribution) body.fit = fit(body, body.box);
182
+ else if (usable(inner) && inner.height > 18) {
183
+ const available = inner.height - 18;
184
+ const preferredBody = fit(body, inner, body.requestedFontSize, body.requestedFontSize);
185
+ const minimumBody = body.requestedFontSize === minimum ? preferredBody : fit(body, inner, minimum, minimum);
186
+ const preferredBodyHeight = preferredBody.lines.length * preferredBody.lineHeight;
187
+ const minimumBodyHeight = minimumBody.lines.length * minimumBody.lineHeight;
188
+ let selected;
189
+ for (const size of /* @__PURE__ */ new Set([attribution.requestedFontSize, minimum])) {
190
+ const natural = fit(attribution, inner, size, size);
191
+ const naturalHeight = natural.lines.length * natural.lineHeight;
192
+ const preferredHeight = Math.max(40, naturalHeight);
193
+ const bodyReservation = Math.min(minimumBodyHeight, Math.max(minimumBody.lineHeight, available - natural.lineHeight));
194
+ const footerHeight = preferredBodyHeight + preferredHeight <= available + 0.01 ? preferredHeight : Math.min(naturalHeight, Math.max(0, available - bodyReservation));
195
+ const bodyBox = { ...inner, height: available - footerHeight };
196
+ const footerBox = { ...inner, y: inner.y + inner.height - footerHeight, height: footerHeight };
197
+ const bodyFit = fit(body, bodyBox);
198
+ const footerFit = usable(footerBox) ? { ...natural, overflow: natural.overflow || naturalHeight > footerHeight + 0.01 } : void 0;
199
+ const overflow = !bodyFit || !footerFit || bodyFit.overflow || footerFit.overflow;
200
+ const score = (body.requestedFontSize - (bodyFit?.fontSize ?? minimum) + attribution.requestedFontSize - size) / scale;
201
+ if (!selected || !overflow && (selected.overflow || score < selected.score) || overflow && selected.overflow) {
202
+ selected = { bodyBox, footerBox, bodyFit, footerFit, score, overflow };
203
+ }
204
+ }
205
+ if (selected) {
206
+ body.box = selected.bodyBox;
207
+ body.fit = selected.bodyFit;
208
+ attribution.box = selected.footerBox;
209
+ attribution.fit = selected.footerFit;
210
+ }
211
+ }
212
+ for (const part of parts) {
213
+ const area = part.box;
214
+ if (!part.fit) {
215
+ report("invalid-part-box", part.path, [part.role], `Quote ${part.role} has no usable space after its insets; increase the cell size or change the arrangement.`);
216
+ } else if (part.fit.overflow) {
217
+ report("text-fit", part.path, [part.role], `Quote ${part.role} exceeds its available space at the readability floor; increase its space or change the arrangement.`);
218
+ }
219
+ if (usable(area) && (area.x < box.x || area.y < box.y || area.x + area.width > box.x + box.width + 0.01 || area.y + area.height > box.y + box.height + 0.01)) {
220
+ report("part-outside-cell", part.path, [part.role], `Quote ${part.role} extends outside its cell; increase the cell size or change the arrangement.`);
221
+ }
222
+ }
223
+ if (body?.fit && attribution?.fit && body.box.y + body.fit.lines.length * body.fit.lineHeight > attribution.box.y + 0.01 && attribution.box.y + attribution.fit.lines.length * attribution.fit.lineHeight > body.box.y + 0.01) {
224
+ report("part-overlap", path, ["body", "footer"], "Quote body and footer line boxes overlap; do not accept this layout without more space.");
225
+ }
226
+ if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
227
+ return { algorithm: "quote-flow-v1", textMeasurement: options.textMeasurement ? "provided" : "estimated", parts, diagnostics, overflow: diagnostics.length > 0 };
228
+ }
86
229
  function fitRichText(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
87
230
  if (![box.width, box.height, requestedSize, minFontSize].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Rich text dimensions and font sizes must be finite and positive.");
88
231
  const layout = richTextLayouter(input, box, requestedSize, options), minimum = Math.min(requestedSize, minFontSize);
@@ -236,53 +379,61 @@ function fitList(input, box, requestedSize = 25, minFontSize = 16, options = { s
236
379
  function contentText(field, value) {
237
380
  if (field === "text" || headings.has(field)) return flatten(value);
238
381
  if (field === "items" || field === "bullets") return (Array.isArray(value) ? value : [value]).map((item) => {
239
- const entry = record(item);
382
+ const entry = record2(item);
240
383
  return `\u2022 ${flatten(item)}${entry.description ? `
241
384
  ${flatten(entry.description)}` : ""}`;
242
385
  }).join("\n");
243
- if (field === "code") return flatten(record(value).source ?? value);
244
- if (field === "quote") return flatten(record(value).text ?? value);
386
+ if (field === "code") return flatten(record2(value).source ?? value);
387
+ if (field === "quote") return flatten(record2(value).text ?? value);
245
388
  return void 0;
246
389
  }
247
390
  function layoutTable(value, box, options = {}) {
248
391
  const scale = options.scale ?? 1, requested = 15 * scale, minimum = (options.minFontSize ?? 16) * scale;
249
392
  if (![box.x, box.y, box.width, box.height, scale, minimum].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) throw new RangeError("Table dimensions, scale and font sizes must be finite and positive.");
250
- const table = record(value), headers = Array.isArray(table.columns) && table.columns.length > 0;
251
- const values = [...headers ? [table.columns] : [], ...Array.isArray(table.rows) ? table.rows : []];
252
- const columnCount = Math.max(1, ...values.map((row) => row.length)), cellWidth = box.width / columnCount;
253
- const width = Math.max(scale, cellWidth - 20 * scale), padding = 12 * scale;
393
+ const grid = tableGrid(value, options.path ?? "table");
394
+ if (grid.issues.length) throw new RangeError(`${grid.issues[0].path}: ${grid.issues[0].message}`);
395
+ const columnCount = grid.columnCount, cellWidth = box.width / columnCount;
396
+ const cells = grid.rows.flat().map((cell) => {
397
+ const padding = { top: 8, right: 10, bottom: 4, left: 10, ...cell.style.padding };
398
+ const width = cellWidth * cell.colSpan - (padding.left + padding.right) * scale;
399
+ const style = { fontFamily: options.fontFamily ?? "sans-serif", fontWeight: cell.header ? 700 : 400, italic: false, path: cell.valuePath };
400
+ return { ...cell, padding, width, textStyle: style };
401
+ });
254
402
  const fitCell = (cell, height, min) => {
255
- const textBox = { width, height: Math.max(scale, height) };
256
- return Array.isArray(cell.value) ? fitRichText(cell.value, textBox, requested, min, { style: cell.style, textMeasurement: options.textMeasurement, uniformLineHeight: true }) : fitText(flatten(cell.value), textBox, requested, min, textWidthMeasurer(resolveTextStyle(cell.style, options.textMeasurement), options.textMeasurement));
403
+ const textBox = { width: Math.max(scale, cell.width), height: Math.max(scale, height) };
404
+ return Array.isArray(cell.value) ? fitRichText(cell.value, textBox, requested, min, { style: cell.textStyle, textMeasurement: options.textMeasurement, uniformLineHeight: true }) : fitText(flatten(cell.value), textBox, requested, min, textWidthMeasurer(resolveTextStyle(cell.textStyle, options.textMeasurement), options.textMeasurement));
257
405
  };
258
406
  const textHeight = (fit) => "height" in fit ? fit.height : fit.lines.length * fit.lineHeight;
259
- const measured = values.map((row, index) => {
260
- const header = headers && index === 0;
261
- const cells = Array.from({ length: columnCount }, (_, column) => {
262
- const path = `${options.path ?? "table"}.${header ? "columns" : `rows.${index - Number(headers)}`}.${column}`;
263
- return { value: row[column], path, header, style: { fontFamily: options.fontFamily ?? "sans-serif", fontWeight: header ? 700 : 400, italic: false, path } };
264
- });
265
- const natural2 = Math.max(...cells.map((cell) => textHeight(fitCell(cell, scale, requested)) + padding));
266
- const preferred2 = Math.max(54 * scale, natural2);
267
- const needed2 = Math.min(preferred2, Math.max(...cells.map((cell) => textHeight(fitCell(cell, scale, minimum)) + padding)));
268
- return { cells, preferred: preferred2, natural: natural2, needed: needed2 };
269
- });
270
- const preferred = measured.reduce((sum, row) => sum + row.preferred, 0), natural = measured.reduce((sum, row) => sum + row.natural, 0), needed = measured.reduce((sum, row) => sum + row.needed, 0);
271
- let y = box.y, overflow = cellWidth <= 20 * scale;
272
- const rows2 = measured.map((row) => {
273
- const height = preferred <= box.height ? row.preferred : natural <= box.height ? row.natural + (row.preferred - row.natural) * (box.height - natural) / Math.max(Number.EPSILON, preferred - natural) : needed <= box.height ? row.needed + (row.natural - row.needed) * (box.height - needed) / Math.max(Number.EPSILON, natural - needed) : row.needed * box.height / needed;
274
- const rowBox = { x: box.x, y, width: box.width, height };
275
- const cells = row.cells.map((cell, column) => {
276
- const cellBox = { x: box.x + column * cellWidth, y, width: cellWidth, height };
277
- const textBox = { x: cellBox.x + 10 * scale, y: y + 8 * scale, width, height: Math.max(scale, height - padding) };
278
- const fit = fitCell(cell, textBox.height, minimum);
279
- overflow ||= height <= padding || fit.overflow;
280
- return { value: cell.value, path: cell.path, header: cell.header, rich: Array.isArray(cell.value), box: cellBox, textBox, textStyle: resolveTextStyle(cell.style, options.textMeasurement), fit };
281
- });
282
- y += height;
283
- return { box: rowBox, cells };
284
- });
285
- return { rows: rows2, columnCount, height: y - box.y, overflow };
407
+ const required = (cell, size) => textHeight(fitCell(cell, scale, size)) + (cell.padding.top + cell.padding.bottom) * scale;
408
+ const natural = Array(grid.rowCount).fill(0), needed = Array(grid.rowCount).fill(0);
409
+ for (const cell of cells) if (cell.rowSpan === 1) {
410
+ natural[cell.row] = Math.max(natural[cell.row], required(cell, requested));
411
+ needed[cell.row] = Math.max(needed[cell.row], required(cell, minimum));
412
+ }
413
+ for (const cell of cells) if (cell.rowSpan > 1) for (const [heights2, size] of [[natural, requested], [needed, minimum]]) {
414
+ const current = heights2.slice(cell.row, cell.row + cell.rowSpan).reduce((a, b) => a + b, 0);
415
+ const extra = Math.max(0, required(cell, size) - current) / cell.rowSpan;
416
+ for (let r = cell.row; r < cell.row + cell.rowSpan; r++) heights2[r] += extra;
417
+ }
418
+ const preferred = natural.map((height) => Math.max(54 * scale, height));
419
+ for (let r = 0; r < needed.length; r++) needed[r] = Math.min(preferred[r], needed[r]);
420
+ const sum = (values) => values.reduce((a, b) => a + b, 0);
421
+ const preferredTotal = sum(preferred), naturalTotal = sum(natural), neededTotal = sum(needed);
422
+ const heights = preferred.map((height, r) => preferredTotal <= box.height ? height : naturalTotal <= box.height ? natural[r] + (height - natural[r]) * (box.height - naturalTotal) / Math.max(Number.EPSILON, preferredTotal - naturalTotal) : neededTotal <= box.height ? needed[r] + (natural[r] - needed[r]) * (box.height - neededTotal) / Math.max(Number.EPSILON, naturalTotal - neededTotal) : needed[r] * box.height / neededTotal);
423
+ const ys = [box.y];
424
+ for (const height of heights) ys.push(ys.at(-1) + height);
425
+ let overflow = false;
426
+ const rows2 = heights.map((height, r) => ({ box: { x: box.x, y: ys[r], width: box.width, height }, cells: [] }));
427
+ for (const cell of cells) {
428
+ const cellBox = { x: box.x + cell.column * cellWidth, y: ys[cell.row], width: cell.colSpan * cellWidth, height: ys[cell.row + cell.rowSpan] - ys[cell.row] };
429
+ const available = cellBox.height - (cell.padding.top + cell.padding.bottom) * scale;
430
+ const fit = fitCell(cell, available, minimum), height = textHeight(fit);
431
+ const offset = cell.style.verticalAlign === "bottom" ? Math.max(0, available - height) : cell.style.verticalAlign === "middle" ? Math.max(0, (available - height) / 2) : 0;
432
+ const textBox = { x: cellBox.x + cell.padding.left * scale, y: cellBox.y + cell.padding.top * scale + offset, width: Math.max(scale, cell.width), height: Math.max(scale, available - offset) };
433
+ overflow ||= cell.width <= 0 || available <= 0 || fit.overflow;
434
+ rows2[cell.row].cells.push({ value: cell.value, input: cell.input, path: cell.valuePath, sourcePath: cell.path, style: cell.style, row: cell.row, column: cell.column, rowSpan: cell.rowSpan, colSpan: cell.colSpan, header: cell.header, rich: Array.isArray(cell.value), box: cellBox, textBox, textStyle: resolveTextStyle(cell.textStyle, options.textMeasurement), fit });
435
+ }
436
+ return { rows: rows2, columnCount, height: sum(heights), overflow };
286
437
  }
287
438
  function tableOverflows(value, box, scale, settings, options, path) {
288
439
  return box.width <= 0 || box.height <= 0 || layoutTable(value, box, { scale, minFontSize: settings.minFontSize, fontFamily: options.fonts?.body, textMeasurement: options.textMeasurement, path }).overflow;
@@ -291,7 +442,7 @@ function flatten(value) {
291
442
  if (value == null) return "";
292
443
  if (typeof value !== "object") return String(value);
293
444
  if (Array.isArray(value)) return value.map(flatten).join("");
294
- const item = record(value);
445
+ const item = record2(value);
295
446
  return flatten(item.text ?? item.value ?? item.source ?? JSON.stringify(value));
296
447
  }
297
448
  function regionParts(key) {
@@ -347,11 +498,11 @@ function assertComposition(value) {
347
498
  if (value.overflow !== void 0 && !["warn", "error"].includes(value.overflow)) throw new RangeError("Invalid composition.overflow.");
348
499
  }
349
500
  function composeSlide(input, options = {}) {
350
- const slide = record(input), layout = record(options.layout);
501
+ const slide = record2(input), layout = record2(options.layout);
351
502
  const width = options.width ?? 1280, height = options.height ?? 720;
352
503
  if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Canvas dimensions must be finite and positive.");
353
504
  const scale = Math.min(width, height) / 720;
354
- const composition = { ...record(layout.composition), ...record(slide.composition) };
505
+ const composition = { ...record2(layout.composition), ...record2(slide.composition) };
355
506
  assertComposition(composition);
356
507
  const padding = (composition.padding ?? 0.08) * Math.min(width, height);
357
508
  const gap = (composition.gap ?? 1 / 30) * Math.min(width, height);
@@ -381,7 +532,7 @@ function composeSlide(input, options = {}) {
381
532
  const collect = (host, basePath, depth = 0, ancestors = []) => {
382
533
  if (Array.isArray(host.blocks)) {
383
534
  if (depth >= MAX_COMPOSITION_DEPTH || ancestors.includes(host)) throw new RangeError(`Content groups must be acyclic and nest at most ${MAX_COMPOSITION_DEPTH} levels.`);
384
- const settings = record(host.composition);
535
+ const settings = record2(host.composition);
385
536
  assertComposition(settings);
386
537
  return [{
387
538
  field: "blocks",
@@ -390,14 +541,15 @@ function composeSlide(input, options = {}) {
390
541
  path: basePath,
391
542
  payload: host,
392
543
  composition: settings,
393
- children: host.blocks.flatMap((block, index) => collect(record(block), `${basePath}.blocks.${index}`, depth + 1, [...ancestors, host]))
544
+ children: host.blocks.flatMap((block, index) => collect(record2(block), `${basePath}.blocks.${index}`, depth + 1, [...ancestors, host]))
394
545
  }];
395
546
  }
396
547
  return fields.filter((field) => host[field] !== void 0).map((field) => ({ field, type: host.type ?? kind(field), value: host[field], path: `${basePath}.${field}`, payload: { type: host.type ?? kind(field), [field]: host[field] } }));
397
548
  };
398
549
  const regions = Object.keys(slide).filter((key) => regionParts(key)).sort();
399
- const pending = regions.length ? regions.flatMap((key) => collect(record(slide[key]), `${path}.${key}`).map((item) => ({ ...item, region: regionParts(key) }))) : Array.isArray(slide.blocks) ? slide.blocks.flatMap((block, index) => collect(record(block), `${path}.blocks.${index}`)) : collect(slide, path);
550
+ const pending = regions.length ? regions.flatMap((key) => collect(record2(slide[key]), `${path}.${key}`).map((item) => ({ ...item, region: regionParts(key) }))) : Array.isArray(slide.blocks) ? slide.blocks.flatMap((block, index) => collect(record2(block), `${path}.blocks.${index}`)) : collect(slide, path);
400
551
  const groups = [], flows = [];
552
+ const decisions = options.explain ? [] : void 0;
401
553
  const inheritedSettings = (parent, own = {}) => ({
402
554
  minFontSize: parent.minFontSize,
403
555
  ...own,
@@ -407,15 +559,43 @@ function composeSlide(input, options = {}) {
407
559
  const amount = (settings.padding ?? 0) * Math.min(box.width, box.height);
408
560
  return { x: box.x + amount, y: box.y + amount, width: box.width - amount * 2, height: box.height - amount * 2 };
409
561
  };
410
- const leafScore = (node, box, settings) => {
562
+ const quoteBox = (box) => ({ x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) });
563
+ const measureQuote = (node, box, settings) => layoutQuote(node.value, quoteBox(box), {
564
+ fonts: options.fonts,
565
+ textMeasurement: options.textMeasurement,
566
+ scale,
567
+ minFontSize: settings.minFontSize,
568
+ path: node.path
569
+ });
570
+ const leafScore = (node, box, settings, penalties) => {
411
571
  const text = contentText(node.field, node.value);
412
572
  let score = Math.abs(Math.log(box.width / box.height / 1.6));
413
- if (text) {
573
+ if (penalties) penalties.cellProportions += score;
574
+ if (node.field === "quote") {
575
+ const quote = measureQuote(node, box, settings);
576
+ const reduction = quote.parts.reduce((sum, part) => sum + (part.fit ? (part.requestedFontSize - part.fit.fontSize) / scale : 0), 0);
577
+ score += reduction + (quote.overflow ? 1e3 : 0);
578
+ if (penalties) {
579
+ penalties.fontReduction += reduction;
580
+ penalties.textOverflow += quote.overflow ? 1e3 : 0;
581
+ }
582
+ } else if (text) {
414
583
  const fit = fitContent(node.field, node.value, text, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path);
415
- score += (25 * scale - fit.fontSize) / scale + (fit.overflow ? 1e3 : 0);
584
+ const reduction = (25 * scale - fit.fontSize) / scale;
585
+ score += reduction + (fit.overflow ? 1e3 : 0);
586
+ if (penalties) {
587
+ penalties.fontReduction += reduction;
588
+ penalties.textOverflow += fit.overflow ? 1e3 : 0;
589
+ }
590
+ }
591
+ if (node.field === "table" && tableOverflows(node.value, box, scale, settings, options, node.path)) {
592
+ score += 1e3;
593
+ if (penalties) penalties.tableOverflow += 1e3;
594
+ }
595
+ if (box.width < 100 * scale || box.height < 60 * scale) {
596
+ score += 100;
597
+ if (penalties) penalties.smallCells += 100;
416
598
  }
417
- if (node.field === "table" && tableOverflows(node.value, box, scale, settings, options, node.path)) score += 1e3;
418
- if (box.width < 100 * scale || box.height < 60 * scale) score += 100;
419
599
  return score;
420
600
  };
421
601
  const modeFor = (settings) => settings.mode ?? "auto";
@@ -423,12 +603,12 @@ function composeSlide(input, options = {}) {
423
603
  const mode = modeFor(settings);
424
604
  return mode === "column" ? 1 : mode === "row" ? Math.max(1, count) : settings.columns ?? Math.max(1, Math.min(count, Math.ceil(Math.sqrt(count * area.width / area.height / 1.6))));
425
605
  };
426
- const scoreNode = (node, box, settings) => {
427
- if (!node.children) return leafScore(node, box, settings);
606
+ const scoreNode = (node, box, settings, penalties) => {
607
+ if (!node.children) return leafScore(node, box, settings, penalties);
428
608
  const own = inheritedSettings(settings, node.composition), area = inset(box, own);
429
609
  const cols = defaultColumns(node.children.length, area, own);
430
610
  const boxes = gridBoxes(node.children.length, area, cols, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), own.weights ?? [], modeFor(own) === "column");
431
- return node.children.reduce((score, child, index) => score + scoreNode(child, boxes[index], own), 0);
611
+ return node.children.reduce((score, child, index) => score + scoreNode(child, boxes[index], own, penalties), 0);
432
612
  };
433
613
  const arrange = (nodes, area, settings, reserved = 0, gapOverride, containerPath = path) => {
434
614
  const count = Math.max(nodes.length, reserved);
@@ -436,17 +616,29 @@ function composeSlide(input, options = {}) {
436
616
  const mode = modeFor(settings), localGap = (settings.gap ?? 1 / 30) * Math.min(area.width, area.height);
437
617
  const actualGap = gapOverride ?? (settings === rootSettings ? gap : localGap);
438
618
  let cols = defaultColumns(count, area, settings);
439
- if (mode === "auto" && !nodes.some((node) => node.region)) {
619
+ const hasRegions = nodes.some((node) => node.region);
620
+ const candidates = decisions ? [] : void 0;
621
+ if (mode === "auto" && !hasRegions) {
440
622
  let best = Infinity;
441
623
  for (let candidate = 1; candidate <= Math.min(count, settings.columns ?? 6); candidate++) {
442
624
  const boxes2 = gridBoxes(count, area, candidate, actualGap, settings.weights ?? [], false);
443
- const score = nodes.reduce((sum, node, i) => sum + scoreNode(node, boxes2[i], settings), 0) + (Math.ceil(count / candidate) * candidate - count) * 2;
625
+ const emptySlots = (Math.ceil(count / candidate) * candidate - count) * 2;
626
+ const penalties = candidates ? { cellProportions: 0, fontReduction: 0, textOverflow: 0, tableOverflow: 0, smallCells: 0, emptySlots } : void 0;
627
+ const score = nodes.reduce((sum, node, i) => sum + scoreNode(node, boxes2[i], settings, penalties), 0) + emptySlots;
628
+ if (penalties && candidates) candidates.push({ columns: candidate, rows: Math.ceil(count / candidate), score, penalties });
444
629
  if (score < best) {
445
630
  best = score;
446
631
  cols = candidate;
447
632
  }
448
633
  }
449
634
  }
635
+ decisions?.push({
636
+ path: containerPath,
637
+ mode: hasRegions ? "regions" : mode,
638
+ reason: hasRegions ? "promoted-regions" : mode === "auto" ? "lowest-score" : "configured-mode",
639
+ ...hasRegions ? {} : { selectedColumns: cols },
640
+ candidates: candidates ?? []
641
+ });
450
642
  const grid = gridGeometry(count, area, cols, actualGap, settings.weights ?? [], mode === "column");
451
643
  const boxes = grid.boxes;
452
644
  if (!nodes.some((node) => node.region)) flows.push({ path: containerPath, box: { ...area }, composition: { ...settings }, columns: grid.columns, rows: grid.rows, gap: grid.gap, itemCount: nodes.length, slotCount: count });
@@ -458,8 +650,20 @@ function composeSlide(input, options = {}) {
458
650
  arrange(node.children, inner, own, 0, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), node.path);
459
651
  } else {
460
652
  const textValue = contentText(node.field, node.value);
461
- const text = textValue !== void 0 ? fitContent(node.field, node.value, textValue, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path) : void 0;
462
- items.push({ path: node.path, field: node.field, type: node.type, value: node.value, payload: node.payload, box, text, textStyle: styleFor(node.field, node.path), composition: settings });
653
+ const quoteLayout = node.field === "quote" ? measureQuote(node, box, settings) : void 0;
654
+ const text = quoteLayout ? quoteLayout.parts[0]?.fit : textValue !== void 0 ? fitContent(node.field, node.value, textValue, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path) : void 0;
655
+ items.push({
656
+ path: node.path,
657
+ field: node.field,
658
+ type: node.type,
659
+ value: node.value,
660
+ payload: node.payload,
661
+ box: quoteLayout ? quoteBox(box) : box,
662
+ text,
663
+ textStyle: quoteLayout?.parts[0]?.style ?? styleFor(node.field, node.path),
664
+ composition: settings,
665
+ ...quoteLayout ? { quoteLayout } : {}
666
+ });
463
667
  if (box.width < 100 * scale || box.height < 60 * scale) diagnostics.push({ code: "small-cell", path: node.path, message: "Content cell is too small for comfortable reading; use fewer blocks or a different composition." });
464
668
  }
465
669
  });
@@ -470,13 +674,29 @@ function composeSlide(input, options = {}) {
470
674
  for (const item of items) {
471
675
  for (const key of ["x", "y", "width", "height"]) item.box[key] = round(item.box[key]);
472
676
  if (item.field === "table" && tableOverflows(item.value, item.box, scale, item.composition, options, item.path)) diagnostics.push({ code: "text-overflow", path: item.path, message: "Table cells do not fit; use fewer rows, fewer columns, or split the table across slides." });
473
- if (item.text?.overflow) diagnostics.push({ code: "text-overflow", path: item.path, message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." });
677
+ if (item.quoteLayout) diagnostics.push(...item.quoteLayout.diagnostics);
678
+ else if (item.text?.overflow) diagnostics.push({ code: "text-overflow", path: item.path, message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." });
474
679
  }
475
680
  for (const group of groups) for (const box of [group.box, group.contentBox]) for (const key of ["x", "y", "width", "height"]) box[key] = round(box[key]);
476
681
  const strictPaths = new Set(items.filter((item) => item.composition.overflow === "error").map((item) => item.path));
477
- const failures = diagnostics.filter((diagnostic) => strictPaths.has(diagnostic.path));
478
- if (failures.length) throw new OPFCompositionError(failures);
479
- return { width, height, contentBox, items, groups, flows, diagnostics, composition };
682
+ const failures = diagnostics.filter((diagnostic) => {
683
+ let path2 = diagnostic.path;
684
+ while (path2) {
685
+ if (strictPaths.has(path2)) return true;
686
+ const boundary = path2.lastIndexOf(".");
687
+ if (boundary < 0) break;
688
+ path2 = path2.slice(0, boundary);
689
+ }
690
+ return false;
691
+ });
692
+ const explanation = decisions ? {
693
+ algorithm: "grid-score-v2",
694
+ textMeasurement: options.textMeasurement ? "provided" : "estimated",
695
+ decisions,
696
+ unmeasuredPayloads: items.filter((item) => !headings.has(item.field) && !item.quoteLayout && !["text", "items", "bullets", "table"].includes(item.field)).map((item) => item.path)
697
+ } : void 0;
698
+ if (failures.length) throw new OPFCompositionError(failures, explanation);
699
+ return { width, height, contentBox, items, groups, flows, diagnostics, composition, ...explanation ? { explanation } : {} };
480
700
  }
481
701
  function resolveCanvasDimensions(input) {
482
702
  const presets = {
@@ -488,7 +708,7 @@ function resolveCanvasDimensions(input) {
488
708
  letter: [11, 8.5],
489
709
  a4: [11.69, 8.27]
490
710
  };
491
- const value = record(input);
711
+ const value = record2(input);
492
712
  const preset = presets[typeof input === "string" ? input : value.preset] ?? presets.widescreen;
493
713
  const width = (value.widthInches ?? preset[0]) * 96;
494
714
  const height = (value.heightInches ?? preset[1]) * 96;
@@ -496,4 +716,4 @@ function resolveCanvasDimensions(input) {
496
716
  return { width, height };
497
717
  }
498
718
 
499
- export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textWidthMeasurer, wrapText };
719
+ export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutQuote, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
@@ -1,5 +1,5 @@
1
- import { assertValidPresentation } from './chunk-23MXJL2K.js';
2
- import { resolveFontFamilies, resolveCanvasDimensions, composeSlide } from './chunk-C7GKRLI6.js';
1
+ import { assertValidPresentation } from './chunk-HIY26FJV.js';
2
+ import { resolveFontFamilies, resolveCanvasDimensions, composeSlide, tableRowBoundaries } from './chunk-NEQAPMS2.js';
3
3
  import { catalogs } from './chunk-TWMRZ43O.js';
4
4
 
5
5
  // src/pagination.ts
@@ -52,7 +52,7 @@ function leafFor(path, field, value) {
52
52
  const values = Array.isArray(value) ? value : value[key];
53
53
  if (Array.isArray(values)) {
54
54
  leaf.unit = "items";
55
- leaf.boundaries = Array.from({ length: values.length + 1 }, (_, i) => i);
55
+ leaf.boundaries = field === "table" ? tableRowBoundaries(value) : Array.from({ length: values.length + 1 }, (_, i) => i);
56
56
  leaf.slice = (a, b) => Array.isArray(value) ? value.slice(a, b) : { ...value, [key]: values.slice(a, b) };
57
57
  }
58
58
  }
@@ -60,27 +60,32 @@ function leafFor(path, field, value) {
60
60
  }
61
61
  function paginateSlide(input, options = {}) {
62
62
  assertValidPresentation({ slides: [input] });
63
- const source = clone(input);
63
+ let source = clone(input);
64
64
  const maxSlides = options.maxSlides ?? 100;
65
65
  if (!Number.isInteger(maxSlides) || maxSlides < 1 || maxSlides > 1e4) throw new RangeError("maxSlides must be an integer between 1 and 10000.");
66
66
  const minFontSize = options.minFontSize ?? 24;
67
67
  if (!Number.isFinite(minFontSize) || minFontSize < 8 || minFontSize > 32) throw new RangeError("Pagination minFontSize must be between 8 and 32.");
68
68
  const sourceIndex = options.slideIndex ?? 0, sourceBase = `slides.${sourceIndex}`;
69
- const warnOnly = (slide) => {
69
+ const withReadability = (slide, warn = false) => {
70
70
  const result = clone(slide);
71
- const visit = (node) => {
72
- if (node.composition) node.composition = { ...node.composition, overflow: "warn", minFontSize: Math.max(minFontSize, node.composition.minFontSize ?? 16) };
73
- if (Array.isArray(node.blocks)) node.blocks.forEach(visit);
71
+ const rootMinimum = Math.max(minFontSize, result.composition?.minFontSize ?? options.layout?.composition?.minFontSize ?? 16);
72
+ result.composition = { ...result.composition, minFontSize: rootMinimum };
73
+ const visit = (node, inheritedMinimum) => {
74
+ const minimum = Math.max(minFontSize, node.composition?.minFontSize ?? inheritedMinimum);
75
+ if (node.composition) node.composition = { ...node.composition, minFontSize: minimum, ...warn ? { overflow: "warn" } : {} };
76
+ if (Array.isArray(node.blocks)) node.blocks.forEach((child) => {
77
+ visit(child, minimum);
78
+ });
74
79
  };
75
- visit(result);
76
- for (const [key, value] of Object.entries(result)) if (isRecord(value) && /^(top|middle|bottom|left|center|right)([+:]|$)/.test(key)) visit(value);
77
- result.composition = { ...result.composition, overflow: "warn", minFontSize: Math.max(minFontSize, result.composition?.minFontSize ?? options.layout?.composition?.minFontSize ?? 16) };
80
+ visit(result, rootMinimum);
81
+ for (const [key, value] of Object.entries(result)) if (isRecord(value) && /^(top|middle|bottom|left|center|right)([+:]|$)/.test(key)) visit(value, rootMinimum);
78
82
  return result;
79
83
  };
84
+ source = withReadability(source);
80
85
  let evaluations = 0;
81
86
  const geometry = (slide) => {
82
87
  if (++evaluations > 2e4) throw new OPFPaginationError("Pagination exceeded its layout evaluation limit. Split the input into smaller sections.");
83
- return composeSlide(warnOnly(slide), options);
88
+ return composeSlide(withReadability(slide, true), options);
84
89
  };
85
90
  const initial = geometry(source);
86
91
  if (!initial.diagnostics.length) return { slides: [source], pages: [{ slideIndex: sourceIndex, mappings: initial.items.map((item) => ({ sourcePath: item.path, outputPath: item.path })) }] };
@@ -150,7 +155,7 @@ function paginateSlide(input, options = {}) {
150
155
  const boundaries = leaf.boundaries ?? [0, 1];
151
156
  const end = boundaries.at(-1);
152
157
  let start = 0;
153
- do {
158
+ while (true) {
154
159
  const make = (limit) => ({ leaf, start, end: limit, value: leaf.slice ? leaf.slice(start, limit) : leaf.value });
155
160
  let candidate = new Map(selected).set(leaf.path, make(end));
156
161
  let issues = diagnosticsFor(candidate);
@@ -187,7 +192,7 @@ function paginateSlide(input, options = {}) {
187
192
  start = best;
188
193
  } else if (selected.size) finish();
189
194
  else throw new OPFPaginationError(`Content at ${leaf.path} cannot fit on an otherwise empty slide. Change its layout or split an atomic item.`, issues);
190
- } while (start < end);
195
+ }
191
196
  }
192
197
  finish();
193
198
  if (!slides.length) throw new OPFPaginationError("No body content can be paginated.", initial.diagnostics);