@openpresentation/opf 0.9.0 → 0.10.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.
@@ -35,6 +35,45 @@ function tableRowBoundaries(value) {
35
35
  return [...allowed];
36
36
  }
37
37
 
38
+ // src/color.ts
39
+ function luminance(value) {
40
+ if (typeof value !== "string") return void 0;
41
+ let hex = value.trim();
42
+ if (/^#[0-9a-f]{3}$/i.test(hex)) hex = `#${[...hex.slice(1)].map((c) => c + c).join("")}`;
43
+ if (/^#[0-9a-f]{6}ff$/i.test(hex)) hex = hex.slice(0, 7);
44
+ if (!/^#[0-9a-f]{6}$/i.test(hex)) return void 0;
45
+ return [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => {
46
+ const offset = index * 2 + 1;
47
+ const n = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
48
+ return sum + weight * (n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4);
49
+ }, 0);
50
+ }
51
+ function colorContrast(foreground, background) {
52
+ const a = luminance(foreground), b = luminance(background);
53
+ return a === void 0 || b === void 0 ? void 0 : (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
54
+ }
55
+ function textColorForFill(fill, preferred) {
56
+ const background = luminance(fill), contrast = colorContrast(preferred, fill);
57
+ if (background === void 0 || contrast === void 0 || contrast >= 4.5) return preferred;
58
+ return (background + 0.05) / 0.05 >= 1.05 / (background + 0.05) ? "#000000" : "#FFFFFF";
59
+ }
60
+ function chartColorForFill(fill, preferred) {
61
+ const contrast = colorContrast(preferred, fill);
62
+ if (contrast === void 0 || contrast >= 3) return preferred;
63
+ let hex = preferred.trim();
64
+ if (hex.length === 4) hex = `#${[...hex.slice(1)].map((c) => c + c).join("")}`;
65
+ const channels = [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16));
66
+ for (let step = 1; step <= 255; step++) {
67
+ const candidates = [0, 255].map((target) => {
68
+ const color = `#${channels.map((channel) => Math.round(channel + (target - channel) * step / 255).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
69
+ return { color, contrast: colorContrast(color, fill) ?? 0 };
70
+ }).sort((a, b) => b.contrast - a.contrast);
71
+ const best = candidates[0];
72
+ if (best && best.contrast >= 3) return best.color;
73
+ }
74
+ return textColorForFill(fill, preferred);
75
+ }
76
+
38
77
  // src/composition.ts
39
78
  var MAX_COMPOSITION_DEPTH = 32;
40
79
  function resolveFontFamilies(input) {
@@ -57,6 +96,40 @@ function textWidthMeasurer(style, measurement) {
57
96
  return width;
58
97
  };
59
98
  }
99
+ function measureTextOutline(text, fontSize, style, measurement) {
100
+ if (measurement?.outlineBounds === void 0) return void 0;
101
+ if (typeof measurement.outlineBounds !== "function") throw new TypeError("Text outline provider must be a function.");
102
+ const bounds = measurement.outlineBounds(text, fontSize, style);
103
+ if (bounds === null) return null;
104
+ if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) || bounds.width < 0 || bounds.height < 0) {
105
+ throw new RangeError("Text outline measurement must return null or finite bounds with nonnegative dimensions.");
106
+ }
107
+ return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
108
+ }
109
+ function placeTextLines(lines, box, alignment = "left", rasterPadding = 0) {
110
+ if (!Array.isArray(lines) || !box || ![box.x, box.y, box.width, box.height, rasterPadding].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || rasterPadding < 0 || !["left", "center", "right"].includes(alignment)) throw new RangeError("Text placement requires lines, finite positive dimensions, nonnegative padding and a valid alignment.");
111
+ const factor = alignment === "right" ? 1 : alignment === "center" ? 0.5 : 0, placed = [];
112
+ let shift = 0, bottom = box.y, height = 0, overflow = false;
113
+ for (const line of lines) {
114
+ if (!line || ![line.width, line.y, line.baseline, line.height].every(Number.isFinite) || line.width < 0 || line.height <= 0 || line.y < 0 || line.baseline < line.y) throw new RangeError("Text lines require finite coordinates, nonnegative advances and a baseline at or below their top.");
115
+ const ink = line.outline;
116
+ if (ink !== null && (!ink || ![ink.x, ink.y, ink.width, ink.height].every(Number.isFinite) || ink.width < 0 || ink.height < 0)) throw new RangeError("Text outlines must be null or finite coordinates with nonnegative dimensions.");
117
+ let x = box.x + (box.width - line.width) * factor;
118
+ if (ink) {
119
+ const low = box.x + rasterPadding - ink.x, high = box.x + box.width - rasterPadding - ink.x - ink.width;
120
+ if (low > high + 0.01) overflow = true;
121
+ else x = Math.max(low, Math.min(x, high));
122
+ shift += Math.max(0, bottom + rasterPadding - (box.y + line.baseline + shift + ink.y));
123
+ }
124
+ const y = box.y + line.y + shift, baseline = box.y + line.baseline + shift;
125
+ const outline = ink ? { ...ink, x: x + ink.x, y: baseline + ink.y } : null;
126
+ if (outline) bottom = outline.y + outline.height + rasterPadding;
127
+ height = Math.max(height, y + line.height - box.y, outline ? bottom - box.y : 0);
128
+ if (line.width > box.width + 0.01 || height > box.height + 0.01) overflow = true;
129
+ placed.push({ x, y, baseline, height: line.height, width: line.width, outline });
130
+ }
131
+ return { alignment, rasterPadding, lines: placed, height, overflow };
132
+ }
60
133
  var OPFCompositionError = class extends Error {
61
134
  constructor(diagnostics, explanation) {
62
135
  super("Slide content does not fit its composition.");
@@ -85,42 +158,22 @@ function measureText(text, fontSize) {
85
158
  return units * fontSize;
86
159
  }
87
160
  function wrapText(text, width, fontSize, measure = measureText) {
88
- const result = [];
89
- for (const paragraph of text.replace(/\r\n?/g, "\n").split("\n")) {
90
- let line = "";
91
- for (const word of paragraph.split(/\s+/u).filter(Boolean)) {
92
- if (line && measure(`${line} ${word}`, fontSize) > width) {
93
- result.push(line);
94
- line = "";
95
- }
96
- if (measure(word, fontSize) <= width) {
97
- line += (line ? " " : "") + word;
98
- continue;
99
- }
100
- for (const { segment: character } of textSegments.segment(word)) {
101
- if (line && measure(line + character, fontSize) > width) {
102
- result.push(line);
103
- line = "";
104
- }
105
- line += character;
106
- }
107
- }
108
- result.push(line);
109
- }
110
- return result;
161
+ return fitSourceText(text, { width, height: Number.MAX_VALUE }, fontSize, fontSize, 1, measure, true).lines;
111
162
  }
112
163
  function fitText(text, box, requestedSize = 25, minFontSize = 16, measure = measureText) {
113
164
  if (![box.width, box.height, requestedSize, minFontSize].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || requestedSize <= 0 || minFontSize <= 0) {
114
165
  throw new RangeError("Text dimensions and font sizes must be finite and positive.");
115
166
  }
116
- const minimum = Math.min(minFontSize, requestedSize);
117
- let fontSize = requestedSize;
118
- let lines = wrapText(text, box.width, fontSize, measure);
119
- while (fontSize > minimum && (lines.length * fontSize * 1.22 > box.height || lines.some((line) => measure(line, fontSize) > box.width))) {
120
- fontSize = Math.max(minimum, fontSize - 1);
121
- lines = wrapText(text, box.width, fontSize, measure);
167
+ return fitSourceText(text, box, Math.max(requestedSize, minFontSize), minFontSize, 1, measure, true);
168
+ }
169
+ function fitAtSizes(layout, requested, minimum, step = 1) {
170
+ const start = Math.max(requested, minimum);
171
+ if (![start, minimum, step].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Readable font sizes and fitting steps must be finite and positive.");
172
+ for (let trial = 0; trial <= 64; trial++) {
173
+ const size = trial === 64 ? minimum : Math.max(minimum, start - trial * step), result = layout(size);
174
+ if (!result.overflow || size === minimum) return result;
122
175
  }
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) };
176
+ throw new Error("Text fitting did not evaluate its bounded floor trial.");
124
177
  }
125
178
  function layoutQuote(value, box, options = {}) {
126
179
  const shorthand = typeof value === "string";
@@ -226,10 +279,308 @@ function layoutQuote(value, box, options = {}) {
226
279
  if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
227
280
  return { algorithm: "quote-flow-v1", textMeasurement: options.textMeasurement ? "provided" : "estimated", parts, diagnostics, overflow: diagnostics.length > 0 };
228
281
  }
282
+ function layoutMetric(value, box, options = {}) {
283
+ const scalar = typeof value === "string" || typeof value === "number";
284
+ const metric = scalar ? { value } : value;
285
+ const isValue = (input) => typeof input === "string" || typeof input === "number" && Number.isFinite(input);
286
+ if (!metric || Array.isArray(metric) || !isValue(metric.value) || [metric.label, metric.description, metric.unit].some((field) => field !== void 0 && typeof field !== "string") || metric.delta !== void 0 && !isValue(metric.delta) || metric.trend !== void 0 && !["up", "down", "flat"].includes(metric.trend)) {
287
+ throw new TypeError("Metric content requires a finite numeric or string value and schema-valid display metadata.");
288
+ }
289
+ const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale;
290
+ const rasterPadding = (options.textRasterPadding ?? 1) * scale, hasOutlines = options.textMeasurement?.outlineBounds !== void 0;
291
+ if (![box.x, box.y, box.width, box.height, box.x + box.width, box.y + box.height, scale, minimum, Math.max(76 * scale, minimum) * 1.22].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) {
292
+ throw new RangeError("Metric dimensions, scale and minimum font size must be finite and positive.");
293
+ }
294
+ if (!Number.isFinite(rasterPadding) || rasterPadding < 0) throw new RangeError("Metric raster padding must be finite and nonnegative.");
295
+ if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid metric overflow policy.");
296
+ const alignment = options.align ?? "left";
297
+ if (!["left", "center", "right"].includes(alignment)) throw new RangeError("Invalid metric alignment.");
298
+ const sourcePath = options.path ?? "metric", parts = [], diagnostics = [];
299
+ for (const role of ["value", "unit", "label", "description", "delta", "trend"]) {
300
+ const sourceValue = metric[role];
301
+ if (sourceValue === void 0) continue;
302
+ const text = String(sourceValue), path = scalar ? sourcePath : `${sourcePath}.${role}`;
303
+ const nominal = role === "value" ? Math.min(76 * scale, box.height * 0.28) : (role === "description" ? 20 : role === "trend" ? 18 : 23) * scale;
304
+ const requestedStyle = { fontFamily: (role === "value" ? options.fonts?.heading : options.fonts?.body) ?? "sans-serif", fontWeight: role === "value" ? 800 : role === "description" ? 400 : 500, italic: false, path };
305
+ const part = {
306
+ role,
307
+ path,
308
+ text,
309
+ sources: [{ path, value: sourceValue, start: 0, end: text.length }],
310
+ visible: role === "value" || text.length > 0,
311
+ linePositions: [],
312
+ box: { ...box, height: 0 },
313
+ requestedFontSize: Math.max(nominal, minimum),
314
+ minFontSize: minimum,
315
+ requestedStyle,
316
+ style: resolveTextStyle({ ...requestedStyle }, options.textMeasurement)
317
+ };
318
+ if (!part.visible) part.fit = { lines: [], sourceLines: [], fontSize: part.requestedFontSize, lineHeight: part.requestedFontSize * 1.22, tabSize: 4, tabWidth: 0, overflow: false };
319
+ parts.push(part);
320
+ }
321
+ const primary = parts[0], metadata = parts.filter((part) => part !== primary && part.visible), unit = metadata.find((part) => part.role === "unit");
322
+ const usable = (area) => [area.x, area.y, area.width, area.height, area.x + area.width, area.y + area.height].every(Number.isFinite) && area.width > 0 && area.height > 0;
323
+ const occupied = (fit) => fit.placement?.height ?? fit.lines.length * fit.lineHeight;
324
+ const gap = 8 * scale, primaryGap = 12 * scale;
325
+ const lineInk = (part, fit) => fit.sourceLines.map((line, index) => {
326
+ let outline = null;
327
+ for (const segment of line.segments) {
328
+ if (segment.kind !== "text") continue;
329
+ const bounds = measureTextOutline(part.text.slice(segment.start, segment.end), fit.fontSize, part.style, options.textMeasurement);
330
+ if (!bounds) continue;
331
+ const next = { ...bounds, x: segment.x + bounds.x };
332
+ if (!outline) outline = next;
333
+ else {
334
+ const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
335
+ outline.x = Math.min(outline.x, next.x);
336
+ outline.y = Math.min(outline.y, next.y);
337
+ outline.width = right - outline.x;
338
+ outline.height = bottom - outline.y;
339
+ }
340
+ }
341
+ return { width: line.width, y: index * fit.lineHeight, baseline: fit.fontSize + index * fit.lineHeight, height: fit.lineHeight, outline };
342
+ });
343
+ const place = (part, fit, width) => {
344
+ if (!hasOutlines) return fit;
345
+ const placement = placeTextLines(lineInk(part, fit), { x: 0, y: 0, width, height: Number.MAX_VALUE }, alignment, rasterPadding);
346
+ return { ...fit, placement, overflow: placement.overflow };
347
+ };
348
+ const naturalWidth = (part, fit) => {
349
+ if (!hasOutlines) return Math.max(...fit.sourceLines.map((line) => line.width));
350
+ const width = Math.max(...lineInk(part, fit).map((line) => Math.max(line.width, line.outline ? line.outline.x + line.outline.width : 0) - Math.min(0, line.outline?.x ?? 0)));
351
+ return width > 0 ? width + 2 * rasterPadding : 0;
352
+ };
353
+ const cache = /* @__PURE__ */ new Map();
354
+ const measure = (part, size, width) => {
355
+ const key = JSON.stringify([part.role, size, width]);
356
+ let fit = cache.get(key);
357
+ if (!fit) {
358
+ fit = fitCodeText(part.text, { ...box, width: hasOutlines ? Math.max(Number.MIN_VALUE, width - 2 * rasterPadding) : width }, size, size, scale, textWidthMeasurer(part.style, options.textMeasurement));
359
+ fit = place(part, fit, width);
360
+ if (!Number.isFinite(occupied(fit))) throw new RangeError("Metric text layout exceeds finite coordinates.");
361
+ cache.set(key, fit);
362
+ }
363
+ return fit;
364
+ };
365
+ const fitValue = (area, singleLine) => {
366
+ if (!usable(area)) return void 0;
367
+ for (let step = 0; step <= 76; step++) {
368
+ const size = Math.max(minimum, primary.requestedFontSize - step * scale);
369
+ const natural = measure(primary, size, area.width);
370
+ const fit = { ...natural, overflow: !!natural.placement?.overflow || singleLine && natural.lines.length !== 1 || occupied(natural) > area.height + 0.01 || natural.sourceLines.some((line) => line.width > area.width + 0.01) };
371
+ if (!fit.overflow || size === minimum) return fit;
372
+ }
373
+ };
374
+ let selected, attempts = 0;
375
+ const reductions = Math.min(23, Math.ceil(Math.max(0, ...metadata.map((part) => (part.requestedFontSize - minimum) / scale))));
376
+ for (let reduction = 0; reduction <= reductions; reduction++) {
377
+ const measured = new Map(metadata.map((part) => [part, measure(part, Math.max(minimum, part.requestedFontSize - reduction * scale), box.width)]));
378
+ const measuredUnit = unit ? measured.get(unit) : void 0;
379
+ const unitWidth = unit && measuredUnit ? naturalWidth(unit, measuredUnit) : 0;
380
+ const unitFit = unit && measuredUnit && unitWidth > 0 ? place(unit, measuredUnit, unitWidth) : measuredUnit;
381
+ const inline = unitFit?.lines.length === 1 && unitWidth > 0 && unitWidth <= box.width * 0.35 && unitWidth + gap < box.width;
382
+ for (const arrangement of inline ? ["inline-unit", "stacked"] : ["stacked"]) {
383
+ attempts++;
384
+ const tail = metadata.filter((part) => arrangement !== "inline-unit" || part !== unit);
385
+ const tailHeight = tail.reduce((sum, part) => sum + occupied(measured.get(part)), 0) + Math.max(0, tail.length - 1) * gap;
386
+ if (!Number.isFinite(tailHeight)) throw new RangeError("Metric metadata exceeds finite coordinates.");
387
+ const area = { ...box, width: arrangement === "inline-unit" ? box.width - unitWidth - gap : box.width, height: box.height - tailHeight - (tail.length ? primaryGap : 0) };
388
+ const valueFit = fitValue(area, arrangement === "inline-unit"), valueHeight = valueFit ? occupied(valueFit) : 0;
389
+ const allocations = [];
390
+ let primaryHeight = valueHeight;
391
+ if (arrangement === "inline-unit" && unit && unitFit && valueFit) {
392
+ const valueBaseline = valueFit.placement?.lines.at(-1)?.baseline ?? valueHeight - valueFit.lineHeight + valueFit.fontSize;
393
+ const unitBaseline = unitFit.placement?.lines.at(-1)?.baseline ?? occupied(unitFit) - unitFit.lineHeight + unitFit.fontSize;
394
+ const valueY = box.y + Math.max(0, unitBaseline - valueBaseline), unitY = box.y + Math.max(0, valueBaseline - unitBaseline);
395
+ const measuredWidth = naturalWidth(primary, valueFit);
396
+ const valueWidth = Math.min(area.width, measuredWidth || valueFit.fontSize);
397
+ allocations.push({ part: primary, box: { ...area, y: valueY, width: valueWidth, height: valueHeight }, fit: place(primary, valueFit, valueWidth) });
398
+ allocations.push({ part: unit, box: { x: box.x + valueWidth + gap, y: unitY, width: unitWidth, height: occupied(unitFit) }, fit: { ...unitFit, overflow: !!unitFit.placement?.overflow } });
399
+ primaryHeight = Math.max(valueY - box.y + valueHeight, unitY - box.y + occupied(unitFit));
400
+ } else allocations.push({ part: primary, box: valueFit ? { ...area, height: valueHeight } : area, fit: valueFit });
401
+ let y = box.y + primaryHeight + (tail.length ? primaryGap : 0);
402
+ for (const part of tail) {
403
+ const natural = measured.get(part), height = occupied(natural);
404
+ allocations.push({ part, box: { ...box, y, height }, fit: { ...natural, overflow: !!natural.placement?.overflow || natural.sourceLines.some((line) => line.width > box.width + 0.01) } });
405
+ y += height + gap;
406
+ }
407
+ const overflow = !valueFit || valueFit.overflow || arrangement === "inline-unit" && valueFit.lines.length !== 1 || primaryHeight > area.height + 0.01 || allocations.some(({ box: area2, fit }) => !fit || fit.overflow || !usable(area2) || area2.y + area2.height > box.y + box.height + 0.01);
408
+ const score = allocations.reduce((sum, { part, fit }) => sum + (part.requestedFontSize - (fit?.fontSize ?? minimum)) / scale, 0);
409
+ const candidate = { arrangement, allocations, score, overflow };
410
+ if (!selected || !overflow && (selected.overflow || score < selected.score) || overflow && selected.overflow) selected = candidate;
411
+ }
412
+ if (selected && !selected.overflow && selected.score === 0) break;
413
+ }
414
+ for (const allocation of selected.allocations) {
415
+ allocation.part.box = allocation.box;
416
+ allocation.part.fit = allocation.fit;
417
+ }
418
+ const alignmentFactor = alignment === "center" ? 0.5 : alignment === "right" ? 1 : 0;
419
+ if (selected.arrangement === "inline-unit" && unit) {
420
+ const offset = (box.width - (unit.box.x + unit.box.width - box.x)) * alignmentFactor;
421
+ primary.box.x += offset;
422
+ unit.box.x += offset;
423
+ }
424
+ for (const part of parts) if (part.fit) {
425
+ if (part.fit.placement) {
426
+ part.fit = { ...part.fit, placement: { ...part.fit.placement, lines: part.fit.placement.lines.map((line) => ({ ...line, x: line.x + part.box.x, y: line.y + part.box.y, baseline: line.baseline + part.box.y, outline: line.outline ? { ...line.outline, x: line.outline.x + part.box.x, y: line.outline.y + part.box.y } : null })) } };
427
+ part.linePositions = part.fit.placement.lines.map(({ x, baseline }) => ({ x, baseline }));
428
+ } else part.linePositions = part.fit.sourceLines.map((line, index) => ({
429
+ x: part.box.x + (part.box.width - line.width) * alignmentFactor,
430
+ baseline: part.box.y + part.fit.fontSize + index * part.fit.lineHeight
431
+ }));
432
+ }
433
+ const report = (reason, part, roles, message) => diagnostics.push({ code: "text-overflow", reason, path: part.path, parts: roles, message });
434
+ for (const part of parts.filter((part2) => part2.visible)) {
435
+ if (!part.fit || !usable(part.box)) report("invalid-part-box", part, [part.role], `Metric ${part.role} has no usable space after metadata; increase the cell or change the arrangement.`);
436
+ else if (part.fit.overflow) report("text-fit", part, [part.role], `Metric ${part.role} exceeds its space at the readability floor; increase the cell or change the arrangement.`);
437
+ if (usable(part.box) && (part.box.x < box.x || part.box.y < box.y || part.box.x + part.box.width > box.x + box.width + 0.01 || part.box.y + part.box.height > box.y + box.height + 0.01)) {
438
+ report("part-outside-cell", part, [part.role], `Metric ${part.role} extends outside its cell; increase the cell or change the arrangement.`);
439
+ }
440
+ }
441
+ const visible = parts.filter((part) => part.visible && part.fit);
442
+ for (const [index, first] of visible.entries()) for (const second of visible.slice(index + 1)) {
443
+ if (first.box.x < second.box.x + second.box.width - 0.01 && second.box.x < first.box.x + first.box.width - 0.01 && first.box.y < second.box.y + occupied(second.fit) - 0.01 && second.box.y < first.box.y + occupied(first.fit) - 0.01) {
444
+ report("part-overlap", first, [first.role, second.role], "Metric part line boxes overlap; do not accept this layout without more space.");
445
+ }
446
+ }
447
+ if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
448
+ return { algorithm: "metric-flow-v1", alignment, textMeasurement: options.textMeasurement ? "provided" : "estimated", arrangement: selected.arrangement, attempts, parts, diagnostics, overflow: diagnostics.length > 0 };
449
+ }
450
+ function layoutTimeline(value, box, options = {}) {
451
+ const shorthand = Array.isArray(value), timeline = shorthand ? { events: value } : value;
452
+ if (!timeline || !Array.isArray(timeline.events) || !timeline.events.length || [timeline.name, timeline.description].some((field) => field !== void 0 && typeof field !== "string") || timeline.events.some((event) => !event || typeof event.what !== "string" || [event.when, event.description].some((field) => field !== void 0 && typeof field !== "string"))) {
453
+ throw new TypeError("Timeline content requires ordered events with string labels and optional string metadata.");
454
+ }
455
+ const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale, padding = (options.textRasterPadding ?? 1) * scale;
456
+ if (![box.x, box.y, box.width, box.height, scale, minimum, padding].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0 || padding < 0) throw new RangeError("Timeline dimensions, scale and minimum must be positive, with finite nonnegative raster padding.");
457
+ if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid timeline overflow policy.");
458
+ const outlines = options.textMeasurement?.outlineBounds !== void 0;
459
+ if (outlines && typeof options.textMeasurement?.outlineBounds !== "function") throw new TypeError("Text outline provider must be a function.");
460
+ const path = options.path ?? "timeline", eventPath = (index) => shorthand ? `${path}.${index}` : `${path}.events.${index}`;
461
+ const fonts = resolveFontFamilies(options.fonts), source = [];
462
+ const add = (role, text, partPath, size, weight, eventIndex) => {
463
+ if (text === void 0) return;
464
+ const requestedStyle = { fontFamily: fonts.body, fontWeight: weight, italic: false, path: partPath };
465
+ source.push({ role, eventIndex, path: partPath, text, sources: [{ path: partPath, start: 0, end: text.length }], box: { ...box }, alignment: "center", requestedFontSize: Math.max(size * scale, minimum), minFontSize: minimum, requestedStyle, style: resolveTextStyle({ ...requestedStyle }, options.textMeasurement) });
466
+ };
467
+ add("name", timeline.name, `${path}.name`, 24, 700);
468
+ add("description", timeline.description, `${path}.description`, 18, 400);
469
+ timeline.events.forEach((event, index) => {
470
+ add("when", event.when, `${eventPath(index)}.when`, 16, 500, index);
471
+ add("what", event.what, `${eventPath(index)}.what`, 16, 500, index);
472
+ add("event-description", event.description, `${eventPath(index)}.description`, 16, 500, index);
473
+ });
474
+ const metadata = [], events = timeline.events.map(() => []);
475
+ for (const part of source) if (part.eventIndex === void 0) metadata.push(part);
476
+ else events[part.eventIndex].push(part);
477
+ const cache = /* @__PURE__ */ new Map();
478
+ const measure = (part, size, width, alignment) => {
479
+ const key = JSON.stringify([part.path, size, width, alignment]);
480
+ let measured = cache.get(key);
481
+ if (measured) return measured;
482
+ const fit = fitText(part.text, { width: Math.max(Number.MIN_VALUE, width - (outlines ? 2 * padding : 0)), height: Number.MAX_VALUE }, size, size, textWidthMeasurer(part.style, options.textMeasurement));
483
+ const ink = fit.sourceLines.map((line, index) => {
484
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity, hasInk = false;
485
+ if (outlines) {
486
+ for (const segment of line.segments) if (segment.kind === "text") {
487
+ const outline = measureTextOutline(part.text.slice(segment.start, segment.end), size, part.style, options.textMeasurement);
488
+ if (outline) {
489
+ hasInk = true;
490
+ left = Math.min(left, outline.x + segment.x);
491
+ top = Math.min(top, outline.y);
492
+ right = Math.max(right, outline.x + segment.x + outline.width);
493
+ bottom = Math.max(bottom, outline.y + outline.height);
494
+ }
495
+ }
496
+ }
497
+ return { width: line.width, y: index * fit.lineHeight, baseline: size + index * fit.lineHeight, height: fit.lineHeight, outline: hasInk ? { x: left, y: top, width: right - left, height: bottom - top } : null };
498
+ });
499
+ const placement = outlines ? placeTextLines(ink, { x: 0, y: 0, width, height: Number.MAX_VALUE }, alignment, padding) : void 0;
500
+ const height = placement?.height ?? fit.sourceLines.length * fit.lineHeight;
501
+ if (!Number.isFinite(height)) throw new RangeError("Timeline text exceeds finite layout coordinates.");
502
+ measured = { fit, ink, height };
503
+ cache.set(key, measured);
504
+ return measured;
505
+ };
506
+ let selected, attempts = 0;
507
+ let reductionLimit = 0;
508
+ for (const part of source) reductionLimit = Math.max(reductionLimit, (part.requestedFontSize - minimum) / scale);
509
+ const reductions = Math.min(24, Math.ceil(reductionLimit));
510
+ for (let reduction = 0; reduction <= reductions; reduction++) {
511
+ for (const arrangement of ["alternating", "vertical"]) {
512
+ attempts++;
513
+ const parts = [], markers = [], diagnostics = [];
514
+ let score2 = 0;
515
+ const report = (part, reason, message, excess = 1) => {
516
+ diagnostics.push({ code: "text-overflow", reason, path: part.path, message });
517
+ score2 += Math.max(1, excess);
518
+ };
519
+ const place = (part, x, y2, width, alignment) => {
520
+ const measured = measure(part, Math.max(minimum, part.requestedFontSize - reduction * scale), Math.max(scale, width), alignment);
521
+ const area = { x, y: y2, width: Math.max(scale, width), height: Math.max(scale, measured.height) }, placement = outlines ? placeTextLines(measured.ink, area, alignment, padding) : void 0;
522
+ const fit = { ...measured.fit, ...placement ? { placement } : {}, overflow: measured.fit.overflow || !!placement?.overflow };
523
+ const accepted = { ...part, box: area, alignment, fit };
524
+ parts.push(accepted);
525
+ if (fit.overflow) report(accepted, "text-fit", "Timeline field exceeds its readable width; increase its space or split the timeline.");
526
+ if (area.x < box.x - 0.01 || area.y < box.y - 0.01 || area.x + area.width > box.x + box.width + 0.01 || area.y + area.height > box.y + box.height + 0.01) report(accepted, "part-outside-cell", "Timeline field extends outside its cell; increase the cell or paginate events.", Math.max(area.x + area.width - box.x - box.width, area.y + area.height - box.y - box.height));
527
+ return accepted;
528
+ };
529
+ let y = box.y;
530
+ for (const part of metadata) {
531
+ const placed = place(part, box.x, y, box.width, "center");
532
+ y += placed.box.height + 8 * scale;
533
+ }
534
+ const available = box.y + box.height - y, count = events.length, radius = Math.min(9 * scale, box.width / Math.max(2, count) / 5, Math.max(scale, available) * 0.04);
535
+ if (arrangement === "alternating") {
536
+ const width = box.width / Math.max(2, count), step = (box.width - width) / Math.max(1, count - 1), start = count === 1 ? box.x + box.width / 2 : box.x + width / 2;
537
+ const lineY = y + Math.max(scale, available) * 0.46;
538
+ events.forEach((fields2, index) => {
539
+ const x = start + index * step, top = index % 2 === 0 ? y : lineY + 24 * scale, bottom = index % 2 === 0 ? lineY - 24 * scale : box.y + box.height;
540
+ markers.push({ path: eventPath(index), eventIndex: index, x, y: lineY, radius });
541
+ let cursor = top;
542
+ for (const part of fields2) {
543
+ const placed = place(part, x - width / 2, cursor, width, "center");
544
+ cursor += placed.box.height;
545
+ if (cursor > bottom + 0.01) report(placed, "event-space", "Timeline event labels exceed their side of the connector; change the arrangement or paginate events.", cursor - bottom);
546
+ }
547
+ });
548
+ } else {
549
+ const x = box.x + radius, textX = box.x + 24 * scale, width = box.width - 24 * scale;
550
+ events.forEach((fields2, index) => {
551
+ const top = y;
552
+ let first2;
553
+ for (const part of fields2) {
554
+ const placed = place(part, textX, y, width, "left");
555
+ first2 ??= placed;
556
+ y += placed.box.height;
557
+ }
558
+ markers.push({ path: eventPath(index), eventIndex: index, x, y: top + Math.min(first2?.box.height ?? 2 * radius, 2 * radius) / 2, radius });
559
+ y += 16 * scale;
560
+ });
561
+ }
562
+ for (const marker of markers) if (marker.x - marker.radius < box.x - 0.01 || marker.y - marker.radius < box.y - 0.01 || marker.x + marker.radius > box.x + box.width + 0.01 || marker.y + marker.radius > box.y + box.height + 0.01) {
563
+ diagnostics.push({ code: "text-overflow", reason: "event-space", path: marker.path, message: "Timeline marker has no usable space; increase the cell or paginate events." });
564
+ score2 += 1;
565
+ }
566
+ const first = markers[0], last = markers.at(-1), connector = { x1: first.x, y1: first.y, x2: last.x, y2: last.y };
567
+ const candidate = { arrangement, parts, markers, connector, diagnostics, score: score2 };
568
+ if (!selected || score2 < selected.score) selected = candidate;
569
+ if (!diagnostics.length) break;
570
+ }
571
+ if (!selected.diagnostics.length) break;
572
+ }
573
+ const { score, ...result } = selected;
574
+ if (result.diagnostics.length && options.overflow === "error") throw new OPFCompositionError(result.diagnostics);
575
+ return { algorithm: "timeline-flow-v1", attempts, textMeasurement: options.textMeasurement ? "provided" : "estimated", textOutlines: outlines ? "provided" : "unavailable", ...result, overflow: result.diagnostics.length > 0 };
576
+ }
229
577
  function fitCodeText(text, box, size, minimum, step, measure) {
578
+ return fitSourceText(text, box, size, minimum, step, measure);
579
+ }
580
+ function fitSourceText(text, box, size, minimum, step, measure, prose = false) {
230
581
  const layout = (fontSize) => {
231
582
  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.");
583
+ if (!Number.isFinite(tabWidth) || text.includes(" ") && tabWidth <= 0) throw new RangeError("Text tabs require a positive finite measured space advance.");
233
584
  const measureLine = (start2, end2, record3 = false) => {
234
585
  let x = 0, offset = start2;
235
586
  const segments = [];
@@ -257,7 +608,8 @@ function fitCodeText(text, box, size, minimum, step, measure) {
257
608
  start = nextStart;
258
609
  end = nextStart;
259
610
  };
260
- for (const token of text.matchAll(/\r\n|\r|\n|[^\S\r\n]+|[^\s]+/gu)) {
611
+ const tokens = prose ? /\r\n|\r|\n|[^\S\r\n\u00a0\u202f\ufeff]+|(?:[^\s]|\u00a0|\u202f|\ufeff)+/gu : /\r\n|\r|\n|[^\S\r\n]+|[^\s]+/gu;
612
+ for (const token of text.matchAll(tokens)) {
261
613
  if (token.index === void 0) throw new Error("Missing code token source offset.");
262
614
  const a = token.index, b = a + token[0].length;
263
615
  if (/^[\r\n]/.test(token[0])) {
@@ -273,6 +625,10 @@ function fitCodeText(text, box, size, minimum, step, measure) {
273
625
  end = b;
274
626
  continue;
275
627
  }
628
+ if (prose && /[\u00a0\u202f\u2060\ufeff]/u.test(token[0])) {
629
+ end = b;
630
+ continue;
631
+ }
276
632
  for (const segment of textSegments.segment(token[0])) {
277
633
  const next = a + segment.index + segment.segment.length;
278
634
  if (end > start && width(start, next) > box.width + 0.01) push(a + segment.index, a + segment.index, "soft");
@@ -291,12 +647,7 @@ function fitCodeText(text, box, size, minimum, step, measure) {
291
647
  overflow: sourceLines.length * lineHeight > box.height + 0.01 || sourceLines.some((line) => line.width > box.width + 0.01)
292
648
  };
293
649
  };
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;
650
+ return fitAtSizes(layout, size, minimum, step);
300
651
  }
301
652
  function layoutCode(value, box, options = {}) {
302
653
  const shorthand = typeof value === "string", code = shorthand ? { source: value } : value;
@@ -376,13 +727,19 @@ function layoutCode(value, box, options = {}) {
376
727
  }
377
728
  function fitRichText(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
378
729
  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.");
379
- const layout = richTextLayouter(input, box, requestedSize, options), minimum = Math.min(requestedSize, minFontSize);
380
- let size = requestedSize, result = layout(size);
381
- while (result.overflow && size > minimum) {
382
- size = Math.max(minimum, size - 1);
383
- result = layout(size);
730
+ const layout = richTextLayouter(input, box, requestedSize, options);
731
+ return fitAtSizes(layout, requestedSize, richMinimum(input, requestedSize, minFontSize));
732
+ }
733
+ function richMinimum(input, requested, minimum) {
734
+ let floor = Math.min(requested, minimum), visible = false;
735
+ for (const value of input) {
736
+ const run = typeof value === "string" ? { text: value } : value;
737
+ if (!run.text) continue;
738
+ const ratio = (run.fontSize === void 0 ? 1 : run.fontSize * 4 / 3 / requested) * (run.superscript || run.subscript ? 0.7 : 1);
739
+ floor = Math.max(floor, minimum / ratio);
740
+ visible = true;
384
741
  }
385
- return result;
742
+ return visible ? floor : minimum;
386
743
  }
387
744
  function richTextLayouter(input, box, requestedSize, options) {
388
745
  let offset = 0;
@@ -516,13 +873,12 @@ function fitList(input, box, requestedSize = 25, minFontSize = 16, options = { s
516
873
  const height = y - box.y;
517
874
  return { listEntries: entries, lines, fontSize, lineHeight: fontSize * 1.22, height, overflow: overflow || height > box.height + 0.01 };
518
875
  };
519
- const minimum = Math.min(requestedSize, minFontSize);
520
- let size = requestedSize, result = layout(size);
521
- while (result.overflow && size > minimum) {
522
- size = Math.max(minimum, size - 1);
523
- result = layout(size);
876
+ let minimum = minFontSize;
877
+ for (const item of source) {
878
+ minimum = Math.max(minimum, richMinimum(item.runs, requestedSize, minFontSize));
879
+ if (item.descriptionRuns !== void 0) minimum = Math.max(minimum, richMinimum(item.descriptionRuns, requestedSize * 0.82, minFontSize) / 0.82);
524
880
  }
525
- return result;
881
+ return fitAtSizes(layout, requestedSize, minimum);
526
882
  }
527
883
  function contentText(field, value) {
528
884
  if (field === "text" || headings.has(field)) return flatten(value);
@@ -536,7 +892,7 @@ ${flatten(entry.description)}` : ""}`;
536
892
  return void 0;
537
893
  }
538
894
  function layoutTable(value, box, options = {}) {
539
- const scale = options.scale ?? 1, requested = 15 * scale, minimum = (options.minFontSize ?? 16) * scale;
895
+ const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale, requested = Math.max(15 * scale, minimum);
540
896
  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.");
541
897
  const grid = tableGrid(value, options.path ?? "table");
542
898
  if (grid.issues.length) throw new RangeError(`${grid.issues[0].path}: ${grid.issues[0].message}`);
@@ -547,20 +903,26 @@ function layoutTable(value, box, options = {}) {
547
903
  const style = { fontFamily: options.fontFamily ?? "sans-serif", fontWeight: cell.header ? 700 : 400, italic: false, path: cell.valuePath };
548
904
  return { ...cell, padding, width, textStyle: style };
549
905
  });
906
+ const cellMinimum = (cell) => Array.isArray(cell.value) ? richMinimum(cell.value, requested, minimum) : minimum;
550
907
  const fitCell = (cell, height, min) => {
551
908
  const textBox = { width: Math.max(scale, cell.width), height: Math.max(scale, height) };
552
909
  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));
553
910
  };
554
911
  const textHeight = (fit) => "height" in fit ? fit.height : fit.lines.length * fit.lineHeight;
555
- const required = (cell, size) => textHeight(fitCell(cell, scale, size)) + (cell.padding.top + cell.padding.bottom) * scale;
912
+ const required = (cell, natural2) => {
913
+ const floor = cellMinimum(cell), size = natural2 ? Math.max(requested, floor) : floor;
914
+ const textBox = { width: Math.max(scale, cell.width), height: scale };
915
+ const fit = Array.isArray(cell.value) ? richTextLayouter(cell.value, textBox, requested, { style: cell.textStyle, textMeasurement: options.textMeasurement, uniformLineHeight: true })(size) : fitText(flatten(cell.value), textBox, size, size, textWidthMeasurer(resolveTextStyle(cell.textStyle, options.textMeasurement), options.textMeasurement));
916
+ return textHeight(fit) + (cell.padding.top + cell.padding.bottom) * scale;
917
+ };
556
918
  const natural = Array(grid.rowCount).fill(0), needed = Array(grid.rowCount).fill(0);
557
919
  for (const cell of cells) if (cell.rowSpan === 1) {
558
- natural[cell.row] = Math.max(natural[cell.row], required(cell, requested));
559
- needed[cell.row] = Math.max(needed[cell.row], required(cell, minimum));
920
+ natural[cell.row] = Math.max(natural[cell.row], required(cell, true));
921
+ needed[cell.row] = Math.max(needed[cell.row], required(cell, false));
560
922
  }
561
- for (const cell of cells) if (cell.rowSpan > 1) for (const [heights2, size] of [[natural, requested], [needed, minimum]]) {
923
+ for (const cell of cells) if (cell.rowSpan > 1) for (const [heights2, preferredSize] of [[natural, true], [needed, false]]) {
562
924
  const current = heights2.slice(cell.row, cell.row + cell.rowSpan).reduce((a, b) => a + b, 0);
563
- const extra = Math.max(0, required(cell, size) - current) / cell.rowSpan;
925
+ const extra = Math.max(0, required(cell, preferredSize) - current) / cell.rowSpan;
564
926
  for (let r = cell.row; r < cell.row + cell.rowSpan; r++) heights2[r] += extra;
565
927
  }
566
928
  const preferred = natural.map((height) => Math.max(54 * scale, height));
@@ -650,28 +1012,80 @@ function composeSlide(input, options = {}) {
650
1012
  const width = options.width ?? 1280, height = options.height ?? 720;
651
1013
  if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Canvas dimensions must be finite and positive.");
652
1014
  const scale = Math.min(width, height) / 720;
1015
+ const hasCards = record2(slide.design).contentBox ?? options.contentBox ?? false;
653
1016
  const composition = { ...record2(layout.composition), ...record2(slide.composition) };
654
1017
  assertComposition(composition);
655
1018
  const padding = (composition.padding ?? 0.08) * Math.min(width, height);
656
1019
  const gap = (composition.gap ?? 1 / 30) * Math.min(width, height);
657
1020
  const minSize = (composition.minFontSize ?? 16) * scale;
1021
+ const rasterPadding = (options.textRasterPadding ?? 1) * scale;
1022
+ if (!Number.isFinite(rasterPadding) || rasterPadding < 0) throw new RangeError("Text raster padding must be finite and nonnegative.");
658
1023
  const styleFor = (field, path2) => resolveTextStyle({
659
1024
  fontFamily: (field === "title" ? options.fonts?.heading : field === "code" ? options.fonts?.code : options.fonts?.body) ?? (field === "code" ? "monospace" : "sans-serif"),
660
1025
  fontWeight: field === "title" ? 700 : 400,
661
1026
  path: path2
662
1027
  }, options.textMeasurement);
663
1028
  const widthFor = (field, path2) => textWidthMeasurer(styleFor(field, path2), options.textMeasurement);
664
- const fitContent = (field, value, text, box, size, minimum, path2) => field === "text" && Array.isArray(value) ? fitRichText(value, box, size, minimum, { style: styleFor(field, path2), textMeasurement: options.textMeasurement }) : field === "items" || field === "bullets" ? fitList(value, box, size, minimum, { style: styleFor(field, path2), textMeasurement: options.textMeasurement }) : fitText(text, box, size, minimum, widthFor(field, path2));
1029
+ const fitPlacedText = (field, value, text, box, size, minimum, path2) => {
1030
+ const style = styleFor(field, path2), rich = field === "text" && Array.isArray(value);
1031
+ if (!options.textMeasurement?.outlineBounds) return rich ? fitRichText(value, box, size, minimum, { style, textMeasurement: options.textMeasurement }) : fitText(text, box, size, minimum, textWidthMeasurer(style, options.textMeasurement));
1032
+ const alignment = (field === "title" ? record2(slide.design).titleAlignment ?? options.titleAlignment : record2(slide.design).contentAlignment ?? options.contentAlignment) ?? "left";
1033
+ const richLayout = rich ? richTextLayouter(value, box, size, { style, textMeasurement: options.textMeasurement }) : void 0;
1034
+ const measure = textWidthMeasurer(style, options.textMeasurement), floor = rich ? richMinimum(value, size, minimum) : minimum, start = Math.max(size, floor);
1035
+ for (let trial = 0; trial <= 64; trial++) {
1036
+ const fontSize = trial === 64 ? floor : Math.max(floor, start - trial * scale);
1037
+ const fit = richLayout ? richLayout(fontSize) : fitText(text, box, fontSize, fontSize, measure);
1038
+ const richLines = "richLines" in fit ? fit.richLines : void 0;
1039
+ const lines = richLines ? richLines.map((line) => {
1040
+ let outline = null;
1041
+ for (const fragment of line.fragments) {
1042
+ const bounds = measureTextOutline(fragment.text, fragment.fontSize, fragment.style, options.textMeasurement);
1043
+ if (!bounds) continue;
1044
+ const next = { ...bounds, x: fragment.x + bounds.x, y: fragment.baselineShift + bounds.y };
1045
+ if (!outline) outline = next;
1046
+ else {
1047
+ const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
1048
+ outline.x = Math.min(outline.x, next.x);
1049
+ outline.y = Math.min(outline.y, next.y);
1050
+ outline.width = right - outline.x;
1051
+ outline.height = bottom - outline.y;
1052
+ }
1053
+ }
1054
+ return { width: line.width, y: line.y, baseline: line.baseline, height: line.height, outline };
1055
+ }) : fit.sourceLines.map((line, index) => {
1056
+ let outline = null;
1057
+ for (const segment of line.segments) {
1058
+ if (segment.kind === "tab") continue;
1059
+ const bounds = measureTextOutline(text.slice(segment.start, segment.end), fontSize, style, options.textMeasurement);
1060
+ if (!bounds) continue;
1061
+ const next = { ...bounds, x: segment.x + bounds.x };
1062
+ if (!outline) outline = next;
1063
+ else {
1064
+ const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
1065
+ outline.x = Math.min(outline.x, next.x);
1066
+ outline.y = Math.min(outline.y, next.y);
1067
+ outline.width = right - outline.x;
1068
+ outline.height = bottom - outline.y;
1069
+ }
1070
+ }
1071
+ return { width: line.width, y: index * fit.lineHeight, baseline: fontSize + index * fit.lineHeight, height: fit.lineHeight, outline };
1072
+ });
1073
+ const placement = placeTextLines(lines, box, alignment, rasterPadding), result = { ...fit, placement, overflow: fit.overflow || placement.overflow };
1074
+ if (!result.overflow || fontSize === floor) return result;
1075
+ }
1076
+ throw new Error("Text placement did not evaluate its bounded floor trial.");
1077
+ };
1078
+ const fitContent = (field, value, text, box, size, minimum, path2) => field === "text" ? fitPlacedText(field, value, text, box, size, minimum, path2) : field === "items" || field === "bullets" ? fitList(value, box, size, minimum, { style: styleFor(field, path2), textMeasurement: options.textMeasurement }) : fitText(text, box, size, minimum, widthFor(field, path2));
665
1079
  const path = `slides.${options.slideIndex ?? 0}`;
666
1080
  const items = [], diagnostics = [];
667
1081
  let y = padding;
668
1082
  for (const field of ["tag", "title", "subtitle"]) {
669
1083
  if (!slide[field]) continue;
670
1084
  const requested = (field === "title" ? 54 : field === "tag" ? 16 : 25) * scale;
671
- const maxHeight = height * (field === "title" ? 0.26 : field === "subtitle" ? 0.12 : 0.045);
1085
+ const maxHeight = Math.max(height * (field === "title" ? 0.26 : field === "subtitle" ? 0.12 : 0.045), minSize * 1.22 + 2 * rasterPadding);
672
1086
  const box = { x: padding, y, width: width - padding * 2, height: maxHeight };
673
- const text = fitText(String(slide[field]), box, requested, minSize, widthFor(field, `${path}.${field}`));
674
- box.height = Math.min(maxHeight, text.lines.length * text.lineHeight);
1087
+ const text = fitPlacedText(field, slide[field], String(slide[field]), box, requested, minSize, `${path}.${field}`);
1088
+ box.height = Math.min(maxHeight, Math.max(text.lines.length * text.lineHeight, text.placement?.height ?? 0));
675
1089
  items.push({ path: `${path}.${field}`, field, type: "text", value: slide[field], payload: { text: slide[field] }, box, text, textStyle: styleFor(field, `${path}.${field}`), composition });
676
1090
  y += box.height + gap * 0.5;
677
1091
  }
@@ -708,6 +1122,11 @@ function composeSlide(input, options = {}) {
708
1122
  return { x: box.x + amount, y: box.y + amount, width: box.width - amount * 2, height: box.height - amount * 2 };
709
1123
  };
710
1124
  const acceptedBox = (box) => ({ x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) });
1125
+ const payloadBox = (box) => {
1126
+ if (!hasCards) return box;
1127
+ const frame = acceptedBox(box), padding2 = Math.min(12 * scale, frame.width / 4, frame.height / 4);
1128
+ return acceptedBox({ x: frame.x + padding2, y: frame.y + padding2, width: frame.width - 2 * padding2, height: frame.height - 2 * padding2 });
1129
+ };
711
1130
  const measureQuote = (node, box, settings) => layoutQuote(node.value, acceptedBox(box), {
712
1131
  fonts: options.fonts,
713
1132
  textMeasurement: options.textMeasurement,
@@ -722,12 +1141,30 @@ function composeSlide(input, options = {}) {
722
1141
  minFontSize: settings.minFontSize,
723
1142
  path: node.path
724
1143
  });
1144
+ const measureMetric = (node, box, settings) => layoutMetric(node.value, acceptedBox(box), {
1145
+ fonts: options.fonts,
1146
+ textMeasurement: options.textMeasurement,
1147
+ scale,
1148
+ minFontSize: settings.minFontSize,
1149
+ path: node.path,
1150
+ textRasterPadding: options.textRasterPadding,
1151
+ align: record2(slide.design).contentAlignment ?? options.contentAlignment
1152
+ });
1153
+ const measureTimeline = (node, box, settings) => layoutTimeline(node.value, acceptedBox(box), {
1154
+ fonts: options.fonts,
1155
+ textMeasurement: options.textMeasurement,
1156
+ scale,
1157
+ minFontSize: settings.minFontSize,
1158
+ path: node.path,
1159
+ textRasterPadding: options.textRasterPadding
1160
+ });
725
1161
  const leafScore = (node, box, settings, penalties) => {
1162
+ box = payloadBox(box);
726
1163
  const text = contentText(node.field, node.value);
727
1164
  let score = Math.abs(Math.log(box.width / box.height / 1.6));
728
1165
  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);
1166
+ if (node.field === "quote" || node.field === "code" || node.field === "metric" || node.field === "timeline") {
1167
+ const internal = node.field === "quote" ? measureQuote(node, box, settings) : node.field === "code" ? measureCode(node, box, settings) : node.field === "metric" ? measureMetric(node, box, settings) : measureTimeline(node, box, settings);
731
1168
  const reduction = internal.parts.reduce((sum, part) => sum + (part.fit ? (part.requestedFontSize - part.fit.fontSize) / scale : 0), 0);
732
1169
  score += reduction + (internal.overflow ? 1e3 : 0);
733
1170
  if (penalties) {
@@ -736,7 +1173,7 @@ function composeSlide(input, options = {}) {
736
1173
  }
737
1174
  } else if (text) {
738
1175
  const fit = fitContent(node.field, node.value, text, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path);
739
- const reduction = (25 * scale - fit.fontSize) / scale;
1176
+ const reduction = Math.max(0, 25 * scale - fit.fontSize) / scale;
740
1177
  score += reduction + (fit.overflow ? 1e3 : 0);
741
1178
  if (penalties) {
742
1179
  penalties.fontReduction += reduction;
@@ -798,16 +1235,20 @@ function composeSlide(input, options = {}) {
798
1235
  const boxes = grid.boxes;
799
1236
  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 });
800
1237
  nodes.forEach((node, index) => {
801
- const box = node.region ? regionBox(node.region, area, actualGap) : boxes[index];
1238
+ let box = node.region ? regionBox(node.region, area, actualGap) : boxes[index];
802
1239
  if (node.children) {
803
1240
  const own = inheritedSettings(settings, node.composition), inner = inset(box, own);
804
1241
  groups.push({ path: node.path, box, contentBox: inner, composition: own });
805
1242
  arrange(node.children, inner, own, 0, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), node.path);
806
1243
  } else {
1244
+ const frameBox = hasCards ? acceptedBox(box) : void 0;
1245
+ box = payloadBox(box);
807
1246
  const textValue = contentText(node.field, node.value);
808
1247
  const quoteLayout = node.field === "quote" ? measureQuote(node, box, settings) : void 0;
809
1248
  const codeLayout = node.field === "code" ? measureCode(node, box, settings) : void 0;
810
- const internal = quoteLayout ?? codeLayout, body = internal?.parts.find((part) => part.role === "body");
1249
+ const metricLayout = node.field === "metric" ? measureMetric(node, box, settings) : void 0;
1250
+ const timelineLayout = node.field === "timeline" ? measureTimeline(node, box, settings) : void 0;
1251
+ const internal = quoteLayout ?? codeLayout ?? metricLayout ?? timelineLayout, body = internal?.parts.find((part) => part.role === "body" || part.role === "value");
811
1252
  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
1253
  items.push({
813
1254
  path: node.path,
@@ -816,11 +1257,14 @@ function composeSlide(input, options = {}) {
816
1257
  value: node.value,
817
1258
  payload: node.payload,
818
1259
  box: internal ? acceptedBox(box) : box,
1260
+ ...frameBox ? { frameBox } : {},
819
1261
  text,
820
1262
  textStyle: body?.style ?? styleFor(node.field, node.path),
821
1263
  composition: settings,
822
1264
  ...quoteLayout ? { quoteLayout } : {},
823
- ...codeLayout ? { codeLayout } : {}
1265
+ ...codeLayout ? { codeLayout } : {},
1266
+ ...metricLayout ? { metricLayout } : {},
1267
+ ...timelineLayout ? { timelineLayout } : {}
824
1268
  });
825
1269
  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." });
826
1270
  }
@@ -834,6 +1278,8 @@ function composeSlide(input, options = {}) {
834
1278
  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." });
835
1279
  if (item.quoteLayout) diagnostics.push(...item.quoteLayout.diagnostics);
836
1280
  else if (item.codeLayout) diagnostics.push(...item.codeLayout.diagnostics);
1281
+ else if (item.metricLayout) diagnostics.push(...item.metricLayout.diagnostics);
1282
+ else if (item.timelineLayout) diagnostics.push(...item.timelineLayout.diagnostics);
837
1283
  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." });
838
1284
  }
839
1285
  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]);
@@ -849,10 +1295,12 @@ function composeSlide(input, options = {}) {
849
1295
  return false;
850
1296
  });
851
1297
  const explanation = decisions ? {
852
- algorithm: "grid-score-v3",
1298
+ algorithm: "grid-score-v8",
853
1299
  textMeasurement: options.textMeasurement ? "provided" : "estimated",
1300
+ textOutlines: options.textMeasurement?.outlineBounds ? "provided" : "unavailable",
1301
+ textRasterPadding: rasterPadding,
854
1302
  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)
1303
+ unmeasuredPayloads: items.filter((item) => !headings.has(item.field) && !item.quoteLayout && !item.codeLayout && !item.metricLayout && !item.timelineLayout && !["text", "items", "bullets", "table"].includes(item.field)).map((item) => item.path)
856
1304
  } : void 0;
857
1305
  if (failures.length) throw new OPFCompositionError(failures, explanation);
858
1306
  return { width, height, contentBox, items, groups, flows, diagnostics, composition, ...explanation ? { explanation } : {} };
@@ -875,4 +1323,4 @@ function resolveCanvasDimensions(input) {
875
1323
  return { width, height };
876
1324
  }
877
1325
 
878
- export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutCode, layoutQuote, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
1326
+ export { MAX_COMPOSITION_DEPTH, OPFCompositionError, chartColorForFill, colorContrast, composeSlide, fitList, fitRichText, fitText, layoutCode, layoutMetric, layoutQuote, layoutTable, layoutTimeline, measureText, measureTextOutline, placeTextLines, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textColorForFill, textWidthMeasurer, wrapText };