@openpresentation/opf 0.7.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,4 +1,4 @@
1
- import { MAX_COMPOSITION_DEPTH, tableGrid } from './chunk-OVPWNQIJ.js';
1
+ import { MAX_COMPOSITION_DEPTH, tableGrid } from './chunk-NEQAPMS2.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';
@@ -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,110 @@ 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
+ }
123
229
  function fitRichText(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
124
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.");
125
231
  const layout = richTextLayouter(input, box, requestedSize, options), minimum = Math.min(requestedSize, minFontSize);
@@ -443,6 +549,7 @@ function composeSlide(input, options = {}) {
443
549
  const regions = Object.keys(slide).filter((key) => regionParts(key)).sort();
444
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);
445
551
  const groups = [], flows = [];
552
+ const decisions = options.explain ? [] : void 0;
446
553
  const inheritedSettings = (parent, own = {}) => ({
447
554
  minFontSize: parent.minFontSize,
448
555
  ...own,
@@ -452,15 +559,43 @@ function composeSlide(input, options = {}) {
452
559
  const amount = (settings.padding ?? 0) * Math.min(box.width, box.height);
453
560
  return { x: box.x + amount, y: box.y + amount, width: box.width - amount * 2, height: box.height - amount * 2 };
454
561
  };
455
- 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) => {
456
571
  const text = contentText(node.field, node.value);
457
572
  let score = Math.abs(Math.log(box.width / box.height / 1.6));
458
- 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) {
459
583
  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);
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;
461
598
  }
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
599
  return score;
465
600
  };
466
601
  const modeFor = (settings) => settings.mode ?? "auto";
@@ -468,12 +603,12 @@ function composeSlide(input, options = {}) {
468
603
  const mode = modeFor(settings);
469
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))));
470
605
  };
471
- const scoreNode = (node, box, settings) => {
472
- 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);
473
608
  const own = inheritedSettings(settings, node.composition), area = inset(box, own);
474
609
  const cols = defaultColumns(node.children.length, area, own);
475
610
  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);
611
+ return node.children.reduce((score, child, index) => score + scoreNode(child, boxes[index], own, penalties), 0);
477
612
  };
478
613
  const arrange = (nodes, area, settings, reserved = 0, gapOverride, containerPath = path) => {
479
614
  const count = Math.max(nodes.length, reserved);
@@ -481,17 +616,29 @@ function composeSlide(input, options = {}) {
481
616
  const mode = modeFor(settings), localGap = (settings.gap ?? 1 / 30) * Math.min(area.width, area.height);
482
617
  const actualGap = gapOverride ?? (settings === rootSettings ? gap : localGap);
483
618
  let cols = defaultColumns(count, area, settings);
484
- 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) {
485
622
  let best = Infinity;
486
623
  for (let candidate = 1; candidate <= Math.min(count, settings.columns ?? 6); candidate++) {
487
624
  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;
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 });
489
629
  if (score < best) {
490
630
  best = score;
491
631
  cols = candidate;
492
632
  }
493
633
  }
494
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
+ });
495
642
  const grid = gridGeometry(count, area, cols, actualGap, settings.weights ?? [], mode === "column");
496
643
  const boxes = grid.boxes;
497
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 });
@@ -503,8 +650,20 @@ function composeSlide(input, options = {}) {
503
650
  arrange(node.children, inner, own, 0, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), node.path);
504
651
  } else {
505
652
  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 });
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
+ });
508
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." });
509
668
  }
510
669
  });
@@ -515,13 +674,29 @@ function composeSlide(input, options = {}) {
515
674
  for (const item of items) {
516
675
  for (const key of ["x", "y", "width", "height"]) item.box[key] = round(item.box[key]);
517
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." });
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." });
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." });
519
679
  }
520
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]);
521
681
  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 };
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 } : {} };
525
700
  }
526
701
  function resolveCanvasDimensions(input) {
527
702
  const presets = {
@@ -541,4 +716,4 @@ function resolveCanvasDimensions(input) {
541
716
  return { width, height };
542
717
  }
543
718
 
544
- export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, 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-H7UXKXEJ.js';
2
- import { resolveFontFamilies, resolveCanvasDimensions, composeSlide, tableRowBoundaries } from './chunk-OVPWNQIJ.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
@@ -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);
@@ -97,6 +97,8 @@ interface ComposedItem {
97
97
  box: LayoutBox;
98
98
  text?: TextFit | RichTextFit | ListFit;
99
99
  textStyle?: TextStyle;
100
+ /** Complete accepted quote internals; consumers must reuse these fits and styles. */
101
+ quoteLayout?: QuoteLayout;
100
102
  /** Effective container settings, including inherited readability constraints. */
101
103
  composition: Composition;
102
104
  }
@@ -121,6 +123,37 @@ interface ComposedFlow {
121
123
  itemCount: number;
122
124
  slotCount: number;
123
125
  }
126
+ /** Additive penalties in grid-score-v2; lower is preferred. These are not quality percentages. */
127
+ interface CompositionPenalties {
128
+ cellProportions: number;
129
+ fontReduction: number;
130
+ textOverflow: number;
131
+ tableOverflow: number;
132
+ smallCells: number;
133
+ emptySlots: number;
134
+ }
135
+ interface CompositionCandidate {
136
+ columns: number;
137
+ rows: number;
138
+ score: number;
139
+ penalties: CompositionPenalties;
140
+ }
141
+ interface CompositionDecision {
142
+ path: string;
143
+ mode: NonNullable<Composition['mode']> | 'regions';
144
+ reason: 'lowest-score' | 'configured-mode' | 'promoted-regions';
145
+ selectedColumns?: number;
146
+ /** Only candidates actually evaluated by automatic selection, in tie-break order. */
147
+ candidates: CompositionCandidate[];
148
+ }
149
+ interface CompositionExplanation {
150
+ algorithm: 'grid-score-v2';
151
+ /** Provided widths do not establish shaping, glyph coverage or native fidelity. */
152
+ textMeasurement: 'estimated' | 'provided';
153
+ decisions: CompositionDecision[];
154
+ /** Payloads whose complete internal fit is not covered by this scoring model. */
155
+ unmeasuredPayloads: string[];
156
+ }
124
157
  interface SlideComposition {
125
158
  width: number;
126
159
  height: number;
@@ -130,6 +163,7 @@ interface SlideComposition {
130
163
  flows: ComposedFlow[];
131
164
  diagnostics: LayoutDiagnostic[];
132
165
  composition: Composition;
166
+ explanation?: CompositionExplanation;
133
167
  }
134
168
  interface ComposeSlideOptions {
135
169
  fonts?: Partial<FontFamilies>;
@@ -138,16 +172,72 @@ interface ComposeSlideOptions {
138
172
  height?: number;
139
173
  slideIndex?: number;
140
174
  layout?: Record<string, unknown>;
175
+ /** Return candidate scores and coverage without changing the selected geometry. */
176
+ explain?: boolean;
141
177
  }
142
178
  declare class OPFCompositionError extends Error {
143
179
  readonly diagnostics: LayoutDiagnostic[];
144
180
  readonly code = "layout-overflow";
145
- constructor(diagnostics: LayoutDiagnostic[]);
181
+ readonly explanation?: CompositionExplanation;
182
+ constructor(diagnostics: LayoutDiagnostic[], explanation?: CompositionExplanation);
146
183
  }
147
184
  /** Deterministic estimate, not a font shaping engine. Preserves explicit line breaks. */
148
185
  declare function measureText(text: string, fontSize: number): number;
149
186
  declare function wrapText(text: string, width: number, fontSize: number, measure?: MeasureTextWidth): string[];
150
187
  declare function fitText(text: string, box: LayoutBox, requestedSize?: number, minFontSize?: number, measure?: MeasureTextWidth): TextFit;
188
+ interface QuoteContent {
189
+ text: string;
190
+ attribution?: string;
191
+ source?: string;
192
+ }
193
+ /** Source and displayed-text ranges are half-open UTF-16 offsets. Added punctuation has no source range. */
194
+ interface QuoteTextSource {
195
+ path: string;
196
+ start: number;
197
+ end: number;
198
+ outputStart: number;
199
+ outputEnd: number;
200
+ }
201
+ interface QuoteTextPart {
202
+ role: 'body' | 'footer';
203
+ path: string;
204
+ text: string;
205
+ sources: QuoteTextSource[];
206
+ /** Available space, before fitting. Invalid dimensions remain visible in failed results. */
207
+ box: LayoutBox;
208
+ requestedFontSize: number;
209
+ minFontSize: number;
210
+ requestedStyle: TextStyle;
211
+ style: TextStyle;
212
+ /** Absent when the available box is invalid; never fit against an invented one-pixel box. */
213
+ fit?: TextFit;
214
+ }
215
+ interface QuoteLayoutDiagnostic extends LayoutDiagnostic {
216
+ reason: 'invalid-part-box' | 'part-outside-cell' | 'text-fit' | 'part-overlap';
217
+ parts: QuoteTextPart['role'][];
218
+ }
219
+ interface QuoteLayout {
220
+ algorithm: 'quote-flow-v1';
221
+ textMeasurement: 'estimated' | 'provided';
222
+ parts: QuoteTextPart[];
223
+ diagnostics: QuoteLayoutDiagnostic[];
224
+ overflow: boolean;
225
+ }
226
+ interface QuoteLayoutOptions {
227
+ /** Canvas short edge divided by 720. Insets retain the current 18 reference-pixel contract. */
228
+ scale?: number;
229
+ fonts?: Partial<FontFamilies>;
230
+ /** Readability floor in reference pixels, before canvas scaling. */
231
+ minFontSize?: number;
232
+ overflow?: Composition['overflow'];
233
+ path?: string;
234
+ textMeasurement?: TextMeasurement;
235
+ }
236
+ /**
237
+ * Allocate and measure quote body/footer space for composition, rendering and export. Callers must
238
+ * check overflow before accepting the parts. Line boxes are not shaped glyph or native raster bounds.
239
+ */
240
+ declare function layoutQuote(value: string | QuoteContent, box: LayoutBox, options?: QuoteLayoutOptions): QuoteLayout;
151
241
  interface RichTextRun {
152
242
  text: string;
153
243
  bold?: boolean;
@@ -272,4 +362,4 @@ declare function resolveCanvasDimensions(input: unknown): {
272
362
  height: number;
273
363
  };
274
364
 
275
- export { type ComposeSlideOptions, type ComposedFlow, type ComposedGroup, type ComposedItem, type Composition, type CompositionTrack, type FontFamilies, type LayoutBox, type LayoutDiagnostic, type ListEntryLayout, type ListFit, type ListText, type ListValue, MAX_COMPOSITION_DEPTH, type MeasureTextWidth, OPFCompositionError, type RichTextFit, type RichTextFragment, type RichTextLine, type RichTextOptions, type RichTextRun, type SlideComposition, type TableBorder, type TableCellLayout, type TableCellStyle, type TableGrid, type TableGridCell, type TableGridIssue, type TableLayout, type TableLayoutOptions, type TableRowLayout, type TextFit, type TextMeasurement, type TextStyle, composeSlide, fitList, fitRichText, fitText, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
365
+ export { type ComposeSlideOptions, type ComposedFlow, type ComposedGroup, type ComposedItem, type Composition, type CompositionCandidate, type CompositionDecision, type CompositionExplanation, type CompositionPenalties, type CompositionTrack, type FontFamilies, type LayoutBox, type LayoutDiagnostic, type ListEntryLayout, type ListFit, type ListText, type ListValue, MAX_COMPOSITION_DEPTH, type MeasureTextWidth, OPFCompositionError, type QuoteContent, type QuoteLayout, type QuoteLayoutDiagnostic, type QuoteLayoutOptions, type QuoteTextPart, type QuoteTextSource, type RichTextFit, type RichTextFragment, type RichTextLine, type RichTextOptions, type RichTextRun, type SlideComposition, type TableBorder, type TableCellLayout, type TableCellStyle, type TableGrid, type TableGridCell, type TableGridIssue, type TableLayout, type TableLayoutOptions, type TableRowLayout, type TextFit, type TextMeasurement, type TextStyle, composeSlide, fitList, fitRichText, fitText, layoutQuote, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
@@ -1 +1 @@
1
- export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText } from './chunk-OVPWNQIJ.js';
1
+ export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutQuote, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText } from './chunk-NEQAPMS2.js';