@openpresentation/opf 0.9.0 → 0.10.1
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-DoVmvDr7.d.ts} +6 -6
- package/dist/catalogs.d.ts +2 -2
- package/dist/catalogs.js +1 -1
- package/dist/{chunk-GQSK3T7X.js → chunk-D3GQIREP.js} +13 -4
- package/dist/chunk-EWJNRHXA.js +595 -0
- package/dist/{chunk-3GPZMY2W.js → chunk-FHGJX5QK.js} +24 -12
- package/dist/{chunk-JA5W5TVA.js → chunk-PQJRDNA6.js} +624 -73
- package/dist/{chunk-OZULJBSG.js → chunk-RS5GZ5RW.js} +14 -0
- package/dist/{chunk-TWMRZ43O.js → chunk-TU7I3KSB.js} +59 -21
- package/dist/{chunk-ZGV6I23P.js → chunk-ZIL7MGZU.js} +50 -5
- package/dist/composition.d.ts +257 -10
- package/dist/composition.js +1 -1
- package/dist/docs.js +58 -10
- package/dist/examples.js +2284 -1417
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7 -6
- package/dist/lint.d.ts +70 -0
- package/dist/lint.js +5 -0
- package/dist/pagination.d.ts +4 -0
- package/dist/pagination.js +5 -5
- package/dist/{schemas--QRVBhU1.d.ts → schemas-BYe5y8-i.d.ts} +1 -1
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.js +1 -1
- package/dist/spec/catalogs/layouts/extract/canonical.json +45 -21
- package/dist/spec/catalogs/layouts/index.json +10 -0
- package/dist/spec/catalogs/layouts/number-1x.json +1 -1
- package/dist/spec/catalogs/layouts/number-2x.json +2 -2
- package/dist/spec/catalogs/layouts/number-3x.json +3 -3
- package/dist/spec/catalogs/layouts/number-4x.json +4 -4
- package/dist/spec/catalogs/layouts/number-5x.json +5 -5
- package/dist/spec/catalogs/layouts/number-6x.json +6 -6
- package/dist/spec/catalogs/layouts/quote-1x.json +13 -0
- package/dist/spec/catalogs/layouts/timeline-1x.json +13 -0
- package/dist/spec/schemas/layout.schema.json +12 -3
- package/dist/spec/schemas/narrative.schema.json +1 -1
- package/dist/spec-files.d.ts +1 -1
- package/dist/spec-files.js +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/validator.d.ts +2 -2
- package/dist/validator.js +4 -4
- package/package.json +11 -6
|
@@ -35,6 +35,45 @@ function tableRowBoundaries(value) {
|
|
|
35
35
|
return [...allowed];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// src/color.ts
|
|
39
|
+
function luminance(value) {
|
|
40
|
+
if (typeof value !== "string") return void 0;
|
|
41
|
+
let hex = value.trim();
|
|
42
|
+
if (/^#[0-9a-f]{3}$/i.test(hex)) hex = `#${[...hex.slice(1)].map((c) => c + c).join("")}`;
|
|
43
|
+
if (/^#[0-9a-f]{6}ff$/i.test(hex)) hex = hex.slice(0, 7);
|
|
44
|
+
if (!/^#[0-9a-f]{6}$/i.test(hex)) return void 0;
|
|
45
|
+
return [0.2126, 0.7152, 0.0722].reduce((sum, weight, index) => {
|
|
46
|
+
const offset = index * 2 + 1;
|
|
47
|
+
const n = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
|
48
|
+
return sum + weight * (n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4);
|
|
49
|
+
}, 0);
|
|
50
|
+
}
|
|
51
|
+
function colorContrast(foreground, background) {
|
|
52
|
+
const a = luminance(foreground), b = luminance(background);
|
|
53
|
+
return a === void 0 || b === void 0 ? void 0 : (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
54
|
+
}
|
|
55
|
+
function textColorForFill(fill, preferred) {
|
|
56
|
+
const background = luminance(fill), contrast = colorContrast(preferred, fill);
|
|
57
|
+
if (background === void 0 || contrast === void 0 || contrast >= 4.5) return preferred;
|
|
58
|
+
return (background + 0.05) / 0.05 >= 1.05 / (background + 0.05) ? "#000000" : "#FFFFFF";
|
|
59
|
+
}
|
|
60
|
+
function chartColorForFill(fill, preferred) {
|
|
61
|
+
const contrast = colorContrast(preferred, fill);
|
|
62
|
+
if (contrast === void 0 || contrast >= 3) return preferred;
|
|
63
|
+
let hex = preferred.trim();
|
|
64
|
+
if (hex.length === 4) hex = `#${[...hex.slice(1)].map((c) => c + c).join("")}`;
|
|
65
|
+
const channels = [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16));
|
|
66
|
+
for (let step = 1; step <= 255; step++) {
|
|
67
|
+
const candidates = [0, 255].map((target) => {
|
|
68
|
+
const color = `#${channels.map((channel) => Math.round(channel + (target - channel) * step / 255).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
|
69
|
+
return { color, contrast: colorContrast(color, fill) ?? 0 };
|
|
70
|
+
}).sort((a, b) => b.contrast - a.contrast);
|
|
71
|
+
const best = candidates[0];
|
|
72
|
+
if (best && best.contrast >= 3) return best.color;
|
|
73
|
+
}
|
|
74
|
+
return textColorForFill(fill, preferred);
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
// src/composition.ts
|
|
39
78
|
var MAX_COMPOSITION_DEPTH = 32;
|
|
40
79
|
function resolveFontFamilies(input) {
|
|
@@ -57,6 +96,40 @@ function textWidthMeasurer(style, measurement) {
|
|
|
57
96
|
return width;
|
|
58
97
|
};
|
|
59
98
|
}
|
|
99
|
+
function measureTextOutline(text, fontSize, style, measurement) {
|
|
100
|
+
if (measurement?.outlineBounds === void 0) return void 0;
|
|
101
|
+
if (typeof measurement.outlineBounds !== "function") throw new TypeError("Text outline provider must be a function.");
|
|
102
|
+
const bounds = measurement.outlineBounds(text, fontSize, style);
|
|
103
|
+
if (bounds === null) return null;
|
|
104
|
+
if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) || bounds.width < 0 || bounds.height < 0) {
|
|
105
|
+
throw new RangeError("Text outline measurement must return null or finite bounds with nonnegative dimensions.");
|
|
106
|
+
}
|
|
107
|
+
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
|
|
108
|
+
}
|
|
109
|
+
function placeTextLines(lines, box, alignment = "left", rasterPadding = 0) {
|
|
110
|
+
if (!Array.isArray(lines) || !box || ![box.x, box.y, box.width, box.height, rasterPadding].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || rasterPadding < 0 || !["left", "center", "right"].includes(alignment)) throw new RangeError("Text placement requires lines, finite positive dimensions, nonnegative padding and a valid alignment.");
|
|
111
|
+
const factor = alignment === "right" ? 1 : alignment === "center" ? 0.5 : 0, placed = [];
|
|
112
|
+
let shift = 0, bottom = box.y, height = 0, overflow = false;
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
if (!line || ![line.width, line.y, line.baseline, line.height].every(Number.isFinite) || line.width < 0 || line.height <= 0 || line.y < 0 || line.baseline < line.y) throw new RangeError("Text lines require finite coordinates, nonnegative advances and a baseline at or below their top.");
|
|
115
|
+
const ink = line.outline;
|
|
116
|
+
if (ink !== null && (!ink || ![ink.x, ink.y, ink.width, ink.height].every(Number.isFinite) || ink.width < 0 || ink.height < 0)) throw new RangeError("Text outlines must be null or finite coordinates with nonnegative dimensions.");
|
|
117
|
+
let x = box.x + (box.width - line.width) * factor;
|
|
118
|
+
if (ink) {
|
|
119
|
+
const low = box.x + rasterPadding - ink.x, high = box.x + box.width - rasterPadding - ink.x - ink.width;
|
|
120
|
+
if (low > high + 0.01) overflow = true;
|
|
121
|
+
else x = Math.max(low, Math.min(x, high));
|
|
122
|
+
shift += Math.max(0, bottom + rasterPadding - (box.y + line.baseline + shift + ink.y));
|
|
123
|
+
}
|
|
124
|
+
const y = box.y + line.y + shift, baseline = box.y + line.baseline + shift;
|
|
125
|
+
const outline = ink ? { ...ink, x: x + ink.x, y: baseline + ink.y } : null;
|
|
126
|
+
if (outline) bottom = outline.y + outline.height + rasterPadding;
|
|
127
|
+
height = Math.max(height, y + line.height - box.y, outline ? bottom - box.y : 0);
|
|
128
|
+
if (line.width > box.width + 0.01 || height > box.height + 0.01) overflow = true;
|
|
129
|
+
placed.push({ x, y, baseline, height: line.height, width: line.width, outline });
|
|
130
|
+
}
|
|
131
|
+
return { alignment, rasterPadding, lines: placed, height, overflow };
|
|
132
|
+
}
|
|
60
133
|
var OPFCompositionError = class extends Error {
|
|
61
134
|
constructor(diagnostics, explanation) {
|
|
62
135
|
super("Slide content does not fit its composition.");
|
|
@@ -73,8 +146,105 @@ var headings = /* @__PURE__ */ new Set(["title", "subtitle", "tag"]);
|
|
|
73
146
|
var rows = ["top", "middle", "bottom"];
|
|
74
147
|
var columns = ["left", "center", "right"];
|
|
75
148
|
var record2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
76
|
-
var kind = (field) => field === "items"
|
|
149
|
+
var kind = (field) => field === "items" ? "list" : field === "bullets" ? "text" : field;
|
|
77
150
|
var round = (value) => Math.round(value * 1e6) / 1e6 || value;
|
|
151
|
+
function layoutFurniture(input, options = {}) {
|
|
152
|
+
const slide = record2(input), width = options.width ?? 1280, height = options.height ?? 720, scale = Math.min(width, height) / 720;
|
|
153
|
+
const settings = { ...record2(record2(options.layout).composition), ...record2(slide.composition) };
|
|
154
|
+
assertComposition(settings);
|
|
155
|
+
const minimum = (settings.minFontSize ?? 16) * scale, size = Math.max(13 * scale, minimum), padding = (options.textRasterPadding ?? 2) * scale;
|
|
156
|
+
if (![width, height, scale, minimum, padding].every(Number.isFinite) || width <= 0 || height <= 0 || minimum <= 0 || padding < 0) throw new RangeError("Furniture requires finite positive dimensions and nonnegative raster padding.");
|
|
157
|
+
const number = options.slideNumber ?? (options.slideIndex ?? 0) + 1;
|
|
158
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new RangeError("Displayed slide number must be a positive safe integer.");
|
|
159
|
+
const outlines = options.textMeasurement?.outlineBounds !== void 0, parts = [], diagnostics = [];
|
|
160
|
+
const sourceRoot = `slides.${options.slideIndex ?? 0}`, organizations = Array.isArray(options.presentation?.organization) ? options.presentation.organization : [options.presentation?.organization];
|
|
161
|
+
const primaryIndex = organizations.findIndex((item) => record2(item).role === "primary"), organizationIndex = primaryIndex >= 0 ? primaryIndex : organizations.findIndex(Boolean);
|
|
162
|
+
const organization = record2(organizations[organizationIndex]), organizationPath = Array.isArray(options.presentation?.organization) ? `organization.${organizationIndex}.name` : "organization.name";
|
|
163
|
+
const fontFamily = options.fonts?.body ?? "sans-serif";
|
|
164
|
+
let headerBottom = 0, footerTop = height, configured = false;
|
|
165
|
+
const error = (path, message, code = "text-overflow") => diagnostics.push({ code, path, message });
|
|
166
|
+
for (const kind2 of ["header", "footer"]) {
|
|
167
|
+
const local = record2(slide.design)[kind2] !== void 0, value = local ? record2(slide.design)[kind2] : options.presentation?.design?.[kind2];
|
|
168
|
+
if (value === void 0 || value === false) continue;
|
|
169
|
+
const root = local ? `${sourceRoot}.design.${kind2}` : `design.${kind2}`;
|
|
170
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("Header/footer content must be an object or false.");
|
|
171
|
+
configured = true;
|
|
172
|
+
const zones = [];
|
|
173
|
+
for (const [index, zone] of ["left", "center", "right"].entries()) {
|
|
174
|
+
const content = record2(record2(value)[zone]), path = `${root}.${zone}`, x = width * (0.07 + index * 0.3), zoneWidth = width * 0.26, zoneParts = [];
|
|
175
|
+
let y = 0;
|
|
176
|
+
const add = (field, text, generated = false, sourcePath) => {
|
|
177
|
+
if (text === void 0) return;
|
|
178
|
+
if (typeof text !== "string") throw new TypeError(`Furniture field ${path}.${field} requires string content.`);
|
|
179
|
+
const partPath = `${path}.${field}`, style = resolveTextStyle({ fontFamily, fontWeight: 400, italic: false, path: partPath }, options.textMeasurement);
|
|
180
|
+
const fit = fitText(text, { width: Math.max(Number.MIN_VALUE, zoneWidth - (outlines ? 2 * padding : 0)), height: Number.MAX_VALUE }, size, size, textWidthMeasurer(style, options.textMeasurement));
|
|
181
|
+
const ink = fit.sourceLines.map((line, index2) => {
|
|
182
|
+
let outline = null;
|
|
183
|
+
if (outlines) {
|
|
184
|
+
for (const segment of line.segments) if (segment.kind === "text") {
|
|
185
|
+
const bounds = measureTextOutline(text.slice(segment.start, segment.end), size, style, options.textMeasurement);
|
|
186
|
+
if (bounds) {
|
|
187
|
+
const next = { ...bounds, x: bounds.x + segment.x };
|
|
188
|
+
if (!outline) outline = next;
|
|
189
|
+
else {
|
|
190
|
+
const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
|
|
191
|
+
outline.x = Math.min(outline.x, next.x);
|
|
192
|
+
outline.y = Math.min(outline.y, next.y);
|
|
193
|
+
outline.width = right - outline.x;
|
|
194
|
+
outline.height = bottom - outline.y;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { width: line.width, y: index2 * fit.lineHeight, baseline: size + index2 * fit.lineHeight, height: fit.lineHeight, outline };
|
|
200
|
+
});
|
|
201
|
+
const natural = outlines ? placeTextLines(ink, { x, y, width: zoneWidth, height: Number.MAX_VALUE }, zone, padding) : void 0;
|
|
202
|
+
const partHeight = natural?.height ?? fit.sourceLines.length * fit.lineHeight;
|
|
203
|
+
if (!Number.isFinite(partHeight)) throw new RangeError("Furniture exceeds finite layout coordinates.");
|
|
204
|
+
const box = { x, y, width: zoneWidth, height: Math.max(scale, partHeight) }, placement = outlines ? placeTextLines(ink, box, zone, padding) : void 0;
|
|
205
|
+
const accepted = { ...fit, ...placement ? { placement } : {}, overflow: fit.overflow || !!placement?.overflow };
|
|
206
|
+
zoneParts.push({ type: "text", kind: kind2, zone, field, path: partPath, sourcePath: sourcePath ?? (!generated ? partPath : void 0), generated, text, style, requestedFontSize: 13 * scale, minFontSize: minimum, box, alignment: zone, fit: accepted });
|
|
207
|
+
if (accepted.overflow) error(partPath, "Repeated text exceeds its zone at the selected readability floor; change the furniture or slide design.");
|
|
208
|
+
y += box.height;
|
|
209
|
+
};
|
|
210
|
+
if (content.image !== void 0) {
|
|
211
|
+
const box = { x, y, width: zoneWidth, height: Math.max(32 * scale, Math.min(height * 0.05, 72 * scale)) };
|
|
212
|
+
zoneParts.push({ type: "image", kind: kind2, zone, field: "image", path: `${path}.image`, sourcePath: `${path}.image`, generated: false, image: content.image, box, alignment: zone });
|
|
213
|
+
y += box.height;
|
|
214
|
+
}
|
|
215
|
+
add("text", content.text);
|
|
216
|
+
if (content.organization === true) {
|
|
217
|
+
if (typeof organization.name === "string") add("organization", organization.name, true, organizationPath);
|
|
218
|
+
else error(`${path}.organization`, "Generated organization name needs a named organization in the presentation.", "unresolved-content");
|
|
219
|
+
}
|
|
220
|
+
if (content.section === true) {
|
|
221
|
+
if (typeof slide.section === "string") add("section", slide.section, true, `${sourceRoot}.section`);
|
|
222
|
+
else error(`${path}.section`, "Generated section needs a literal slide section.", "unresolved-content");
|
|
223
|
+
}
|
|
224
|
+
if (content.slideNumber === true) add("slideNumber", String(number), true);
|
|
225
|
+
if (content.date === true) error(`${path}.date`, "Use a literal date string for deterministic header/footer content.", "unresolved-content");
|
|
226
|
+
else if (typeof content.date === "string") add("date", content.date);
|
|
227
|
+
zones.push(zoneParts);
|
|
228
|
+
}
|
|
229
|
+
const tallest = Math.max(0, ...zones.map((zone) => zone.reduce((sum, part) => sum + part.box.height, 0)));
|
|
230
|
+
if (!tallest) continue;
|
|
231
|
+
const top = kind2 === "header" ? height * 0.025 : Math.max(height * 0.025, height - height * 0.04 - tallest);
|
|
232
|
+
if (kind2 === "header") headerBottom = top + tallest;
|
|
233
|
+
else footerTop = top;
|
|
234
|
+
for (const zone of zones) for (const part of zone) {
|
|
235
|
+
part.box.y += top;
|
|
236
|
+
if (part.type === "text" && part.fit.placement) for (const line of part.fit.placement.lines) {
|
|
237
|
+
line.y += top;
|
|
238
|
+
line.baseline += top;
|
|
239
|
+
if (line.outline) line.outline.y += top;
|
|
240
|
+
}
|
|
241
|
+
if (part.box.y + part.box.height > height - height * 0.04 + 0.01) error(part.path, "Repeated header/footer content extends beyond the usable slide height. Change its content or design before pagination.");
|
|
242
|
+
parts.push(part);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (headerBottom > footerTop + 0.01) error(parts.find((part) => part.kind === "footer")?.path ?? sourceRoot, "Header and footer content overlap; repeated content cannot be repaired by splitting body content.");
|
|
246
|
+
return { algorithm: "furniture-flow-v2", configured, textMeasurement: options.textMeasurement ? "provided" : "estimated", textOutlines: outlines ? "provided" : "unavailable", parts, headerBottom, footerTop, diagnostics, overflow: diagnostics.length > 0 };
|
|
247
|
+
}
|
|
78
248
|
var textSegments = new Intl.Segmenter("und", { granularity: "grapheme" });
|
|
79
249
|
function measureText(text, fontSize) {
|
|
80
250
|
let units = 0;
|
|
@@ -85,42 +255,22 @@ function measureText(text, fontSize) {
|
|
|
85
255
|
return units * fontSize;
|
|
86
256
|
}
|
|
87
257
|
function wrapText(text, width, fontSize, measure = measureText) {
|
|
88
|
-
|
|
89
|
-
for (const paragraph of text.replace(/\r\n?/g, "\n").split("\n")) {
|
|
90
|
-
let line = "";
|
|
91
|
-
for (const word of paragraph.split(/\s+/u).filter(Boolean)) {
|
|
92
|
-
if (line && measure(`${line} ${word}`, fontSize) > width) {
|
|
93
|
-
result.push(line);
|
|
94
|
-
line = "";
|
|
95
|
-
}
|
|
96
|
-
if (measure(word, fontSize) <= width) {
|
|
97
|
-
line += (line ? " " : "") + word;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
for (const { segment: character } of textSegments.segment(word)) {
|
|
101
|
-
if (line && measure(line + character, fontSize) > width) {
|
|
102
|
-
result.push(line);
|
|
103
|
-
line = "";
|
|
104
|
-
}
|
|
105
|
-
line += character;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
result.push(line);
|
|
109
|
-
}
|
|
110
|
-
return result;
|
|
258
|
+
return fitSourceText(text, { width, height: Number.MAX_VALUE }, fontSize, fontSize, 1, measure, true).lines;
|
|
111
259
|
}
|
|
112
260
|
function fitText(text, box, requestedSize = 25, minFontSize = 16, measure = measureText) {
|
|
113
261
|
if (![box.width, box.height, requestedSize, minFontSize].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || requestedSize <= 0 || minFontSize <= 0) {
|
|
114
262
|
throw new RangeError("Text dimensions and font sizes must be finite and positive.");
|
|
115
263
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
264
|
+
return fitSourceText(text, box, Math.max(requestedSize, minFontSize), minFontSize, 1, measure, true);
|
|
265
|
+
}
|
|
266
|
+
function fitAtSizes(layout, requested, minimum, step = 1) {
|
|
267
|
+
const start = Math.max(requested, minimum);
|
|
268
|
+
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.");
|
|
269
|
+
for (let trial = 0; trial <= 64; trial++) {
|
|
270
|
+
const size = trial === 64 ? minimum : Math.max(minimum, start - trial * step), result = layout(size);
|
|
271
|
+
if (!result.overflow || size === minimum) return result;
|
|
122
272
|
}
|
|
123
|
-
|
|
273
|
+
throw new Error("Text fitting did not evaluate its bounded floor trial.");
|
|
124
274
|
}
|
|
125
275
|
function layoutQuote(value, box, options = {}) {
|
|
126
276
|
const shorthand = typeof value === "string";
|
|
@@ -226,10 +376,308 @@ function layoutQuote(value, box, options = {}) {
|
|
|
226
376
|
if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
|
|
227
377
|
return { algorithm: "quote-flow-v1", textMeasurement: options.textMeasurement ? "provided" : "estimated", parts, diagnostics, overflow: diagnostics.length > 0 };
|
|
228
378
|
}
|
|
379
|
+
function layoutMetric(value, box, options = {}) {
|
|
380
|
+
const scalar = typeof value === "string" || typeof value === "number";
|
|
381
|
+
const metric = scalar ? { value } : value;
|
|
382
|
+
const isValue = (input) => typeof input === "string" || typeof input === "number" && Number.isFinite(input);
|
|
383
|
+
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)) {
|
|
384
|
+
throw new TypeError("Metric content requires a finite numeric or string value and schema-valid display metadata.");
|
|
385
|
+
}
|
|
386
|
+
const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale;
|
|
387
|
+
const rasterPadding = (options.textRasterPadding ?? 1) * scale, hasOutlines = options.textMeasurement?.outlineBounds !== void 0;
|
|
388
|
+
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) {
|
|
389
|
+
throw new RangeError("Metric dimensions, scale and minimum font size must be finite and positive.");
|
|
390
|
+
}
|
|
391
|
+
if (!Number.isFinite(rasterPadding) || rasterPadding < 0) throw new RangeError("Metric raster padding must be finite and nonnegative.");
|
|
392
|
+
if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid metric overflow policy.");
|
|
393
|
+
const alignment = options.align ?? "left";
|
|
394
|
+
if (!["left", "center", "right"].includes(alignment)) throw new RangeError("Invalid metric alignment.");
|
|
395
|
+
const sourcePath = options.path ?? "metric", parts = [], diagnostics = [];
|
|
396
|
+
for (const role of ["value", "unit", "label", "description", "delta", "trend"]) {
|
|
397
|
+
const sourceValue = metric[role];
|
|
398
|
+
if (sourceValue === void 0) continue;
|
|
399
|
+
const text = String(sourceValue), path = scalar ? sourcePath : `${sourcePath}.${role}`;
|
|
400
|
+
const nominal = role === "value" ? Math.min(76 * scale, box.height * 0.28) : (role === "description" ? 20 : role === "trend" ? 18 : 23) * scale;
|
|
401
|
+
const requestedStyle = { fontFamily: (role === "value" ? options.fonts?.heading : options.fonts?.body) ?? "sans-serif", fontWeight: role === "value" ? 800 : role === "description" ? 400 : 500, italic: false, path };
|
|
402
|
+
const part = {
|
|
403
|
+
role,
|
|
404
|
+
path,
|
|
405
|
+
text,
|
|
406
|
+
sources: [{ path, value: sourceValue, start: 0, end: text.length }],
|
|
407
|
+
visible: role === "value" || text.length > 0,
|
|
408
|
+
linePositions: [],
|
|
409
|
+
box: { ...box, height: 0 },
|
|
410
|
+
requestedFontSize: Math.max(nominal, minimum),
|
|
411
|
+
minFontSize: minimum,
|
|
412
|
+
requestedStyle,
|
|
413
|
+
style: resolveTextStyle({ ...requestedStyle }, options.textMeasurement)
|
|
414
|
+
};
|
|
415
|
+
if (!part.visible) part.fit = { lines: [], sourceLines: [], fontSize: part.requestedFontSize, lineHeight: part.requestedFontSize * 1.22, tabSize: 4, tabWidth: 0, overflow: false };
|
|
416
|
+
parts.push(part);
|
|
417
|
+
}
|
|
418
|
+
const primary = parts[0], metadata = parts.filter((part) => part !== primary && part.visible), unit = metadata.find((part) => part.role === "unit");
|
|
419
|
+
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;
|
|
420
|
+
const occupied = (fit) => fit.placement?.height ?? fit.lines.length * fit.lineHeight;
|
|
421
|
+
const gap = 8 * scale, primaryGap = 12 * scale;
|
|
422
|
+
const lineInk = (part, fit) => fit.sourceLines.map((line, index) => {
|
|
423
|
+
let outline = null;
|
|
424
|
+
for (const segment of line.segments) {
|
|
425
|
+
if (segment.kind !== "text") continue;
|
|
426
|
+
const bounds = measureTextOutline(part.text.slice(segment.start, segment.end), fit.fontSize, part.style, options.textMeasurement);
|
|
427
|
+
if (!bounds) continue;
|
|
428
|
+
const next = { ...bounds, x: segment.x + bounds.x };
|
|
429
|
+
if (!outline) outline = next;
|
|
430
|
+
else {
|
|
431
|
+
const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
|
|
432
|
+
outline.x = Math.min(outline.x, next.x);
|
|
433
|
+
outline.y = Math.min(outline.y, next.y);
|
|
434
|
+
outline.width = right - outline.x;
|
|
435
|
+
outline.height = bottom - outline.y;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { width: line.width, y: index * fit.lineHeight, baseline: fit.fontSize + index * fit.lineHeight, height: fit.lineHeight, outline };
|
|
439
|
+
});
|
|
440
|
+
const place = (part, fit, width) => {
|
|
441
|
+
if (!hasOutlines) return fit;
|
|
442
|
+
const placement = placeTextLines(lineInk(part, fit), { x: 0, y: 0, width, height: Number.MAX_VALUE }, alignment, rasterPadding);
|
|
443
|
+
return { ...fit, placement, overflow: placement.overflow };
|
|
444
|
+
};
|
|
445
|
+
const naturalWidth = (part, fit) => {
|
|
446
|
+
if (!hasOutlines) return Math.max(...fit.sourceLines.map((line) => line.width));
|
|
447
|
+
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)));
|
|
448
|
+
return width > 0 ? width + 2 * rasterPadding : 0;
|
|
449
|
+
};
|
|
450
|
+
const cache = /* @__PURE__ */ new Map();
|
|
451
|
+
const measure = (part, size, width) => {
|
|
452
|
+
const key = JSON.stringify([part.role, size, width]);
|
|
453
|
+
let fit = cache.get(key);
|
|
454
|
+
if (!fit) {
|
|
455
|
+
fit = fitCodeText(part.text, { ...box, width: hasOutlines ? Math.max(Number.MIN_VALUE, width - 2 * rasterPadding) : width }, size, size, scale, textWidthMeasurer(part.style, options.textMeasurement));
|
|
456
|
+
fit = place(part, fit, width);
|
|
457
|
+
if (!Number.isFinite(occupied(fit))) throw new RangeError("Metric text layout exceeds finite coordinates.");
|
|
458
|
+
cache.set(key, fit);
|
|
459
|
+
}
|
|
460
|
+
return fit;
|
|
461
|
+
};
|
|
462
|
+
const fitValue = (area, singleLine) => {
|
|
463
|
+
if (!usable(area)) return void 0;
|
|
464
|
+
for (let step = 0; step <= 76; step++) {
|
|
465
|
+
const size = Math.max(minimum, primary.requestedFontSize - step * scale);
|
|
466
|
+
const natural = measure(primary, size, area.width);
|
|
467
|
+
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) };
|
|
468
|
+
if (!fit.overflow || size === minimum) return fit;
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
let selected, attempts = 0;
|
|
472
|
+
const reductions = Math.min(23, Math.ceil(Math.max(0, ...metadata.map((part) => (part.requestedFontSize - minimum) / scale))));
|
|
473
|
+
for (let reduction = 0; reduction <= reductions; reduction++) {
|
|
474
|
+
const measured = new Map(metadata.map((part) => [part, measure(part, Math.max(minimum, part.requestedFontSize - reduction * scale), box.width)]));
|
|
475
|
+
const measuredUnit = unit ? measured.get(unit) : void 0;
|
|
476
|
+
const unitWidth = unit && measuredUnit ? naturalWidth(unit, measuredUnit) : 0;
|
|
477
|
+
const unitFit = unit && measuredUnit && unitWidth > 0 ? place(unit, measuredUnit, unitWidth) : measuredUnit;
|
|
478
|
+
const inline = unitFit?.lines.length === 1 && unitWidth > 0 && unitWidth <= box.width * 0.35 && unitWidth + gap < box.width;
|
|
479
|
+
for (const arrangement of inline ? ["inline-unit", "stacked"] : ["stacked"]) {
|
|
480
|
+
attempts++;
|
|
481
|
+
const tail = metadata.filter((part) => arrangement !== "inline-unit" || part !== unit);
|
|
482
|
+
const tailHeight = tail.reduce((sum, part) => sum + occupied(measured.get(part)), 0) + Math.max(0, tail.length - 1) * gap;
|
|
483
|
+
if (!Number.isFinite(tailHeight)) throw new RangeError("Metric metadata exceeds finite coordinates.");
|
|
484
|
+
const area = { ...box, width: arrangement === "inline-unit" ? box.width - unitWidth - gap : box.width, height: box.height - tailHeight - (tail.length ? primaryGap : 0) };
|
|
485
|
+
const valueFit = fitValue(area, arrangement === "inline-unit"), valueHeight = valueFit ? occupied(valueFit) : 0;
|
|
486
|
+
const allocations = [];
|
|
487
|
+
let primaryHeight = valueHeight;
|
|
488
|
+
if (arrangement === "inline-unit" && unit && unitFit && valueFit) {
|
|
489
|
+
const valueBaseline = valueFit.placement?.lines.at(-1)?.baseline ?? valueHeight - valueFit.lineHeight + valueFit.fontSize;
|
|
490
|
+
const unitBaseline = unitFit.placement?.lines.at(-1)?.baseline ?? occupied(unitFit) - unitFit.lineHeight + unitFit.fontSize;
|
|
491
|
+
const valueY = box.y + Math.max(0, unitBaseline - valueBaseline), unitY = box.y + Math.max(0, valueBaseline - unitBaseline);
|
|
492
|
+
const measuredWidth = naturalWidth(primary, valueFit);
|
|
493
|
+
const valueWidth = Math.min(area.width, measuredWidth || valueFit.fontSize);
|
|
494
|
+
allocations.push({ part: primary, box: { ...area, y: valueY, width: valueWidth, height: valueHeight }, fit: place(primary, valueFit, valueWidth) });
|
|
495
|
+
allocations.push({ part: unit, box: { x: box.x + valueWidth + gap, y: unitY, width: unitWidth, height: occupied(unitFit) }, fit: { ...unitFit, overflow: !!unitFit.placement?.overflow } });
|
|
496
|
+
primaryHeight = Math.max(valueY - box.y + valueHeight, unitY - box.y + occupied(unitFit));
|
|
497
|
+
} else allocations.push({ part: primary, box: valueFit ? { ...area, height: valueHeight } : area, fit: valueFit });
|
|
498
|
+
let y = box.y + primaryHeight + (tail.length ? primaryGap : 0);
|
|
499
|
+
for (const part of tail) {
|
|
500
|
+
const natural = measured.get(part), height = occupied(natural);
|
|
501
|
+
allocations.push({ part, box: { ...box, y, height }, fit: { ...natural, overflow: !!natural.placement?.overflow || natural.sourceLines.some((line) => line.width > box.width + 0.01) } });
|
|
502
|
+
y += height + gap;
|
|
503
|
+
}
|
|
504
|
+
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);
|
|
505
|
+
const score = allocations.reduce((sum, { part, fit }) => sum + (part.requestedFontSize - (fit?.fontSize ?? minimum)) / scale, 0);
|
|
506
|
+
const candidate = { arrangement, allocations, score, overflow };
|
|
507
|
+
if (!selected || !overflow && (selected.overflow || score < selected.score) || overflow && selected.overflow) selected = candidate;
|
|
508
|
+
}
|
|
509
|
+
if (selected && !selected.overflow && selected.score === 0) break;
|
|
510
|
+
}
|
|
511
|
+
for (const allocation of selected.allocations) {
|
|
512
|
+
allocation.part.box = allocation.box;
|
|
513
|
+
allocation.part.fit = allocation.fit;
|
|
514
|
+
}
|
|
515
|
+
const alignmentFactor = alignment === "center" ? 0.5 : alignment === "right" ? 1 : 0;
|
|
516
|
+
if (selected.arrangement === "inline-unit" && unit) {
|
|
517
|
+
const offset = (box.width - (unit.box.x + unit.box.width - box.x)) * alignmentFactor;
|
|
518
|
+
primary.box.x += offset;
|
|
519
|
+
unit.box.x += offset;
|
|
520
|
+
}
|
|
521
|
+
for (const part of parts) if (part.fit) {
|
|
522
|
+
if (part.fit.placement) {
|
|
523
|
+
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 })) } };
|
|
524
|
+
part.linePositions = part.fit.placement.lines.map(({ x, baseline }) => ({ x, baseline }));
|
|
525
|
+
} else part.linePositions = part.fit.sourceLines.map((line, index) => ({
|
|
526
|
+
x: part.box.x + (part.box.width - line.width) * alignmentFactor,
|
|
527
|
+
baseline: part.box.y + part.fit.fontSize + index * part.fit.lineHeight
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
const report = (reason, part, roles, message) => diagnostics.push({ code: "text-overflow", reason, path: part.path, parts: roles, message });
|
|
531
|
+
for (const part of parts.filter((part2) => part2.visible)) {
|
|
532
|
+
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.`);
|
|
533
|
+
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.`);
|
|
534
|
+
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)) {
|
|
535
|
+
report("part-outside-cell", part, [part.role], `Metric ${part.role} extends outside its cell; increase the cell or change the arrangement.`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const visible = parts.filter((part) => part.visible && part.fit);
|
|
539
|
+
for (const [index, first] of visible.entries()) for (const second of visible.slice(index + 1)) {
|
|
540
|
+
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) {
|
|
541
|
+
report("part-overlap", first, [first.role, second.role], "Metric part line boxes overlap; do not accept this layout without more space.");
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (diagnostics.length && options.overflow === "error") throw new OPFCompositionError(diagnostics);
|
|
545
|
+
return { algorithm: "metric-flow-v1", alignment, textMeasurement: options.textMeasurement ? "provided" : "estimated", arrangement: selected.arrangement, attempts, parts, diagnostics, overflow: diagnostics.length > 0 };
|
|
546
|
+
}
|
|
547
|
+
function layoutTimeline(value, box, options = {}) {
|
|
548
|
+
const shorthand = Array.isArray(value), timeline = shorthand ? { events: value } : value;
|
|
549
|
+
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"))) {
|
|
550
|
+
throw new TypeError("Timeline content requires ordered events with string labels and optional string metadata.");
|
|
551
|
+
}
|
|
552
|
+
const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale, padding = (options.textRasterPadding ?? 1) * scale;
|
|
553
|
+
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.");
|
|
554
|
+
if (options.overflow !== void 0 && !["warn", "error"].includes(options.overflow)) throw new RangeError("Invalid timeline overflow policy.");
|
|
555
|
+
const outlines = options.textMeasurement?.outlineBounds !== void 0;
|
|
556
|
+
if (outlines && typeof options.textMeasurement?.outlineBounds !== "function") throw new TypeError("Text outline provider must be a function.");
|
|
557
|
+
const path = options.path ?? "timeline", eventPath = (index) => shorthand ? `${path}.${index}` : `${path}.events.${index}`;
|
|
558
|
+
const fonts = resolveFontFamilies(options.fonts), source = [];
|
|
559
|
+
const add = (role, text, partPath, size, weight, eventIndex) => {
|
|
560
|
+
if (text === void 0) return;
|
|
561
|
+
const requestedStyle = { fontFamily: fonts.body, fontWeight: weight, italic: false, path: partPath };
|
|
562
|
+
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) });
|
|
563
|
+
};
|
|
564
|
+
add("name", timeline.name, `${path}.name`, 24, 700);
|
|
565
|
+
add("description", timeline.description, `${path}.description`, 18, 400);
|
|
566
|
+
timeline.events.forEach((event, index) => {
|
|
567
|
+
add("when", event.when, `${eventPath(index)}.when`, 16, 500, index);
|
|
568
|
+
add("what", event.what, `${eventPath(index)}.what`, 16, 500, index);
|
|
569
|
+
add("event-description", event.description, `${eventPath(index)}.description`, 16, 500, index);
|
|
570
|
+
});
|
|
571
|
+
const metadata = [], events = timeline.events.map(() => []);
|
|
572
|
+
for (const part of source) if (part.eventIndex === void 0) metadata.push(part);
|
|
573
|
+
else events[part.eventIndex].push(part);
|
|
574
|
+
const cache = /* @__PURE__ */ new Map();
|
|
575
|
+
const measure = (part, size, width, alignment) => {
|
|
576
|
+
const key = JSON.stringify([part.path, size, width, alignment]);
|
|
577
|
+
let measured = cache.get(key);
|
|
578
|
+
if (measured) return measured;
|
|
579
|
+
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));
|
|
580
|
+
const ink = fit.sourceLines.map((line, index) => {
|
|
581
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity, hasInk = false;
|
|
582
|
+
if (outlines) {
|
|
583
|
+
for (const segment of line.segments) if (segment.kind === "text") {
|
|
584
|
+
const outline = measureTextOutline(part.text.slice(segment.start, segment.end), size, part.style, options.textMeasurement);
|
|
585
|
+
if (outline) {
|
|
586
|
+
hasInk = true;
|
|
587
|
+
left = Math.min(left, outline.x + segment.x);
|
|
588
|
+
top = Math.min(top, outline.y);
|
|
589
|
+
right = Math.max(right, outline.x + segment.x + outline.width);
|
|
590
|
+
bottom = Math.max(bottom, outline.y + outline.height);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
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 };
|
|
595
|
+
});
|
|
596
|
+
const placement = outlines ? placeTextLines(ink, { x: 0, y: 0, width, height: Number.MAX_VALUE }, alignment, padding) : void 0;
|
|
597
|
+
const height = placement?.height ?? fit.sourceLines.length * fit.lineHeight;
|
|
598
|
+
if (!Number.isFinite(height)) throw new RangeError("Timeline text exceeds finite layout coordinates.");
|
|
599
|
+
measured = { fit, ink, height };
|
|
600
|
+
cache.set(key, measured);
|
|
601
|
+
return measured;
|
|
602
|
+
};
|
|
603
|
+
let selected, attempts = 0;
|
|
604
|
+
let reductionLimit = 0;
|
|
605
|
+
for (const part of source) reductionLimit = Math.max(reductionLimit, (part.requestedFontSize - minimum) / scale);
|
|
606
|
+
const reductions = Math.min(24, Math.ceil(reductionLimit));
|
|
607
|
+
for (let reduction = 0; reduction <= reductions; reduction++) {
|
|
608
|
+
for (const arrangement of ["alternating", "vertical"]) {
|
|
609
|
+
attempts++;
|
|
610
|
+
const parts = [], markers = [], diagnostics = [];
|
|
611
|
+
let score2 = 0;
|
|
612
|
+
const report = (part, reason, message, excess = 1) => {
|
|
613
|
+
diagnostics.push({ code: "text-overflow", reason, path: part.path, message });
|
|
614
|
+
score2 += Math.max(1, excess);
|
|
615
|
+
};
|
|
616
|
+
const place = (part, x, y2, width, alignment) => {
|
|
617
|
+
const measured = measure(part, Math.max(minimum, part.requestedFontSize - reduction * scale), Math.max(scale, width), alignment);
|
|
618
|
+
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;
|
|
619
|
+
const fit = { ...measured.fit, ...placement ? { placement } : {}, overflow: measured.fit.overflow || !!placement?.overflow };
|
|
620
|
+
const accepted = { ...part, box: area, alignment, fit };
|
|
621
|
+
parts.push(accepted);
|
|
622
|
+
if (fit.overflow) report(accepted, "text-fit", "Timeline field exceeds its readable width; increase its space or split the timeline.");
|
|
623
|
+
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));
|
|
624
|
+
return accepted;
|
|
625
|
+
};
|
|
626
|
+
let y = box.y;
|
|
627
|
+
for (const part of metadata) {
|
|
628
|
+
const placed = place(part, box.x, y, box.width, "center");
|
|
629
|
+
y += placed.box.height + 8 * scale;
|
|
630
|
+
}
|
|
631
|
+
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);
|
|
632
|
+
if (arrangement === "alternating") {
|
|
633
|
+
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;
|
|
634
|
+
const lineY = y + Math.max(scale, available) * 0.46;
|
|
635
|
+
events.forEach((fields2, index) => {
|
|
636
|
+
const x = start + index * step, top = index % 2 === 0 ? y : lineY + 24 * scale, bottom = index % 2 === 0 ? lineY - 24 * scale : box.y + box.height;
|
|
637
|
+
markers.push({ path: eventPath(index), eventIndex: index, x, y: lineY, radius });
|
|
638
|
+
let cursor = top;
|
|
639
|
+
for (const part of fields2) {
|
|
640
|
+
const placed = place(part, x - width / 2, cursor, width, "center");
|
|
641
|
+
cursor += placed.box.height;
|
|
642
|
+
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);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
} else {
|
|
646
|
+
const x = box.x + radius, textX = box.x + 24 * scale, width = box.width - 24 * scale;
|
|
647
|
+
events.forEach((fields2, index) => {
|
|
648
|
+
const top = y;
|
|
649
|
+
let first2;
|
|
650
|
+
for (const part of fields2) {
|
|
651
|
+
const placed = place(part, textX, y, width, "left");
|
|
652
|
+
first2 ??= placed;
|
|
653
|
+
y += placed.box.height;
|
|
654
|
+
}
|
|
655
|
+
markers.push({ path: eventPath(index), eventIndex: index, x, y: top + Math.min(first2?.box.height ?? 2 * radius, 2 * radius) / 2, radius });
|
|
656
|
+
y += 16 * scale;
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
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) {
|
|
660
|
+
diagnostics.push({ code: "text-overflow", reason: "event-space", path: marker.path, message: "Timeline marker has no usable space; increase the cell or paginate events." });
|
|
661
|
+
score2 += 1;
|
|
662
|
+
}
|
|
663
|
+
const first = markers[0], last = markers.at(-1), connector = { x1: first.x, y1: first.y, x2: last.x, y2: last.y };
|
|
664
|
+
const candidate = { arrangement, parts, markers, connector, diagnostics, score: score2 };
|
|
665
|
+
if (!selected || score2 < selected.score) selected = candidate;
|
|
666
|
+
if (!diagnostics.length) break;
|
|
667
|
+
}
|
|
668
|
+
if (!selected.diagnostics.length) break;
|
|
669
|
+
}
|
|
670
|
+
const { score, ...result } = selected;
|
|
671
|
+
if (result.diagnostics.length && options.overflow === "error") throw new OPFCompositionError(result.diagnostics);
|
|
672
|
+
return { algorithm: "timeline-flow-v1", attempts, textMeasurement: options.textMeasurement ? "provided" : "estimated", textOutlines: outlines ? "provided" : "unavailable", ...result, overflow: result.diagnostics.length > 0 };
|
|
673
|
+
}
|
|
229
674
|
function fitCodeText(text, box, size, minimum, step, measure) {
|
|
675
|
+
return fitSourceText(text, box, size, minimum, step, measure);
|
|
676
|
+
}
|
|
677
|
+
function fitSourceText(text, box, size, minimum, step, measure, prose = false) {
|
|
230
678
|
const layout = (fontSize) => {
|
|
231
679
|
const tabWidth = measure(" ", fontSize) * 4;
|
|
232
|
-
if (!Number.isFinite(tabWidth) || text.includes(" ") && tabWidth <= 0) throw new RangeError("
|
|
680
|
+
if (!Number.isFinite(tabWidth) || text.includes(" ") && tabWidth <= 0) throw new RangeError("Text tabs require a positive finite measured space advance.");
|
|
233
681
|
const measureLine = (start2, end2, record3 = false) => {
|
|
234
682
|
let x = 0, offset = start2;
|
|
235
683
|
const segments = [];
|
|
@@ -257,7 +705,8 @@ function fitCodeText(text, box, size, minimum, step, measure) {
|
|
|
257
705
|
start = nextStart;
|
|
258
706
|
end = nextStart;
|
|
259
707
|
};
|
|
260
|
-
|
|
708
|
+
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;
|
|
709
|
+
for (const token of text.matchAll(tokens)) {
|
|
261
710
|
if (token.index === void 0) throw new Error("Missing code token source offset.");
|
|
262
711
|
const a = token.index, b = a + token[0].length;
|
|
263
712
|
if (/^[\r\n]/.test(token[0])) {
|
|
@@ -273,6 +722,10 @@ function fitCodeText(text, box, size, minimum, step, measure) {
|
|
|
273
722
|
end = b;
|
|
274
723
|
continue;
|
|
275
724
|
}
|
|
725
|
+
if (prose && /[\u00a0\u202f\u2060\ufeff]/u.test(token[0])) {
|
|
726
|
+
end = b;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
276
729
|
for (const segment of textSegments.segment(token[0])) {
|
|
277
730
|
const next = a + segment.index + segment.segment.length;
|
|
278
731
|
if (end > start && width(start, next) > box.width + 0.01) push(a + segment.index, a + segment.index, "soft");
|
|
@@ -291,12 +744,7 @@ function fitCodeText(text, box, size, minimum, step, measure) {
|
|
|
291
744
|
overflow: sourceLines.length * lineHeight > box.height + 0.01 || sourceLines.some((line) => line.width > box.width + 0.01)
|
|
292
745
|
};
|
|
293
746
|
};
|
|
294
|
-
|
|
295
|
-
while (result.overflow && size > minimum) {
|
|
296
|
-
size = Math.max(minimum, size - step);
|
|
297
|
-
result = layout(size);
|
|
298
|
-
}
|
|
299
|
-
return result;
|
|
747
|
+
return fitAtSizes(layout, size, minimum, step);
|
|
300
748
|
}
|
|
301
749
|
function layoutCode(value, box, options = {}) {
|
|
302
750
|
const shorthand = typeof value === "string", code = shorthand ? { source: value } : value;
|
|
@@ -376,13 +824,19 @@ function layoutCode(value, box, options = {}) {
|
|
|
376
824
|
}
|
|
377
825
|
function fitRichText(input, box, requestedSize = 25, minFontSize = 16, options = { style: { fontFamily: "sans-serif", fontWeight: 400 } }) {
|
|
378
826
|
if (![box.width, box.height, requestedSize, minFontSize].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Rich text dimensions and font sizes must be finite and positive.");
|
|
379
|
-
const layout = richTextLayouter(input, box, requestedSize, options)
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
827
|
+
const layout = richTextLayouter(input, box, requestedSize, options);
|
|
828
|
+
return fitAtSizes(layout, requestedSize, richMinimum(input, requestedSize, minFontSize));
|
|
829
|
+
}
|
|
830
|
+
function richMinimum(input, requested, minimum) {
|
|
831
|
+
let floor = Math.min(requested, minimum), visible = false;
|
|
832
|
+
for (const value of input) {
|
|
833
|
+
const run = typeof value === "string" ? { text: value } : value;
|
|
834
|
+
if (!run.text) continue;
|
|
835
|
+
const ratio = (run.fontSize === void 0 ? 1 : run.fontSize * 4 / 3 / requested) * (run.superscript || run.subscript ? 0.7 : 1);
|
|
836
|
+
floor = Math.max(floor, minimum / ratio);
|
|
837
|
+
visible = true;
|
|
384
838
|
}
|
|
385
|
-
return
|
|
839
|
+
return visible ? floor : minimum;
|
|
386
840
|
}
|
|
387
841
|
function richTextLayouter(input, box, requestedSize, options) {
|
|
388
842
|
let offset = 0;
|
|
@@ -516,13 +970,12 @@ function fitList(input, box, requestedSize = 25, minFontSize = 16, options = { s
|
|
|
516
970
|
const height = y - box.y;
|
|
517
971
|
return { listEntries: entries, lines, fontSize, lineHeight: fontSize * 1.22, height, overflow: overflow || height > box.height + 0.01 };
|
|
518
972
|
};
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
result = layout(size);
|
|
973
|
+
let minimum = minFontSize;
|
|
974
|
+
for (const item of source) {
|
|
975
|
+
minimum = Math.max(minimum, richMinimum(item.runs, requestedSize, minFontSize));
|
|
976
|
+
if (item.descriptionRuns !== void 0) minimum = Math.max(minimum, richMinimum(item.descriptionRuns, requestedSize * 0.82, minFontSize) / 0.82);
|
|
524
977
|
}
|
|
525
|
-
return
|
|
978
|
+
return fitAtSizes(layout, requestedSize, minimum);
|
|
526
979
|
}
|
|
527
980
|
function contentText(field, value) {
|
|
528
981
|
if (field === "text" || headings.has(field)) return flatten(value);
|
|
@@ -536,7 +989,7 @@ ${flatten(entry.description)}` : ""}`;
|
|
|
536
989
|
return void 0;
|
|
537
990
|
}
|
|
538
991
|
function layoutTable(value, box, options = {}) {
|
|
539
|
-
const scale = options.scale ?? 1,
|
|
992
|
+
const scale = options.scale ?? 1, minimum = (options.minFontSize ?? 16) * scale, requested = Math.max(15 * scale, minimum);
|
|
540
993
|
if (![box.x, box.y, box.width, box.height, scale, minimum].every(Number.isFinite) || box.width <= 0 || box.height <= 0 || scale <= 0 || minimum <= 0) throw new RangeError("Table dimensions, scale and font sizes must be finite and positive.");
|
|
541
994
|
const grid = tableGrid(value, options.path ?? "table");
|
|
542
995
|
if (grid.issues.length) throw new RangeError(`${grid.issues[0].path}: ${grid.issues[0].message}`);
|
|
@@ -547,20 +1000,26 @@ function layoutTable(value, box, options = {}) {
|
|
|
547
1000
|
const style = { fontFamily: options.fontFamily ?? "sans-serif", fontWeight: cell.header ? 700 : 400, italic: false, path: cell.valuePath };
|
|
548
1001
|
return { ...cell, padding, width, textStyle: style };
|
|
549
1002
|
});
|
|
1003
|
+
const cellMinimum = (cell) => Array.isArray(cell.value) ? richMinimum(cell.value, requested, minimum) : minimum;
|
|
550
1004
|
const fitCell = (cell, height, min) => {
|
|
551
1005
|
const textBox = { width: Math.max(scale, cell.width), height: Math.max(scale, height) };
|
|
552
1006
|
return Array.isArray(cell.value) ? fitRichText(cell.value, textBox, requested, min, { style: cell.textStyle, textMeasurement: options.textMeasurement, uniformLineHeight: true }) : fitText(flatten(cell.value), textBox, requested, min, textWidthMeasurer(resolveTextStyle(cell.textStyle, options.textMeasurement), options.textMeasurement));
|
|
553
1007
|
};
|
|
554
1008
|
const textHeight = (fit) => "height" in fit ? fit.height : fit.lines.length * fit.lineHeight;
|
|
555
|
-
const required = (cell,
|
|
1009
|
+
const required = (cell, natural2) => {
|
|
1010
|
+
const floor = cellMinimum(cell), size = natural2 ? Math.max(requested, floor) : floor;
|
|
1011
|
+
const textBox = { width: Math.max(scale, cell.width), height: scale };
|
|
1012
|
+
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));
|
|
1013
|
+
return textHeight(fit) + (cell.padding.top + cell.padding.bottom) * scale;
|
|
1014
|
+
};
|
|
556
1015
|
const natural = Array(grid.rowCount).fill(0), needed = Array(grid.rowCount).fill(0);
|
|
557
1016
|
for (const cell of cells) if (cell.rowSpan === 1) {
|
|
558
|
-
natural[cell.row] = Math.max(natural[cell.row], required(cell,
|
|
559
|
-
needed[cell.row] = Math.max(needed[cell.row], required(cell,
|
|
1017
|
+
natural[cell.row] = Math.max(natural[cell.row], required(cell, true));
|
|
1018
|
+
needed[cell.row] = Math.max(needed[cell.row], required(cell, false));
|
|
560
1019
|
}
|
|
561
|
-
for (const cell of cells) if (cell.rowSpan > 1) for (const [heights2,
|
|
1020
|
+
for (const cell of cells) if (cell.rowSpan > 1) for (const [heights2, preferredSize] of [[natural, true], [needed, false]]) {
|
|
562
1021
|
const current = heights2.slice(cell.row, cell.row + cell.rowSpan).reduce((a, b) => a + b, 0);
|
|
563
|
-
const extra = Math.max(0, required(cell,
|
|
1022
|
+
const extra = Math.max(0, required(cell, preferredSize) - current) / cell.rowSpan;
|
|
564
1023
|
for (let r = cell.row; r < cell.row + cell.rowSpan; r++) heights2[r] += extra;
|
|
565
1024
|
}
|
|
566
1025
|
const preferred = natural.map((height) => Math.max(54 * scale, height));
|
|
@@ -650,33 +1109,90 @@ function composeSlide(input, options = {}) {
|
|
|
650
1109
|
const width = options.width ?? 1280, height = options.height ?? 720;
|
|
651
1110
|
if (![width, height].every((value) => Number.isFinite(value) && value > 0)) throw new RangeError("Canvas dimensions must be finite and positive.");
|
|
652
1111
|
const scale = Math.min(width, height) / 720;
|
|
1112
|
+
const hasCards = record2(slide.design).contentBox ?? options.contentBox ?? false;
|
|
653
1113
|
const composition = { ...record2(layout.composition), ...record2(slide.composition) };
|
|
654
1114
|
assertComposition(composition);
|
|
655
1115
|
const padding = (composition.padding ?? 0.08) * Math.min(width, height);
|
|
656
1116
|
const gap = (composition.gap ?? 1 / 30) * Math.min(width, height);
|
|
657
1117
|
const minSize = (composition.minFontSize ?? 16) * scale;
|
|
1118
|
+
const rasterPadding = (options.textRasterPadding ?? 1) * scale;
|
|
1119
|
+
if (!Number.isFinite(rasterPadding) || rasterPadding < 0) throw new RangeError("Text raster padding must be finite and nonnegative.");
|
|
658
1120
|
const styleFor = (field, path2) => resolveTextStyle({
|
|
659
1121
|
fontFamily: (field === "title" ? options.fonts?.heading : field === "code" ? options.fonts?.code : options.fonts?.body) ?? (field === "code" ? "monospace" : "sans-serif"),
|
|
660
1122
|
fontWeight: field === "title" ? 700 : 400,
|
|
661
1123
|
path: path2
|
|
662
1124
|
}, options.textMeasurement);
|
|
663
1125
|
const widthFor = (field, path2) => textWidthMeasurer(styleFor(field, path2), options.textMeasurement);
|
|
664
|
-
const
|
|
1126
|
+
const fitPlacedText = (field, value, text, box, size, minimum, path2) => {
|
|
1127
|
+
const style = styleFor(field, path2), rich = field === "text" && Array.isArray(value);
|
|
1128
|
+
if (!options.textMeasurement?.outlineBounds) return rich ? fitRichText(value, box, size, minimum, { style, textMeasurement: options.textMeasurement }) : fitText(text, box, size, minimum, textWidthMeasurer(style, options.textMeasurement));
|
|
1129
|
+
const alignment = (field === "title" ? record2(slide.design).titleAlignment ?? options.titleAlignment : record2(slide.design).contentAlignment ?? options.contentAlignment) ?? "left";
|
|
1130
|
+
const richLayout = rich ? richTextLayouter(value, box, size, { style, textMeasurement: options.textMeasurement }) : void 0;
|
|
1131
|
+
const measure = textWidthMeasurer(style, options.textMeasurement), floor = rich ? richMinimum(value, size, minimum) : minimum, start = Math.max(size, floor);
|
|
1132
|
+
for (let trial = 0; trial <= 64; trial++) {
|
|
1133
|
+
const fontSize = trial === 64 ? floor : Math.max(floor, start - trial * scale);
|
|
1134
|
+
const fit = richLayout ? richLayout(fontSize) : fitText(text, box, fontSize, fontSize, measure);
|
|
1135
|
+
const richLines = "richLines" in fit ? fit.richLines : void 0;
|
|
1136
|
+
const lines = richLines ? richLines.map((line) => {
|
|
1137
|
+
let outline = null;
|
|
1138
|
+
for (const fragment of line.fragments) {
|
|
1139
|
+
const bounds = measureTextOutline(fragment.text, fragment.fontSize, fragment.style, options.textMeasurement);
|
|
1140
|
+
if (!bounds) continue;
|
|
1141
|
+
const next = { ...bounds, x: fragment.x + bounds.x, y: fragment.baselineShift + bounds.y };
|
|
1142
|
+
if (!outline) outline = next;
|
|
1143
|
+
else {
|
|
1144
|
+
const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
|
|
1145
|
+
outline.x = Math.min(outline.x, next.x);
|
|
1146
|
+
outline.y = Math.min(outline.y, next.y);
|
|
1147
|
+
outline.width = right - outline.x;
|
|
1148
|
+
outline.height = bottom - outline.y;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return { width: line.width, y: line.y, baseline: line.baseline, height: line.height, outline };
|
|
1152
|
+
}) : fit.sourceLines.map((line, index) => {
|
|
1153
|
+
let outline = null;
|
|
1154
|
+
for (const segment of line.segments) {
|
|
1155
|
+
if (segment.kind === "tab") continue;
|
|
1156
|
+
const bounds = measureTextOutline(text.slice(segment.start, segment.end), fontSize, style, options.textMeasurement);
|
|
1157
|
+
if (!bounds) continue;
|
|
1158
|
+
const next = { ...bounds, x: segment.x + bounds.x };
|
|
1159
|
+
if (!outline) outline = next;
|
|
1160
|
+
else {
|
|
1161
|
+
const right = Math.max(outline.x + outline.width, next.x + next.width), bottom = Math.max(outline.y + outline.height, next.y + next.height);
|
|
1162
|
+
outline.x = Math.min(outline.x, next.x);
|
|
1163
|
+
outline.y = Math.min(outline.y, next.y);
|
|
1164
|
+
outline.width = right - outline.x;
|
|
1165
|
+
outline.height = bottom - outline.y;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return { width: line.width, y: index * fit.lineHeight, baseline: fontSize + index * fit.lineHeight, height: fit.lineHeight, outline };
|
|
1169
|
+
});
|
|
1170
|
+
const placement = placeTextLines(lines, box, alignment, rasterPadding), result = { ...fit, placement, overflow: fit.overflow || placement.overflow };
|
|
1171
|
+
if (!result.overflow || fontSize === floor) return result;
|
|
1172
|
+
}
|
|
1173
|
+
throw new Error("Text placement did not evaluate its bounded floor trial.");
|
|
1174
|
+
};
|
|
1175
|
+
const fitContent = (field, value, text, box, size, minimum, path2) => field === "text" ? fitPlacedText(field, value, text, box, size, minimum, path2) : field === "items" || field === "bullets" ? fitList(value, box, size, minimum, { style: styleFor(field, path2), textMeasurement: options.textMeasurement }) : fitText(text, box, size, minimum, widthFor(field, path2));
|
|
665
1176
|
const path = `slides.${options.slideIndex ?? 0}`;
|
|
666
1177
|
const items = [], diagnostics = [];
|
|
667
|
-
|
|
1178
|
+
const measuredFurniture = layoutFurniture(slide, options);
|
|
1179
|
+
const furniture = measuredFurniture.configured || measuredFurniture.diagnostics.length ? measuredFurniture : void 0;
|
|
1180
|
+
if (furniture) diagnostics.push(...furniture.diagnostics);
|
|
1181
|
+
const bodyBottom = Math.min(height - padding, furniture && furniture.footerTop < height ? furniture.footerTop - gap * 0.5 : height - padding);
|
|
1182
|
+
let y = Math.max(padding, furniture?.headerBottom ? furniture.headerBottom + gap * 0.5 : padding);
|
|
668
1183
|
for (const field of ["tag", "title", "subtitle"]) {
|
|
669
1184
|
if (!slide[field]) continue;
|
|
670
1185
|
const requested = (field === "title" ? 54 : field === "tag" ? 16 : 25) * scale;
|
|
671
|
-
const maxHeight = height * (field === "title" ? 0.26 : field === "subtitle" ? 0.12 : 0.045);
|
|
1186
|
+
const maxHeight = Math.max(height * (field === "title" ? 0.26 : field === "subtitle" ? 0.12 : 0.045), minSize * 1.22 + 2 * rasterPadding);
|
|
672
1187
|
const box = { x: padding, y, width: width - padding * 2, height: maxHeight };
|
|
673
|
-
const text =
|
|
674
|
-
box.height = Math.min(maxHeight, text.lines.length * text.lineHeight);
|
|
1188
|
+
const text = fitPlacedText(field, slide[field], String(slide[field]), box, requested, minSize, `${path}.${field}`);
|
|
1189
|
+
box.height = Math.min(maxHeight, Math.max(text.lines.length * text.lineHeight, text.placement?.height ?? 0));
|
|
1190
|
+
if (furniture && box.y + box.height > bodyBottom + 0.01) diagnostics.push({ code: "text-overflow", path: `${path}.${field}`, message: "Repeated furniture leaves too little room for this heading. Change the header/footer or slide design." });
|
|
675
1191
|
items.push({ path: `${path}.${field}`, field, type: "text", value: slide[field], payload: { text: slide[field] }, box, text, textStyle: styleFor(field, `${path}.${field}`), composition });
|
|
676
1192
|
y += box.height + gap * 0.5;
|
|
677
1193
|
}
|
|
678
1194
|
if (items.length) y += gap * 0.5;
|
|
679
|
-
const contentBox = { x: padding, y, width: width - padding * 2, height: Math.max(scale,
|
|
1195
|
+
const contentBox = { x: padding, y, width: width - padding * 2, height: Math.max(scale, bodyBottom - y) };
|
|
680
1196
|
const collect = (host, basePath, depth = 0, ancestors = []) => {
|
|
681
1197
|
if (Array.isArray(host.blocks)) {
|
|
682
1198
|
if (depth >= MAX_COMPOSITION_DEPTH || ancestors.includes(host)) throw new RangeError(`Content groups must be acyclic and nest at most ${MAX_COMPOSITION_DEPTH} levels.`);
|
|
@@ -708,6 +1224,11 @@ function composeSlide(input, options = {}) {
|
|
|
708
1224
|
return { x: box.x + amount, y: box.y + amount, width: box.width - amount * 2, height: box.height - amount * 2 };
|
|
709
1225
|
};
|
|
710
1226
|
const acceptedBox = (box) => ({ x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) });
|
|
1227
|
+
const payloadBox = (box) => {
|
|
1228
|
+
if (!hasCards) return box;
|
|
1229
|
+
const frame = acceptedBox(box), padding2 = Math.min(12 * scale, frame.width / 4, frame.height / 4);
|
|
1230
|
+
return acceptedBox({ x: frame.x + padding2, y: frame.y + padding2, width: frame.width - 2 * padding2, height: frame.height - 2 * padding2 });
|
|
1231
|
+
};
|
|
711
1232
|
const measureQuote = (node, box, settings) => layoutQuote(node.value, acceptedBox(box), {
|
|
712
1233
|
fonts: options.fonts,
|
|
713
1234
|
textMeasurement: options.textMeasurement,
|
|
@@ -722,12 +1243,30 @@ function composeSlide(input, options = {}) {
|
|
|
722
1243
|
minFontSize: settings.minFontSize,
|
|
723
1244
|
path: node.path
|
|
724
1245
|
});
|
|
1246
|
+
const measureMetric = (node, box, settings) => layoutMetric(node.value, acceptedBox(box), {
|
|
1247
|
+
fonts: options.fonts,
|
|
1248
|
+
textMeasurement: options.textMeasurement,
|
|
1249
|
+
scale,
|
|
1250
|
+
minFontSize: settings.minFontSize,
|
|
1251
|
+
path: node.path,
|
|
1252
|
+
textRasterPadding: options.textRasterPadding,
|
|
1253
|
+
align: record2(slide.design).contentAlignment ?? options.contentAlignment
|
|
1254
|
+
});
|
|
1255
|
+
const measureTimeline = (node, box, settings) => layoutTimeline(node.value, acceptedBox(box), {
|
|
1256
|
+
fonts: options.fonts,
|
|
1257
|
+
textMeasurement: options.textMeasurement,
|
|
1258
|
+
scale,
|
|
1259
|
+
minFontSize: settings.minFontSize,
|
|
1260
|
+
path: node.path,
|
|
1261
|
+
textRasterPadding: options.textRasterPadding
|
|
1262
|
+
});
|
|
725
1263
|
const leafScore = (node, box, settings, penalties) => {
|
|
1264
|
+
box = payloadBox(box);
|
|
726
1265
|
const text = contentText(node.field, node.value);
|
|
727
1266
|
let score = Math.abs(Math.log(box.width / box.height / 1.6));
|
|
728
1267
|
if (penalties) penalties.cellProportions += score;
|
|
729
|
-
if (node.field === "quote" || node.field === "code") {
|
|
730
|
-
const internal = node.field === "quote" ? measureQuote(node, box, settings) : measureCode(node, box, settings);
|
|
1268
|
+
if (node.field === "quote" || node.field === "code" || node.field === "metric" || node.field === "timeline") {
|
|
1269
|
+
const internal = node.field === "quote" ? measureQuote(node, box, settings) : node.field === "code" ? measureCode(node, box, settings) : node.field === "metric" ? measureMetric(node, box, settings) : measureTimeline(node, box, settings);
|
|
731
1270
|
const reduction = internal.parts.reduce((sum, part) => sum + (part.fit ? (part.requestedFontSize - part.fit.fontSize) / scale : 0), 0);
|
|
732
1271
|
score += reduction + (internal.overflow ? 1e3 : 0);
|
|
733
1272
|
if (penalties) {
|
|
@@ -736,7 +1275,7 @@ function composeSlide(input, options = {}) {
|
|
|
736
1275
|
}
|
|
737
1276
|
} else if (text) {
|
|
738
1277
|
const fit = fitContent(node.field, node.value, text, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path);
|
|
739
|
-
const reduction = (25 * scale - fit.fontSize) / scale;
|
|
1278
|
+
const reduction = Math.max(0, 25 * scale - fit.fontSize) / scale;
|
|
740
1279
|
score += reduction + (fit.overflow ? 1e3 : 0);
|
|
741
1280
|
if (penalties) {
|
|
742
1281
|
penalties.fontReduction += reduction;
|
|
@@ -798,16 +1337,20 @@ function composeSlide(input, options = {}) {
|
|
|
798
1337
|
const boxes = grid.boxes;
|
|
799
1338
|
if (!nodes.some((node) => node.region)) flows.push({ path: containerPath, box: { ...area }, composition: { ...settings }, columns: grid.columns, rows: grid.rows, gap: grid.gap, itemCount: nodes.length, slotCount: count });
|
|
800
1339
|
nodes.forEach((node, index) => {
|
|
801
|
-
|
|
1340
|
+
let box = node.region ? regionBox(node.region, area, actualGap) : boxes[index];
|
|
802
1341
|
if (node.children) {
|
|
803
1342
|
const own = inheritedSettings(settings, node.composition), inner = inset(box, own);
|
|
804
1343
|
groups.push({ path: node.path, box, contentBox: inner, composition: own });
|
|
805
1344
|
arrange(node.children, inner, own, 0, (own.gap ?? 1 / 30) * Math.min(box.width, box.height), node.path);
|
|
806
1345
|
} else {
|
|
1346
|
+
const frameBox = hasCards ? acceptedBox(box) : void 0;
|
|
1347
|
+
box = payloadBox(box);
|
|
807
1348
|
const textValue = contentText(node.field, node.value);
|
|
808
1349
|
const quoteLayout = node.field === "quote" ? measureQuote(node, box, settings) : void 0;
|
|
809
1350
|
const codeLayout = node.field === "code" ? measureCode(node, box, settings) : void 0;
|
|
810
|
-
const
|
|
1351
|
+
const metricLayout = node.field === "metric" ? measureMetric(node, box, settings) : void 0;
|
|
1352
|
+
const timelineLayout = node.field === "timeline" ? measureTimeline(node, box, settings) : void 0;
|
|
1353
|
+
const internal = quoteLayout ?? codeLayout ?? metricLayout ?? timelineLayout, body = internal?.parts.find((part) => part.role === "body" || part.role === "value");
|
|
811
1354
|
const text = internal ? body?.fit : textValue !== void 0 ? fitContent(node.field, node.value, textValue, box, 25 * scale, (settings.minFontSize ?? 16) * scale, node.path) : void 0;
|
|
812
1355
|
items.push({
|
|
813
1356
|
path: node.path,
|
|
@@ -816,11 +1359,14 @@ function composeSlide(input, options = {}) {
|
|
|
816
1359
|
value: node.value,
|
|
817
1360
|
payload: node.payload,
|
|
818
1361
|
box: internal ? acceptedBox(box) : box,
|
|
1362
|
+
...frameBox ? { frameBox } : {},
|
|
819
1363
|
text,
|
|
820
1364
|
textStyle: body?.style ?? styleFor(node.field, node.path),
|
|
821
1365
|
composition: settings,
|
|
822
1366
|
...quoteLayout ? { quoteLayout } : {},
|
|
823
|
-
...codeLayout ? { codeLayout } : {}
|
|
1367
|
+
...codeLayout ? { codeLayout } : {},
|
|
1368
|
+
...metricLayout ? { metricLayout } : {},
|
|
1369
|
+
...timelineLayout ? { timelineLayout } : {}
|
|
824
1370
|
});
|
|
825
1371
|
if (box.width < 100 * scale || box.height < 60 * scale) diagnostics.push({ code: "small-cell", path: node.path, message: "Content cell is too small for comfortable reading; use fewer blocks or a different composition." });
|
|
826
1372
|
}
|
|
@@ -834,11 +1380,14 @@ function composeSlide(input, options = {}) {
|
|
|
834
1380
|
if (item.field === "table" && tableOverflows(item.value, item.box, scale, item.composition, options, item.path)) diagnostics.push({ code: "text-overflow", path: item.path, message: "Table cells do not fit; use fewer rows, fewer columns, or split the table across slides." });
|
|
835
1381
|
if (item.quoteLayout) diagnostics.push(...item.quoteLayout.diagnostics);
|
|
836
1382
|
else if (item.codeLayout) diagnostics.push(...item.codeLayout.diagnostics);
|
|
1383
|
+
else if (item.metricLayout) diagnostics.push(...item.metricLayout.diagnostics);
|
|
1384
|
+
else if (item.timelineLayout) diagnostics.push(...item.timelineLayout.diagnostics);
|
|
837
1385
|
else if (item.text?.overflow) diagnostics.push({ code: "text-overflow", path: item.path, message: "Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide." });
|
|
838
1386
|
}
|
|
839
1387
|
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]);
|
|
840
1388
|
const strictPaths = new Set(items.filter((item) => item.composition.overflow === "error").map((item) => item.path));
|
|
841
1389
|
const failures = diagnostics.filter((diagnostic) => {
|
|
1390
|
+
if (composition.overflow === "error") return true;
|
|
842
1391
|
let path2 = diagnostic.path;
|
|
843
1392
|
while (path2) {
|
|
844
1393
|
if (strictPaths.has(path2)) return true;
|
|
@@ -849,13 +1398,15 @@ function composeSlide(input, options = {}) {
|
|
|
849
1398
|
return false;
|
|
850
1399
|
});
|
|
851
1400
|
const explanation = decisions ? {
|
|
852
|
-
algorithm: "grid-score-
|
|
1401
|
+
algorithm: "grid-score-v9",
|
|
853
1402
|
textMeasurement: options.textMeasurement ? "provided" : "estimated",
|
|
1403
|
+
textOutlines: options.textMeasurement?.outlineBounds ? "provided" : "unavailable",
|
|
1404
|
+
textRasterPadding: rasterPadding,
|
|
854
1405
|
decisions,
|
|
855
|
-
unmeasuredPayloads: items.filter((item) => !headings.has(item.field) && !item.quoteLayout && !item.codeLayout && !["text", "items", "bullets", "table"].includes(item.field)).map((item) => item.path)
|
|
1406
|
+
unmeasuredPayloads: items.filter((item) => !headings.has(item.field) && !item.quoteLayout && !item.codeLayout && !item.metricLayout && !item.timelineLayout && !["text", "items", "bullets", "table"].includes(item.field)).map((item) => item.path)
|
|
856
1407
|
} : void 0;
|
|
857
1408
|
if (failures.length) throw new OPFCompositionError(failures, explanation);
|
|
858
|
-
return { width, height, contentBox, items, groups, flows, diagnostics, composition, ...explanation ? { explanation } : {} };
|
|
1409
|
+
return { width, height, contentBox, items, groups, flows, diagnostics, composition, ...furniture ? { furniture } : {}, ...explanation ? { explanation } : {} };
|
|
859
1410
|
}
|
|
860
1411
|
function resolveCanvasDimensions(input) {
|
|
861
1412
|
const presets = {
|
|
@@ -875,4 +1426,4 @@ function resolveCanvasDimensions(input) {
|
|
|
875
1426
|
return { width, height };
|
|
876
1427
|
}
|
|
877
1428
|
|
|
878
|
-
export { MAX_COMPOSITION_DEPTH, OPFCompositionError, composeSlide, fitList, fitRichText, fitText, layoutCode, layoutQuote, layoutTable, measureText, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textWidthMeasurer, wrapText };
|
|
1429
|
+
export { MAX_COMPOSITION_DEPTH, OPFCompositionError, chartColorForFill, colorContrast, composeSlide, fitList, fitRichText, fitText, layoutCode, layoutFurniture, layoutMetric, layoutQuote, layoutTable, layoutTimeline, measureText, measureTextOutline, placeTextLines, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, tableGrid, tableRowBoundaries, textColorForFill, textWidthMeasurer, wrapText };
|