@openpresentation/opf 0.7.0 → 0.9.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,5 +1,5 @@
1
- import { assertValidPresentation } from './chunk-H7UXKXEJ.js';
2
- import { resolveFontFamilies, resolveCanvasDimensions, composeSlide, tableRowBoundaries } from './chunk-OVPWNQIJ.js';
1
+ import { assertValidPresentation } from './chunk-ZGV6I23P.js';
2
+ import { resolveFontFamilies, resolveCanvasDimensions, composeSlide, tableRowBoundaries } from './chunk-JA5W5TVA.js';
3
3
  import { catalogs } from './chunk-TWMRZ43O.js';
4
4
 
5
5
  // src/pagination.ts
@@ -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);
@@ -58,13 +58,15 @@ function textWidthMeasurer(style, measurement) {
58
58
  };
59
59
  }
60
60
  var OPFCompositionError = class extends Error {
61
- constructor(diagnostics) {
61
+ constructor(diagnostics, explanation) {
62
62
  super("Slide content does not fit its composition.");
63
63
  this.diagnostics = diagnostics;
64
64
  this.name = "OPFCompositionError";
65
+ if (explanation) this.explanation = explanation;
65
66
  }
66
67
  diagnostics;
67
68
  code = "layout-overflow";
69
+ explanation;
68
70
  };
69
71
  var fields = ["text", "items", "bullets", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"];
70
72
  var headings = /* @__PURE__ */ new Set(["title", "subtitle", "tag"]);
@@ -120,6 +122,258 @@ function fitText(text, box, requestedSize = 25, minFontSize = 16, measure = meas
120
122
  }
121
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) };
122
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
+ }
229
+ function fitCodeText(text, box, size, minimum, step, measure) {
230
+ const layout = (fontSize) => {
231
+ const tabWidth = measure(" ", fontSize) * 4;
232
+ if (!Number.isFinite(tabWidth) || text.includes(" ") && tabWidth <= 0) throw new RangeError("Code tabs require a positive finite measured space advance.");
233
+ const measureLine = (start2, end2, record3 = false) => {
234
+ let x = 0, offset = start2;
235
+ const segments = [];
236
+ for (const [index, segment] of text.slice(start2, end2).split(" ").entries()) {
237
+ if (index) {
238
+ const next = (Math.floor(x / tabWidth + 1e-9) + 1) * tabWidth;
239
+ if (record3) segments.push({ kind: "tab", start: offset, end: offset + 1, x, width: next - x });
240
+ x = next;
241
+ offset++;
242
+ }
243
+ if (segment) {
244
+ const advance = measure(segment, fontSize);
245
+ if (record3) segments.push({ kind: "text", start: offset, end: offset + segment.length, x, width: advance });
246
+ x += advance;
247
+ offset += segment.length;
248
+ }
249
+ }
250
+ return { width: x, segments };
251
+ };
252
+ const width = (start2, end2) => measureLine(start2, end2).width;
253
+ const sourceLines = [];
254
+ let start = 0, end = 0;
255
+ const push = (last, nextStart, boundary) => {
256
+ sourceLines.push({ start, end: last, nextStart, boundary, ...measureLine(start, last, true) });
257
+ start = nextStart;
258
+ end = nextStart;
259
+ };
260
+ for (const token of text.matchAll(/\r\n|\r|\n|[^\S\r\n]+|[^\s]+/gu)) {
261
+ if (token.index === void 0) throw new Error("Missing code token source offset.");
262
+ const a = token.index, b = a + token[0].length;
263
+ if (/^[\r\n]/.test(token[0])) {
264
+ push(a, b, "hard");
265
+ continue;
266
+ }
267
+ if (width(start, b) <= box.width + 0.01) {
268
+ end = b;
269
+ continue;
270
+ }
271
+ if (end > start && /\S/u.test(text.slice(start, end))) push(a, a, "soft");
272
+ if (width(start, b) <= box.width + 0.01) {
273
+ end = b;
274
+ continue;
275
+ }
276
+ for (const segment of textSegments.segment(token[0])) {
277
+ const next = a + segment.index + segment.segment.length;
278
+ if (end > start && width(start, next) > box.width + 0.01) push(a + segment.index, a + segment.index, "soft");
279
+ end = next;
280
+ }
281
+ }
282
+ push(end, end, "end");
283
+ const lineHeight = fontSize * 1.22;
284
+ return {
285
+ lines: sourceLines.map((line) => text.slice(line.start, line.end)),
286
+ sourceLines,
287
+ fontSize,
288
+ lineHeight,
289
+ tabSize: 4,
290
+ tabWidth,
291
+ overflow: sourceLines.length * lineHeight > box.height + 0.01 || sourceLines.some((line) => line.width > box.width + 0.01)
292
+ };
293
+ };
294
+ let result = layout(size);
295
+ while (result.overflow && size > minimum) {
296
+ size = Math.max(minimum, size - step);
297
+ result = layout(size);
298
+ }
299
+ return result;
300
+ }
301
+ function layoutCode(value, box, options = {}) {
302
+ const shorthand = typeof value === "string", code = shorthand ? { source: value } : value;
303
+ if (!code || Array.isArray(code) || typeof code.source !== "string" || [code.language, code.filename].some((field) => field !== void 0 && typeof field !== "string")) {
304
+ throw new TypeError("Code content must be a string or source object with optional string language/filename.");
305
+ }
306
+ const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale;
307
+ if (![box.x, box.y, box.width, box.height, box.x + box.width, box.y + box.height, scale, minimum, Math.max(18 * scale, minimum) * 1.22].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) {
308
+ throw new RangeError("Code dimensions, scale and minimum font size must be finite and positive.");
309
+ }
310
+ if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid code overflow policy.");
311
+ const path = options.path ?? "code", inner = { x: box.x + 18, y: box.y + 18, width: box.width - 36, height: box.height - 36 };
312
+ const parts = [], diagnostics = [];
313
+ const add = (role, text, partPath, generated = false) => {
314
+ const requestedStyle = { fontFamily: options.fonts?.code ?? "monospace", fontWeight: role === "body" ? 400 : 700, italic: false, path: partPath };
315
+ const part = {
316
+ role,
317
+ path: partPath,
318
+ text,
319
+ sources: generated ? [] : [{ path: partPath, start: 0, end: text.length }],
320
+ generated,
321
+ box: { ...inner },
322
+ requestedFontSize: Math.max((role === "body" ? 18 : 14) * scale, minimum),
323
+ minFontSize: minimum,
324
+ requestedStyle,
325
+ style: resolveTextStyle({ ...requestedStyle }, options.textMeasurement)
326
+ };
327
+ parts.push(part);
328
+ return part;
329
+ };
330
+ if (code.filename) add("filename", code.filename, `${path}.filename`);
331
+ if (code.language) add("language", code.language, `${path}.language`);
332
+ if (!parts.length) add("language", "code", path, true);
333
+ const body = add("body", code.source, shorthand ? path : `${path}.source`), headers = parts.filter((part) => part !== body);
334
+ const usable = (area) => [area.x, area.y, area.width, area.height].every(Number.isFinite) && area.width > 0 && area.height > 0;
335
+ const fit = (part, area, size = part.requestedFontSize, floor = minimum) => usable(area) ? fitCodeText(part.text, area, size, floor, scale, textWidthMeasurer(part.style, options.textMeasurement)) : void 0;
336
+ if (usable(inner)) {
337
+ const combinations = headers.reduce((choices, part) => choices.flatMap((choice) => [.../* @__PURE__ */ new Set([part.requestedFontSize, minimum])].map((size) => [...choice, { part, size }])), [[]]);
338
+ let selected;
339
+ for (const combination of combinations) {
340
+ let y = inner.y;
341
+ const allocations = [];
342
+ for (const [index, { part, size }] of combination.entries()) {
343
+ const measured = fitCodeText(part.text, inner, size, size, scale, textWidthMeasurer(part.style, options.textMeasurement));
344
+ const area2 = { ...inner, y, height: measured.lines.length * measured.lineHeight };
345
+ allocations.push({ part, box: area2, fit: usable(area2) ? { ...measured, overflow: measured.sourceLines.some((line) => line.width > area2.width + 0.01) } : void 0 });
346
+ y += area2.height + (index < headers.length - 1 ? 8 : 12);
347
+ }
348
+ const area = { ...inner, y, height: inner.y + inner.height - y };
349
+ allocations.push({ part: body, box: area, fit: fit(body, area) });
350
+ const overflow = allocations.some(({ box: box2, fit: fit2 }) => !fit2 || fit2.overflow || !usable(box2) || box2.y + box2.height > inner.y + inner.height + 0.01);
351
+ const score = allocations.reduce((sum, { part, fit: fit2 }) => sum + (part.requestedFontSize - (fit2?.fontSize ?? minimum)) / scale, 0);
352
+ if (!selected || !overflow && (selected.overflow || score < selected.score) || overflow && selected.overflow) selected = { allocations, score, overflow };
353
+ }
354
+ if (selected) for (const { part, box: box2, fit: fit2 } of selected.allocations) {
355
+ part.box = box2;
356
+ part.fit = fit2;
357
+ }
358
+ }
359
+ const report = (reason, part, roles, message) => diagnostics.push({ code: "text-overflow", reason, path: part.path, parts: roles, message });
360
+ for (const part of parts) {
361
+ if (!part.fit) report("invalid-part-box", part, [part.role], `Code ${part.role} has no usable space after metadata and insets; increase the cell or change the arrangement.`);
362
+ else if (part.fit.overflow) report("text-fit", part, [part.role], `Code ${part.role} exceeds its space at the readability floor; increase its space or change the arrangement.`);
363
+ const area = part.box;
364
+ 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)) {
365
+ report("part-outside-cell", part, [part.role], `Code ${part.role} extends outside its cell; increase the cell or change the arrangement.`);
366
+ }
367
+ }
368
+ for (const [index, first] of parts.entries()) {
369
+ const second = parts[index + 1];
370
+ if (first.fit && second?.fit && first.box.y + first.fit.lines.length * first.fit.lineHeight > second.box.y + 0.01) {
371
+ report("part-overlap", first, [first.role, second.role], "Code part line boxes overlap; do not accept this layout without more space.");
372
+ }
373
+ }
374
+ if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
375
+ return { algorithm: "code-flow-v1", textMeasurement: options.textMeasurement ? "provided" : "estimated", parts, diagnostics, overflow: diagnostics.length > 0 };
376
+ }
123
377
  function fitRichText(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
124
378
  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.");
125
379
  const layout = richTextLayouter(input, box, requestedSize, options), minimum = Math.min(requestedSize, minFontSize);
@@ -443,6 +697,7 @@ function composeSlide(input, options = {}) {
443
697
  const regions = Object.keys(slide).filter((key) => regionParts(key)).sort();
444
698
  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);
445
699
  const groups = [], flows = [];
700
+ const decisions = options.explain ? [] : void 0;
446
701
  const inheritedSettings = (parent, own = {}) => ({
447
702
  minFontSize: parent.minFontSize,
448
703
  ...own,
@@ -452,15 +707,50 @@ function composeSlide(input, options = {}) {
452
707
  const amount = (settings.padding ?? 0) * Math.min(box.width, box.height);
453
708
  return { x: box.x + amount, y: box.y + amount, width: box.width - amount * 2, height: box.height - amount * 2 };
454
709
  };
455
- const leafScore = (node, box, settings) => {
710
+ const acceptedBox = (box) => ({ x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) });
711
+ const measureQuote = (node, box, settings) => layoutQuote(node.value, acceptedBox(box), {
712
+ fonts: options.fonts,
713
+ textMeasurement: options.textMeasurement,
714
+ scale,
715
+ minFontSize: settings.minFontSize,
716
+ path: node.path
717
+ });
718
+ const measureCode = (node, box, settings) => layoutCode(node.value, acceptedBox(box), {
719
+ fonts: options.fonts,
720
+ textMeasurement: options.textMeasurement,
721
+ scale,
722
+ minFontSize: settings.minFontSize,
723
+ path: node.path
724
+ });
725
+ const leafScore = (node, box, settings, penalties) => {
456
726
  const text = contentText(node.field, node.value);
457
727
  let score = Math.abs(Math.log(box.width / box.height / 1.6));
458
- if (text) {
728
+ if (penalties) penalties.cellProportions += score;
729
+ if (node.field === "quote" || node.field === "code") {
730
+ const internal = node.field === "quote" ? measureQuote(node, box, settings) : measureCode(node, box, settings);
731
+ const reduction = internal.parts.reduce((sum, part) => sum + (part.fit ? (part.requestedFontSize - part.fit.fontSize) / scale : 0), 0);
732
+ score += reduction + (internal.overflow ? 1e3 : 0);
733
+ if (penalties) {
734
+ penalties.fontReduction += reduction;
735
+ penalties.textOverflow += internal.overflow ? 1e3 : 0;
736
+ }
737
+ } else if (text) {
459
738
  const fit = fitContent(node.field, node.value, text, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path);
460
- score += (25 * scale - fit.fontSize) / scale + (fit.overflow ? 1e3 : 0);
739
+ const reduction = (25 * scale - fit.fontSize) / scale;
740
+ score += reduction + (fit.overflow ? 1e3 : 0);
741
+ if (penalties) {
742
+ penalties.fontReduction += reduction;
743
+ penalties.textOverflow += fit.overflow ? 1e3 : 0;
744
+ }
745
+ }
746
+ if (node.field === "table" && tableOverflows(node.value, box, scale, settings, options, node.path)) {
747
+ score += 1e3;
748
+ if (penalties) penalties.tableOverflow += 1e3;
749
+ }
750
+ if (box.width < 100 * scale || box.height < 60 * scale) {
751
+ score += 100;
752
+ if (penalties) penalties.smallCells += 100;
461
753
  }
462
- if (node.field === "table" && tableOverflows(node.value, box, scale, settings, options, node.path)) score += 1e3;
463
- if (box.width < 100 * scale || box.height < 60 * scale) score += 100;
464
754
  return score;
465
755
  };
466
756
  const modeFor = (settings) => settings.mode ?? "auto";
@@ -468,12 +758,12 @@ function composeSlide(input, options = {}) {
468
758
  const mode = modeFor(settings);
469
759
  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))));
470
760
  };
471
- const scoreNode = (node, box, settings) => {
472
- if (!node.children) return leafScore(node, box, settings);
761
+ const scoreNode = (node, box, settings, penalties) => {
762
+ if (!node.children) return leafScore(node, box, settings, penalties);
473
763
  const own = inheritedSettings(settings, node.composition), area = inset(box, own);
474
764
  const cols = defaultColumns(node.children.length, area, own);
475
765
  const boxes = gridBoxes(node.children.length, area, cols, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), own.weights ?? [], modeFor(own) === "column");
476
- return node.children.reduce((score, child, index) => score + scoreNode(child, boxes[index], own), 0);
766
+ return node.children.reduce((score, child, index) => score + scoreNode(child, boxes[index], own, penalties), 0);
477
767
  };
478
768
  const arrange = (nodes, area, settings, reserved = 0, gapOverride, containerPath = path) => {
479
769
  const count = Math.max(nodes.length, reserved);
@@ -481,17 +771,29 @@ function composeSlide(input, options = {}) {
481
771
  const mode = modeFor(settings), localGap = (settings.gap ?? 1 / 30) * Math.min(area.width, area.height);
482
772
  const actualGap = gapOverride ?? (settings === rootSettings ? gap : localGap);
483
773
  let cols = defaultColumns(count, area, settings);
484
- if (mode === "auto" && !nodes.some((node) => node.region)) {
774
+ const hasRegions = nodes.some((node) => node.region);
775
+ const candidates = decisions ? [] : void 0;
776
+ if (mode === "auto" && !hasRegions) {
485
777
  let best = Infinity;
486
778
  for (let candidate = 1; candidate <= Math.min(count, settings.columns ?? 6); candidate++) {
487
779
  const boxes2 = gridBoxes(count, area, candidate, actualGap, settings.weights ?? [], false);
488
- const score = nodes.reduce((sum, node, i) => sum + scoreNode(node, boxes2[i], settings), 0) + (Math.ceil(count / candidate) * candidate - count) * 2;
780
+ const emptySlots = (Math.ceil(count / candidate) * candidate - count) * 2;
781
+ const penalties = candidates ? { cellProportions: 0, fontReduction: 0, textOverflow: 0, tableOverflow: 0, smallCells: 0, emptySlots } : void 0;
782
+ const score = nodes.reduce((sum, node, i) => sum + scoreNode(node, boxes2[i], settings, penalties), 0) + emptySlots;
783
+ if (penalties && candidates) candidates.push({ columns: candidate, rows: Math.ceil(count / candidate), score, penalties });
489
784
  if (score < best) {
490
785
  best = score;
491
786
  cols = candidate;
492
787
  }
493
788
  }
494
789
  }
790
+ decisions?.push({
791
+ path: containerPath,
792
+ mode: hasRegions ? "regions" : mode,
793
+ reason: hasRegions ? "promoted-regions" : mode === "auto" ? "lowest-score" : "configured-mode",
794
+ ...hasRegions ? {} : { selectedColumns: cols },
795
+ candidates: candidates ?? []
796
+ });
495
797
  const grid = gridGeometry(count, area, cols, actualGap, settings.weights ?? [], mode === "column");
496
798
  const boxes = grid.boxes;
497
799
  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 });
@@ -503,8 +805,23 @@ function composeSlide(input, options = {}) {
503
805
  arrange(node.children, inner, own, 0, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), node.path);
504
806
  } else {
505
807
  const textValue = contentText(node.field, node.value);
506
- const text = textValue !== void 0 ? fitContent(node.field, node.value, textValue, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path) : void 0;
507
- 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 });
808
+ const quoteLayout = node.field === "quote" ? measureQuote(node, box, settings) : void 0;
809
+ const codeLayout = node.field === "code" ? measureCode(node, box, settings) : void 0;
810
+ const internal = quoteLayout ?? codeLayout, body = internal?.parts.find((part) => part.role === "body");
811
+ const text = internal ? body?.fit : textValue !== void 0 ? fitContent(node.field, node.value, textValue, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path) : void 0;
812
+ items.push({
813
+ path: node.path,
814
+ field: node.field,
815
+ type: node.type,
816
+ value: node.value,
817
+ payload: node.payload,
818
+ box: internal ? acceptedBox(box) : box,
819
+ text,
820
+ textStyle: body?.style ?? styleFor(node.field, node.path),
821
+ composition: settings,
822
+ ...quoteLayout ? { quoteLayout } : {},
823
+ ...codeLayout ? { codeLayout } : {}
824
+ });
508
825
  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." });
509
826
  }
510
827
  });
@@ -515,13 +832,30 @@ function composeSlide(input, options = {}) {
515
832
  for (const item of items) {
516
833
  for (const key of ["x", "y", "width", "height"]) item.box[key] = round(item.box[key]);
517
834
  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." });
518
- 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." });
835
+ if (item.quoteLayout) diagnostics.push(...item.quoteLayout.diagnostics);
836
+ else if (item.codeLayout) diagnostics.push(...item.codeLayout.diagnostics);
837
+ 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." });
519
838
  }
520
839
  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]);
521
840
  const strictPaths = new Set(items.filter((item) => item.composition.overflow === "error").map((item) => item.path));
522
- const failures = diagnostics.filter((diagnostic) => strictPaths.has(diagnostic.path));
523
- if (failures.length) throw new OPFCompositionError(failures);
524
- return { width, height, contentBox, items, groups, flows, diagnostics, composition };
841
+ const failures = diagnostics.filter((diagnostic) => {
842
+ let path2 = diagnostic.path;
843
+ while (path2) {
844
+ if (strictPaths.has(path2)) return true;
845
+ const boundary = path2.lastIndexOf(".");
846
+ if (boundary < 0) break;
847
+ path2 = path2.slice(0, boundary);
848
+ }
849
+ return false;
850
+ });
851
+ const explanation = decisions ? {
852
+ algorithm: "grid-score-v3",
853
+ textMeasurement: options.textMeasurement ? "provided" : "estimated",
854
+ decisions,
855
+ unmeasuredPayloads: items.filter((item) => !headings.has(item.field) && !item.quoteLayout && !item.codeLayout && !["text", "items", "bullets", "table"].includes(item.field)).map((item) => item.path)
856
+ } : void 0;
857
+ if (failures.length) throw new OPFCompositionError(failures, explanation);
858
+ return { width, height, contentBox, items, groups, flows, diagnostics, composition, ...explanation ? { explanation } : {} };
525
859
  }
526
860
  function resolveCanvasDimensions(input) {
527
861
  const presets = {
@@ -541,4 +875,4 @@ function resolveCanvasDimensions(input) {
541
875
  return { width, height };
542
876
  }
543
877
 
544
- export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
878
+ export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutCode, layoutQuote, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
@@ -1,4 +1,4 @@
1
- import { MAX_COMPOSITION_DEPTH, tableGrid } from './chunk-OVPWNQIJ.js';
1
+ import { MAX_COMPOSITION_DEPTH, tableGrid } from './chunk-JA5W5TVA.js';
2
2
  import { schemas } from './chunk-GQSK3T7X.js';
3
3
  import { catalogSchemaNames } from './chunk-TWMRZ43O.js';
4
4
  import Ajv2020 from 'ajv/dist/2020.js';