@openpresentation/opf 0.8.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-BAWLYSUN.js +1326 -0
- package/dist/{chunk-HIY26FJV.js → chunk-GCB6LGJM.js} +47 -4
- package/dist/chunk-NPOKUFEX.js +595 -0
- package/dist/{chunk-X22DFHMW.js → chunk-TK3QHM3U.js} +3 -3
- package/dist/composition.d.ts +264 -5
- 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
- package/dist/chunk-NEQAPMS2.js +0 -719
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
// src/table.ts
|
|
2
|
+
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3
|
+
function tableGrid(value, path = "table") {
|
|
4
|
+
const table = record(value), hasHeaders = Array.isArray(table.columns) && table.columns.length > 0;
|
|
5
|
+
const values = [...hasHeaders ? [table.columns] : [], ...Array.isArray(table.rows) ? table.rows : []];
|
|
6
|
+
const columnCount = values.reduce((count, row) => Math.max(count, Array.isArray(row) ? row.length : 0), 1), rowCount = values.length;
|
|
7
|
+
const owners = Array.from({ length: rowCount }, () => Array(columnCount));
|
|
8
|
+
const rows2 = Array.from({ length: rowCount }, () => []), issues = [];
|
|
9
|
+
const location = (r, c) => `${path}.${hasHeaders && r === 0 ? "columns" : `rows.${r - Number(hasHeaders)}`}.${c}`;
|
|
10
|
+
for (let r = 0; r < rowCount; r++) for (let c = 0; c < columnCount; c++) {
|
|
11
|
+
const input = values[r]?.[c], cellPath = location(r, c), existing = owners[r][c];
|
|
12
|
+
if (existing) {
|
|
13
|
+
if (input !== null) issues.push({ path: cellPath, message: "A position covered by a spanning cell must explicitly contain null; content cannot be hidden." });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const object = record(input), styled = Object.hasOwn(object, "value");
|
|
17
|
+
const rowSpan = styled ? object.rowSpan ?? 1 : 1, colSpan = styled ? object.colSpan ?? 1 : 1;
|
|
18
|
+
if (!Number.isSafeInteger(rowSpan) || !Number.isSafeInteger(colSpan) || rowSpan < 1 || colSpan < 1 || r + rowSpan > rowCount || c + colSpan > columnCount || hasHeaders && r === 0 && rowSpan > 1) {
|
|
19
|
+
issues.push({ path: cellPath, message: "Cell spans must be positive integers within the table grid and cannot cross from headers into body rows." });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const cell = { input, value: styled ? object.value : input, style: styled ? record(object.style) : {}, row: r, column: c, rowSpan, colSpan, header: hasHeaders && r === 0, path: cellPath, valuePath: styled ? cellPath + ".value" : cellPath };
|
|
23
|
+
rows2[r].push(cell);
|
|
24
|
+
for (let y = r; y < r + rowSpan; y++) for (let x = c; x < c + colSpan; x++) {
|
|
25
|
+
if (owners[y][x]) issues.push({ path: cellPath, message: "Cell spans cannot overlap another spanning cell." });
|
|
26
|
+
else owners[y][x] = cell;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { rows: rows2, columnCount, rowCount, hasHeaders, owners, issues };
|
|
30
|
+
}
|
|
31
|
+
function tableRowBoundaries(value) {
|
|
32
|
+
const grid = tableGrid(value), offset = Number(grid.hasHeaders), count = grid.rowCount - offset;
|
|
33
|
+
const allowed = new Set(Array.from({ length: count + 1 }, (_, i) => i));
|
|
34
|
+
for (const row of grid.rows) for (const cell of row) if (!cell.header) for (let r = 1; r < cell.rowSpan; r++) allowed.delete(cell.row - offset + r);
|
|
35
|
+
return [...allowed];
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
|
|
77
|
+
// src/composition.ts
|
|
78
|
+
var MAX_COMPOSITION_DEPTH = 32;
|
|
79
|
+
function resolveFontFamilies(input) {
|
|
80
|
+
const scheme = record2(input);
|
|
81
|
+
const family = (value) => typeof value === "string" ? value : record2(value).family;
|
|
82
|
+
return {
|
|
83
|
+
heading: family(scheme.heading) ?? scheme.major ?? scheme.minor ?? "Roboto",
|
|
84
|
+
body: family(scheme.body) ?? scheme.minor ?? scheme.major ?? "Roboto",
|
|
85
|
+
code: family(scheme.code) ?? "Roboto Mono"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function resolveTextStyle(style, measurement) {
|
|
89
|
+
return measurement?.resolveStyle ? measurement.resolveStyle(style) : style;
|
|
90
|
+
}
|
|
91
|
+
function textWidthMeasurer(style, measurement) {
|
|
92
|
+
if (!measurement) return measureText;
|
|
93
|
+
return (text, size) => {
|
|
94
|
+
const width = measurement.measure(text, size, style);
|
|
95
|
+
if (!Number.isFinite(width) || width < 0) throw new RangeError("Text measurement must return a finite, nonnegative width.");
|
|
96
|
+
return width;
|
|
97
|
+
};
|
|
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
|
+
}
|
|
133
|
+
var OPFCompositionError = class extends Error {
|
|
134
|
+
constructor(diagnostics, explanation) {
|
|
135
|
+
super("Slide content does not fit its composition.");
|
|
136
|
+
this.diagnostics = diagnostics;
|
|
137
|
+
this.name = "OPFCompositionError";
|
|
138
|
+
if (explanation) this.explanation = explanation;
|
|
139
|
+
}
|
|
140
|
+
diagnostics;
|
|
141
|
+
code = "layout-overflow";
|
|
142
|
+
explanation;
|
|
143
|
+
};
|
|
144
|
+
var fields = ["text", "items", "bullets", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"];
|
|
145
|
+
var headings = /* @__PURE__ */ new Set(["title", "subtitle", "tag"]);
|
|
146
|
+
var rows = ["top", "middle", "bottom"];
|
|
147
|
+
var columns = ["left", "center", "right"];
|
|
148
|
+
var record2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
149
|
+
var kind = (field) => field === "items" || field === "bullets" ? "list" : field;
|
|
150
|
+
var round = (value) => Math.round(value * 1e6) / 1e6 || value;
|
|
151
|
+
var textSegments = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
152
|
+
function measureText(text, fontSize) {
|
|
153
|
+
let units = 0;
|
|
154
|
+
for (const character of text) {
|
|
155
|
+
if (/\p{Mark}|\u200d|\ufe0f/u.test(character)) continue;
|
|
156
|
+
units += character === " " ? 0.32 : /[A-Z0-9]/.test(character) ? 0.62 : character.codePointAt(0) > 11904 ? 1 : 0.54;
|
|
157
|
+
}
|
|
158
|
+
return units * fontSize;
|
|
159
|
+
}
|
|
160
|
+
function wrapText(text, width, fontSize, measure = measureText) {
|
|
161
|
+
return fitSourceText(text, { width, height: Number.MAX_VALUE }, fontSize, fontSize, 1, measure, true).lines;
|
|
162
|
+
}
|
|
163
|
+
function fitText(text, box, requestedSize = 25, minFontSize = 16, measure = measureText) {
|
|
164
|
+
if (![box.width, box.height, requestedSize, minFontSize].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || requestedSize <= 0 || minFontSize <= 0) {
|
|
165
|
+
throw new RangeError("Text dimensions and font sizes must be finite and positive.");
|
|
166
|
+
}
|
|
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;
|
|
175
|
+
}
|
|
176
|
+
throw new Error("Text fitting did not evaluate its bounded floor trial.");
|
|
177
|
+
}
|
|
178
|
+
function layoutQuote(value, box, options = {}) {
|
|
179
|
+
const shorthand = typeof value === "string";
|
|
180
|
+
const quote = shorthand ? { text: value } : value;
|
|
181
|
+
if (!quote || Array.isArray(quote) || typeof quote.text !== "string" || [quote.attribution, quote.source].some((field) => field !== void 0 && typeof field !== "string")) {
|
|
182
|
+
throw new TypeError("Quote content must be a string or a text object with optional string attribution/source.");
|
|
183
|
+
}
|
|
184
|
+
const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale;
|
|
185
|
+
if (![box.x, box.y, box.width, box.height, scale, minimum].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) {
|
|
186
|
+
throw new RangeError("Quote dimensions, scale and minimum font size must be finite and positive.");
|
|
187
|
+
}
|
|
188
|
+
if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid quote overflow policy.");
|
|
189
|
+
const path = options.path ?? "quote";
|
|
190
|
+
const diagnostics = [], parts = [];
|
|
191
|
+
const report = (reason, diagnosticPath, roles, message) => diagnostics.push({ code: "text-overflow", reason, path: diagnosticPath, parts: roles, message });
|
|
192
|
+
const source = (sourcePath, text, outputStart) => ({ path: sourcePath, start: 0, end: text.length, outputStart, outputEnd: outputStart + text.length });
|
|
193
|
+
const add = (role, text, sources, area, fontSize, fontFamily, fontWeight, partPath) => {
|
|
194
|
+
const requestedStyle = { fontFamily, fontWeight, italic: false, path: partPath };
|
|
195
|
+
const style = resolveTextStyle({ ...requestedStyle }, options.textMeasurement);
|
|
196
|
+
const requestedFontSize = Math.max(fontSize * scale, minimum);
|
|
197
|
+
const part = { role, path: partPath, text, sources, box: area, requestedFontSize, minFontSize: minimum, requestedStyle, style };
|
|
198
|
+
parts.push(part);
|
|
199
|
+
return part;
|
|
200
|
+
};
|
|
201
|
+
let footer = "";
|
|
202
|
+
const footerSources = [];
|
|
203
|
+
for (const field of ["attribution", "source"]) {
|
|
204
|
+
const text = quote[field];
|
|
205
|
+
if (!text) continue;
|
|
206
|
+
if (footer) footer += " - ";
|
|
207
|
+
footerSources.push(source(`${path}.${field}`, text, footer.length));
|
|
208
|
+
footer += text;
|
|
209
|
+
}
|
|
210
|
+
const bodyPath = shorthand ? path : `${path}.text`;
|
|
211
|
+
const body = add(
|
|
212
|
+
"body",
|
|
213
|
+
`"${quote.text}"`,
|
|
214
|
+
[source(bodyPath, quote.text, 1)],
|
|
215
|
+
{ x: box.x + 18, y: box.y + 18, width: box.width - 36, height: box.height - (footer ? 94 : 36) },
|
|
216
|
+
28,
|
|
217
|
+
options.fonts?.heading ?? "sans-serif",
|
|
218
|
+
600,
|
|
219
|
+
bodyPath
|
|
220
|
+
);
|
|
221
|
+
const attribution = footer ? add(
|
|
222
|
+
"footer",
|
|
223
|
+
footer,
|
|
224
|
+
footerSources,
|
|
225
|
+
{ x: box.x + 18, y: box.y + box.height - 58, width: box.width - 36, height: 40 },
|
|
226
|
+
17,
|
|
227
|
+
options.fonts?.body ?? "sans-serif",
|
|
228
|
+
500,
|
|
229
|
+
path
|
|
230
|
+
) : void 0;
|
|
231
|
+
const usable = (area) => [area.x, area.y, area.width, area.height].every(Number.isFinite) && area.width > 0 && area.height > 0;
|
|
232
|
+
const fit = (part, area, size = part.requestedFontSize, floor = minimum) => usable(area) ? fitText(part.text, area, size, floor, textWidthMeasurer(part.style, options.textMeasurement)) : void 0;
|
|
233
|
+
const inner = { x: box.x + 18, y: box.y + 18, width: box.width - 36, height: box.height - 36 };
|
|
234
|
+
if (!attribution) body.fit = fit(body, body.box);
|
|
235
|
+
else if (usable(inner) && inner.height > 18) {
|
|
236
|
+
const available = inner.height - 18;
|
|
237
|
+
const preferredBody = fit(body, inner, body.requestedFontSize, body.requestedFontSize);
|
|
238
|
+
const minimumBody = body.requestedFontSize === minimum ? preferredBody : fit(body, inner, minimum, minimum);
|
|
239
|
+
const preferredBodyHeight = preferredBody.lines.length * preferredBody.lineHeight;
|
|
240
|
+
const minimumBodyHeight = minimumBody.lines.length * minimumBody.lineHeight;
|
|
241
|
+
let selected;
|
|
242
|
+
for (const size of /* @__PURE__ */ new Set([attribution.requestedFontSize, minimum])) {
|
|
243
|
+
const natural = fit(attribution, inner, size, size);
|
|
244
|
+
const naturalHeight = natural.lines.length * natural.lineHeight;
|
|
245
|
+
const preferredHeight = Math.max(40, naturalHeight);
|
|
246
|
+
const bodyReservation = Math.min(minimumBodyHeight, Math.max(minimumBody.lineHeight, available - natural.lineHeight));
|
|
247
|
+
const footerHeight = preferredBodyHeight + preferredHeight <= available + 0.01 ? preferredHeight : Math.min(naturalHeight, Math.max(0, available - bodyReservation));
|
|
248
|
+
const bodyBox = { ...inner, height: available - footerHeight };
|
|
249
|
+
const footerBox = { ...inner, y: inner.y + inner.height - footerHeight, height: footerHeight };
|
|
250
|
+
const bodyFit = fit(body, bodyBox);
|
|
251
|
+
const footerFit = usable(footerBox) ? { ...natural, overflow: natural.overflow || naturalHeight > footerHeight + 0.01 } : void 0;
|
|
252
|
+
const overflow = !bodyFit || !footerFit || bodyFit.overflow || footerFit.overflow;
|
|
253
|
+
const score = (body.requestedFontSize - (bodyFit?.fontSize ?? minimum) + attribution.requestedFontSize - size) / scale;
|
|
254
|
+
if (!selected || !overflow && (selected.overflow || score < selected.score) || overflow && selected.overflow) {
|
|
255
|
+
selected = { bodyBox, footerBox, bodyFit, footerFit, score, overflow };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (selected) {
|
|
259
|
+
body.box = selected.bodyBox;
|
|
260
|
+
body.fit = selected.bodyFit;
|
|
261
|
+
attribution.box = selected.footerBox;
|
|
262
|
+
attribution.fit = selected.footerFit;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const part of parts) {
|
|
266
|
+
const area = part.box;
|
|
267
|
+
if (!part.fit) {
|
|
268
|
+
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.`);
|
|
269
|
+
} else if (part.fit.overflow) {
|
|
270
|
+
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.`);
|
|
271
|
+
}
|
|
272
|
+
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)) {
|
|
273
|
+
report("part-outside-cell", part.path, [part.role], `Quote ${part.role} extends outside its cell; increase the cell size or change the arrangement.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
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) {
|
|
277
|
+
report("part-overlap", path, ["body", "footer"], "Quote body and footer line boxes overlap; do not accept this layout without more space.");
|
|
278
|
+
}
|
|
279
|
+
if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
|
|
280
|
+
return { algorithm: "quote-flow-v1", textMeasurement: options.textMeasurement ? "provided" : "estimated", parts, diagnostics, overflow: diagnostics.length > 0 };
|
|
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
|
+
}
|
|
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) {
|
|
581
|
+
const layout = (fontSize) => {
|
|
582
|
+
const tabWidth = measure(" ", fontSize) * 4;
|
|
583
|
+
if (!Number.isFinite(tabWidth) || text.includes(" ") && tabWidth <= 0) throw new RangeError("Text tabs require a positive finite measured space advance.");
|
|
584
|
+
const measureLine = (start2, end2, record3 = false) => {
|
|
585
|
+
let x = 0, offset = start2;
|
|
586
|
+
const segments = [];
|
|
587
|
+
for (const [index, segment] of text.slice(start2, end2).split(" ").entries()) {
|
|
588
|
+
if (index) {
|
|
589
|
+
const next = (Math.floor(x / tabWidth + 1e-9) + 1) * tabWidth;
|
|
590
|
+
if (record3) segments.push({ kind: "tab", start: offset, end: offset + 1, x, width: next - x });
|
|
591
|
+
x = next;
|
|
592
|
+
offset++;
|
|
593
|
+
}
|
|
594
|
+
if (segment) {
|
|
595
|
+
const advance = measure(segment, fontSize);
|
|
596
|
+
if (record3) segments.push({ kind: "text", start: offset, end: offset + segment.length, x, width: advance });
|
|
597
|
+
x += advance;
|
|
598
|
+
offset += segment.length;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { width: x, segments };
|
|
602
|
+
};
|
|
603
|
+
const width = (start2, end2) => measureLine(start2, end2).width;
|
|
604
|
+
const sourceLines = [];
|
|
605
|
+
let start = 0, end = 0;
|
|
606
|
+
const push = (last, nextStart, boundary) => {
|
|
607
|
+
sourceLines.push({ start, end: last, nextStart, boundary, ...measureLine(start, last, true) });
|
|
608
|
+
start = nextStart;
|
|
609
|
+
end = nextStart;
|
|
610
|
+
};
|
|
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)) {
|
|
613
|
+
if (token.index === void 0) throw new Error("Missing code token source offset.");
|
|
614
|
+
const a = token.index, b = a + token[0].length;
|
|
615
|
+
if (/^[\r\n]/.test(token[0])) {
|
|
616
|
+
push(a, b, "hard");
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (width(start, b) <= box.width + 0.01) {
|
|
620
|
+
end = b;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (end > start && /\S/u.test(text.slice(start, end))) push(a, a, "soft");
|
|
624
|
+
if (width(start, b) <= box.width + 0.01) {
|
|
625
|
+
end = b;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (prose && /[\u00a0\u202f\u2060\ufeff]/u.test(token[0])) {
|
|
629
|
+
end = b;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
for (const segment of textSegments.segment(token[0])) {
|
|
633
|
+
const next = a + segment.index + segment.segment.length;
|
|
634
|
+
if (end > start && width(start, next) > box.width + 0.01) push(a + segment.index, a + segment.index, "soft");
|
|
635
|
+
end = next;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
push(end, end, "end");
|
|
639
|
+
const lineHeight = fontSize * 1.22;
|
|
640
|
+
return {
|
|
641
|
+
lines: sourceLines.map((line) => text.slice(line.start, line.end)),
|
|
642
|
+
sourceLines,
|
|
643
|
+
fontSize,
|
|
644
|
+
lineHeight,
|
|
645
|
+
tabSize: 4,
|
|
646
|
+
tabWidth,
|
|
647
|
+
overflow: sourceLines.length * lineHeight > box.height + 0.01 || sourceLines.some((line) => line.width > box.width + 0.01)
|
|
648
|
+
};
|
|
649
|
+
};
|
|
650
|
+
return fitAtSizes(layout, size, minimum, step);
|
|
651
|
+
}
|
|
652
|
+
function layoutCode(value, box, options = {}) {
|
|
653
|
+
const shorthand = typeof value === "string", code = shorthand ? { source: value } : value;
|
|
654
|
+
if (!code || Array.isArray(code) || typeof code.source !== "string" || [code.language, code.filename].some((field) => field !== void 0 && typeof field !== "string")) {
|
|
655
|
+
throw new TypeError("Code content must be a string or source object with optional string language/filename.");
|
|
656
|
+
}
|
|
657
|
+
const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale;
|
|
658
|
+
if (![box.x, box.y, box.width, box.height, box.x + box.width, box.y + box.height, scale, minimum, Math.max(18 * scale, minimum) * 1.22].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) {
|
|
659
|
+
throw new RangeError("Code dimensions, scale and minimum font size must be finite and positive.");
|
|
660
|
+
}
|
|
661
|
+
if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid code overflow policy.");
|
|
662
|
+
const path = options.path ?? "code", inner = { x: box.x + 18, y: box.y + 18, width: box.width - 36, height: box.height - 36 };
|
|
663
|
+
const parts = [], diagnostics = [];
|
|
664
|
+
const add = (role, text, partPath, generated = false) => {
|
|
665
|
+
const requestedStyle = { fontFamily: options.fonts?.code ?? "monospace", fontWeight: role === "body" ? 400 : 700, italic: false, path: partPath };
|
|
666
|
+
const part = {
|
|
667
|
+
role,
|
|
668
|
+
path: partPath,
|
|
669
|
+
text,
|
|
670
|
+
sources: generated ? [] : [{ path: partPath, start: 0, end: text.length }],
|
|
671
|
+
generated,
|
|
672
|
+
box: { ...inner },
|
|
673
|
+
requestedFontSize: Math.max((role === "body" ? 18 : 14) * scale, minimum),
|
|
674
|
+
minFontSize: minimum,
|
|
675
|
+
requestedStyle,
|
|
676
|
+
style: resolveTextStyle({ ...requestedStyle }, options.textMeasurement)
|
|
677
|
+
};
|
|
678
|
+
parts.push(part);
|
|
679
|
+
return part;
|
|
680
|
+
};
|
|
681
|
+
if (code.filename) add("filename", code.filename, `${path}.filename`);
|
|
682
|
+
if (code.language) add("language", code.language, `${path}.language`);
|
|
683
|
+
if (!parts.length) add("language", "code", path, true);
|
|
684
|
+
const body = add("body", code.source, shorthand ? path : `${path}.source`), headers = parts.filter((part) => part !== body);
|
|
685
|
+
const usable = (area) => [area.x, area.y, area.width, area.height].every(Number.isFinite) && area.width > 0 && area.height > 0;
|
|
686
|
+
const fit = (part, area, size = part.requestedFontSize, floor = minimum) => usable(area) ? fitCodeText(part.text, area, size, floor, scale, textWidthMeasurer(part.style, options.textMeasurement)) : void 0;
|
|
687
|
+
if (usable(inner)) {
|
|
688
|
+
const combinations = headers.reduce((choices, part) => choices.flatMap((choice) => [.../* @__PURE__ */ new Set([part.requestedFontSize, minimum])].map((size) => [...choice, { part, size }])), [[]]);
|
|
689
|
+
let selected;
|
|
690
|
+
for (const combination of combinations) {
|
|
691
|
+
let y = inner.y;
|
|
692
|
+
const allocations = [];
|
|
693
|
+
for (const [index, { part, size }] of combination.entries()) {
|
|
694
|
+
const measured = fitCodeText(part.text, inner, size, size, scale, textWidthMeasurer(part.style, options.textMeasurement));
|
|
695
|
+
const area2 = { ...inner, y, height: measured.lines.length * measured.lineHeight };
|
|
696
|
+
allocations.push({ part, box: area2, fit: usable(area2) ? { ...measured, overflow: measured.sourceLines.some((line) => line.width > area2.width + 0.01) } : void 0 });
|
|
697
|
+
y += area2.height + (index < headers.length - 1 ? 8 : 12);
|
|
698
|
+
}
|
|
699
|
+
const area = { ...inner, y, height: inner.y + inner.height - y };
|
|
700
|
+
allocations.push({ part: body, box: area, fit: fit(body, area) });
|
|
701
|
+
const overflow = allocations.some(({ box: box2, fit: fit2 }) => !fit2 || fit2.overflow || !usable(box2) || box2.y + box2.height > inner.y + inner.height + 0.01);
|
|
702
|
+
const score = allocations.reduce((sum, { part, fit: fit2 }) => sum + (part.requestedFontSize - (fit2?.fontSize ?? minimum)) / scale, 0);
|
|
703
|
+
if (!selected || !overflow && (selected.overflow || score < selected.score) || overflow && selected.overflow) selected = { allocations, score, overflow };
|
|
704
|
+
}
|
|
705
|
+
if (selected) for (const { part, box: box2, fit: fit2 } of selected.allocations) {
|
|
706
|
+
part.box = box2;
|
|
707
|
+
part.fit = fit2;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const report = (reason, part, roles, message) => diagnostics.push({ code: "text-overflow", reason, path: part.path, parts: roles, message });
|
|
711
|
+
for (const part of parts) {
|
|
712
|
+
if (!part.fit) report("invalid-part-box", part, [part.role], `Code ${part.role} has no usable space after metadata and insets; increase the cell or change the arrangement.`);
|
|
713
|
+
else if (part.fit.overflow) report("text-fit", part, [part.role], `Code ${part.role} exceeds its space at the readability floor; increase its space or change the arrangement.`);
|
|
714
|
+
const area = part.box;
|
|
715
|
+
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)) {
|
|
716
|
+
report("part-outside-cell", part, [part.role], `Code ${part.role} extends outside its cell; increase the cell or change the arrangement.`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
for (const [index, first] of parts.entries()) {
|
|
720
|
+
const second = parts[index + 1];
|
|
721
|
+
if (first.fit && second?.fit && first.box.y + first.fit.lines.length * first.fit.lineHeight > second.box.y + 0.01) {
|
|
722
|
+
report("part-overlap", first, [first.role, second.role], "Code part line boxes overlap; do not accept this layout without more space.");
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
|
|
726
|
+
return { algorithm: "code-flow-v1", textMeasurement: options.textMeasurement ? "provided" : "estimated", parts, diagnostics, overflow: diagnostics.length > 0 };
|
|
727
|
+
}
|
|
728
|
+
function fitRichText(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
|
|
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.");
|
|
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;
|
|
741
|
+
}
|
|
742
|
+
return visible ? floor : minimum;
|
|
743
|
+
}
|
|
744
|
+
function richTextLayouter(input, box, requestedSize, options) {
|
|
745
|
+
let offset = 0;
|
|
746
|
+
const source = input.map((value, runIndex) => {
|
|
747
|
+
const run = typeof value === "string" ? { text: value } : value;
|
|
748
|
+
if (typeof run?.text !== "string" || run.fontSize !== void 0 && (!Number.isFinite(run.fontSize) || run.fontSize <= 0)) throw new RangeError("Rich text runs need text and a positive finite font size.");
|
|
749
|
+
const start = offset;
|
|
750
|
+
offset += run.text.length;
|
|
751
|
+
const style = resolveTextStyle({ ...options.style, fontFamily: run.fontFamily ?? options.style.fontFamily, fontWeight: run.bold === void 0 ? options.style.fontWeight : run.bold ? 700 : 400, italic: run.italic ?? options.style.italic, path: options.style.path ? `${options.style.path}.${runIndex}` : void 0 }, options.textMeasurement);
|
|
752
|
+
return { run, runIndex, start, end: offset, style };
|
|
753
|
+
});
|
|
754
|
+
const whole = source.map((entry) => entry.run.text).join("");
|
|
755
|
+
const layout = (fontSize) => {
|
|
756
|
+
const ratio = fontSize / requestedSize;
|
|
757
|
+
const fragments = (start, end) => {
|
|
758
|
+
let x = 0;
|
|
759
|
+
const result = [];
|
|
760
|
+
for (const entry of source) {
|
|
761
|
+
const a = Math.max(start, entry.start), b = Math.min(end, entry.end);
|
|
762
|
+
if (b <= a) continue;
|
|
763
|
+
const text = whole.slice(a, b), normalSize = (entry.run.fontSize !== void 0 ? entry.run.fontSize * 4 / 3 : requestedSize) * ratio;
|
|
764
|
+
const script = entry.run.superscript || entry.run.subscript;
|
|
765
|
+
const size = normalSize * (script ? 0.7 : 1), baselineShift = entry.run.superscript ? -normalSize * 0.35 : entry.run.subscript ? normalSize * 0.2 : 0;
|
|
766
|
+
const width2 = textWidthMeasurer(entry.style, options.textMeasurement)(text, size);
|
|
767
|
+
result.push({ text, runIndex: entry.runIndex, start: a - entry.start, end: b - entry.start, x, width: width2, fontSize: size, baselineShift, style: entry.style, run: entry.run });
|
|
768
|
+
x += width2;
|
|
769
|
+
}
|
|
770
|
+
return result;
|
|
771
|
+
};
|
|
772
|
+
const width = (start, end) => fragments(start, end).reduce((sum, fragment) => sum + fragment.width, 0);
|
|
773
|
+
const ranges = [];
|
|
774
|
+
let lineStart = 0, lineEnd = 0;
|
|
775
|
+
const push = (end) => {
|
|
776
|
+
ranges.push({ start: lineStart, end });
|
|
777
|
+
lineStart = end;
|
|
778
|
+
lineEnd = end;
|
|
779
|
+
};
|
|
780
|
+
for (const match of whole.matchAll(/\r\n|\r|\n|[^\S\r\n]+|[^\s]+/gu)) {
|
|
781
|
+
const start = match.index, end = start + match[0].length;
|
|
782
|
+
if (/^[\r\n]/.test(match[0])) {
|
|
783
|
+
push(start);
|
|
784
|
+
lineStart = end;
|
|
785
|
+
lineEnd = end;
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (width(lineStart, end) <= box.width) {
|
|
789
|
+
lineEnd = end;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (lineEnd > lineStart) push(start);
|
|
793
|
+
if (width(start, end) <= box.width) {
|
|
794
|
+
lineEnd = end;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
for (const { segment, index } of textSegments.segment(match[0])) {
|
|
798
|
+
const next = start + index + segment.length;
|
|
799
|
+
if (lineEnd > lineStart && width(lineStart, next) > box.width) push(start + index);
|
|
800
|
+
lineEnd = next;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
ranges.push({ start: lineStart, end: lineEnd });
|
|
804
|
+
let y = 0;
|
|
805
|
+
const richLines = ranges.map((range) => {
|
|
806
|
+
const parts = fragments(range.start, range.end), ascent = Math.max(fontSize, ...parts.map((part) => part.fontSize - part.baselineShift)), descent = Math.max(fontSize * 0.22, ...parts.map((part) => part.fontSize * 0.22 + part.baselineShift));
|
|
807
|
+
const height = ascent + descent, line = { fragments: parts, width: parts.reduce((sum, p) => sum + p.width, 0), y, baseline: y + ascent, height };
|
|
808
|
+
y += height;
|
|
809
|
+
return line;
|
|
810
|
+
});
|
|
811
|
+
if (options.uniformLineHeight) {
|
|
812
|
+
const height = Math.max(...richLines.map((line) => line.height));
|
|
813
|
+
y = 0;
|
|
814
|
+
for (const line of richLines) {
|
|
815
|
+
line.baseline += y - line.y;
|
|
816
|
+
line.y = y;
|
|
817
|
+
line.height = height;
|
|
818
|
+
y += height;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return { lines: ranges.map((range) => whole.slice(range.start, range.end)), fontSize, lineHeight: Math.max(fontSize * 1.22, ...richLines.map((line) => line.height)), richLines, height: y, overflow: y > box.height + 0.01 || richLines.some((line) => line.width > box.width + 0.01) };
|
|
822
|
+
};
|
|
823
|
+
return layout;
|
|
824
|
+
}
|
|
825
|
+
function fitList(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
|
|
826
|
+
if (!Array.isArray(input) || ![box.width, box.height, requestedSize, minFontSize].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("List content, dimensions and font sizes must be valid.");
|
|
827
|
+
const source = input.map((value, index) => {
|
|
828
|
+
const object = typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
829
|
+
const text = object ? object.text : value, description = object?.description, level = object?.level ?? 0;
|
|
830
|
+
if (!Number.isInteger(level) || level < 0) throw new RangeError("List levels must be nonnegative integers.");
|
|
831
|
+
const runs = (value2) => typeof value2 === "string" ? [value2] : value2;
|
|
832
|
+
if (!Array.isArray(runs(text)) || description !== void 0 && !Array.isArray(runs(description))) throw new TypeError("List text and descriptions must be strings or text runs.");
|
|
833
|
+
const base = options.style.path ? `${options.style.path}.${index}` : void 0;
|
|
834
|
+
return { index, level, value: text, descriptionValue: description, textPath: base ? base + (object ? ".text" : "") : void 0, descriptionPath: base ? base + ".description" : void 0, runs: runs(text), descriptionRuns: description === void 0 ? void 0 : runs(description) };
|
|
835
|
+
});
|
|
836
|
+
const layout = (fontSize) => {
|
|
837
|
+
let y = box.y, overflow = false;
|
|
838
|
+
const entries = [], lines = [];
|
|
839
|
+
for (const item of source) {
|
|
840
|
+
const indent = fontSize * 1.1, offset = Math.min(box.width, item.level * indent), x = box.x + offset + indent, width = Math.max(1, box.x + box.width - x);
|
|
841
|
+
if (offset + indent >= box.width) overflow = true;
|
|
842
|
+
const textBox = { x, y, width, height: box.height };
|
|
843
|
+
const style = { ...options.style, path: item.textPath };
|
|
844
|
+
const text = richTextLayouter(item.runs, textBox, requestedSize, { ...options, style })(fontSize);
|
|
845
|
+
textBox.height = text.height;
|
|
846
|
+
lines.push(...text.lines);
|
|
847
|
+
y += text.height;
|
|
848
|
+
let description, descriptionBox;
|
|
849
|
+
if (item.descriptionRuns !== void 0) {
|
|
850
|
+
y += fontSize * 0.12;
|
|
851
|
+
descriptionBox = { x, y, width, height: box.height };
|
|
852
|
+
description = richTextLayouter(item.descriptionRuns, descriptionBox, requestedSize * 0.82, { ...options, style: { ...options.style, path: item.descriptionPath } })(fontSize * 0.82);
|
|
853
|
+
descriptionBox.height = description.height;
|
|
854
|
+
lines.push(...description.lines);
|
|
855
|
+
y += description.height;
|
|
856
|
+
}
|
|
857
|
+
overflow ||= text.overflow || !!description?.overflow;
|
|
858
|
+
entries.push({
|
|
859
|
+
index: item.index,
|
|
860
|
+
level: item.level,
|
|
861
|
+
value: item.value,
|
|
862
|
+
descriptionValue: item.descriptionValue,
|
|
863
|
+
textPath: item.textPath,
|
|
864
|
+
descriptionPath: item.descriptionPath,
|
|
865
|
+
text,
|
|
866
|
+
textBox,
|
|
867
|
+
description,
|
|
868
|
+
descriptionBox,
|
|
869
|
+
marker: { text: ["\u2022", "\u25E6", "\u25AA"][item.level % 3], x: box.x + offset, y: textBox.y + (text.richLines[0]?.baseline ?? fontSize), fontSize, style: resolveTextStyle(style, options.textMeasurement), indent }
|
|
870
|
+
});
|
|
871
|
+
if (item.index < source.length - 1) y += fontSize * 0.28;
|
|
872
|
+
}
|
|
873
|
+
const height = y - box.y;
|
|
874
|
+
return { listEntries: entries, lines, fontSize, lineHeight: fontSize * 1.22, height, overflow: overflow || height > box.height + 0.01 };
|
|
875
|
+
};
|
|
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);
|
|
880
|
+
}
|
|
881
|
+
return fitAtSizes(layout, requestedSize, minimum);
|
|
882
|
+
}
|
|
883
|
+
function contentText(field, value) {
|
|
884
|
+
if (field === "text" || headings.has(field)) return flatten(value);
|
|
885
|
+
if (field === "items" || field === "bullets") return (Array.isArray(value) ? value : [value]).map((item) => {
|
|
886
|
+
const entry = record2(item);
|
|
887
|
+
return `\u2022 ${flatten(item)}${entry.description ? `
|
|
888
|
+
${flatten(entry.description)}` : ""}`;
|
|
889
|
+
}).join("\n");
|
|
890
|
+
if (field === "code") return flatten(record2(value).source ?? value);
|
|
891
|
+
if (field === "quote") return flatten(record2(value).text ?? value);
|
|
892
|
+
return void 0;
|
|
893
|
+
}
|
|
894
|
+
function layoutTable(value, box, options = {}) {
|
|
895
|
+
const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale, requested = Math.max(15 * scale, minimum);
|
|
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.");
|
|
897
|
+
const grid = tableGrid(value, options.path ?? "table");
|
|
898
|
+
if (grid.issues.length) throw new RangeError(`${grid.issues[0].path}: ${grid.issues[0].message}`);
|
|
899
|
+
const columnCount = grid.columnCount, cellWidth = box.width / columnCount;
|
|
900
|
+
const cells = grid.rows.flat().map((cell) => {
|
|
901
|
+
const padding = { top: 8, right: 10, bottom: 4, left: 10, ...cell.style.padding };
|
|
902
|
+
const width = cellWidth * cell.colSpan - (padding.left + padding.right) * scale;
|
|
903
|
+
const style = { fontFamily: options.fontFamily ?? "sans-serif", fontWeight: cell.header ? 700 : 400, italic: false, path: cell.valuePath };
|
|
904
|
+
return { ...cell, padding, width, textStyle: style };
|
|
905
|
+
});
|
|
906
|
+
const cellMinimum = (cell) => Array.isArray(cell.value) ? richMinimum(cell.value, requested, minimum) : minimum;
|
|
907
|
+
const fitCell = (cell, height, min) => {
|
|
908
|
+
const textBox = { width: Math.max(scale, cell.width), height: Math.max(scale, height) };
|
|
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));
|
|
910
|
+
};
|
|
911
|
+
const textHeight = (fit) => "height" in fit ? fit.height : fit.lines.length * fit.lineHeight;
|
|
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
|
+
};
|
|
918
|
+
const natural = Array(grid.rowCount).fill(0), needed = Array(grid.rowCount).fill(0);
|
|
919
|
+
for (const cell of cells) if (cell.rowSpan === 1) {
|
|
920
|
+
natural[cell.row] = Math.max(natural[cell.row], required(cell, true));
|
|
921
|
+
needed[cell.row] = Math.max(needed[cell.row], required(cell, false));
|
|
922
|
+
}
|
|
923
|
+
for (const cell of cells) if (cell.rowSpan > 1) for (const [heights2, preferredSize] of [[natural, true], [needed, false]]) {
|
|
924
|
+
const current = heights2.slice(cell.row, cell.row + cell.rowSpan).reduce((a, b) => a + b, 0);
|
|
925
|
+
const extra = Math.max(0, required(cell, preferredSize) - current) / cell.rowSpan;
|
|
926
|
+
for (let r = cell.row; r < cell.row + cell.rowSpan; r++) heights2[r] += extra;
|
|
927
|
+
}
|
|
928
|
+
const preferred = natural.map((height) => Math.max(54 * scale, height));
|
|
929
|
+
for (let r = 0; r < needed.length; r++) needed[r] = Math.min(preferred[r], needed[r]);
|
|
930
|
+
const sum = (values) => values.reduce((a, b) => a + b, 0);
|
|
931
|
+
const preferredTotal = sum(preferred), naturalTotal = sum(natural), neededTotal = sum(needed);
|
|
932
|
+
const heights = preferred.map((height, r) => preferredTotal <= box.height ? height : naturalTotal <= box.height ? natural[r] + (height - natural[r]) * (box.height - naturalTotal) / Math.max(Number.EPSILON, preferredTotal - naturalTotal) : neededTotal <= box.height ? needed[r] + (natural[r] - needed[r]) * (box.height - neededTotal) / Math.max(Number.EPSILON, naturalTotal - neededTotal) : needed[r] * box.height / neededTotal);
|
|
933
|
+
const ys = [box.y];
|
|
934
|
+
for (const height of heights) ys.push(ys.at(-1) + height);
|
|
935
|
+
let overflow = false;
|
|
936
|
+
const rows2 = heights.map((height, r) => ({ box: { x: box.x, y: ys[r], width: box.width, height }, cells: [] }));
|
|
937
|
+
for (const cell of cells) {
|
|
938
|
+
const cellBox = { x: box.x + cell.column * cellWidth, y: ys[cell.row], width: cell.colSpan * cellWidth, height: ys[cell.row + cell.rowSpan] - ys[cell.row] };
|
|
939
|
+
const available = cellBox.height - (cell.padding.top + cell.padding.bottom) * scale;
|
|
940
|
+
const fit = fitCell(cell, available, minimum), height = textHeight(fit);
|
|
941
|
+
const offset = cell.style.verticalAlign === "bottom" ? Math.max(0, available - height) : cell.style.verticalAlign === "middle" ? Math.max(0, (available - height) / 2) : 0;
|
|
942
|
+
const textBox = { x: cellBox.x + cell.padding.left * scale, y: cellBox.y + cell.padding.top * scale + offset, width: Math.max(scale, cell.width), height: Math.max(scale, available - offset) };
|
|
943
|
+
overflow ||= cell.width <= 0 || available <= 0 || fit.overflow;
|
|
944
|
+
rows2[cell.row].cells.push({ value: cell.value, input: cell.input, path: cell.valuePath, sourcePath: cell.path, style: cell.style, row: cell.row, column: cell.column, rowSpan: cell.rowSpan, colSpan: cell.colSpan, header: cell.header, rich: Array.isArray(cell.value), box: cellBox, textBox, textStyle: resolveTextStyle(cell.textStyle, options.textMeasurement), fit });
|
|
945
|
+
}
|
|
946
|
+
return { rows: rows2, columnCount, height: sum(heights), overflow };
|
|
947
|
+
}
|
|
948
|
+
function tableOverflows(value, box, scale, settings, options, path) {
|
|
949
|
+
return box.width <= 0 || box.height <= 0 || layoutTable(value, box, { scale, minFontSize: settings.minFontSize, fontFamily: options.fonts?.body, textMeasurement: options.textMeasurement, path }).overflow;
|
|
950
|
+
}
|
|
951
|
+
function flatten(value) {
|
|
952
|
+
if (value == null) return "";
|
|
953
|
+
if (typeof value !== "object") return String(value);
|
|
954
|
+
if (Array.isArray(value)) return value.map(flatten).join("");
|
|
955
|
+
const item = record2(value);
|
|
956
|
+
return flatten(item.text ?? item.value ?? item.source ?? JSON.stringify(value));
|
|
957
|
+
}
|
|
958
|
+
function regionParts(key) {
|
|
959
|
+
const span = (part, order) => {
|
|
960
|
+
const indices = part.split("+").map((value) => order.indexOf(value));
|
|
961
|
+
return indices.every((index) => index >= 0) ? indices : void 0;
|
|
962
|
+
};
|
|
963
|
+
const parts = key.split(":");
|
|
964
|
+
if (parts.length === 2) {
|
|
965
|
+
const r = span(parts[0], rows), c = span(parts[1], columns);
|
|
966
|
+
if (r && c) return [r, c];
|
|
967
|
+
} else if (parts.length === 1) {
|
|
968
|
+
const r = span(key, rows), c = span(key, columns);
|
|
969
|
+
if (r) return [r, [0, 1, 2]];
|
|
970
|
+
if (c) return [[0, 1, 2], c];
|
|
971
|
+
}
|
|
972
|
+
return void 0;
|
|
973
|
+
}
|
|
974
|
+
function regionBox(parts, area, requestedGap) {
|
|
975
|
+
const gap = Math.min(requestedGap, area.width / 6, area.height / 6);
|
|
976
|
+
const cw = (area.width - gap * 2) / 3, ch = (area.height - gap * 2) / 3;
|
|
977
|
+
const [r, c] = parts;
|
|
978
|
+
return { x: area.x + Math.min(...c) * (cw + gap), y: area.y + Math.min(...r) * (ch + gap), width: (Math.max(...c) - Math.min(...c) + 1) * (cw + gap) - gap, height: (Math.max(...r) - Math.min(...r) + 1) * (ch + gap) - gap };
|
|
979
|
+
}
|
|
980
|
+
function gridGeometry(count, area, cols, gap, weights, vertical) {
|
|
981
|
+
const rowCount = Math.ceil(count / cols);
|
|
982
|
+
gap = Math.min(gap, area.width / (cols * 2), area.height / (rowCount * 2));
|
|
983
|
+
const tracks = (length, total, weighted) => {
|
|
984
|
+
const values = Array.from({ length }, (_, i) => weighted ? weights[i] ?? 1 : 1);
|
|
985
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
986
|
+
let cursor = 0;
|
|
987
|
+
return values.map((weight) => {
|
|
988
|
+
const size = (total - gap * (length - 1)) * weight / sum;
|
|
989
|
+
const track = { offset: cursor, size };
|
|
990
|
+
cursor += size + gap;
|
|
991
|
+
return track;
|
|
992
|
+
});
|
|
993
|
+
};
|
|
994
|
+
const xs = tracks(cols, area.width, !vertical), ys = tracks(rowCount, area.height, vertical);
|
|
995
|
+
const boxes = Array.from({ length: count }, (_, i) => ({ x: area.x + xs[i % cols].offset, y: area.y + ys[Math.floor(i / cols)].offset, width: xs[i % cols].size, height: ys[Math.floor(i / cols)].size }));
|
|
996
|
+
return { boxes, columns: xs, rows: ys, gap };
|
|
997
|
+
}
|
|
998
|
+
function gridBoxes(count, area, cols, gap, weights, vertical) {
|
|
999
|
+
return gridGeometry(count, area, cols, gap, weights, vertical).boxes;
|
|
1000
|
+
}
|
|
1001
|
+
function assertComposition(value) {
|
|
1002
|
+
if (value.mode !== void 0 && !["auto", "grid", "row", "column"].includes(value.mode)) throw new RangeError("Unknown composition mode.");
|
|
1003
|
+
for (const [key, min, max] of [["gap", 0, 0.1], ["padding", 0, 0.2], ["minFontSize", 8, 32], ["columns", 1, 12]]) {
|
|
1004
|
+
const number = value[key];
|
|
1005
|
+
if (number !== void 0 && (!Number.isFinite(number) || number < min || number > max || key === "columns" && !Number.isInteger(number))) throw new RangeError(`Invalid composition.${key}.`);
|
|
1006
|
+
}
|
|
1007
|
+
if (value.weights !== void 0 && (!Array.isArray(value.weights) || !value.weights.length || value.weights.length > 12 || value.weights.some((weight) => !Number.isFinite(weight) || weight <= 0 || weight > 100))) throw new RangeError("Invalid composition.weights.");
|
|
1008
|
+
if (value.overflow !== void 0 && !["warn", "error"].includes(value.overflow)) throw new RangeError("Invalid composition.overflow.");
|
|
1009
|
+
}
|
|
1010
|
+
function composeSlide(input, options = {}) {
|
|
1011
|
+
const slide = record2(input), layout = record2(options.layout);
|
|
1012
|
+
const width = options.width ?? 1280, height = options.height ?? 720;
|
|
1013
|
+
if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Canvas dimensions must be finite and positive.");
|
|
1014
|
+
const scale = Math.min(width, height) / 720;
|
|
1015
|
+
const hasCards = record2(slide.design).contentBox ?? options.contentBox ?? false;
|
|
1016
|
+
const composition = { ...record2(layout.composition), ...record2(slide.composition) };
|
|
1017
|
+
assertComposition(composition);
|
|
1018
|
+
const padding = (composition.padding ?? 0.08) * Math.min(width, height);
|
|
1019
|
+
const gap = (composition.gap ?? 1 / 30) * Math.min(width, height);
|
|
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.");
|
|
1023
|
+
const styleFor = (field, path2) => resolveTextStyle({
|
|
1024
|
+
fontFamily: (field === "title" ? options.fonts?.heading : field === "code" ? options.fonts?.code : options.fonts?.body) ?? (field === "code" ? "monospace" : "sans-serif"),
|
|
1025
|
+
fontWeight: field === "title" ? 700 : 400,
|
|
1026
|
+
path: path2
|
|
1027
|
+
}, options.textMeasurement);
|
|
1028
|
+
const widthFor = (field, path2) => textWidthMeasurer(styleFor(field, path2), options.textMeasurement);
|
|
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));
|
|
1079
|
+
const path = `slides.${options.slideIndex ?? 0}`;
|
|
1080
|
+
const items = [], diagnostics = [];
|
|
1081
|
+
let y = padding;
|
|
1082
|
+
for (const field of ["tag", "title", "subtitle"]) {
|
|
1083
|
+
if (!slide[field]) continue;
|
|
1084
|
+
const requested = (field === "title" ? 54 : field === "tag" ? 16 : 25) * scale;
|
|
1085
|
+
const maxHeight = Math.max(height * (field === "title" ? 0.26 : field === "subtitle" ? 0.12 : 0.045), minSize * 1.22 + 2 * rasterPadding);
|
|
1086
|
+
const box = { x: padding, y, width: width - padding * 2, height: maxHeight };
|
|
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));
|
|
1089
|
+
items.push({ path: `${path}.${field}`, field, type: "text", value: slide[field], payload: { text: slide[field] }, box, text, textStyle: styleFor(field, `${path}.${field}`), composition });
|
|
1090
|
+
y += box.height + gap * 0.5;
|
|
1091
|
+
}
|
|
1092
|
+
if (items.length) y += gap * 0.5;
|
|
1093
|
+
const contentBox = { x: padding, y, width: width - padding * 2, height: Math.max(scale, height - padding - y) };
|
|
1094
|
+
const collect = (host, basePath, depth = 0, ancestors = []) => {
|
|
1095
|
+
if (Array.isArray(host.blocks)) {
|
|
1096
|
+
if (depth >= MAX_COMPOSITION_DEPTH || ancestors.includes(host)) throw new RangeError(`Content groups must be acyclic and nest at most ${MAX_COMPOSITION_DEPTH} levels.`);
|
|
1097
|
+
const settings = record2(host.composition);
|
|
1098
|
+
assertComposition(settings);
|
|
1099
|
+
return [{
|
|
1100
|
+
field: "blocks",
|
|
1101
|
+
type: "group",
|
|
1102
|
+
value: host.blocks,
|
|
1103
|
+
path: basePath,
|
|
1104
|
+
payload: host,
|
|
1105
|
+
composition: settings,
|
|
1106
|
+
children: host.blocks.flatMap((block, index) => collect(record2(block), `${basePath}.blocks.${index}`, depth + 1, [...ancestors, host]))
|
|
1107
|
+
}];
|
|
1108
|
+
}
|
|
1109
|
+
return fields.filter((field) => host[field] !== void 0).map((field) => ({ field, type: host.type ?? kind(field), value: host[field], path: `${basePath}.${field}`, payload: { type: host.type ?? kind(field), [field]: host[field] } }));
|
|
1110
|
+
};
|
|
1111
|
+
const regions = Object.keys(slide).filter((key) => regionParts(key)).sort();
|
|
1112
|
+
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);
|
|
1113
|
+
const groups = [], flows = [];
|
|
1114
|
+
const decisions = options.explain ? [] : void 0;
|
|
1115
|
+
const inheritedSettings = (parent, own = {}) => ({
|
|
1116
|
+
minFontSize: parent.minFontSize,
|
|
1117
|
+
...own,
|
|
1118
|
+
overflow: parent.overflow === "error" ? "error" : own.overflow ?? parent.overflow
|
|
1119
|
+
});
|
|
1120
|
+
const inset = (box, settings) => {
|
|
1121
|
+
const amount = (settings.padding ?? 0) * Math.min(box.width, box.height);
|
|
1122
|
+
return { x: box.x + amount, y: box.y + amount, width: box.width - amount * 2, height: box.height - amount * 2 };
|
|
1123
|
+
};
|
|
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
|
+
};
|
|
1130
|
+
const measureQuote = (node, box, settings) => layoutQuote(node.value, acceptedBox(box), {
|
|
1131
|
+
fonts: options.fonts,
|
|
1132
|
+
textMeasurement: options.textMeasurement,
|
|
1133
|
+
scale,
|
|
1134
|
+
minFontSize: settings.minFontSize,
|
|
1135
|
+
path: node.path
|
|
1136
|
+
});
|
|
1137
|
+
const measureCode = (node, box, settings) => layoutCode(node.value, acceptedBox(box), {
|
|
1138
|
+
fonts: options.fonts,
|
|
1139
|
+
textMeasurement: options.textMeasurement,
|
|
1140
|
+
scale,
|
|
1141
|
+
minFontSize: settings.minFontSize,
|
|
1142
|
+
path: node.path
|
|
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
|
+
});
|
|
1161
|
+
const leafScore = (node, box, settings, penalties) => {
|
|
1162
|
+
box = payloadBox(box);
|
|
1163
|
+
const text = contentText(node.field, node.value);
|
|
1164
|
+
let score = Math.abs(Math.log(box.width / box.height / 1.6));
|
|
1165
|
+
if (penalties) penalties.cellProportions += score;
|
|
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);
|
|
1168
|
+
const reduction = internal.parts.reduce((sum, part) => sum + (part.fit ? (part.requestedFontSize - part.fit.fontSize) / scale : 0), 0);
|
|
1169
|
+
score += reduction + (internal.overflow ? 1e3 : 0);
|
|
1170
|
+
if (penalties) {
|
|
1171
|
+
penalties.fontReduction += reduction;
|
|
1172
|
+
penalties.textOverflow += internal.overflow ? 1e3 : 0;
|
|
1173
|
+
}
|
|
1174
|
+
} else if (text) {
|
|
1175
|
+
const fit = fitContent(node.field, node.value, text, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path);
|
|
1176
|
+
const reduction = Math.max(0, 25 * scale - fit.fontSize) / scale;
|
|
1177
|
+
score += reduction + (fit.overflow ? 1e3 : 0);
|
|
1178
|
+
if (penalties) {
|
|
1179
|
+
penalties.fontReduction += reduction;
|
|
1180
|
+
penalties.textOverflow += fit.overflow ? 1e3 : 0;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (node.field === "table" && tableOverflows(node.value, box, scale, settings, options, node.path)) {
|
|
1184
|
+
score += 1e3;
|
|
1185
|
+
if (penalties) penalties.tableOverflow += 1e3;
|
|
1186
|
+
}
|
|
1187
|
+
if (box.width < 100 * scale || box.height < 60 * scale) {
|
|
1188
|
+
score += 100;
|
|
1189
|
+
if (penalties) penalties.smallCells += 100;
|
|
1190
|
+
}
|
|
1191
|
+
return score;
|
|
1192
|
+
};
|
|
1193
|
+
const modeFor = (settings) => settings.mode ?? "auto";
|
|
1194
|
+
const defaultColumns = (count, area, settings) => {
|
|
1195
|
+
const mode = modeFor(settings);
|
|
1196
|
+
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))));
|
|
1197
|
+
};
|
|
1198
|
+
const scoreNode = (node, box, settings, penalties) => {
|
|
1199
|
+
if (!node.children) return leafScore(node, box, settings, penalties);
|
|
1200
|
+
const own = inheritedSettings(settings, node.composition), area = inset(box, own);
|
|
1201
|
+
const cols = defaultColumns(node.children.length, area, own);
|
|
1202
|
+
const boxes = gridBoxes(node.children.length, area, cols, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), own.weights ?? [], modeFor(own) === "column");
|
|
1203
|
+
return node.children.reduce((score, child, index) => score + scoreNode(child, boxes[index], own, penalties), 0);
|
|
1204
|
+
};
|
|
1205
|
+
const arrange = (nodes, area, settings, reserved = 0, gapOverride, containerPath = path) => {
|
|
1206
|
+
const count = Math.max(nodes.length, reserved);
|
|
1207
|
+
if (!count) return;
|
|
1208
|
+
const mode = modeFor(settings), localGap = (settings.gap ?? 1 / 30) * Math.min(area.width, area.height);
|
|
1209
|
+
const actualGap = gapOverride ?? (settings === rootSettings ? gap : localGap);
|
|
1210
|
+
let cols = defaultColumns(count, area, settings);
|
|
1211
|
+
const hasRegions = nodes.some((node) => node.region);
|
|
1212
|
+
const candidates = decisions ? [] : void 0;
|
|
1213
|
+
if (mode === "auto" && !hasRegions) {
|
|
1214
|
+
let best = Infinity;
|
|
1215
|
+
for (let candidate = 1; candidate <= Math.min(count, settings.columns ?? 6); candidate++) {
|
|
1216
|
+
const boxes2 = gridBoxes(count, area, candidate, actualGap, settings.weights ?? [], false);
|
|
1217
|
+
const emptySlots = (Math.ceil(count / candidate) * candidate - count) * 2;
|
|
1218
|
+
const penalties = candidates ? { cellProportions: 0, fontReduction: 0, textOverflow: 0, tableOverflow: 0, smallCells: 0, emptySlots } : void 0;
|
|
1219
|
+
const score = nodes.reduce((sum, node, i) => sum + scoreNode(node, boxes2[i], settings, penalties), 0) + emptySlots;
|
|
1220
|
+
if (penalties && candidates) candidates.push({ columns: candidate, rows: Math.ceil(count / candidate), score, penalties });
|
|
1221
|
+
if (score < best) {
|
|
1222
|
+
best = score;
|
|
1223
|
+
cols = candidate;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
decisions?.push({
|
|
1228
|
+
path: containerPath,
|
|
1229
|
+
mode: hasRegions ? "regions" : mode,
|
|
1230
|
+
reason: hasRegions ? "promoted-regions" : mode === "auto" ? "lowest-score" : "configured-mode",
|
|
1231
|
+
...hasRegions ? {} : { selectedColumns: cols },
|
|
1232
|
+
candidates: candidates ?? []
|
|
1233
|
+
});
|
|
1234
|
+
const grid = gridGeometry(count, area, cols, actualGap, settings.weights ?? [], mode === "column");
|
|
1235
|
+
const boxes = grid.boxes;
|
|
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 });
|
|
1237
|
+
nodes.forEach((node, index) => {
|
|
1238
|
+
let box = node.region ? regionBox(node.region, area, actualGap) : boxes[index];
|
|
1239
|
+
if (node.children) {
|
|
1240
|
+
const own = inheritedSettings(settings, node.composition), inner = inset(box, own);
|
|
1241
|
+
groups.push({ path: node.path, box, contentBox: inner, composition: own });
|
|
1242
|
+
arrange(node.children, inner, own, 0, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), node.path);
|
|
1243
|
+
} else {
|
|
1244
|
+
const frameBox = hasCards ? acceptedBox(box) : void 0;
|
|
1245
|
+
box = payloadBox(box);
|
|
1246
|
+
const textValue = contentText(node.field, node.value);
|
|
1247
|
+
const quoteLayout = node.field === "quote" ? measureQuote(node, box, settings) : void 0;
|
|
1248
|
+
const codeLayout = node.field === "code" ? measureCode(node, box, settings) : void 0;
|
|
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");
|
|
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;
|
|
1253
|
+
items.push({
|
|
1254
|
+
path: node.path,
|
|
1255
|
+
field: node.field,
|
|
1256
|
+
type: node.type,
|
|
1257
|
+
value: node.value,
|
|
1258
|
+
payload: node.payload,
|
|
1259
|
+
box: internal ? acceptedBox(box) : box,
|
|
1260
|
+
...frameBox ? { frameBox } : {},
|
|
1261
|
+
text,
|
|
1262
|
+
textStyle: body?.style ?? styleFor(node.field, node.path),
|
|
1263
|
+
composition: settings,
|
|
1264
|
+
...quoteLayout ? { quoteLayout } : {},
|
|
1265
|
+
...codeLayout ? { codeLayout } : {},
|
|
1266
|
+
...metricLayout ? { metricLayout } : {},
|
|
1267
|
+
...timelineLayout ? { timelineLayout } : {}
|
|
1268
|
+
});
|
|
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." });
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
};
|
|
1273
|
+
const placeholders = Array.isArray(layout.placeholders) ? layout.placeholders.filter((p) => !headings.has(p.type)) : [];
|
|
1274
|
+
const rootSettings = { ...composition, mode: composition.mode ?? (layout.slideLayoutDirection === "Vertical" ? "column" : layout.slideLayoutDirection === "Horizontal" ? "row" : "auto") };
|
|
1275
|
+
arrange(pending, contentBox, rootSettings, composition.mode ? 0 : placeholders.length);
|
|
1276
|
+
for (const item of items) {
|
|
1277
|
+
for (const key of ["x", "y", "width", "height"]) item.box[key] = round(item.box[key]);
|
|
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." });
|
|
1279
|
+
if (item.quoteLayout) diagnostics.push(...item.quoteLayout.diagnostics);
|
|
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);
|
|
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." });
|
|
1284
|
+
}
|
|
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]);
|
|
1286
|
+
const strictPaths = new Set(items.filter((item) => item.composition.overflow === "error").map((item) => item.path));
|
|
1287
|
+
const failures = diagnostics.filter((diagnostic) => {
|
|
1288
|
+
let path2 = diagnostic.path;
|
|
1289
|
+
while (path2) {
|
|
1290
|
+
if (strictPaths.has(path2)) return true;
|
|
1291
|
+
const boundary = path2.lastIndexOf(".");
|
|
1292
|
+
if (boundary < 0) break;
|
|
1293
|
+
path2 = path2.slice(0, boundary);
|
|
1294
|
+
}
|
|
1295
|
+
return false;
|
|
1296
|
+
});
|
|
1297
|
+
const explanation = decisions ? {
|
|
1298
|
+
algorithm: "grid-score-v8",
|
|
1299
|
+
textMeasurement: options.textMeasurement ? "provided" : "estimated",
|
|
1300
|
+
textOutlines: options.textMeasurement?.outlineBounds ? "provided" : "unavailable",
|
|
1301
|
+
textRasterPadding: rasterPadding,
|
|
1302
|
+
decisions,
|
|
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)
|
|
1304
|
+
} : void 0;
|
|
1305
|
+
if (failures.length) throw new OPFCompositionError(failures, explanation);
|
|
1306
|
+
return { width, height, contentBox, items, groups, flows, diagnostics, composition, ...explanation ? { explanation } : {} };
|
|
1307
|
+
}
|
|
1308
|
+
function resolveCanvasDimensions(input) {
|
|
1309
|
+
const presets = {
|
|
1310
|
+
widescreen: [40 / 3, 7.5],
|
|
1311
|
+
"16:9": [40 / 3, 7.5],
|
|
1312
|
+
standard: [10, 7.5],
|
|
1313
|
+
"4:3": [10, 7.5],
|
|
1314
|
+
"16:10": [10, 6.25],
|
|
1315
|
+
letter: [11, 8.5],
|
|
1316
|
+
a4: [11.69, 8.27]
|
|
1317
|
+
};
|
|
1318
|
+
const value = record2(input);
|
|
1319
|
+
const preset = presets[typeof input === "string" ? input : value.preset] ?? presets.widescreen;
|
|
1320
|
+
const width = (value.widthInches ?? preset[0]) * 96;
|
|
1321
|
+
const height = (value.heightInches ?? preset[1]) * 96;
|
|
1322
|
+
if (![width, height].every((n) => Number.isFinite(n) && n > 0)) throw new RangeError("Canvas dimensions must be finite and positive.");
|
|
1323
|
+
return { width, height };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
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 };
|