@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.
- package/README.md +7 -1
- package/dist/{catalogs-BnnkUX9O.d.ts → catalogs-HCTav5Wv.d.ts} +1 -1
- package/dist/catalogs.d.ts +2 -2
- package/dist/{chunk-JA5W5TVA.js → chunk-BAWLYSUN.js} +517 -69
- package/dist/{chunk-ZGV6I23P.js → chunk-GCB6LGJM.js} +47 -4
- package/dist/chunk-NPOKUFEX.js +595 -0
- package/dist/{chunk-3GPZMY2W.js → chunk-TK3QHM3U.js} +3 -3
- package/dist/composition.d.ts +204 -9
- package/dist/composition.js +1 -1
- package/dist/docs.js +36 -6
- package/dist/examples.js +2284 -1417
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lint.d.ts +70 -0
- package/dist/lint.js +5 -0
- package/dist/pagination.d.ts +2 -0
- package/dist/pagination.js +3 -3
- package/dist/{schemas--QRVBhU1.d.ts → schemas-BYe5y8-i.d.ts} +1 -1
- package/dist/schemas.d.ts +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/validator.d.ts +2 -2
- package/dist/validator.js +2 -2
- package/package.json +11 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
559
|
-
needed[cell.row] = Math.max(needed[cell.row], required(cell,
|
|
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,
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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-
|
|
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 };
|