@dojo-video-engine/layout 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +2109 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +371 -0
- package/dist/schema.js.map +1 -0
- package/dist/solver.d.ts +6 -0
- package/dist/solver.d.ts.map +1 -0
- package/dist/solver.js +1736 -0
- package/dist/solver.js.map +1 -0
- package/dist/text.d.ts +136 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +1359 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +39 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -3
package/dist/solver.js
ADDED
|
@@ -0,0 +1,1736 @@
|
|
|
1
|
+
import { evaluateScene, getLayoutContentRect, layoutScene, parseAspectRatio, resolveMediaMetrics, worstMeasurementQuality, } from "@dojo-video-engine/core";
|
|
2
|
+
import { validateScene, } from "@dojo-video-engine/schema";
|
|
3
|
+
import { parseFontFamilyListValue } from "@dojo-video-engine/schema/values";
|
|
4
|
+
import { LAYOUT_ROOT, validateLayoutPlan, } from "./schema.js";
|
|
5
|
+
import { measureLayoutText, projectLayoutTextToSceneV1, } from "./text.js";
|
|
6
|
+
import { LayoutCompileError, defaultLayoutCompilerBudgets, validationIssueError, } from "./types.js";
|
|
7
|
+
const EPSILON = 1e-7;
|
|
8
|
+
const layoutOwnedAnimationTargets = new Set([
|
|
9
|
+
"width",
|
|
10
|
+
"height",
|
|
11
|
+
"minWidth",
|
|
12
|
+
"minHeight",
|
|
13
|
+
"maxWidth",
|
|
14
|
+
"maxHeight",
|
|
15
|
+
"aspectRatio",
|
|
16
|
+
"position",
|
|
17
|
+
"left",
|
|
18
|
+
"right",
|
|
19
|
+
"top",
|
|
20
|
+
"bottom",
|
|
21
|
+
"inset",
|
|
22
|
+
"margin",
|
|
23
|
+
"marginTop",
|
|
24
|
+
"marginRight",
|
|
25
|
+
"marginBottom",
|
|
26
|
+
"marginLeft",
|
|
27
|
+
"padding",
|
|
28
|
+
"paddingTop",
|
|
29
|
+
"paddingRight",
|
|
30
|
+
"paddingBottom",
|
|
31
|
+
"paddingLeft",
|
|
32
|
+
"safeArea",
|
|
33
|
+
"display",
|
|
34
|
+
"flexDirection",
|
|
35
|
+
"justifyContent",
|
|
36
|
+
"alignItems",
|
|
37
|
+
"gap",
|
|
38
|
+
]);
|
|
39
|
+
const textPlanningAnimationTargets = new Set([
|
|
40
|
+
"fontSize",
|
|
41
|
+
"lineHeight",
|
|
42
|
+
"letterSpacing",
|
|
43
|
+
"maxLines",
|
|
44
|
+
"minFontSize",
|
|
45
|
+
"textFit",
|
|
46
|
+
"textOverflow",
|
|
47
|
+
"direction",
|
|
48
|
+
"textAlign",
|
|
49
|
+
]);
|
|
50
|
+
/** Compiles one flat semantic layout plan into ordinary Scene v1 geometry. */
|
|
51
|
+
export function compileLayout(sceneInput, planInput, options = {}) {
|
|
52
|
+
const planValidation = validateLayoutPlan(planInput);
|
|
53
|
+
if (!planValidation.ok) {
|
|
54
|
+
const first = planValidation.issues[0];
|
|
55
|
+
throw new LayoutCompileError("invalid_plan", first.message, first.path, {
|
|
56
|
+
issueCode: first.code,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return compileValidatedLayout(sceneInput, planValidation.plan, options);
|
|
60
|
+
}
|
|
61
|
+
export function tryCompileLayout(sceneInput, planInput, options = {}) {
|
|
62
|
+
const planValidation = validateLayoutPlan(planInput);
|
|
63
|
+
if (!planValidation.ok) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
errors: planValidation.issues.map(validationIssueError),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
result: compileValidatedLayout(sceneInput, planValidation.plan, options),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof LayoutCompileError) {
|
|
77
|
+
return { ok: false, errors: [error.toJSON()] };
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function compileValidatedLayout(sceneInput, plan, options) {
|
|
83
|
+
const sceneValidation = validateScene(sceneInput);
|
|
84
|
+
if (!sceneValidation.ok) {
|
|
85
|
+
throw new LayoutCompileError("invalid_scene", `Layout input is not a valid Scene: ${sceneValidation.errors.join(" ")}`, [], { errors: sceneValidation.errors.slice(0, 16) });
|
|
86
|
+
}
|
|
87
|
+
const scene = sceneValidation.scene;
|
|
88
|
+
const budgets = resolveBudgets(options.budgets);
|
|
89
|
+
if (plan.items.length > budgets.maxItems) {
|
|
90
|
+
budgetError("items", plan.items.length, budgets.maxItems);
|
|
91
|
+
}
|
|
92
|
+
const target = findTarget(scene, plan.containerId);
|
|
93
|
+
const visualChildren = target.children.filter((node) => node.type !== "audio");
|
|
94
|
+
const childById = new Map(visualChildren.map((node) => [node.id, node]));
|
|
95
|
+
for (const [index, item] of plan.items.entries()) {
|
|
96
|
+
const node = childById.get(item.id);
|
|
97
|
+
if (!node) {
|
|
98
|
+
throw new LayoutCompileError("non_child_item", `Layout item ${JSON.stringify(item.id)} is not a direct visual child of ${JSON.stringify(plan.containerId)}.`, ["items", index, "id"], { id: item.id, containerId: plan.containerId });
|
|
99
|
+
}
|
|
100
|
+
rejectLayoutAnimations(node, ["items", index]);
|
|
101
|
+
}
|
|
102
|
+
const plannedIds = new Set(plan.items.map((item) => item.id));
|
|
103
|
+
const unlisted = visualChildren.filter((node) => !plannedIds.has(node.id));
|
|
104
|
+
if (unlisted.length > 0) {
|
|
105
|
+
throw new LayoutCompileError("unlisted_child", `Layout plan must list every direct visual child; missing ${unlisted.map((node) => JSON.stringify(node.id)).join(", ")}.`, ["items"], { ids: unlisted.map((node) => node.id) });
|
|
106
|
+
}
|
|
107
|
+
if (target.container)
|
|
108
|
+
rejectLayoutAnimations(target.container, ["containerId"]);
|
|
109
|
+
const baseBounds = resolveContainerBounds(scene, plan.containerId, options);
|
|
110
|
+
const bounds = insetRect(insetRect(baseBounds, resolveInsets(plan.layout.safeArea, baseBounds)), resolveInsets(plan.layout.padding, baseBounds));
|
|
111
|
+
if (bounds.width < 0 || bounds.height < 0) {
|
|
112
|
+
throw new LayoutCompileError("invalid_geometry", "Safe-area and padding insets exceed the available container.", ["layout"]);
|
|
113
|
+
}
|
|
114
|
+
const constraintCount = plan.items.reduce((sum, item) => sum + (item.constraints?.length ?? 0), 0);
|
|
115
|
+
if (constraintCount > budgets.maxConstraints) {
|
|
116
|
+
budgetError("constraints", constraintCount, budgets.maxConstraints);
|
|
117
|
+
}
|
|
118
|
+
const state = {
|
|
119
|
+
scene,
|
|
120
|
+
plan,
|
|
121
|
+
options,
|
|
122
|
+
budgets,
|
|
123
|
+
bounds,
|
|
124
|
+
diagnostics: [],
|
|
125
|
+
steps: 0,
|
|
126
|
+
lines: 0,
|
|
127
|
+
tracks: 0,
|
|
128
|
+
constraints: constraintCount,
|
|
129
|
+
dependencyDepth: 0,
|
|
130
|
+
textCharacters: 0,
|
|
131
|
+
measuredTextIds: new Set(),
|
|
132
|
+
};
|
|
133
|
+
const work = plan.items.map((item, index) => prepareWorkItem(item, childById.get(item.id), index, state));
|
|
134
|
+
switch (plan.layout.kind) {
|
|
135
|
+
case "stack":
|
|
136
|
+
solveStack(work, plan.layout, state);
|
|
137
|
+
break;
|
|
138
|
+
case "grid":
|
|
139
|
+
solveGrid(work, plan.layout, state);
|
|
140
|
+
break;
|
|
141
|
+
case "overlay":
|
|
142
|
+
solveOverlay(work, plan.layout, state);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
for (const entry of work)
|
|
146
|
+
finalizeTextPlan(entry, state);
|
|
147
|
+
work.forEach((entry) => {
|
|
148
|
+
assertFiniteRect(entry, state);
|
|
149
|
+
assertAspectRatio(entry);
|
|
150
|
+
if (!containsRect(state.bounds, entry)) {
|
|
151
|
+
throw new LayoutCompileError("insufficient_space", `Layout item ${JSON.stringify(entry.item.id)} escapes the bounded container; reduce its size/offset or increase available space.`, ["items", entry.index], { id: entry.item.id });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const replacements = new Map(work.map((entry) => [entry.node.id, compiledNode(entry, state)]));
|
|
155
|
+
if (target.container &&
|
|
156
|
+
legacyContainerLayoutKeys.some((key) => target.container?.style?.[key] !== undefined)) {
|
|
157
|
+
state.diagnostics.push({
|
|
158
|
+
severity: "info",
|
|
159
|
+
code: "layout_style_replaced",
|
|
160
|
+
message: `Semantic layout replaced legacy flex fields on container ${JSON.stringify(target.container.id)}.`,
|
|
161
|
+
path: ["containerId"],
|
|
162
|
+
itemId: target.container.id,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
replaceTargetChildren(scene, plan.containerId, replacements);
|
|
166
|
+
const outputValidation = validateScene(scene);
|
|
167
|
+
if (!outputValidation.ok) {
|
|
168
|
+
throw new LayoutCompileError("invalid_geometry", `Layout compiler produced an invalid Scene: ${outputValidation.errors.join(" ")}`, [], { errors: outputValidation.errors.slice(0, 16) });
|
|
169
|
+
}
|
|
170
|
+
const measurements = work.map(toMeasurement);
|
|
171
|
+
const result = {
|
|
172
|
+
scene: outputValidation.scene,
|
|
173
|
+
plan: structuredClone(plan),
|
|
174
|
+
container: { ...bounds },
|
|
175
|
+
measurements,
|
|
176
|
+
diagnostics: state.diagnostics.map((diagnostic) => ({
|
|
177
|
+
...diagnostic,
|
|
178
|
+
path: [...diagnostic.path],
|
|
179
|
+
})),
|
|
180
|
+
stats: {
|
|
181
|
+
items: work.length,
|
|
182
|
+
tracks: state.tracks,
|
|
183
|
+
lines: state.lines,
|
|
184
|
+
constraints: state.constraints,
|
|
185
|
+
dependencyDepth: state.dependencyDepth,
|
|
186
|
+
textCharacters: state.textCharacters,
|
|
187
|
+
estimatedMeasurements: measurements.filter((measurement) => measurement.measurement === "estimated").length,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
assertStrictJson(result);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
function prepareWorkItem(item, node, index, state) {
|
|
194
|
+
const style = node.style ?? {};
|
|
195
|
+
const widthSizing = item.width ?? style.width ?? "hug";
|
|
196
|
+
const heightSizing = item.height ?? style.height ?? "hug";
|
|
197
|
+
const widthAuto = item.width === undefined && style.width === undefined;
|
|
198
|
+
const heightAuto = item.height === undefined && style.height === undefined;
|
|
199
|
+
const authoredRatio = item.aspectRatio ?? parseAspectRatio(style.aspectRatio);
|
|
200
|
+
const visualMedia = node.type === "img" || node.type === "video" || node.type === "lottie";
|
|
201
|
+
const margins = item.margin
|
|
202
|
+
? resolveInsets(item.margin, state.bounds)
|
|
203
|
+
: resolveSceneMargins(style, state.bounds);
|
|
204
|
+
const needsIntrinsicWidth = widthSizing === "hug" &&
|
|
205
|
+
!(visualMedia && authoredRatio && heightSizing !== "hug");
|
|
206
|
+
const needsIntrinsicHeight = heightSizing === "hug" &&
|
|
207
|
+
!(visualMedia && authoredRatio && widthSizing !== "hug");
|
|
208
|
+
const intrinsic = measureIntrinsic(node, needsIntrinsicWidth, needsIntrinsicHeight, undefined, state);
|
|
209
|
+
assertIntrinsicBounds(intrinsic, item.id, index, state);
|
|
210
|
+
const minWidth = resolveLength(item.minWidth ?? style.minWidth, state.bounds.width, 0);
|
|
211
|
+
const minHeight = resolveLength(item.minHeight ?? style.minHeight, state.bounds.height, 0);
|
|
212
|
+
const maxWidth = resolveLength(item.maxWidth ?? style.maxWidth, state.bounds.width, Number.POSITIVE_INFINITY);
|
|
213
|
+
const maxHeight = resolveLength(item.maxHeight ?? style.maxHeight, state.bounds.height, Number.POSITIVE_INFINITY);
|
|
214
|
+
if (minWidth > maxWidth || minHeight > maxHeight) {
|
|
215
|
+
throw new LayoutCompileError("invalid_geometry", `Layout item ${JSON.stringify(item.id)} has min size greater than max size.`, ["items", index], { id: item.id });
|
|
216
|
+
}
|
|
217
|
+
let width = sizingBase(widthSizing, state.bounds.width, intrinsic.width);
|
|
218
|
+
let height = sizingBase(heightSizing, state.bounds.height, intrinsic.height);
|
|
219
|
+
const ratio = authoredRatio ??
|
|
220
|
+
(intrinsic.width > 0 && intrinsic.height > 0
|
|
221
|
+
? intrinsic.width / intrinsic.height
|
|
222
|
+
: null);
|
|
223
|
+
if (ratio) {
|
|
224
|
+
if (widthSizing !== "hug" && heightSizing === "hug")
|
|
225
|
+
height = width / ratio;
|
|
226
|
+
else if (heightSizing !== "hug" && widthSizing === "hug")
|
|
227
|
+
width = height * ratio;
|
|
228
|
+
else if (widthSizing === "hug" && heightSizing === "hug" && authoredRatio) {
|
|
229
|
+
if (width > 0)
|
|
230
|
+
height = width / ratio;
|
|
231
|
+
else if (height > 0)
|
|
232
|
+
width = height * ratio;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
width = clamp(width, minWidth, maxWidth);
|
|
236
|
+
height = clamp(height, minHeight, maxHeight);
|
|
237
|
+
return {
|
|
238
|
+
index,
|
|
239
|
+
item,
|
|
240
|
+
node,
|
|
241
|
+
widthSizing,
|
|
242
|
+
heightSizing,
|
|
243
|
+
widthAuto,
|
|
244
|
+
heightAuto,
|
|
245
|
+
margins,
|
|
246
|
+
intrinsic,
|
|
247
|
+
width,
|
|
248
|
+
height,
|
|
249
|
+
minWidth,
|
|
250
|
+
minHeight,
|
|
251
|
+
maxWidth,
|
|
252
|
+
maxHeight,
|
|
253
|
+
aspectRatio: ratio,
|
|
254
|
+
aspectRatioIsHard: authoredRatio !== null,
|
|
255
|
+
x: 0,
|
|
256
|
+
y: 0,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function measureIntrinsic(node, needsWidth, needsHeight, availableWidth, state, availableHeight) {
|
|
260
|
+
const declared = {
|
|
261
|
+
measurement: "declared",
|
|
262
|
+
reason: "explicit Scene/Layout size",
|
|
263
|
+
};
|
|
264
|
+
if (!needsWidth && !needsHeight) {
|
|
265
|
+
return {
|
|
266
|
+
width: 0,
|
|
267
|
+
height: 0,
|
|
268
|
+
widthMeasurement: declared,
|
|
269
|
+
heightMeasurement: declared,
|
|
270
|
+
textMeasureCalls: 0,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (node.type === "text") {
|
|
274
|
+
if (node.runs && (state.options.richTextPolicy ?? "reject") === "reject") {
|
|
275
|
+
throw new LayoutCompileError("rich_text_unsafe", `Layout v2 cannot intrinsically plan rich text node ${JSON.stringify(node.id)} without the explicit legacy-unsafe policy.`, ["items", node.id], { id: node.id });
|
|
276
|
+
}
|
|
277
|
+
const text = node.text ?? node.runs?.map((run) => run.text).join("") ?? "";
|
|
278
|
+
if (!state.measuredTextIds.has(node.id)) {
|
|
279
|
+
state.measuredTextIds.add(node.id);
|
|
280
|
+
state.textCharacters += text.length;
|
|
281
|
+
}
|
|
282
|
+
if (state.textCharacters > state.budgets.maxTextCharacters) {
|
|
283
|
+
budgetError("text characters", state.textCharacters, state.budgets.maxTextCharacters);
|
|
284
|
+
}
|
|
285
|
+
const font = resolveTextFont(node, state);
|
|
286
|
+
let measured;
|
|
287
|
+
try {
|
|
288
|
+
measured = measureLayoutText(font.node, {
|
|
289
|
+
...(availableWidth === undefined ? {} : { availableWidth }),
|
|
290
|
+
...(availableHeight === undefined ? {} : { availableHeight }),
|
|
291
|
+
provider: state.options.textMetrics,
|
|
292
|
+
richTextPolicy: state.options.richTextPolicy ?? "reject",
|
|
293
|
+
maxMeasureCalls: Math.max(16, Math.min(16_384, state.budgets.maxSolverSteps - state.steps)),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof LayoutCompileError)
|
|
298
|
+
throw error;
|
|
299
|
+
throw new LayoutCompileError("invalid_geometry", `Text metrics failed for layout item ${JSON.stringify(node.id)}: ${safeErrorMessage(error)}.`, ["items", node.id], { id: node.id });
|
|
300
|
+
}
|
|
301
|
+
step(state, measured.measureCalls);
|
|
302
|
+
if (measured.fit.status === "clamped") {
|
|
303
|
+
state.diagnostics.push({
|
|
304
|
+
severity: "warning",
|
|
305
|
+
code: "text_fit_clamped",
|
|
306
|
+
message: `Text item ${JSON.stringify(node.id)} reached its configured fitting boundary.`,
|
|
307
|
+
path: ["items", node.id],
|
|
308
|
+
itemId: node.id,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
else if (measured.fit.status === "impossible") {
|
|
312
|
+
state.diagnostics.push({
|
|
313
|
+
severity: "warning",
|
|
314
|
+
code: "text_fit_impossible",
|
|
315
|
+
message: `Text item ${JSON.stringify(node.id)} cannot fully fit even at its configured minimum.`,
|
|
316
|
+
path: ["items", node.id],
|
|
317
|
+
itemId: node.id,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (measured.fit.truncated) {
|
|
321
|
+
state.diagnostics.push({
|
|
322
|
+
severity: "warning",
|
|
323
|
+
code: "text_truncated",
|
|
324
|
+
message: `Text item ${JSON.stringify(node.id)} was deterministically truncated by its fit policy.`,
|
|
325
|
+
path: ["items", node.id],
|
|
326
|
+
itemId: node.id,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
for (const diagnostic of measured.diagnostics) {
|
|
330
|
+
state.diagnostics.push({
|
|
331
|
+
severity: "warning",
|
|
332
|
+
code: "unsafe_text_planning",
|
|
333
|
+
message: diagnostic.message,
|
|
334
|
+
path: ["items", node.id],
|
|
335
|
+
itemId: node.id,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
if (measured.measurement === "estimated") {
|
|
339
|
+
estimatedDiagnostic(node.id, measured.reason, state);
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
width: measured.width,
|
|
343
|
+
height: measured.height,
|
|
344
|
+
widthMeasurement: measured.fieldMeasurements.width,
|
|
345
|
+
heightMeasurement: measured.fieldMeasurements.height,
|
|
346
|
+
textMeasureCalls: measured.measureCalls,
|
|
347
|
+
textPlan: measured,
|
|
348
|
+
font: font.evidence,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (node.type === "img" || node.type === "video" || node.type === "lottie") {
|
|
352
|
+
const asset = state.scene.assets[node.assetId];
|
|
353
|
+
return mediaIntrinsic(node, asset, state);
|
|
354
|
+
}
|
|
355
|
+
const box = intrinsicChildUnion(node);
|
|
356
|
+
const estimated = {
|
|
357
|
+
measurement: "estimated",
|
|
358
|
+
reason: box.reason,
|
|
359
|
+
};
|
|
360
|
+
estimatedDiagnostic(node.id, box.reason, state);
|
|
361
|
+
return {
|
|
362
|
+
width: box.width,
|
|
363
|
+
height: box.height,
|
|
364
|
+
widthMeasurement: estimated,
|
|
365
|
+
heightMeasurement: estimated,
|
|
366
|
+
textMeasureCalls: 0,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function finalizeTextPlan(entry, state) {
|
|
370
|
+
if (entry.node.type !== "text")
|
|
371
|
+
return;
|
|
372
|
+
entry.intrinsic = measureIntrinsic(entry.node, true, true, entry.width, state, entry.height);
|
|
373
|
+
}
|
|
374
|
+
function mediaIntrinsic(node, asset, state) {
|
|
375
|
+
let metrics;
|
|
376
|
+
try {
|
|
377
|
+
metrics = resolveMediaMetrics(asset, state.options.mediaMetrics);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
throw new LayoutCompileError("missing_intrinsic_metrics", `Media metrics failed for layout item ${JSON.stringify(node.id)}: ${safeErrorMessage(error)}.`, ["items", node.id], { id: node.id, assetId: asset.id });
|
|
381
|
+
}
|
|
382
|
+
let width = metrics.width;
|
|
383
|
+
let height = metrics.height;
|
|
384
|
+
let widthMeasurement = metrics.fieldMeasurements.width;
|
|
385
|
+
let heightMeasurement = metrics.fieldMeasurements.height;
|
|
386
|
+
if (width === undefined || height === undefined) {
|
|
387
|
+
const policy = state.options.missingMediaMetrics ?? { mode: "error" };
|
|
388
|
+
if (policy.mode === "error") {
|
|
389
|
+
throw new LayoutCompileError("missing_intrinsic_metrics", `Intrinsic ${asset.type} dimensions are unavailable for layout item ${JSON.stringify(node.id)}. Supply prepared/declared metrics, fixed dimensions, or an explicit fallback policy.`, ["items", node.id], { id: node.id, assetId: asset.id, assetType: asset.type });
|
|
390
|
+
}
|
|
391
|
+
const fallback = asset.type === "image" ||
|
|
392
|
+
asset.type === "video" ||
|
|
393
|
+
asset.type === "lottie"
|
|
394
|
+
? policy.sizes[asset.type]
|
|
395
|
+
: undefined;
|
|
396
|
+
if (!fallback) {
|
|
397
|
+
throw new LayoutCompileError("missing_intrinsic_metrics", `Fallback policy has no ${asset.type} size for layout item ${JSON.stringify(node.id)}.`, ["items", node.id], { id: node.id, assetId: asset.id, assetType: asset.type });
|
|
398
|
+
}
|
|
399
|
+
if (!Number.isFinite(fallback.width) ||
|
|
400
|
+
fallback.width <= 0 ||
|
|
401
|
+
!Number.isFinite(fallback.height) ||
|
|
402
|
+
fallback.height <= 0) {
|
|
403
|
+
throw new LayoutCompileError("invalid_geometry", `Fallback dimensions for ${asset.type} must be finite and positive.`, ["options", "missingMediaMetrics", "sizes", asset.type], { assetType: asset.type });
|
|
404
|
+
}
|
|
405
|
+
const fallbackProvenance = {
|
|
406
|
+
measurement: "estimated",
|
|
407
|
+
reason: `explicit host ${asset.type} fallback`,
|
|
408
|
+
};
|
|
409
|
+
width ??= fallback.width;
|
|
410
|
+
height ??= fallback.height;
|
|
411
|
+
widthMeasurement ??= fallbackProvenance;
|
|
412
|
+
heightMeasurement ??= fallbackProvenance;
|
|
413
|
+
}
|
|
414
|
+
widthMeasurement ??= {
|
|
415
|
+
measurement: "estimated",
|
|
416
|
+
reason: "intrinsic width unavailable",
|
|
417
|
+
};
|
|
418
|
+
heightMeasurement ??= {
|
|
419
|
+
measurement: "estimated",
|
|
420
|
+
reason: "intrinsic height unavailable",
|
|
421
|
+
};
|
|
422
|
+
if (widthMeasurement.measurement === "estimated" ||
|
|
423
|
+
heightMeasurement.measurement === "estimated") {
|
|
424
|
+
estimatedDiagnostic(node.id, `${widthMeasurement.reason}; ${heightMeasurement.reason}`, state);
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
width,
|
|
428
|
+
height,
|
|
429
|
+
widthMeasurement,
|
|
430
|
+
heightMeasurement,
|
|
431
|
+
textMeasureCalls: 0,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function resolveTextFont(node, state) {
|
|
435
|
+
const authored = node.style?.fontFamily ?? "system-ui, sans-serif";
|
|
436
|
+
const families = parseFontFamilyListValue(authored);
|
|
437
|
+
if (!families) {
|
|
438
|
+
throw new LayoutCompileError("invalid_geometry", `Text item ${JSON.stringify(node.id)} has an invalid font-family list.`, ["items", node.id, "style", "fontFamily"], { id: node.id });
|
|
439
|
+
}
|
|
440
|
+
const fontAssets = new Map(Object.values(state.scene.assets)
|
|
441
|
+
.filter((asset) => asset.type === "font")
|
|
442
|
+
.map((asset) => [asset.id, asset]));
|
|
443
|
+
const selected = families.find((entry) => fontAssets.has(entry.family));
|
|
444
|
+
const policy = state.options.fontFallbackPolicy ?? "require-prepared";
|
|
445
|
+
if (selected) {
|
|
446
|
+
const exactFamily = parseFontFamilyListValue(selected.family);
|
|
447
|
+
if (exactFamily?.length !== 1 ||
|
|
448
|
+
exactFamily[0]?.family !== selected.family) {
|
|
449
|
+
throw new LayoutCompileError("missing_prepared_font", `Declared font asset id ${JSON.stringify(selected.family)} cannot be represented as one exact Scene font family. Use a single-family-safe asset id without comma or quote escapes.`, ["items", node.id, "style", "fontFamily"], { id: node.id, assetId: selected.family });
|
|
450
|
+
}
|
|
451
|
+
if (!state.options.textMetrics && policy === "require-prepared") {
|
|
452
|
+
throw new LayoutCompileError("missing_prepared_font", `Text item ${JSON.stringify(node.id)} selected declared font asset ${JSON.stringify(selected.family)} but no prepared TextMetricsProvider was supplied.`, ["items", node.id, "style", "fontFamily"], { id: node.id, assetId: selected.family });
|
|
453
|
+
}
|
|
454
|
+
pushDiagnosticOnce(state, {
|
|
455
|
+
severity: "info",
|
|
456
|
+
code: "prepared_font_selected",
|
|
457
|
+
message: `Text item ${JSON.stringify(node.id)} selected declared font asset ${JSON.stringify(selected.family)}.`,
|
|
458
|
+
path: ["items", node.id, "style", "fontFamily"],
|
|
459
|
+
itemId: node.id,
|
|
460
|
+
});
|
|
461
|
+
return {
|
|
462
|
+
node: {
|
|
463
|
+
...node,
|
|
464
|
+
style: { ...(node.style ?? {}), fontFamily: selected.family },
|
|
465
|
+
},
|
|
466
|
+
evidence: {
|
|
467
|
+
family: selected.family,
|
|
468
|
+
assetId: selected.family,
|
|
469
|
+
measurement: "declared",
|
|
470
|
+
reason: "first declared font asset in authored family order",
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (policy === "require-prepared") {
|
|
475
|
+
throw new LayoutCompileError("missing_prepared_font", `Text item ${JSON.stringify(node.id)} has no declared font asset in ${JSON.stringify(authored)}. Add a font asset/family or explicitly allow system fallback.`, ["items", node.id, "style", "fontFamily"], { id: node.id, fontFamily: authored });
|
|
476
|
+
}
|
|
477
|
+
pushDiagnosticOnce(state, {
|
|
478
|
+
severity: "warning",
|
|
479
|
+
code: "system_font_fallback",
|
|
480
|
+
message: `Text item ${JSON.stringify(node.id)} uses host system font fallback ${JSON.stringify(authored)}; cross-host pixels may differ.`,
|
|
481
|
+
path: ["items", node.id, "style", "fontFamily"],
|
|
482
|
+
itemId: node.id,
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
node,
|
|
486
|
+
evidence: {
|
|
487
|
+
family: authored,
|
|
488
|
+
assetId: null,
|
|
489
|
+
measurement: "estimated",
|
|
490
|
+
reason: "explicit allow-system fallback policy",
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function solveStack(work, layout, state) {
|
|
495
|
+
const row = layout.axis === "row";
|
|
496
|
+
const mainSpace = row ? state.bounds.width : state.bounds.height;
|
|
497
|
+
const crossSpace = row ? state.bounds.height : state.bounds.width;
|
|
498
|
+
const mainGap = resolveLength(row ? (layout.columnGap ?? layout.gap) : (layout.rowGap ?? layout.gap), mainSpace, 0);
|
|
499
|
+
const crossGap = resolveLength(row ? (layout.rowGap ?? layout.gap) : (layout.columnGap ?? layout.gap), crossSpace, 0);
|
|
500
|
+
// Column text must know its cross-axis width before intrinsic height is a
|
|
501
|
+
// meaningful main-axis basis.
|
|
502
|
+
for (const entry of work) {
|
|
503
|
+
const crossSizing = row ? entry.heightSizing : entry.widthSizing;
|
|
504
|
+
const align = entry.item.alignSelf ?? layout.align ?? "start";
|
|
505
|
+
if (crossSizing === "fill") {
|
|
506
|
+
setCrossSize(entry, row, layout.wrap === "wrap"
|
|
507
|
+
? row
|
|
508
|
+
? entry.intrinsic.height
|
|
509
|
+
: entry.intrinsic.width
|
|
510
|
+
: Math.max(0, crossSpace - crossMargins(entry, row)));
|
|
511
|
+
}
|
|
512
|
+
else if (layout.wrap !== "wrap" &&
|
|
513
|
+
align === "stretch" &&
|
|
514
|
+
(row ? entry.heightAuto : entry.widthAuto)) {
|
|
515
|
+
setCrossSize(entry, row, Math.max(0, crossSpace - crossMargins(entry, row)));
|
|
516
|
+
}
|
|
517
|
+
refreshMainFromAvailableCross(entry, row, crossSpace);
|
|
518
|
+
if (!row)
|
|
519
|
+
refreshTextHeight(entry, entry.width, state);
|
|
520
|
+
}
|
|
521
|
+
const lines = [];
|
|
522
|
+
let current = [];
|
|
523
|
+
let used = 0;
|
|
524
|
+
for (const entry of work) {
|
|
525
|
+
const outer = mainSize(entry, row) + mainMargins(entry, row);
|
|
526
|
+
const next = current.length === 0 ? outer : used + mainGap + outer;
|
|
527
|
+
if (layout.wrap === "wrap" &&
|
|
528
|
+
current.length > 0 &&
|
|
529
|
+
next > mainSpace + EPSILON) {
|
|
530
|
+
lines.push(current);
|
|
531
|
+
current = [entry];
|
|
532
|
+
used = outer;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
current.push(entry);
|
|
536
|
+
used = next;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (current.length > 0)
|
|
540
|
+
lines.push(current);
|
|
541
|
+
state.lines += lines.length;
|
|
542
|
+
const solvedLines = [];
|
|
543
|
+
for (const entries of lines) {
|
|
544
|
+
distributeMain(entries, row, mainSpace, mainGap, layout.justify ?? "start", state);
|
|
545
|
+
if (row) {
|
|
546
|
+
for (const entry of entries)
|
|
547
|
+
refreshTextHeight(entry, entry.width, state);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
for (const entry of entries)
|
|
551
|
+
refreshWidthFromHeight(entry);
|
|
552
|
+
}
|
|
553
|
+
const naturalCross = entries.reduce((max, entry) => Math.max(max, crossSize(entry, row) + crossMargins(entry, row)), 0);
|
|
554
|
+
solvedLines.push({
|
|
555
|
+
entries,
|
|
556
|
+
crossSize: layout.wrap === "wrap" ? naturalCross : crossSpace,
|
|
557
|
+
crossStart: 0,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
distributeCrossLines(solvedLines, crossSpace, crossGap, layout.alignContent ?? (layout.wrap === "wrap" ? "stretch" : "start"), state);
|
|
561
|
+
if (layout.wrap === "wrap" &&
|
|
562
|
+
layout.alignContent !== undefined &&
|
|
563
|
+
layout.alignContent !== "stretch") {
|
|
564
|
+
const collapsed = solvedLines.find((line) => line.crossSize <= EPSILON &&
|
|
565
|
+
line.entries.some((entry) => (row ? entry.heightSizing : entry.widthSizing) === "fill"));
|
|
566
|
+
if (collapsed) {
|
|
567
|
+
throw new LayoutCompileError("insufficient_space", "Wrapped cross-axis fill has no intrinsic line size under the selected alignContent; use stretch or provide a cross size.", ["layout", "alignContent"]);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
for (const line of solvedLines) {
|
|
571
|
+
for (const entry of line.entries) {
|
|
572
|
+
const align = entry.item.alignSelf ?? layout.align ?? "start";
|
|
573
|
+
const margins = crossMarginPair(entry, row);
|
|
574
|
+
if ((row ? entry.heightSizing : entry.widthSizing) === "fill" ||
|
|
575
|
+
(align === "stretch" && (row ? entry.heightAuto : entry.widthAuto))) {
|
|
576
|
+
setCrossSize(entry, row, Math.max(0, line.crossSize - margins.start - margins.end));
|
|
577
|
+
if (!row)
|
|
578
|
+
refreshTextHeight(entry, entry.width, state);
|
|
579
|
+
}
|
|
580
|
+
const available = line.crossSize - crossSize(entry, row) - margins.start - margins.end;
|
|
581
|
+
const offset = align === "center" ? available / 2 : align === "end" ? available : 0;
|
|
582
|
+
setCrossPosition(entry, row, crossOrigin(state.bounds, row) +
|
|
583
|
+
line.crossStart +
|
|
584
|
+
margins.start +
|
|
585
|
+
offset);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
function distributeMain(entries, row, mainSpace, gap, justify, state) {
|
|
590
|
+
const gaps = Math.max(0, entries.length - 1) * gap;
|
|
591
|
+
const margins = entries.reduce((sum, entry) => sum + mainMargins(entry, row), 0);
|
|
592
|
+
const availableForSizes = Math.max(0, mainSpace - gaps - margins);
|
|
593
|
+
distributeSizes(entries, row, availableForSizes, state);
|
|
594
|
+
const usedSizes = entries.reduce((sum, entry) => sum + mainSize(entry, row), 0);
|
|
595
|
+
let leftover = mainSpace - gaps - margins - usedSizes;
|
|
596
|
+
if (leftover < -EPSILON) {
|
|
597
|
+
throw new LayoutCompileError("insufficient_space", `Stack needs ${formatNumber(-leftover)} more pixels on its ${row ? "row" : "column"} axis after bounded shrink.`, ["layout"]);
|
|
598
|
+
}
|
|
599
|
+
leftover = Math.max(0, leftover);
|
|
600
|
+
let start = 0;
|
|
601
|
+
let extraGap = 0;
|
|
602
|
+
if (justify === "center")
|
|
603
|
+
start = leftover / 2;
|
|
604
|
+
else if (justify === "end")
|
|
605
|
+
start = leftover;
|
|
606
|
+
else if (justify === "space-between" && entries.length > 1)
|
|
607
|
+
extraGap = leftover / (entries.length - 1);
|
|
608
|
+
else if (justify === "space-around" && entries.length > 0) {
|
|
609
|
+
extraGap = leftover / entries.length;
|
|
610
|
+
start = extraGap / 2;
|
|
611
|
+
}
|
|
612
|
+
else if (justify === "space-evenly" && entries.length > 0) {
|
|
613
|
+
extraGap = leftover / (entries.length + 1);
|
|
614
|
+
start = extraGap;
|
|
615
|
+
}
|
|
616
|
+
let cursor = mainOrigin(state.bounds, row) + start;
|
|
617
|
+
for (const entry of entries) {
|
|
618
|
+
const marginsForItem = mainMarginPair(entry, row);
|
|
619
|
+
cursor += marginsForItem.start;
|
|
620
|
+
setMainPosition(entry, row, cursor);
|
|
621
|
+
cursor += mainSize(entry, row) + marginsForItem.end + gap + extraGap;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function distributeSizes(entries, row, available, state) {
|
|
625
|
+
let total = entries.reduce((sum, entry) => sum + mainSize(entry, row), 0);
|
|
626
|
+
if (total < available - EPSILON) {
|
|
627
|
+
let free = available - total;
|
|
628
|
+
let active = entries.filter((entry) => growWeight(entry, row) > 0 &&
|
|
629
|
+
mainSize(entry, row) < mainMax(entry, row) - EPSILON);
|
|
630
|
+
while (free > EPSILON && active.length > 0) {
|
|
631
|
+
step(state, active.length);
|
|
632
|
+
const totalWeight = active.reduce((sum, entry) => sum + growWeight(entry, row), 0);
|
|
633
|
+
let consumed = 0;
|
|
634
|
+
for (const entry of active) {
|
|
635
|
+
const before = mainSize(entry, row);
|
|
636
|
+
const after = clampMain(entry, row, before + free * (growWeight(entry, row) / totalWeight));
|
|
637
|
+
setMainSize(entry, row, after);
|
|
638
|
+
consumed += after - before;
|
|
639
|
+
}
|
|
640
|
+
if (consumed <= EPSILON)
|
|
641
|
+
break;
|
|
642
|
+
free -= consumed;
|
|
643
|
+
active = active.filter((entry) => mainSize(entry, row) < mainMax(entry, row) - EPSILON);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
let deficit = total - available;
|
|
648
|
+
let active = entries.filter((entry) => shrinkWeight(entry, row) > 0 &&
|
|
649
|
+
mainSize(entry, row) > mainMin(entry, row) + EPSILON);
|
|
650
|
+
while (deficit > EPSILON && active.length > 0) {
|
|
651
|
+
step(state, active.length);
|
|
652
|
+
const weighted = active.map((entry) => shrinkWeight(entry, row) * Math.max(1, mainSize(entry, row)));
|
|
653
|
+
const totalWeight = weighted.reduce((sum, value) => sum + value, 0);
|
|
654
|
+
let consumed = 0;
|
|
655
|
+
active.forEach((entry, index) => {
|
|
656
|
+
const share = deficit * ((weighted[index] ?? 0) / totalWeight);
|
|
657
|
+
const before = mainSize(entry, row);
|
|
658
|
+
const after = Math.max(mainMin(entry, row), before - share);
|
|
659
|
+
setMainSize(entry, row, after);
|
|
660
|
+
consumed += before - after;
|
|
661
|
+
});
|
|
662
|
+
if (consumed <= EPSILON)
|
|
663
|
+
break;
|
|
664
|
+
deficit -= consumed;
|
|
665
|
+
active = active.filter((entry) => mainSize(entry, row) > mainMin(entry, row) + EPSILON);
|
|
666
|
+
}
|
|
667
|
+
total = entries.reduce((sum, entry) => sum + mainSize(entry, row), 0);
|
|
668
|
+
if (total > available + EPSILON) {
|
|
669
|
+
throw new LayoutCompileError("insufficient_space", `Layout cannot shrink ${formatNumber(total)} pixels of content into ${formatNumber(available)} pixels without violating fixed/min sizes.`, ["items"]);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function distributeCrossLines(lines, space, gap, align, state) {
|
|
673
|
+
const base = lines.reduce((sum, line) => sum + line.crossSize, 0);
|
|
674
|
+
const gaps = Math.max(0, lines.length - 1) * gap;
|
|
675
|
+
let free = space - base - gaps;
|
|
676
|
+
if (free < -EPSILON) {
|
|
677
|
+
throw new LayoutCompileError("insufficient_space", `Wrapped stack lines exceed the cross axis by ${formatNumber(-free)} pixels.`, ["layout", "alignContent"]);
|
|
678
|
+
}
|
|
679
|
+
free = Math.max(0, free);
|
|
680
|
+
let start = 0;
|
|
681
|
+
let extraGap = 0;
|
|
682
|
+
if (align === "stretch" && lines.length > 0) {
|
|
683
|
+
const addition = free / lines.length;
|
|
684
|
+
lines.forEach((line) => (line.crossSize += addition));
|
|
685
|
+
free = 0;
|
|
686
|
+
}
|
|
687
|
+
else if (align === "center")
|
|
688
|
+
start = free / 2;
|
|
689
|
+
else if (align === "end")
|
|
690
|
+
start = free;
|
|
691
|
+
else if (align === "space-between" && lines.length > 1)
|
|
692
|
+
extraGap = free / (lines.length - 1);
|
|
693
|
+
else if (align === "space-around" && lines.length > 0) {
|
|
694
|
+
extraGap = free / lines.length;
|
|
695
|
+
start = extraGap / 2;
|
|
696
|
+
}
|
|
697
|
+
else if (align === "space-evenly" && lines.length > 0) {
|
|
698
|
+
extraGap = free / (lines.length + 1);
|
|
699
|
+
start = extraGap;
|
|
700
|
+
}
|
|
701
|
+
let cursor = start;
|
|
702
|
+
for (const line of lines) {
|
|
703
|
+
line.crossStart = cursor;
|
|
704
|
+
cursor += line.crossSize + gap + extraGap;
|
|
705
|
+
}
|
|
706
|
+
step(state, lines.length);
|
|
707
|
+
}
|
|
708
|
+
function solveGrid(work, layout, state) {
|
|
709
|
+
const placements = placeGridItems(work, layout, state);
|
|
710
|
+
const rowCount = placements.reduce((max, placement) => Math.max(max, placement.row + placement.rowSpan), layout.rows?.length ?? 0);
|
|
711
|
+
const projectedTracks = layout.columns.length + (layout.rows?.length ?? rowCount);
|
|
712
|
+
if (projectedTracks > state.budgets.maxTracks) {
|
|
713
|
+
budgetError("tracks", projectedTracks, state.budgets.maxTracks);
|
|
714
|
+
}
|
|
715
|
+
const rows = layout.rows
|
|
716
|
+
? layout.rows.map((track) => ({ ...track }))
|
|
717
|
+
: Array.from({ length: rowCount }, () => ({ size: "fill" }));
|
|
718
|
+
const columns = layout.columns.map((track) => ({ ...track }));
|
|
719
|
+
state.tracks += rows.length + columns.length;
|
|
720
|
+
if (state.tracks > state.budgets.maxTracks) {
|
|
721
|
+
budgetError("tracks", state.tracks, state.budgets.maxTracks);
|
|
722
|
+
}
|
|
723
|
+
const columnGap = resolveLength(layout.columnGap ?? layout.gap, state.bounds.width, 0);
|
|
724
|
+
const rowGap = resolveLength(layout.rowGap ?? layout.gap, state.bounds.height, 0);
|
|
725
|
+
const columnSizes = solveTracks(columns, state.bounds.width, columnGap, placements.map((placement) => ({
|
|
726
|
+
start: placement.column,
|
|
727
|
+
span: placement.columnSpan,
|
|
728
|
+
intrinsic: Math.max(placement.item.intrinsic.width, placement.item.width) +
|
|
729
|
+
placement.item.margins.left +
|
|
730
|
+
placement.item.margins.right,
|
|
731
|
+
})), state, "columns");
|
|
732
|
+
for (const placement of placements) {
|
|
733
|
+
const cellWidth = spanSize(columnSizes, placement.column, placement.columnSpan, columnGap);
|
|
734
|
+
const item = placement.item;
|
|
735
|
+
const horizontalMargins = item.margins.left + item.margins.right;
|
|
736
|
+
if (item.widthSizing === "fill") {
|
|
737
|
+
item.width = clamp(Math.max(0, cellWidth - horizontalMargins), item.minWidth, item.maxWidth);
|
|
738
|
+
}
|
|
739
|
+
refreshTextHeight(item, item.width, state);
|
|
740
|
+
}
|
|
741
|
+
const rowSizes = solveTracks(rows, state.bounds.height, rowGap, placements.map((placement) => ({
|
|
742
|
+
start: placement.row,
|
|
743
|
+
span: placement.rowSpan,
|
|
744
|
+
intrinsic: Math.max(placement.item.intrinsic.height, placement.item.height) +
|
|
745
|
+
placement.item.margins.top +
|
|
746
|
+
placement.item.margins.bottom,
|
|
747
|
+
})), state, "rows");
|
|
748
|
+
const columnStarts = trackStarts(columnSizes, columnGap, state.bounds.x);
|
|
749
|
+
const rowStarts = trackStarts(rowSizes, rowGap, state.bounds.y);
|
|
750
|
+
for (const placement of placements) {
|
|
751
|
+
const item = placement.item;
|
|
752
|
+
const cell = {
|
|
753
|
+
x: columnStarts[placement.column],
|
|
754
|
+
y: rowStarts[placement.row],
|
|
755
|
+
width: spanSize(columnSizes, placement.column, placement.columnSpan, columnGap),
|
|
756
|
+
height: spanSize(rowSizes, placement.row, placement.rowSpan, rowGap),
|
|
757
|
+
};
|
|
758
|
+
placeInCell(item, cell, item.item.justifySelf ?? layout.justifyItems ?? "stretch", item.item.alignSelf ?? layout.alignItems ?? "stretch", state);
|
|
759
|
+
if (!containsRect(cell, item)) {
|
|
760
|
+
throw new LayoutCompileError("insufficient_space", `Grid item ${JSON.stringify(item.item.id)} does not fit its assigned cell.`, ["items", item.index], { id: item.item.id, row: placement.row, column: placement.column });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
state.lines += rows.length;
|
|
764
|
+
}
|
|
765
|
+
function placeGridItems(work, layout, state) {
|
|
766
|
+
const columnCount = layout.columns.length;
|
|
767
|
+
const fixedRows = layout.rows?.length;
|
|
768
|
+
const maxRows = Math.max(0, state.budgets.maxTracks - columnCount);
|
|
769
|
+
const occupied = new Set();
|
|
770
|
+
const placementById = new Map();
|
|
771
|
+
let cursor = 0;
|
|
772
|
+
const flow = layout.autoFlow ?? "row";
|
|
773
|
+
const fits = (row, column, rowSpan, columnSpan) => {
|
|
774
|
+
if (column + columnSpan > columnCount)
|
|
775
|
+
return false;
|
|
776
|
+
if (fixedRows !== undefined && row + rowSpan > fixedRows)
|
|
777
|
+
return false;
|
|
778
|
+
if (row + rowSpan > maxRows)
|
|
779
|
+
return false;
|
|
780
|
+
for (let y = row; y < row + rowSpan; y += 1) {
|
|
781
|
+
for (let x = column; x < column + columnSpan; x += 1) {
|
|
782
|
+
if (occupied.has(`${y}:${x}`))
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return true;
|
|
787
|
+
};
|
|
788
|
+
const reserve = (placement) => {
|
|
789
|
+
for (let y = placement.row; y < placement.row + placement.rowSpan; y += 1) {
|
|
790
|
+
for (let x = placement.column; x < placement.column + placement.columnSpan; x += 1) {
|
|
791
|
+
occupied.add(`${y}:${x}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
placementById.set(placement.item.item.id, placement);
|
|
795
|
+
};
|
|
796
|
+
// Explicit cells reserve their complete spans before auto placement. This
|
|
797
|
+
// is stable, prevents an earlier auto item from stealing a later declared
|
|
798
|
+
// cell, and remains deliberately non-dense for the remaining items.
|
|
799
|
+
for (const item of work) {
|
|
800
|
+
const rowSpan = item.item.grid?.rowSpan ?? 1;
|
|
801
|
+
const columnSpan = item.item.grid?.columnSpan ?? 1;
|
|
802
|
+
const row = item.item.grid?.row;
|
|
803
|
+
const column = item.item.grid?.column;
|
|
804
|
+
if ((row === undefined) !== (column === undefined)) {
|
|
805
|
+
throw new LayoutCompileError("grid_out_of_bounds", `Grid item ${JSON.stringify(item.item.id)} must specify both row and column or neither.`, ["items", item.index, "grid"]);
|
|
806
|
+
}
|
|
807
|
+
if (row === undefined || column === undefined) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (row + rowSpan > maxRows) {
|
|
811
|
+
budgetError("tracks", columnCount + row + rowSpan, state.budgets.maxTracks);
|
|
812
|
+
}
|
|
813
|
+
if (!fits(row, column, rowSpan, columnSpan)) {
|
|
814
|
+
const out = column + columnSpan > columnCount ||
|
|
815
|
+
(fixedRows !== undefined && row + rowSpan > fixedRows);
|
|
816
|
+
throw new LayoutCompileError(out ? "grid_out_of_bounds" : "grid_collision", `Grid placement for ${JSON.stringify(item.item.id)} ${out ? "exceeds declared tracks" : "overlaps an occupied cell"}.`, ["items", item.index, "grid"], {
|
|
817
|
+
id: item.item.id,
|
|
818
|
+
row,
|
|
819
|
+
column,
|
|
820
|
+
rowSpan,
|
|
821
|
+
columnSpan,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
reserve({ item, row, column, rowSpan, columnSpan });
|
|
825
|
+
}
|
|
826
|
+
for (const item of work) {
|
|
827
|
+
if (placementById.has(item.item.id))
|
|
828
|
+
continue;
|
|
829
|
+
const rowSpan = item.item.grid?.rowSpan ?? 1;
|
|
830
|
+
const columnSpan = item.item.grid?.columnSpan ?? 1;
|
|
831
|
+
let found;
|
|
832
|
+
const rowCapacity = Math.max(1, fixedRows ?? work.length);
|
|
833
|
+
for (let scan = cursor; scan < state.budgets.maxSolverSteps; scan += 1) {
|
|
834
|
+
step(state);
|
|
835
|
+
const row = flow === "row" ? Math.floor(scan / columnCount) : scan % rowCapacity;
|
|
836
|
+
const column = flow === "row" ? scan % columnCount : Math.floor(scan / rowCapacity);
|
|
837
|
+
if (fits(row, column, rowSpan, columnSpan)) {
|
|
838
|
+
found = { item, row, column, rowSpan, columnSpan };
|
|
839
|
+
cursor = scan + 1;
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (!found) {
|
|
844
|
+
budgetError("grid placement steps", state.steps, state.budgets.maxSolverSteps);
|
|
845
|
+
}
|
|
846
|
+
reserve(found);
|
|
847
|
+
}
|
|
848
|
+
return work.map((item) => placementById.get(item.item.id));
|
|
849
|
+
}
|
|
850
|
+
function solveTracks(tracks, space, gap, contributions, state, path) {
|
|
851
|
+
tracks.forEach((track, index) => {
|
|
852
|
+
const min = resolveLength(track.min, space, 0);
|
|
853
|
+
const max = resolveLength(track.max, space, Number.POSITIVE_INFINITY);
|
|
854
|
+
if (min > max) {
|
|
855
|
+
throw new LayoutCompileError("invalid_geometry", `Grid ${path} track ${index} has min greater than max.`, ["layout", path, index], { index, min, max });
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
const sizes = tracks.map((track) => track.size === "hug" || track.size === "fill"
|
|
859
|
+
? 0
|
|
860
|
+
: resolveLength(track.size, space, 0));
|
|
861
|
+
for (const contribution of contributions.filter((entry) => entry.span === 1)) {
|
|
862
|
+
const track = tracks[contribution.start];
|
|
863
|
+
if (!track || track.size !== "hug")
|
|
864
|
+
continue;
|
|
865
|
+
sizes[contribution.start] = Math.max(sizes[contribution.start], contribution.intrinsic);
|
|
866
|
+
}
|
|
867
|
+
for (const contribution of contributions.filter((entry) => entry.span > 1)) {
|
|
868
|
+
const current = spanSize(sizes, contribution.start, contribution.span, gap);
|
|
869
|
+
const deficit = contribution.intrinsic - current;
|
|
870
|
+
if (deficit <= EPSILON)
|
|
871
|
+
continue;
|
|
872
|
+
const candidates = [];
|
|
873
|
+
for (let index = contribution.start; index < contribution.start + contribution.span; index += 1) {
|
|
874
|
+
const track = tracks[index];
|
|
875
|
+
if (track?.size === "hug" || track?.size === "fill")
|
|
876
|
+
candidates.push(index);
|
|
877
|
+
}
|
|
878
|
+
if (candidates.length === 0)
|
|
879
|
+
continue;
|
|
880
|
+
const addition = deficit / candidates.length;
|
|
881
|
+
candidates.forEach((index) => (sizes[index] = sizes[index] + addition));
|
|
882
|
+
}
|
|
883
|
+
tracks.forEach((track, index) => {
|
|
884
|
+
sizes[index] = clamp(sizes[index], resolveLength(track.min, space, 0), resolveLength(track.max, space, Number.POSITIVE_INFINITY));
|
|
885
|
+
});
|
|
886
|
+
const gaps = Math.max(0, tracks.length - 1) * gap;
|
|
887
|
+
const available = Math.max(0, space - gaps);
|
|
888
|
+
let total = sizes.reduce((sum, value) => sum + value, 0);
|
|
889
|
+
if (total < available - EPSILON) {
|
|
890
|
+
const weights = tracks.map((track) => track.grow ?? (track.size === "fill" ? 1 : 0));
|
|
891
|
+
let free = available - total;
|
|
892
|
+
let activeGrow = tracks
|
|
893
|
+
.map((track, index) => ({ track, index }))
|
|
894
|
+
.filter(({ track, index }) => (weights[index] ?? 0) > 0 &&
|
|
895
|
+
sizes[index] <
|
|
896
|
+
resolveLength(track.max, space, Number.POSITIVE_INFINITY) - EPSILON);
|
|
897
|
+
while (free > EPSILON && activeGrow.length > 0) {
|
|
898
|
+
step(state, activeGrow.length);
|
|
899
|
+
const sum = activeGrow.reduce((acc, { index }) => acc + (weights[index] ?? 0), 0);
|
|
900
|
+
let consumed = 0;
|
|
901
|
+
for (const { track, index } of activeGrow) {
|
|
902
|
+
const before = sizes[index];
|
|
903
|
+
sizes[index] = clamp(before + free * ((weights[index] ?? 0) / sum), resolveLength(track.min, space, 0), resolveLength(track.max, space, Number.POSITIVE_INFINITY));
|
|
904
|
+
consumed += sizes[index] - before;
|
|
905
|
+
}
|
|
906
|
+
if (consumed <= EPSILON)
|
|
907
|
+
break;
|
|
908
|
+
free -= consumed;
|
|
909
|
+
activeGrow = activeGrow.filter(({ track, index }) => sizes[index] <
|
|
910
|
+
resolveLength(track.max, space, Number.POSITIVE_INFINITY) - EPSILON);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
total = sizes.reduce((sum, value) => sum + value, 0);
|
|
914
|
+
let deficit = total - available;
|
|
915
|
+
let active = tracks
|
|
916
|
+
.map((track, index) => ({ track, index }))
|
|
917
|
+
.filter(({ track, index }) => (track.shrink ??
|
|
918
|
+
(track.size === "hug" || track.size === "fill" ? 1 : 0)) > 0 &&
|
|
919
|
+
sizes[index] > resolveLength(track.min, space, 0) + EPSILON);
|
|
920
|
+
while (deficit > EPSILON && active.length > 0) {
|
|
921
|
+
step(state, active.length);
|
|
922
|
+
const weightSum = active.reduce((sum, { track, index }) => sum +
|
|
923
|
+
(track.shrink ??
|
|
924
|
+
(track.size === "hug" || track.size === "fill" ? 1 : 0)) *
|
|
925
|
+
Math.max(1, sizes[index]), 0);
|
|
926
|
+
let consumed = 0;
|
|
927
|
+
for (const { track, index } of active) {
|
|
928
|
+
const weight = (track.shrink ??
|
|
929
|
+
(track.size === "hug" || track.size === "fill" ? 1 : 0)) *
|
|
930
|
+
Math.max(1, sizes[index]);
|
|
931
|
+
const before = sizes[index];
|
|
932
|
+
sizes[index] = Math.max(resolveLength(track.min, space, 0), before - deficit * (weight / weightSum));
|
|
933
|
+
consumed += before - sizes[index];
|
|
934
|
+
}
|
|
935
|
+
if (consumed <= EPSILON)
|
|
936
|
+
break;
|
|
937
|
+
deficit -= consumed;
|
|
938
|
+
active = active.filter(({ track, index }) => sizes[index] > resolveLength(track.min, space, 0) + EPSILON);
|
|
939
|
+
}
|
|
940
|
+
if (sizes.reduce((sum, value) => sum + value, 0) > available + EPSILON) {
|
|
941
|
+
throw new LayoutCompileError("insufficient_space", "Grid tracks cannot fit without violating fixed/min track sizes.", ["layout"]);
|
|
942
|
+
}
|
|
943
|
+
return sizes;
|
|
944
|
+
}
|
|
945
|
+
function solveOverlay(work, layout, state) {
|
|
946
|
+
const byId = new Map(work.map((entry) => [entry.item.id, entry]));
|
|
947
|
+
const dependencies = new Map();
|
|
948
|
+
for (const entry of work) {
|
|
949
|
+
const deps = new Set();
|
|
950
|
+
const coordinateWrites = new Set();
|
|
951
|
+
const dimensionWrites = new Set();
|
|
952
|
+
for (const [constraintIndex, constraint] of (entry.item.constraints ?? []).entries()) {
|
|
953
|
+
if (!byId.has(constraint.target)) {
|
|
954
|
+
throw new LayoutCompileError("unknown_constraint_target", `Constraint on ${JSON.stringify(entry.item.id)} references unknown sibling ${JSON.stringify(constraint.target)}.`, ["items", entry.index, "constraints", constraintIndex, "target"], { id: entry.item.id, target: constraint.target });
|
|
955
|
+
}
|
|
956
|
+
deps.add(constraint.target);
|
|
957
|
+
const write = constraintWrite(constraint);
|
|
958
|
+
const set = write.kind === "axis" ? coordinateWrites : dimensionWrites;
|
|
959
|
+
if (set.has(write.value)) {
|
|
960
|
+
throw new LayoutCompileError("constraint_conflict", `Multiple constraints write ${write.value} on ${JSON.stringify(entry.item.id)}.`, ["items", entry.index, "constraints", constraintIndex], { id: entry.item.id, field: write.value });
|
|
961
|
+
}
|
|
962
|
+
set.add(write.value);
|
|
963
|
+
}
|
|
964
|
+
dependencies.set(entry.item.id, deps);
|
|
965
|
+
}
|
|
966
|
+
const ordered = stableTopologicalOrder(work, dependencies, state);
|
|
967
|
+
for (const entry of ordered) {
|
|
968
|
+
if (entry.widthSizing === "fill")
|
|
969
|
+
entry.width = clamp(state.bounds.width - entry.margins.left - entry.margins.right, entry.minWidth, entry.maxWidth);
|
|
970
|
+
if (entry.heightSizing === "fill")
|
|
971
|
+
entry.height = clamp(state.bounds.height - entry.margins.top - entry.margins.bottom, entry.minHeight, entry.maxHeight);
|
|
972
|
+
refreshWidthFromHeight(entry);
|
|
973
|
+
let matchedWidth = false;
|
|
974
|
+
let matchedHeight = false;
|
|
975
|
+
for (const [constraintIndex, constraint] of (entry.item.constraints ?? []).entries()) {
|
|
976
|
+
if (constraint.kind !== "match")
|
|
977
|
+
continue;
|
|
978
|
+
const target = byId.get(constraint.target);
|
|
979
|
+
const desired = constraint.dimension === "width" ? target.width : target.height;
|
|
980
|
+
const resolved = constraint.dimension === "width"
|
|
981
|
+
? clamp(desired, entry.minWidth, entry.maxWidth)
|
|
982
|
+
: clamp(desired, entry.minHeight, entry.maxHeight);
|
|
983
|
+
if (Math.abs(resolved - desired) > EPSILON) {
|
|
984
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Match constraint on ${JSON.stringify(entry.item.id)} cannot equal ${JSON.stringify(constraint.target)} without violating ${constraint.dimension} min/max.`, ["items", entry.index, "constraints", constraintIndex], {
|
|
985
|
+
id: entry.item.id,
|
|
986
|
+
target: constraint.target,
|
|
987
|
+
dimension: constraint.dimension,
|
|
988
|
+
desired,
|
|
989
|
+
resolved,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
if (constraint.dimension === "width") {
|
|
993
|
+
entry.width = resolved;
|
|
994
|
+
matchedWidth = true;
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
entry.height = resolved;
|
|
998
|
+
matchedHeight = true;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
deriveAspectFromMatches(entry, matchedWidth, matchedHeight);
|
|
1002
|
+
if (!matchedWidth && !matchedHeight)
|
|
1003
|
+
refreshWidthFromHeight(entry);
|
|
1004
|
+
const aspectOwnsHeight = matchedWidth &&
|
|
1005
|
+
!matchedHeight &&
|
|
1006
|
+
entry.aspectRatio !== null &&
|
|
1007
|
+
entry.heightSizing === "hug";
|
|
1008
|
+
refreshTextHeight(entry, entry.width, state, !matchedHeight && !aspectOwnsHeight);
|
|
1009
|
+
anchorOverlay(entry, entry.item.anchor ?? layout.defaultAnchor ?? "center", state.bounds);
|
|
1010
|
+
entry.x += resolveOffset(entry.item.offsetX, state.bounds.width);
|
|
1011
|
+
entry.y += resolveOffset(entry.item.offsetY, state.bounds.height);
|
|
1012
|
+
for (const constraint of entry.item.constraints ?? []) {
|
|
1013
|
+
if (constraint.kind === "match")
|
|
1014
|
+
continue;
|
|
1015
|
+
const target = byId.get(constraint.target);
|
|
1016
|
+
applyCoordinateConstraint(entry, target, constraint, state.bounds);
|
|
1017
|
+
}
|
|
1018
|
+
if ((entry.item.constraints?.length ?? 0) > 0 &&
|
|
1019
|
+
!containsRect(state.bounds, entry)) {
|
|
1020
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Sibling constraints place ${JSON.stringify(entry.item.id)} outside the bounded overlay.`, ["items", entry.index, "constraints"], { id: entry.item.id });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
state.lines += work.length > 0 ? 1 : 0;
|
|
1024
|
+
}
|
|
1025
|
+
function stableTopologicalOrder(work, dependencies, state) {
|
|
1026
|
+
const remaining = new Map([...dependencies].map(([id, values]) => [id, new Set(values)]));
|
|
1027
|
+
const depths = new Map();
|
|
1028
|
+
const output = [];
|
|
1029
|
+
while (output.length < work.length) {
|
|
1030
|
+
const next = work.find((entry) => !output.includes(entry) && remaining.get(entry.item.id)?.size === 0);
|
|
1031
|
+
if (!next) {
|
|
1032
|
+
const cycle = work
|
|
1033
|
+
.filter((entry) => !output.includes(entry))
|
|
1034
|
+
.map((entry) => entry.item.id);
|
|
1035
|
+
throw new LayoutCompileError("constraint_cycle", `Sibling constraints contain a cycle: ${cycle.map((id) => JSON.stringify(id)).join(" -> ")}.`, ["items"], { ids: cycle });
|
|
1036
|
+
}
|
|
1037
|
+
output.push(next);
|
|
1038
|
+
const deps = dependencies.get(next.item.id) ?? new Set();
|
|
1039
|
+
const depth = deps.size === 0
|
|
1040
|
+
? 0
|
|
1041
|
+
: 1 + Math.max(...[...deps].map((id) => depths.get(id) ?? 0));
|
|
1042
|
+
depths.set(next.item.id, depth);
|
|
1043
|
+
state.dependencyDepth = Math.max(state.dependencyDepth, depth);
|
|
1044
|
+
for (const values of remaining.values())
|
|
1045
|
+
values.delete(next.item.id);
|
|
1046
|
+
step(state);
|
|
1047
|
+
}
|
|
1048
|
+
return output;
|
|
1049
|
+
}
|
|
1050
|
+
function applyCoordinateConstraint(entry, target, constraint, bounds) {
|
|
1051
|
+
if (constraint.kind === "align") {
|
|
1052
|
+
if (constraint.axis === "x") {
|
|
1053
|
+
entry.x =
|
|
1054
|
+
constraint.edge === "start"
|
|
1055
|
+
? target.x
|
|
1056
|
+
: constraint.edge === "center"
|
|
1057
|
+
? target.x + (target.width - entry.width) / 2
|
|
1058
|
+
: target.x + target.width - entry.width;
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
entry.y =
|
|
1062
|
+
constraint.edge === "start"
|
|
1063
|
+
? target.y
|
|
1064
|
+
: constraint.edge === "center"
|
|
1065
|
+
? target.y + (target.height - entry.height) / 2
|
|
1066
|
+
: target.y + target.height - entry.height;
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const basis = constraint.axis === "x" ? bounds.width : bounds.height;
|
|
1071
|
+
const gap = resolveLength(constraint.gap, basis, 0);
|
|
1072
|
+
if (constraint.axis === "x") {
|
|
1073
|
+
entry.x =
|
|
1074
|
+
constraint.kind === "after"
|
|
1075
|
+
? target.x + target.width + gap
|
|
1076
|
+
: target.x - entry.width - gap;
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
entry.y =
|
|
1080
|
+
constraint.kind === "after"
|
|
1081
|
+
? target.y + target.height + gap
|
|
1082
|
+
: target.y - entry.height - gap;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
function constraintWrite(constraint) {
|
|
1086
|
+
return constraint.kind === "match"
|
|
1087
|
+
? { kind: "dimension", value: constraint.dimension }
|
|
1088
|
+
: { kind: "axis", value: constraint.axis };
|
|
1089
|
+
}
|
|
1090
|
+
function anchorOverlay(entry, anchor, bounds) {
|
|
1091
|
+
const [vertical, horizontal] = anchor === "center"
|
|
1092
|
+
? ["center", "center"]
|
|
1093
|
+
: anchor.split("-");
|
|
1094
|
+
const horizontalSpace = bounds.width - entry.width - entry.margins.left - entry.margins.right;
|
|
1095
|
+
const verticalSpace = bounds.height - entry.height - entry.margins.top - entry.margins.bottom;
|
|
1096
|
+
entry.x =
|
|
1097
|
+
bounds.x +
|
|
1098
|
+
entry.margins.left +
|
|
1099
|
+
(horizontal === "center"
|
|
1100
|
+
? horizontalSpace / 2
|
|
1101
|
+
: horizontal === "right"
|
|
1102
|
+
? horizontalSpace
|
|
1103
|
+
: 0);
|
|
1104
|
+
entry.y =
|
|
1105
|
+
bounds.y +
|
|
1106
|
+
entry.margins.top +
|
|
1107
|
+
(vertical === "center"
|
|
1108
|
+
? verticalSpace / 2
|
|
1109
|
+
: vertical === "bottom"
|
|
1110
|
+
? verticalSpace
|
|
1111
|
+
: 0);
|
|
1112
|
+
}
|
|
1113
|
+
function placeInCell(item, cell, horizontal, vertical, state) {
|
|
1114
|
+
const hMargins = item.margins.left + item.margins.right;
|
|
1115
|
+
const vMargins = item.margins.top + item.margins.bottom;
|
|
1116
|
+
if (item.widthSizing === "fill" ||
|
|
1117
|
+
(horizontal === "stretch" && item.widthAuto)) {
|
|
1118
|
+
item.width = clamp(Math.max(0, cell.width - hMargins), item.minWidth, item.maxWidth);
|
|
1119
|
+
refreshTextHeight(item, item.width, state);
|
|
1120
|
+
}
|
|
1121
|
+
if (item.heightSizing === "fill" ||
|
|
1122
|
+
(vertical === "stretch" && item.heightAuto)) {
|
|
1123
|
+
item.height = clamp(Math.max(0, cell.height - vMargins), item.minHeight, item.maxHeight);
|
|
1124
|
+
refreshWidthFromHeight(item);
|
|
1125
|
+
}
|
|
1126
|
+
const freeX = cell.width - item.width - hMargins;
|
|
1127
|
+
const freeY = cell.height - item.height - vMargins;
|
|
1128
|
+
item.x =
|
|
1129
|
+
cell.x +
|
|
1130
|
+
item.margins.left +
|
|
1131
|
+
(horizontal === "center" ? freeX / 2 : horizontal === "end" ? freeX : 0);
|
|
1132
|
+
item.y =
|
|
1133
|
+
cell.y +
|
|
1134
|
+
item.margins.top +
|
|
1135
|
+
(vertical === "center" ? freeY / 2 : vertical === "end" ? freeY : 0);
|
|
1136
|
+
}
|
|
1137
|
+
function refreshTextHeight(entry, width, state, applyHeight = true) {
|
|
1138
|
+
if (entry.node.type === "text") {
|
|
1139
|
+
if (entry.heightSizing !== "hug")
|
|
1140
|
+
return;
|
|
1141
|
+
const measured = measureIntrinsic(entry.node, true, true, width, state);
|
|
1142
|
+
entry.intrinsic = measured;
|
|
1143
|
+
if (applyHeight) {
|
|
1144
|
+
entry.height = clamp(measured.height, entry.minHeight, entry.maxHeight);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else if (entry.heightSizing === "hug" &&
|
|
1148
|
+
entry.aspectRatio &&
|
|
1149
|
+
entry.widthSizing !== "hug") {
|
|
1150
|
+
entry.height = clamp(width / entry.aspectRatio, entry.minHeight, entry.maxHeight);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function refreshWidthFromHeight(entry) {
|
|
1154
|
+
if (entry.widthSizing === "hug" &&
|
|
1155
|
+
entry.heightSizing !== "hug" &&
|
|
1156
|
+
entry.aspectRatio) {
|
|
1157
|
+
entry.width = clamp(entry.height * entry.aspectRatio, entry.minWidth, entry.maxWidth);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
function deriveAspectFromMatches(entry, matchedWidth, matchedHeight) {
|
|
1161
|
+
const ratio = entry.aspectRatio;
|
|
1162
|
+
if (!ratio || (!matchedWidth && !matchedHeight))
|
|
1163
|
+
return;
|
|
1164
|
+
if (matchedWidth && matchedHeight) {
|
|
1165
|
+
if (entry.height <= EPSILON ||
|
|
1166
|
+
Math.abs(entry.width / entry.height - ratio) > EPSILON) {
|
|
1167
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Matched width and height on ${JSON.stringify(entry.item.id)} conflict with its aspect ratio.`, ["items", entry.index, "constraints"], { id: entry.item.id, ratio });
|
|
1168
|
+
}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (matchedWidth && entry.heightSizing === "hug") {
|
|
1172
|
+
const desired = entry.width / ratio;
|
|
1173
|
+
const resolved = clamp(desired, entry.minHeight, entry.maxHeight);
|
|
1174
|
+
if (Math.abs(desired - resolved) > EPSILON) {
|
|
1175
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Matched width on ${JSON.stringify(entry.item.id)} cannot derive an aspect-correct height within min/max.`, ["items", entry.index, "constraints"], { id: entry.item.id, desired, resolved });
|
|
1176
|
+
}
|
|
1177
|
+
entry.height = resolved;
|
|
1178
|
+
}
|
|
1179
|
+
else if (matchedHeight && entry.widthSizing === "hug") {
|
|
1180
|
+
const desired = entry.height * ratio;
|
|
1181
|
+
const resolved = clamp(desired, entry.minWidth, entry.maxWidth);
|
|
1182
|
+
if (Math.abs(desired - resolved) > EPSILON) {
|
|
1183
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Matched height on ${JSON.stringify(entry.item.id)} cannot derive an aspect-correct width within min/max.`, ["items", entry.index, "constraints"], { id: entry.item.id, desired, resolved });
|
|
1184
|
+
}
|
|
1185
|
+
entry.width = resolved;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
function refreshMainFromAvailableCross(entry, row, crossSpace) {
|
|
1189
|
+
if (!entry.aspectRatio)
|
|
1190
|
+
return;
|
|
1191
|
+
if (row && entry.widthSizing === "hug" && entry.heightSizing === "fill") {
|
|
1192
|
+
entry.width = clamp(Math.max(0, crossSpace - crossMargins(entry, true)) * entry.aspectRatio, entry.minWidth, entry.maxWidth);
|
|
1193
|
+
}
|
|
1194
|
+
else if (!row &&
|
|
1195
|
+
entry.heightSizing === "hug" &&
|
|
1196
|
+
entry.widthSizing === "fill") {
|
|
1197
|
+
entry.height = clamp(Math.max(0, crossSpace - crossMargins(entry, false)) / entry.aspectRatio, entry.minHeight, entry.maxHeight);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function compiledNode(entry, state) {
|
|
1201
|
+
let projected = entry.node;
|
|
1202
|
+
if (entry.intrinsic.textPlan) {
|
|
1203
|
+
const conflicting = (entry.node.animations ?? [])
|
|
1204
|
+
.filter((animation) => textPlanningAnimationTargets.has(animation.property))
|
|
1205
|
+
.map((animation) => animation.property);
|
|
1206
|
+
if (conflicting.length > 0) {
|
|
1207
|
+
throw new LayoutCompileError("animation_conflict", `Layout v2 planned text ${JSON.stringify(entry.node.id)} cannot animate planning-owned fields ${[...new Set(conflicting)].join(", ")}.`, ["items", entry.index, "animations"], { id: entry.node.id, properties: [...new Set(conflicting)] });
|
|
1208
|
+
}
|
|
1209
|
+
if (entry.node.runs && state.options.richTextPolicy === "legacy-unsafe") {
|
|
1210
|
+
projected = {
|
|
1211
|
+
...entry.node,
|
|
1212
|
+
style: {
|
|
1213
|
+
...(entry.node.style ?? {}),
|
|
1214
|
+
direction: entry.intrinsic.textPlan.baseDirection,
|
|
1215
|
+
textAlign: entry.intrinsic.textPlan.physicalAlign,
|
|
1216
|
+
fontSize: entry.intrinsic.textPlan.fontSize,
|
|
1217
|
+
lineHeight: `${entry.intrinsic.textPlan.lineHeight}px`,
|
|
1218
|
+
...(entry.intrinsic.font?.assetId
|
|
1219
|
+
? { fontFamily: entry.intrinsic.font.family }
|
|
1220
|
+
: {}),
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
else {
|
|
1225
|
+
const textProjection = projectLayoutTextToSceneV1(entry.node, entry.intrinsic.textPlan);
|
|
1226
|
+
const { runs: _runs, ...source } = entry.node;
|
|
1227
|
+
projected = { ...source, ...textProjection };
|
|
1228
|
+
if (entry.intrinsic.font?.assetId) {
|
|
1229
|
+
projected.style = {
|
|
1230
|
+
...(projected.style ?? {}),
|
|
1231
|
+
fontFamily: entry.intrinsic.font.family,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const style = { ...(projected.style ?? {}) };
|
|
1237
|
+
const owned = [
|
|
1238
|
+
"position",
|
|
1239
|
+
"left",
|
|
1240
|
+
"right",
|
|
1241
|
+
"top",
|
|
1242
|
+
"bottom",
|
|
1243
|
+
"inset",
|
|
1244
|
+
"width",
|
|
1245
|
+
"height",
|
|
1246
|
+
"minWidth",
|
|
1247
|
+
"minHeight",
|
|
1248
|
+
"maxWidth",
|
|
1249
|
+
"maxHeight",
|
|
1250
|
+
"aspectRatio",
|
|
1251
|
+
"safeArea",
|
|
1252
|
+
"margin",
|
|
1253
|
+
"marginTop",
|
|
1254
|
+
"marginRight",
|
|
1255
|
+
"marginBottom",
|
|
1256
|
+
"marginLeft",
|
|
1257
|
+
];
|
|
1258
|
+
const replaced = owned.some((key) => style[key] !== undefined);
|
|
1259
|
+
for (const key of owned)
|
|
1260
|
+
delete style[key];
|
|
1261
|
+
Object.assign(style, {
|
|
1262
|
+
position: "absolute",
|
|
1263
|
+
left: finiteNumber(entry.x),
|
|
1264
|
+
top: finiteNumber(entry.y),
|
|
1265
|
+
width: finiteNumber(entry.width),
|
|
1266
|
+
height: finiteNumber(entry.height),
|
|
1267
|
+
});
|
|
1268
|
+
if (replaced) {
|
|
1269
|
+
state.diagnostics.push({
|
|
1270
|
+
severity: "info",
|
|
1271
|
+
code: "layout_style_replaced",
|
|
1272
|
+
message: `Semantic layout replaced geometry-owned style fields on ${JSON.stringify(entry.node.id)}.`,
|
|
1273
|
+
path: ["items", entry.index],
|
|
1274
|
+
itemId: entry.node.id,
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
return { ...projected, style };
|
|
1278
|
+
}
|
|
1279
|
+
function toMeasurement(entry) {
|
|
1280
|
+
const qualities = [
|
|
1281
|
+
entry.intrinsic.widthMeasurement.measurement,
|
|
1282
|
+
entry.intrinsic.heightMeasurement.measurement,
|
|
1283
|
+
];
|
|
1284
|
+
if (entry.intrinsic.font?.measurement === "estimated") {
|
|
1285
|
+
qualities.push("estimated");
|
|
1286
|
+
}
|
|
1287
|
+
const measurement = worstMeasurementQuality(...qualities);
|
|
1288
|
+
const reasons = [
|
|
1289
|
+
entry.intrinsic.widthMeasurement.reason,
|
|
1290
|
+
entry.intrinsic.heightMeasurement.reason,
|
|
1291
|
+
];
|
|
1292
|
+
if (entry.intrinsic.font)
|
|
1293
|
+
reasons.push(entry.intrinsic.font.reason);
|
|
1294
|
+
return {
|
|
1295
|
+
id: entry.item.id,
|
|
1296
|
+
intrinsic: {
|
|
1297
|
+
width: finiteNumber(entry.intrinsic.width),
|
|
1298
|
+
height: finiteNumber(entry.intrinsic.height),
|
|
1299
|
+
},
|
|
1300
|
+
resolved: {
|
|
1301
|
+
x: finiteNumber(entry.x),
|
|
1302
|
+
y: finiteNumber(entry.y),
|
|
1303
|
+
width: finiteNumber(entry.width),
|
|
1304
|
+
height: finiteNumber(entry.height),
|
|
1305
|
+
},
|
|
1306
|
+
fieldMeasurements: {
|
|
1307
|
+
width: { ...entry.intrinsic.widthMeasurement },
|
|
1308
|
+
height: { ...entry.intrinsic.heightMeasurement },
|
|
1309
|
+
},
|
|
1310
|
+
measurement,
|
|
1311
|
+
reason: [...new Set(reasons)].sort().join("; "),
|
|
1312
|
+
...(entry.intrinsic.textPlan
|
|
1313
|
+
? { textPlan: structuredClone(entry.intrinsic.textPlan) }
|
|
1314
|
+
: {}),
|
|
1315
|
+
...(entry.intrinsic.font ? { font: { ...entry.intrinsic.font } } : {}),
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
function findTarget(scene, containerId) {
|
|
1319
|
+
if (containerId === LAYOUT_ROOT)
|
|
1320
|
+
return { children: scene.nodes };
|
|
1321
|
+
const stack = [...scene.nodes];
|
|
1322
|
+
while (stack.length > 0) {
|
|
1323
|
+
const node = stack.pop();
|
|
1324
|
+
if (node.id === containerId) {
|
|
1325
|
+
return { container: node, children: node.children ?? [] };
|
|
1326
|
+
}
|
|
1327
|
+
stack.push(...(node.children ?? []));
|
|
1328
|
+
}
|
|
1329
|
+
throw new LayoutCompileError("missing_container", `No Scene node has layout container id ${JSON.stringify(containerId)}.`, ["containerId"], { containerId });
|
|
1330
|
+
}
|
|
1331
|
+
function resolveContainerBounds(scene, containerId, options) {
|
|
1332
|
+
if (containerId === LAYOUT_ROOT) {
|
|
1333
|
+
return { x: 0, y: 0, width: scene.width, height: scene.height };
|
|
1334
|
+
}
|
|
1335
|
+
const frame = options.frame ?? 0;
|
|
1336
|
+
if (!Number.isFinite(frame) || frame < 0) {
|
|
1337
|
+
throw new LayoutCompileError("invalid_geometry", "Layout compile frame must be finite and non-negative.", ["options", "frame"]);
|
|
1338
|
+
}
|
|
1339
|
+
let layout;
|
|
1340
|
+
try {
|
|
1341
|
+
layout = layoutScene(evaluateScene(scene, frame), {
|
|
1342
|
+
textMetrics: options.textMetrics,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
if (error instanceof LayoutCompileError)
|
|
1347
|
+
throw error;
|
|
1348
|
+
throw new LayoutCompileError("invalid_geometry", `Could not resolve container ${JSON.stringify(containerId)} at frame ${frame}: ${safeErrorMessage(error)}.`, ["containerId"], { containerId, frame });
|
|
1349
|
+
}
|
|
1350
|
+
const box = findLayoutBox(layout.boxes, containerId);
|
|
1351
|
+
if (!box) {
|
|
1352
|
+
throw new LayoutCompileError("missing_container", `Container ${JSON.stringify(containerId)} is not active at compile frame ${frame}.`, ["containerId"], { containerId, frame });
|
|
1353
|
+
}
|
|
1354
|
+
const content = getLayoutContentRect(box);
|
|
1355
|
+
// Child coordinates in Scene are relative to this content box, not global.
|
|
1356
|
+
return { x: 0, y: 0, width: content.width, height: content.height };
|
|
1357
|
+
}
|
|
1358
|
+
function findLayoutBox(boxes, id) {
|
|
1359
|
+
const stack = [...boxes];
|
|
1360
|
+
while (stack.length > 0) {
|
|
1361
|
+
const box = stack.pop();
|
|
1362
|
+
if (box.id === id)
|
|
1363
|
+
return box;
|
|
1364
|
+
stack.push(...box.children);
|
|
1365
|
+
}
|
|
1366
|
+
return undefined;
|
|
1367
|
+
}
|
|
1368
|
+
function replaceTargetChildren(scene, containerId, replacements) {
|
|
1369
|
+
const replace = (nodes) => {
|
|
1370
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
1371
|
+
const node = nodes[index];
|
|
1372
|
+
if (node.id === containerId) {
|
|
1373
|
+
const style = { ...(node.style ?? {}) };
|
|
1374
|
+
for (const key of legacyContainerLayoutKeys)
|
|
1375
|
+
delete style[key];
|
|
1376
|
+
node.style = style;
|
|
1377
|
+
node.children = (node.children ?? []).map((child) => replacements.get(child.id) ?? child);
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
if (replace(node.children ?? []))
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
return false;
|
|
1384
|
+
};
|
|
1385
|
+
if (containerId === LAYOUT_ROOT) {
|
|
1386
|
+
scene.nodes = scene.nodes.map((node) => replacements.get(node.id) ?? node);
|
|
1387
|
+
}
|
|
1388
|
+
else if (!replace(scene.nodes)) {
|
|
1389
|
+
throw new LayoutCompileError("missing_container", "Layout container disappeared during compilation.");
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
const legacyContainerLayoutKeys = [
|
|
1393
|
+
"display",
|
|
1394
|
+
"flexDirection",
|
|
1395
|
+
"justifyContent",
|
|
1396
|
+
"alignItems",
|
|
1397
|
+
"gap",
|
|
1398
|
+
];
|
|
1399
|
+
function rejectLayoutAnimations(node, path) {
|
|
1400
|
+
const conflicting = (node.animations ?? [])
|
|
1401
|
+
.filter((animation) => layoutOwnedAnimationTargets.has(animation.property))
|
|
1402
|
+
.map((animation) => animation.property);
|
|
1403
|
+
if (conflicting.length > 0) {
|
|
1404
|
+
throw new LayoutCompileError("animation_conflict", `Layout v2 compiles a static target-size snapshot; ${JSON.stringify(node.id)} animates layout-owned fields ${[...new Set(conflicting)].join(", ")}. A retained/animated semantic layout requires a future Scene version.`, path, { id: node.id, properties: [...new Set(conflicting)] });
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
function intrinsicChildUnion(node) {
|
|
1408
|
+
let width = 0;
|
|
1409
|
+
let height = 0;
|
|
1410
|
+
let measured = 0;
|
|
1411
|
+
for (const child of node.children ?? []) {
|
|
1412
|
+
const style = child.style ?? {};
|
|
1413
|
+
const childWidth = absoluteLength(style.width);
|
|
1414
|
+
const childHeight = absoluteLength(style.height);
|
|
1415
|
+
if (childWidth === undefined || childHeight === undefined)
|
|
1416
|
+
continue;
|
|
1417
|
+
const left = absoluteLength(style.left) ?? 0;
|
|
1418
|
+
const top = absoluteLength(style.top) ?? 0;
|
|
1419
|
+
width = Math.max(width, left + childWidth);
|
|
1420
|
+
height = Math.max(height, top + childHeight);
|
|
1421
|
+
measured += 1;
|
|
1422
|
+
}
|
|
1423
|
+
return {
|
|
1424
|
+
width,
|
|
1425
|
+
height,
|
|
1426
|
+
reason: measured > 0
|
|
1427
|
+
? "estimated union of explicitly-sized child boxes"
|
|
1428
|
+
: "no intrinsic metrics; empty generic-node fallback",
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
function resolveSceneMargins(style, bounds) {
|
|
1432
|
+
const all = resolveLength(style.margin, bounds.width, 0);
|
|
1433
|
+
return {
|
|
1434
|
+
top: resolveLength(style.marginTop, bounds.width, all),
|
|
1435
|
+
right: resolveLength(style.marginRight, bounds.width, all),
|
|
1436
|
+
bottom: resolveLength(style.marginBottom, bounds.width, all),
|
|
1437
|
+
left: resolveLength(style.marginLeft, bounds.width, all),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
function resolveInsets(input, basis) {
|
|
1441
|
+
if (input === undefined)
|
|
1442
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
1443
|
+
if (typeof input === "number" || typeof input === "string") {
|
|
1444
|
+
const horizontal = resolveLength(input, basis.width, 0);
|
|
1445
|
+
const vertical = resolveLength(input, basis.height, 0);
|
|
1446
|
+
return {
|
|
1447
|
+
top: vertical,
|
|
1448
|
+
right: horizontal,
|
|
1449
|
+
bottom: vertical,
|
|
1450
|
+
left: horizontal,
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
const x = resolveLength(input.x, basis.width, 0);
|
|
1454
|
+
const y = resolveLength(input.y, basis.height, 0);
|
|
1455
|
+
return {
|
|
1456
|
+
top: resolveLength(input.top, basis.height, y),
|
|
1457
|
+
right: resolveLength(input.right, basis.width, x),
|
|
1458
|
+
bottom: resolveLength(input.bottom, basis.height, y),
|
|
1459
|
+
left: resolveLength(input.left, basis.width, x),
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
function insetRect(rect, insets) {
|
|
1463
|
+
return {
|
|
1464
|
+
x: rect.x + insets.left,
|
|
1465
|
+
y: rect.y + insets.top,
|
|
1466
|
+
width: rect.width - insets.left - insets.right,
|
|
1467
|
+
height: rect.height - insets.top - insets.bottom,
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
function resolveLength(value, basis, fallback) {
|
|
1471
|
+
if (value === undefined)
|
|
1472
|
+
return fallback;
|
|
1473
|
+
if (typeof value === "number")
|
|
1474
|
+
return value;
|
|
1475
|
+
if (value === "hug" || value === "fill")
|
|
1476
|
+
return fallback;
|
|
1477
|
+
const parsed = Number.parseFloat(value);
|
|
1478
|
+
if (!Number.isFinite(parsed))
|
|
1479
|
+
return fallback;
|
|
1480
|
+
return value.endsWith("%") ? (parsed / 100) * basis : parsed;
|
|
1481
|
+
}
|
|
1482
|
+
function resolveOffset(value, basis) {
|
|
1483
|
+
if (value === undefined)
|
|
1484
|
+
return 0;
|
|
1485
|
+
if (typeof value === "number")
|
|
1486
|
+
return value;
|
|
1487
|
+
const parsed = Number.parseFloat(value);
|
|
1488
|
+
return value.endsWith("%") ? (parsed / 100) * basis : parsed;
|
|
1489
|
+
}
|
|
1490
|
+
function absoluteLength(value) {
|
|
1491
|
+
if (typeof value === "number")
|
|
1492
|
+
return value;
|
|
1493
|
+
if (typeof value === "string" && value.endsWith("px")) {
|
|
1494
|
+
const parsed = Number.parseFloat(value);
|
|
1495
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1496
|
+
}
|
|
1497
|
+
return undefined;
|
|
1498
|
+
}
|
|
1499
|
+
function sizingBase(sizing, basis, intrinsic) {
|
|
1500
|
+
if (sizing === "hug")
|
|
1501
|
+
return intrinsic;
|
|
1502
|
+
if (sizing === "fill")
|
|
1503
|
+
return 0;
|
|
1504
|
+
return resolveLength(sizing, basis, intrinsic);
|
|
1505
|
+
}
|
|
1506
|
+
function resolveBudgets(input) {
|
|
1507
|
+
const allowed = new Set(Object.keys(defaultLayoutCompilerBudgets));
|
|
1508
|
+
for (const name of Object.keys(input ?? {})) {
|
|
1509
|
+
if (!allowed.has(name)) {
|
|
1510
|
+
throw new LayoutCompileError("invalid_geometry", `Unknown layout compiler budget ${JSON.stringify(name)}.`, ["options", "budgets", name], { budget: name });
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const budgets = { ...defaultLayoutCompilerBudgets, ...input };
|
|
1514
|
+
for (const [name, value] of Object.entries(budgets)) {
|
|
1515
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
1516
|
+
throw new LayoutCompileError("invalid_geometry", `${name} must be a positive safe integer.`, ["options", "budgets", name]);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return budgets;
|
|
1520
|
+
}
|
|
1521
|
+
function budgetError(name, actual, limit) {
|
|
1522
|
+
throw new LayoutCompileError("budget_exceeded", `Layout ${name} budget ${limit} exceeded.`, [], { actual, limit, budget: name });
|
|
1523
|
+
}
|
|
1524
|
+
function step(state, count = 1) {
|
|
1525
|
+
state.steps += count;
|
|
1526
|
+
if (state.steps > state.budgets.maxSolverSteps) {
|
|
1527
|
+
budgetError("solver steps", state.steps, state.budgets.maxSolverSteps);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
function estimatedDiagnostic(id, reason, state) {
|
|
1531
|
+
if (state.diagnostics.some((entry) => entry.code === "estimated_intrinsic_metrics" && entry.itemId === id))
|
|
1532
|
+
return;
|
|
1533
|
+
state.diagnostics.push({
|
|
1534
|
+
severity: "warning",
|
|
1535
|
+
code: "estimated_intrinsic_metrics",
|
|
1536
|
+
message: `Layout item ${JSON.stringify(id)} uses estimated intrinsic metrics: ${reason}.`,
|
|
1537
|
+
path: ["items", id],
|
|
1538
|
+
itemId: id,
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
function pushDiagnosticOnce(state, diagnostic) {
|
|
1542
|
+
if (state.diagnostics.some((entry) => entry.code === diagnostic.code && entry.itemId === diagnostic.itemId)) {
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
state.diagnostics.push(diagnostic);
|
|
1546
|
+
}
|
|
1547
|
+
function assertFiniteRect(entry, state) {
|
|
1548
|
+
for (const [field, value] of Object.entries({
|
|
1549
|
+
x: entry.x,
|
|
1550
|
+
y: entry.y,
|
|
1551
|
+
width: entry.width,
|
|
1552
|
+
height: entry.height,
|
|
1553
|
+
})) {
|
|
1554
|
+
if (!Number.isFinite(value) ||
|
|
1555
|
+
value <
|
|
1556
|
+
(field === "width" || field === "height"
|
|
1557
|
+
? 0
|
|
1558
|
+
: -state.budgets.maxCoordinate) ||
|
|
1559
|
+
value > state.budgets.maxCoordinate) {
|
|
1560
|
+
throw new LayoutCompileError("invalid_geometry", `Layout item ${JSON.stringify(entry.item.id)} produced invalid ${field}=${String(value)}.`, ["items", entry.index], { id: entry.item.id, field, value });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
function assertAspectRatio(entry) {
|
|
1565
|
+
if (!entry.aspectRatio || !entry.aspectRatioIsHard)
|
|
1566
|
+
return;
|
|
1567
|
+
if (entry.width <= EPSILON ||
|
|
1568
|
+
entry.height <= EPSILON ||
|
|
1569
|
+
Math.abs(entry.width / entry.height - entry.aspectRatio) > EPSILON) {
|
|
1570
|
+
throw new LayoutCompileError("constraint_unsatisfied", `Layout item ${JSON.stringify(entry.item.id)} cannot satisfy aspect ratio ${formatNumber(entry.aspectRatio)} within its resolved sizing/min/max constraints.`, ["items", entry.index, "aspectRatio"], {
|
|
1571
|
+
id: entry.item.id,
|
|
1572
|
+
ratio: entry.aspectRatio,
|
|
1573
|
+
width: entry.width,
|
|
1574
|
+
height: entry.height,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
function finiteNumber(value) {
|
|
1579
|
+
if (Math.abs(value) > Number.MAX_SAFE_INTEGER / 1_000_000)
|
|
1580
|
+
return value;
|
|
1581
|
+
const rounded = Math.round(value * 1_000_000) / 1_000_000;
|
|
1582
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
1583
|
+
}
|
|
1584
|
+
function assertIntrinsicBounds(intrinsic, id, index, state) {
|
|
1585
|
+
for (const [field, value] of Object.entries({
|
|
1586
|
+
width: intrinsic.width,
|
|
1587
|
+
height: intrinsic.height,
|
|
1588
|
+
})) {
|
|
1589
|
+
if (!Number.isFinite(value) ||
|
|
1590
|
+
value < 0 ||
|
|
1591
|
+
value > state.budgets.maxCoordinate) {
|
|
1592
|
+
throw new LayoutCompileError("invalid_geometry", `Intrinsic ${field} for ${JSON.stringify(id)} must be finite, non-negative, and at most ${state.budgets.maxCoordinate}.`, ["items", index], { id, field, value });
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
function assertStrictJson(value) {
|
|
1597
|
+
const stack = [value];
|
|
1598
|
+
const seen = new WeakSet();
|
|
1599
|
+
let visited = 0;
|
|
1600
|
+
while (stack.length > 0) {
|
|
1601
|
+
const current = stack.pop();
|
|
1602
|
+
visited += 1;
|
|
1603
|
+
if (visited > 1_000_000) {
|
|
1604
|
+
throw new LayoutCompileError("budget_exceeded", "Layout result exceeds the strict JSON verification budget.");
|
|
1605
|
+
}
|
|
1606
|
+
if (current === null ||
|
|
1607
|
+
typeof current === "string" ||
|
|
1608
|
+
typeof current === "boolean")
|
|
1609
|
+
continue;
|
|
1610
|
+
if (typeof current === "number") {
|
|
1611
|
+
if (!Number.isFinite(current)) {
|
|
1612
|
+
throw new LayoutCompileError("invalid_geometry", "Layout result contains a non-finite number.");
|
|
1613
|
+
}
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
if (typeof current !== "object") {
|
|
1617
|
+
throw new LayoutCompileError("invalid_geometry", "Layout result must contain only strict JSON values.");
|
|
1618
|
+
}
|
|
1619
|
+
if (seen.has(current)) {
|
|
1620
|
+
throw new LayoutCompileError("invalid_geometry", "Layout result must be a JSON tree without shared references.");
|
|
1621
|
+
}
|
|
1622
|
+
seen.add(current);
|
|
1623
|
+
if (Array.isArray(current)) {
|
|
1624
|
+
stack.push(...current);
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
const prototype = Object.getPrototypeOf(current);
|
|
1628
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1629
|
+
throw new LayoutCompileError("invalid_geometry", "Layout result contains a non-plain object.");
|
|
1630
|
+
}
|
|
1631
|
+
stack.push(...Object.values(current));
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function formatNumber(value) {
|
|
1635
|
+
return finiteNumber(value).toString();
|
|
1636
|
+
}
|
|
1637
|
+
function safeErrorMessage(error) {
|
|
1638
|
+
if (error instanceof Error && error.message.trim() !== "") {
|
|
1639
|
+
return error.message.slice(0, 512);
|
|
1640
|
+
}
|
|
1641
|
+
return "unknown provider failure";
|
|
1642
|
+
}
|
|
1643
|
+
function clamp(value, min, max) {
|
|
1644
|
+
return Math.max(min, Math.min(max, value));
|
|
1645
|
+
}
|
|
1646
|
+
function containsRect(bounds, rect) {
|
|
1647
|
+
return (rect.x >= bounds.x - EPSILON &&
|
|
1648
|
+
rect.y >= bounds.y - EPSILON &&
|
|
1649
|
+
rect.x + rect.width <= bounds.x + bounds.width + EPSILON &&
|
|
1650
|
+
rect.y + rect.height <= bounds.y + bounds.height + EPSILON);
|
|
1651
|
+
}
|
|
1652
|
+
function spanSize(sizes, start, span, gap) {
|
|
1653
|
+
return (sizes.slice(start, start + span).reduce((sum, value) => sum + value, 0) +
|
|
1654
|
+
Math.max(0, span - 1) * gap);
|
|
1655
|
+
}
|
|
1656
|
+
function trackStarts(sizes, gap, origin) {
|
|
1657
|
+
const starts = [];
|
|
1658
|
+
let cursor = origin;
|
|
1659
|
+
for (const size of sizes) {
|
|
1660
|
+
starts.push(cursor);
|
|
1661
|
+
cursor += size + gap;
|
|
1662
|
+
}
|
|
1663
|
+
return starts;
|
|
1664
|
+
}
|
|
1665
|
+
function mainSize(entry, row) {
|
|
1666
|
+
return row ? entry.width : entry.height;
|
|
1667
|
+
}
|
|
1668
|
+
function crossSize(entry, row) {
|
|
1669
|
+
return row ? entry.height : entry.width;
|
|
1670
|
+
}
|
|
1671
|
+
function setMainSize(entry, row, value) {
|
|
1672
|
+
if (row)
|
|
1673
|
+
entry.width = value;
|
|
1674
|
+
else
|
|
1675
|
+
entry.height = value;
|
|
1676
|
+
}
|
|
1677
|
+
function setCrossSize(entry, row, value) {
|
|
1678
|
+
if (row)
|
|
1679
|
+
entry.height = clamp(value, entry.minHeight, entry.maxHeight);
|
|
1680
|
+
else
|
|
1681
|
+
entry.width = clamp(value, entry.minWidth, entry.maxWidth);
|
|
1682
|
+
}
|
|
1683
|
+
function setMainPosition(entry, row, value) {
|
|
1684
|
+
if (row)
|
|
1685
|
+
entry.x = value;
|
|
1686
|
+
else
|
|
1687
|
+
entry.y = value;
|
|
1688
|
+
}
|
|
1689
|
+
function setCrossPosition(entry, row, value) {
|
|
1690
|
+
if (row)
|
|
1691
|
+
entry.y = value;
|
|
1692
|
+
else
|
|
1693
|
+
entry.x = value;
|
|
1694
|
+
}
|
|
1695
|
+
function mainOrigin(bounds, row) {
|
|
1696
|
+
return row ? bounds.x : bounds.y;
|
|
1697
|
+
}
|
|
1698
|
+
function crossOrigin(bounds, row) {
|
|
1699
|
+
return row ? bounds.y : bounds.x;
|
|
1700
|
+
}
|
|
1701
|
+
function mainMargins(entry, row) {
|
|
1702
|
+
const pair = mainMarginPair(entry, row);
|
|
1703
|
+
return pair.start + pair.end;
|
|
1704
|
+
}
|
|
1705
|
+
function crossMargins(entry, row) {
|
|
1706
|
+
const pair = crossMarginPair(entry, row);
|
|
1707
|
+
return pair.start + pair.end;
|
|
1708
|
+
}
|
|
1709
|
+
function mainMarginPair(entry, row) {
|
|
1710
|
+
return row
|
|
1711
|
+
? { start: entry.margins.left, end: entry.margins.right }
|
|
1712
|
+
: { start: entry.margins.top, end: entry.margins.bottom };
|
|
1713
|
+
}
|
|
1714
|
+
function crossMarginPair(entry, row) {
|
|
1715
|
+
return row
|
|
1716
|
+
? { start: entry.margins.top, end: entry.margins.bottom }
|
|
1717
|
+
: { start: entry.margins.left, end: entry.margins.right };
|
|
1718
|
+
}
|
|
1719
|
+
function mainMin(entry, row) {
|
|
1720
|
+
return row ? entry.minWidth : entry.minHeight;
|
|
1721
|
+
}
|
|
1722
|
+
function mainMax(entry, row) {
|
|
1723
|
+
return row ? entry.maxWidth : entry.maxHeight;
|
|
1724
|
+
}
|
|
1725
|
+
function clampMain(entry, row, value) {
|
|
1726
|
+
return clamp(value, mainMin(entry, row), mainMax(entry, row));
|
|
1727
|
+
}
|
|
1728
|
+
function growWeight(entry, row) {
|
|
1729
|
+
const sizing = row ? entry.widthSizing : entry.heightSizing;
|
|
1730
|
+
return entry.item.grow ?? (sizing === "fill" ? 1 : 0);
|
|
1731
|
+
}
|
|
1732
|
+
function shrinkWeight(entry, row) {
|
|
1733
|
+
const sizing = row ? entry.widthSizing : entry.heightSizing;
|
|
1734
|
+
return entry.item.shrink ?? (sizing === "hug" || sizing === "fill" ? 1 : 0);
|
|
1735
|
+
}
|
|
1736
|
+
//# sourceMappingURL=solver.js.map
|