@anthusai/papyrus 1.2.0 → 1.4.0-next.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.
Files changed (37) hide show
  1. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  2. package/app/newsroom/[section]/page.tsx +6 -0
  3. package/app/newsroom/articles/page.tsx +3 -0
  4. package/components/news-desk-client-provider.tsx +17 -2
  5. package/components/news-desk-taxonomy-client.ts +133 -2
  6. package/components/newspaper.tsx +4 -4
  7. package/components/newsroom-board-view.tsx +74 -50
  8. package/components/newsroom-client-shell.tsx +8 -1
  9. package/components/newsroom-organizer-shell.tsx +12 -7
  10. package/components/newsroom-organizer.css +4 -3
  11. package/components/newsroom-references-view.tsx +18 -0
  12. package/components/reference-cyclotron-priming.tsx +85 -0
  13. package/components/topic-steering-workspace.tsx +35 -1
  14. package/lib/article-flow.ts +95 -0
  15. package/lib/article-frame-solver.ts +519 -0
  16. package/lib/chrome-text.ts +504 -0
  17. package/lib/content-item-lifecycle.ts +25 -26
  18. package/lib/front-mosaic.ts +1132 -0
  19. package/lib/furniture.ts +385 -0
  20. package/lib/labels.ts +37 -0
  21. package/lib/layout-config.ts +237 -0
  22. package/lib/layout-plan.ts +37 -9
  23. package/lib/layout-scenarios.ts +21 -11
  24. package/lib/layout-types.ts +457 -0
  25. package/lib/local-preview-seed.ts +97 -0
  26. package/lib/local-preview.ts +3 -0
  27. package/lib/newspaper-layout.ts +17 -3410
  28. package/lib/newsroom-board.ts +63 -62
  29. package/lib/newsroom-organizer-nav.ts +3 -4
  30. package/lib/pretext-layout.ts +22 -0
  31. package/lib/relevance-status.ts +73 -0
  32. package/lib/site-brand.ts +15 -1
  33. package/lib/text-columns.ts +251 -0
  34. package/package.json +13 -2
  35. package/renderers/pretext/presentation-shell.tsx +1 -1
  36. package/routes.manifest.json +1 -9
  37. package/app/newsroom/work/page.tsx +0 -10
@@ -0,0 +1,1132 @@
1
+ import {
2
+ type ArticleFrameBlockSpec,
3
+ type ArticleFrameCompositionSlotSpec,
4
+ type EditorialPriorityId,
5
+ type FrontResponsiveLayoutOrder,
6
+ type FrontResponsiveLayoutSpec,
7
+ type FrontResponsiveSlotSpec,
8
+ type LayoutPageSpec,
9
+ type LayoutRegionSpec,
10
+ type ResponsivePlacementSpec,
11
+ } from "./layout-plan";
12
+ import {
13
+ type ArticlePublicationItem,
14
+ getPublicationItemImageAssets,
15
+ type PublicationItem,
16
+ } from "./publication-items";
17
+ import {
18
+ clampRhythmHeight,
19
+ reserveRhythmRows,
20
+ snapDownToRhythm,
21
+ snapPreferredHeightToRhythm,
22
+ snapPreservedImageHeightToRhythm,
23
+ type VerticalRhythm,
24
+ } from "./blog-rhythm";
25
+ import { toPublicReaderPath } from "./reader-base-path";
26
+ import { layoutTextLines, type TextObstacle } from "./pretext-layout";
27
+ import {
28
+ type ArticleFlow,
29
+ type FrontArticlePlacement,
30
+ type FrontArticleSolveOptions,
31
+ type FrontCompositionFlowMode,
32
+ type FrontRow,
33
+ type LayoutConfig,
34
+ type PlacementVariant,
35
+ type SolvedBlock,
36
+ type SolvedChromeBox,
37
+ type SolvedFrontFooter,
38
+ type SolvedFrontFooterEntry,
39
+ type SolvedFrontFooterUtilityEntry,
40
+ type SolvedFrontGridPlacement,
41
+ type SolvedFurniture,
42
+ type SolvedImageFurniture,
43
+ type SolvedPage,
44
+ } from "./layout-types";
45
+ import {
46
+ IMAGE_CAPTION_FONT_SIZE,
47
+ SERIF_TEXT_FONT,
48
+ clamp,
49
+ getMinimumTextFrameHeight,
50
+ getSpanWidth,
51
+ pageIdFor,
52
+ } from "./layout-config";
53
+ import {
54
+ commitTextRange,
55
+ commitUsedImageFurniture,
56
+ createTextRange,
57
+ getOrCreateFlow,
58
+ requireArticleItem,
59
+ resetArticleFlow,
60
+ } from "./article-flow";
61
+ import {
62
+ getStoryChromeHeight,
63
+ getStoryChromeMetrics,
64
+ getStoryJumpReserveHeight,
65
+ isTextCompositionSlot,
66
+ resolveFrontStoryRole,
67
+ resolveHeadlineScale,
68
+ solveCompositionChromeBox,
69
+ } from "./chrome-text";
70
+ import { getLineLimitHeight, getPrepared, layoutTextColumns } from "./text-columns";
71
+ import { formatContinuationJumpLabel } from "./labels";
72
+ import { getLocalColumnCountCandidates } from "./article-frame-solver";
73
+ import {
74
+ createImageFurniture,
75
+ getImageAspectRatio,
76
+ getImageCaption,
77
+ getImageCaptionHeight,
78
+ getObjectPosition,
79
+ getPreferredImage,
80
+ resolvePreferredPlacementVariant,
81
+ } from "./furniture";
82
+
83
+ export function solveFrontMosaicPage(
84
+ pageSpec: LayoutPageSpec,
85
+ itemsBySlug: Map<string, PublicationItem>,
86
+ flows: Map<string, ArticleFlow>,
87
+ config: LayoutConfig,
88
+ ): SolvedPage {
89
+ const regionSpec = pageSpec.regions[0];
90
+ const frontBlockSpecs = regionSpec.blocks.filter((block): block is ArticleFrameBlockSpec => block.type === "articleFrame");
91
+ const activeResponsiveLayout = getActiveFrontResponsiveLayout(regionSpec, config);
92
+ const placements = activeResponsiveLayout
93
+ ? resolveResponsiveFrontArticlePlacements(frontBlockSpecs, activeResponsiveLayout, config)
94
+ : resolveDefaultFrontArticlePlacements(frontBlockSpecs, config);
95
+ const baseRowHeights = getResponsiveFrontRowHeights(config, placements);
96
+ const solvedPlacements = placements.map((placement) => ({
97
+ ...placement,
98
+ rowHeight: getResponsiveFrontPlacementHeight(placement, baseRowHeights, config.rowGap),
99
+ }));
100
+ const previewFlows = new Map<string, ArticleFlow>();
101
+ const provisionalBlocks = solvedPlacements.map((placement) => (
102
+ solveFrontArticleFrame(placement, itemsBySlug, previewFlows, config, pageSpec.pageNumber)
103
+ ));
104
+ const rowHeights = getSolvedResponsiveFrontRowHeights(baseRowHeights, provisionalBlocks, config);
105
+ const finalPlacements = solvedPlacements.map((placement) => ({
106
+ ...placement,
107
+ rowHeight: getResponsiveFrontPlacementHeight(placement, rowHeights, config.rowGap),
108
+ }));
109
+ const flowKeysResolvedAgain = new Set<string>();
110
+ const blocks = finalPlacements.map((placement, index) => {
111
+ const flowKey = placement.block.flowKey ?? placement.block.itemId;
112
+ const provisionalBlock = provisionalBlocks[index];
113
+ const provisionalRowHeightStillHolds = placement.rowHeight === solvedPlacements[index].rowHeight;
114
+ if (provisionalRowHeightStillHolds && !flowKeysResolvedAgain.has(flowKey)) {
115
+ return adoptProvisionalFrontBlock(provisionalBlock, placement, itemsBySlug, flows);
116
+ }
117
+ flowKeysResolvedAgain.add(flowKey);
118
+ return solveFrontArticleFrame(placement, itemsBySlug, flows, config, pageSpec.pageNumber, {
119
+ enforcePlacementHeight: true,
120
+ });
121
+ });
122
+ const gridHeight = getFrontPageGridHeightFromRowHeights(rowHeights, config.rowGap);
123
+ const frontFooter = solveFrontFooter(blocks, config, pageSpec.pageNumber);
124
+ const regionY = config.pageChrome.pagePaddingTop + config.pageChrome.mastheadHeight + config.pageChrome.frontGridMarginTop;
125
+ const pageHeight = getFrontPageHeightForRegion(config, regionY, gridHeight, frontFooter);
126
+
127
+ return {
128
+ id: pageSpec.id ?? pageIdFor(pageSpec.pageNumber),
129
+ pageNumber: pageSpec.pageNumber,
130
+ presetId: pageSpec.presetId,
131
+ kind: "front",
132
+ height: pageHeight,
133
+ columnCount: config.columnCount,
134
+ frontFooter,
135
+ regions: [
136
+ {
137
+ id: regionSpec.id,
138
+ type: regionSpec.type,
139
+ role: regionSpec.role,
140
+ x: 0,
141
+ y: regionY,
142
+ width: config.contentWidth,
143
+ height: gridHeight,
144
+ rowHeights,
145
+ columnCount: config.columnCount,
146
+ blocks,
147
+ },
148
+ ],
149
+ };
150
+ }
151
+
152
+ function adoptProvisionalFrontBlock(
153
+ provisionalBlock: SolvedBlock,
154
+ placement: FrontArticlePlacement,
155
+ itemsBySlug: Map<string, PublicationItem>,
156
+ flows: Map<string, ArticleFlow>,
157
+ ): SolvedBlock {
158
+ const { block: blockSpec } = placement;
159
+ const flow = getOrCreateFlow(flows, blockSpec.flowKey ?? blockSpec.itemId, requireArticleItem(blockSpec.itemId, itemsBySlug));
160
+ if (blockSpec.startCursor === "beginning") resetArticleFlow(flow);
161
+ if (provisionalBlock.textRange) commitTextRange(flow, provisionalBlock.textRange);
162
+ commitUsedImageFurniture(flow, provisionalBlock.furniture ?? []);
163
+ return provisionalBlock;
164
+ }
165
+
166
+ function getActiveFrontResponsiveLayout(regionSpec: LayoutRegionSpec, config: LayoutConfig): FrontResponsiveLayoutSpec | null {
167
+ return regionSpec.responsiveLayouts?.find((layout) => (
168
+ config.columnCount >= layout.minColumns && config.columnCount <= layout.maxColumns
169
+ )) ?? null;
170
+ }
171
+
172
+ function resolveResponsiveFrontArticlePlacements(
173
+ blocks: ArticleFrameBlockSpec[],
174
+ layout: FrontResponsiveLayoutSpec,
175
+ config: LayoutConfig,
176
+ ): FrontArticlePlacement[] {
177
+ const orderedBlocks = orderFrontArticleBlocks(blocks, layout.order);
178
+ const placements: FrontArticlePlacement[] = [];
179
+ const assignedBlockIds = new Set<string>();
180
+
181
+ for (const slot of layout.slots) {
182
+ const block = findFrontSlotBlock(slot, blocks, orderedBlocks);
183
+ if (!block || assignedBlockIds.has(block.id)) continue;
184
+ assignedBlockIds.add(block.id);
185
+ placements.push({
186
+ block,
187
+ originalIndex: blocks.indexOf(block),
188
+ solveIndex: placements.length,
189
+ gridPlacement: resolveFrontSlotGridPlacement(slot, config),
190
+ });
191
+ }
192
+
193
+ const overflowBlocks = orderedBlocks.filter((block) => !assignedBlockIds.has(block.id));
194
+ const overflowPlacements = resolveFrontOverflowPlacements(
195
+ overflowBlocks,
196
+ blocks,
197
+ placements.length,
198
+ getNextFrontOverflowRow(placements),
199
+ layout.overflow.columnSpan,
200
+ layout.overflow.rowSpan,
201
+ config,
202
+ );
203
+
204
+ return [...placements, ...overflowPlacements]
205
+ .sort((left, right) => (
206
+ (left.gridPlacement?.rowStart ?? 0) - (right.gridPlacement?.rowStart ?? 0) ||
207
+ (left.gridPlacement?.columnStart ?? 0) - (right.gridPlacement?.columnStart ?? 0) ||
208
+ left.solveIndex - right.solveIndex
209
+ ))
210
+ .map((placement, solveIndex) => ({ ...placement, solveIndex }));
211
+ }
212
+
213
+ function resolveDefaultFrontArticlePlacements(blocks: ArticleFrameBlockSpec[], config: LayoutConfig): FrontArticlePlacement[] {
214
+ let columnStart = 0;
215
+ let rowStart = 0;
216
+ return blocks.map((block, index) => {
217
+ const columnSpan = getDefaultFrontArticlePlacementSpan(block, config);
218
+ if (columnStart + columnSpan > config.columnCount) {
219
+ rowStart += 1;
220
+ columnStart = 0;
221
+ }
222
+ const placement: FrontArticlePlacement = {
223
+ block,
224
+ originalIndex: index,
225
+ solveIndex: index,
226
+ gridPlacement: {
227
+ columnStart,
228
+ columnSpan,
229
+ rowStart,
230
+ rowSpan: 1,
231
+ },
232
+ };
233
+ columnStart += columnSpan;
234
+ return placement;
235
+ });
236
+ }
237
+
238
+ function getDefaultFrontArticlePlacementSpan(block: ArticleFrameBlockSpec, config: LayoutConfig): number {
239
+ return clamp(block.span?.preferred ?? 1, 1, config.columnCount);
240
+ }
241
+
242
+ function orderFrontArticleBlocks(blocks: ArticleFrameBlockSpec[], order: FrontResponsiveLayoutOrder): ArticleFrameBlockSpec[] {
243
+ if (order !== "editorialPriority") return blocks;
244
+ return blocks
245
+ .map((block, index) => ({ block, index }))
246
+ .sort((left, right) => (
247
+ getEditorialPriorityRank(left.block.editorialPriority) - getEditorialPriorityRank(right.block.editorialPriority) ||
248
+ left.index - right.index
249
+ ))
250
+ .map(({ block }) => block);
251
+ }
252
+
253
+ function findFrontSlotBlock(
254
+ slot: FrontResponsiveSlotSpec,
255
+ blocks: ArticleFrameBlockSpec[],
256
+ orderedBlocks: ArticleFrameBlockSpec[],
257
+ ): ArticleFrameBlockSpec | null {
258
+ if (slot.blockId) return blocks.find((block) => block.id === slot.blockId) ?? null;
259
+ if (!slot.editorialPriority) return null;
260
+ const occurrence = slot.priorityOccurrence ?? 1;
261
+ return orderedBlocks.filter((block) => block.editorialPriority === slot.editorialPriority)[occurrence - 1] ?? null;
262
+ }
263
+
264
+ function resolveFrontSlotGridPlacement(slot: FrontResponsiveSlotSpec, config: LayoutConfig): SolvedFrontGridPlacement {
265
+ const columnStart = clamp(slot.columnStart - 1, 0, Math.max(0, config.columnCount - 1));
266
+ return {
267
+ columnStart,
268
+ columnSpan: clamp(slot.columnSpan, 1, Math.max(1, config.columnCount - columnStart)),
269
+ rowStart: slot.rowStart - 1,
270
+ rowSpan: slot.rowSpan,
271
+ };
272
+ }
273
+
274
+ function resolveFrontOverflowPlacements(
275
+ overflowBlocks: ArticleFrameBlockSpec[],
276
+ allBlocks: ArticleFrameBlockSpec[],
277
+ solveIndexStart: number,
278
+ rowStart: number,
279
+ requestedColumnSpan: number | "full",
280
+ rowSpan: number,
281
+ config: LayoutConfig,
282
+ ): FrontArticlePlacement[] {
283
+ const columnSpan = requestedColumnSpan === "full"
284
+ ? config.columnCount
285
+ : clamp(requestedColumnSpan, 1, config.columnCount);
286
+ let columnStart = 0;
287
+ let currentRow = rowStart;
288
+ return overflowBlocks.map((block, index) => {
289
+ if (columnStart + columnSpan > config.columnCount) {
290
+ currentRow += rowSpan;
291
+ columnStart = 0;
292
+ }
293
+ const placement: FrontArticlePlacement = {
294
+ block,
295
+ originalIndex: allBlocks.indexOf(block),
296
+ solveIndex: solveIndexStart + index,
297
+ gridPlacement: {
298
+ columnStart,
299
+ columnSpan,
300
+ rowStart: currentRow,
301
+ rowSpan,
302
+ },
303
+ };
304
+ columnStart += columnSpan;
305
+ return placement;
306
+ });
307
+ }
308
+
309
+ function getNextFrontOverflowRow(placements: FrontArticlePlacement[]): number {
310
+ return placements.reduce((nextRow, placement) => {
311
+ const gridPlacement = placement.gridPlacement;
312
+ if (!gridPlacement) return nextRow;
313
+ return Math.max(nextRow, gridPlacement.rowStart + gridPlacement.rowSpan);
314
+ }, 0);
315
+ }
316
+
317
+ function getResponsiveFrontRowHeights(config: LayoutConfig, placements: FrontArticlePlacement[]): number[] {
318
+ const rowCount = placements.reduce((count, placement) => {
319
+ const gridPlacement = placement.gridPlacement;
320
+ if (!gridPlacement) return count;
321
+ return Math.max(count, gridPlacement.rowStart + gridPlacement.rowSpan);
322
+ }, 0);
323
+ const baseHeights = config.frontRows.map((row) => row.height);
324
+ const fallbackHeight = baseHeights[1] ?? baseHeights[0] ?? reserveRhythmRows(420, config.rhythm);
325
+ return Array.from({ length: rowCount }, (_, index) => baseHeights[index] ?? fallbackHeight);
326
+ }
327
+
328
+ function getResponsiveFrontPlacementHeight(placement: FrontArticlePlacement, rowHeights: number[], rowGap: number): number {
329
+ const gridPlacement = placement.gridPlacement;
330
+ if (!gridPlacement) return getFrontRowHeightFallback(rowHeights);
331
+ return getResponsiveFrontGridPlacementHeight(gridPlacement, rowHeights, rowGap);
332
+ }
333
+
334
+ function getResponsiveFrontGridPlacementHeight(gridPlacement: SolvedFrontGridPlacement, rowHeights: number[], rowGap: number): number {
335
+ return rowHeights
336
+ .slice(gridPlacement.rowStart, gridPlacement.rowStart + gridPlacement.rowSpan)
337
+ .reduce((total, height) => total + height, 0) + rowGap * Math.max(0, gridPlacement.rowSpan - 1);
338
+ }
339
+
340
+ function getFrontRowHeightFallback(rowHeights: number[]): number {
341
+ return rowHeights[0] ?? 0;
342
+ }
343
+
344
+ function solveFrontFooter(blocks: SolvedBlock[], config: LayoutConfig, pageNumber: number): SolvedFrontFooter {
345
+ const entries: SolvedFrontFooterEntry[] = [];
346
+ const seenSections = new Set<string>();
347
+ for (const block of blocks) {
348
+ const section = block.section ?? block.article?.section;
349
+ const articleSlug = block.article?.slug;
350
+ const articleTitle = block.title ?? block.article?.headline;
351
+ if (!section || !articleSlug || !articleTitle) continue;
352
+ const sectionKey = section.trim().toLowerCase();
353
+ if (!sectionKey || seenSections.has(sectionKey)) continue;
354
+ seenSections.add(sectionKey);
355
+ entries.push({
356
+ section,
357
+ articleSlug,
358
+ articleTitle,
359
+ blockId: block.id,
360
+ pageNumber,
361
+ });
362
+ }
363
+
364
+ const sectionColumns = entries.length === 0
365
+ ? 1
366
+ : Math.min(entries.length, Math.max(1, config.columnCount <= 1 ? 1 : config.columnCount <= 3 ? 2 : 4));
367
+ const sectionRows = entries.length === 0 ? 0 : Math.ceil(entries.length / sectionColumns);
368
+ const utilityEntries: SolvedFrontFooterUtilityEntry[] = [
369
+ { id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
370
+ { id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
371
+ { id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
372
+ { id: "login", label: "LOGIN", disabled: true },
373
+ ];
374
+ const rowCount = 1 + Math.max(sectionRows, utilityEntries.length);
375
+ return {
376
+ rowHeight: config.rhythm.rowHeight,
377
+ marginTop: config.rhythm.rowHeight,
378
+ height: reserveRhythmRows(rowCount * config.rhythm.rowHeight, config.rhythm),
379
+ sectionRows,
380
+ sectionColumns,
381
+ entries,
382
+ utilityEntries,
383
+ };
384
+ }
385
+
386
+ function getSolvedResponsiveFrontRowHeights(baseRowHeights: number[], blocks: SolvedBlock[], config: LayoutConfig): number[] {
387
+ const rowHeights = [...baseRowHeights];
388
+ for (const block of blocks) {
389
+ const placement = block.front?.gridPlacement;
390
+ if (!placement) continue;
391
+ while (rowHeights.length < placement.rowStart + placement.rowSpan) {
392
+ rowHeights.push(baseRowHeights[baseRowHeights.length - 1] ?? reserveRhythmRows(420, config.rhythm));
393
+ }
394
+ const currentHeight = getResponsiveFrontGridPlacementHeight(placement, rowHeights, config.rowGap);
395
+ if (block.height <= currentHeight) continue;
396
+ const lastRowIndex = placement.rowStart + placement.rowSpan - 1;
397
+ rowHeights[lastRowIndex] = reserveRhythmRows(rowHeights[lastRowIndex] + block.height - currentHeight, config.rhythm);
398
+ }
399
+ return rowHeights;
400
+ }
401
+
402
+ function getEditorialPriorityRank(priority: EditorialPriorityId): number {
403
+ if (priority === "primary") return 0;
404
+ if (priority === "secondary") return 1;
405
+ if (priority === "tertiary") return 2;
406
+ return 3;
407
+ }
408
+
409
+ function solveFrontArticleFrame(
410
+ placement: FrontArticlePlacement,
411
+ itemsBySlug: Map<string, PublicationItem>,
412
+ flows: Map<string, ArticleFlow>,
413
+ config: LayoutConfig,
414
+ pageNumber: number,
415
+ options: FrontArticleSolveOptions = {},
416
+ ): SolvedBlock {
417
+ const { block: blockSpec } = placement;
418
+ const item = requireArticleItem(blockSpec.itemId, itemsBySlug);
419
+ const flow = getOrCreateFlow(flows, blockSpec.flowKey ?? blockSpec.itemId, item);
420
+ if (blockSpec.startCursor === "beginning") resetArticleFlow(flow);
421
+
422
+ const span = placement.gridPlacement?.columnSpan ?? Math.min(blockSpec.span?.preferred ?? 1, config.columnCount);
423
+ const blockWidth = getSpanWidth(config, span);
424
+ if (blockSpec.composition) {
425
+ return solveComposedFrontArticleFrame(placement, item, flow, config, pageNumber, span, blockWidth, options);
426
+ }
427
+ const preludeImage = createFrontPreludeImage(item, blockSpec, blockWidth, config, flow.usedImageAssetIds);
428
+ const storyRole = resolveFrontStoryRole(blockSpec, span, preludeImage !== null);
429
+ const headlineScale = resolveHeadlineScale(blockSpec, storyRole);
430
+ const chrome = getStoryChromeMetrics(config, item, storyRole, headlineScale, blockWidth, preludeImage?.height ?? 0, blockSpec.chrome);
431
+ const chromeHeight = getStoryChromeHeight(chrome);
432
+ const jumpReserveHeight = getStoryJumpReserveHeight(chrome);
433
+ const rowHeight = getFrontArticleRowHeight(placement, config);
434
+ const baseBodySlotHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(rowHeight - chromeHeight - jumpReserveHeight, config.rhythm));
435
+ const imageWrap = !preludeImage && storyRole === "feature" ? getLeadImageWrap(item, blockWidth, config) : null;
436
+ let bodySlotHeight = imageWrap
437
+ ? Math.max(baseBodySlotHeight, reserveRhythmRows(imageWrap.height, config.rhythm))
438
+ : baseBodySlotHeight;
439
+ const textLimit = getFrontTeaserTextLimit(blockSpec, config);
440
+ if (textLimit?.mode === "bodyDepth") {
441
+ bodySlotHeight = Math.max(bodySlotHeight, textLimit.height);
442
+ }
443
+ const startCursor = { ...flow.currentCursor };
444
+ const text = getPrepared(item, config.bodyFont);
445
+ let maxHeight = getFrontTeaserMeasureHeight(textLimit, bodySlotHeight);
446
+ let result = layoutTextLines({
447
+ prepared: text,
448
+ cursor: startCursor,
449
+ maxHeight,
450
+ maxWidth: blockWidth,
451
+ lineHeight: config.lineHeight,
452
+ linePaintHeight: config.linePaintHeight,
453
+ fontSize: config.bodyFontSize,
454
+ fontFamily: SERIF_TEXT_FONT,
455
+ obstacles: imageWrap ? [imageWrap] : [],
456
+ });
457
+ while (shouldGrowFrontArticleToContent(blockSpec) && result.hasMore) {
458
+ bodySlotHeight = reserveRhythmRows(bodySlotHeight + config.rhythm.rowHeight, config.rhythm);
459
+ maxHeight = bodySlotHeight;
460
+ result = layoutTextLines({
461
+ prepared: text,
462
+ cursor: startCursor,
463
+ maxHeight,
464
+ maxWidth: blockWidth,
465
+ lineHeight: config.lineHeight,
466
+ linePaintHeight: config.linePaintHeight,
467
+ fontSize: config.bodyFontSize,
468
+ fontFamily: SERIF_TEXT_FONT,
469
+ obstacles: imageWrap ? [imageWrap] : [],
470
+ });
471
+ }
472
+ const requiredBlockHeight = Math.max(rowHeight, reserveRhythmRows(chromeHeight + bodySlotHeight + jumpReserveHeight, config.rhythm));
473
+ const blockHeight = options.enforcePlacementHeight ? rowHeight : requiredBlockHeight;
474
+ const range = createTextRange({
475
+ flow,
476
+ pageId: pageIdFor(pageNumber),
477
+ blockId: blockSpec.id,
478
+ startCursor,
479
+ endCursor: result.cursor,
480
+ exhausted: !result.hasMore,
481
+ });
482
+ const furniture = [preludeImage, imageWrap ? leadImageToFurniture(item, imageWrap, config) : null].filter(
483
+ (furniture): furniture is SolvedImageFurniture => furniture !== null,
484
+ );
485
+ commitTextRange(flow, range);
486
+ commitUsedImageFurniture(flow, furniture);
487
+
488
+ return {
489
+ id: blockSpec.id,
490
+ type: "articleFrame",
491
+ presetId: blockSpec.presetId,
492
+ headlineScale,
493
+ editorialPriority: blockSpec.editorialPriority,
494
+ item,
495
+ article: item,
496
+ pageNumber,
497
+ jumpTargetPage: result.hasMore ? blockSpec.cutPolicy?.jumpTargetPage : undefined,
498
+ jumpLabel:
499
+ result.hasMore && blockSpec.cutPolicy?.jumpTargetPage
500
+ ? formatContinuationJumpLabel(item, blockSpec.cutPolicy.jumpTargetPage)
501
+ : undefined,
502
+ label: item.section,
503
+ title: item.headline,
504
+ deck: item.deck,
505
+ byline: item.byline,
506
+ dateline: item.dateline,
507
+ section: item.section,
508
+ x: 0,
509
+ y: 0,
510
+ width: blockWidth,
511
+ height: blockHeight,
512
+ span,
513
+ columnCount: 1,
514
+ columns: [result.lines],
515
+ furniture,
516
+ textRange: range,
517
+ hasMore: result.hasMore,
518
+ front: {
519
+ rowHeight: blockHeight,
520
+ bodySlotHeight,
521
+ chromeHeight,
522
+ jumpReserveHeight,
523
+ chrome,
524
+ gridPlacement: placement.gridPlacement,
525
+ },
526
+ bodyHeight: bodySlotHeight,
527
+ };
528
+ }
529
+
530
+ function solveComposedFrontArticleFrame(
531
+ placement: FrontArticlePlacement,
532
+ item: ArticlePublicationItem,
533
+ flow: ArticleFlow,
534
+ config: LayoutConfig,
535
+ pageNumber: number,
536
+ span: number,
537
+ blockWidth: number,
538
+ options: FrontArticleSolveOptions = {},
539
+ ): SolvedBlock {
540
+ const { block: blockSpec } = placement;
541
+ const storyRole = resolveFrontStoryRole(blockSpec, span, false);
542
+ const headlineScale = resolveHeadlineScale(blockSpec, storyRole);
543
+ const localColumnCount = getLocalColumnCountCandidates(blockSpec.localGrid?.columns, config, blockWidth)[0] ?? 1;
544
+ const localConfig = { ...config, columnCount: localColumnCount, contentWidth: blockWidth };
545
+ const chrome = getStoryChromeMetrics(config, item, storyRole, headlineScale, blockWidth, 0, blockSpec.chrome);
546
+
547
+ const rowHeight = getFrontArticleRowHeight(placement, config);
548
+ const jumpReserveHeight = getStoryJumpReserveHeight(chrome);
549
+ const topMediaVariant = resolveTopMediaCompositionVariant(blockSpec, localConfig);
550
+ const compositionMode = getFrontCompositionFlowMode(placement, config, localConfig, topMediaVariant);
551
+ const titleBoxes: SolvedChromeBox[] = [];
552
+ let titleBottom = reserveRhythmRows(chrome.paddingTop + chrome.borderTopHeight, config.rhythm);
553
+
554
+ for (const slot of blockSpec.composition?.title ?? []) {
555
+ const variant = resolveCompositionChromeSlotVariant(slot, localConfig, compositionMode, topMediaVariant);
556
+ const box = solveCompositionChromeBox({
557
+ slot,
558
+ item,
559
+ storyRole,
560
+ headlineScale,
561
+ localConfig,
562
+ y: titleBottom,
563
+ globalYOffset: 0,
564
+ chromeOverrides: blockSpec.chrome,
565
+ variant,
566
+ });
567
+ if (!box) continue;
568
+ titleBoxes.push(box.globalBox);
569
+ titleBottom = Math.max(titleBottom, box.localReserveBottom);
570
+ }
571
+
572
+ const bodyTop = compositionMode === "offsetBody" ? reserveRhythmRows(titleBottom, config.rhythm) : 0;
573
+ let bodySlotHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(rowHeight - bodyTop - jumpReserveHeight, config.rhythm));
574
+ const textLimit = getFrontTeaserTextLimit(blockSpec, config);
575
+ const leadBoxes: SolvedChromeBox[] = [];
576
+ const renderLeadBoxes: SolvedChromeBox[] = [];
577
+ const furniture: SolvedFurniture[] = [];
578
+ const columnBottoms = Array.from({ length: localColumnCount }, () => 0);
579
+ if (compositionMode !== "offsetBody") {
580
+ for (const box of titleBoxes) {
581
+ updateOccupiedColumnBottoms(columnBottoms, box.columnStart, box.columnSpan, box.y + box.height);
582
+ }
583
+ }
584
+
585
+ for (const slot of getOrderedCompositionLeadSlots(blockSpec, compositionMode)) {
586
+ const slotVariant = slot.slot === "media"
587
+ ? resolveCompositionMediaSlotVariant(slot.placement, localConfig, compositionMode)
588
+ : resolveCompositionChromeSlotVariant(slot, localConfig, compositionMode, topMediaVariant);
589
+ if (!slotVariant) continue;
590
+
591
+ if (slot.slot === "media") {
592
+ const mediaSpec = blockSpec.media[slot.mediaIndex ?? 0];
593
+ const image = createImageFurniture(
594
+ item,
595
+ {
596
+ id: slotVariant.id,
597
+ placement: slot.placement,
598
+ assetRole: mediaSpec?.assetRole,
599
+ columnStart: slotVariant.columnStart,
600
+ columnSpan: slotVariant.columnSpan,
601
+ fallbackPenalty: slotVariant.fallbackPenalty,
602
+ },
603
+ localConfig,
604
+ bodySlotHeight,
605
+ flow.usedImageAssetIds,
606
+ );
607
+ if (!image) {
608
+ if (mediaSpec?.required) throw new Error(`${blockSpec.id} requires media slot ${slot.mediaIndex ?? 0} but no image could be solved`);
609
+ continue;
610
+ }
611
+ const stackTop = getOccupiedColumnBottom(columnBottoms, image.columnStart, image.columnSpan);
612
+ const y = compositionMode === "stackedMedia"
613
+ ? reserveRhythmRows(stackTop, config.rhythm)
614
+ : Math.max(image.y, stackTop);
615
+ const placedImage = { ...image, y };
616
+ furniture.push(placedImage);
617
+ updateOccupiedColumnBottoms(columnBottoms, placedImage.columnStart, placedImage.columnSpan, placedImage.y + placedImage.height);
618
+ continue;
619
+ }
620
+
621
+ const stackTop = getOccupiedColumnBottom(columnBottoms, slotVariant.columnStart, slotVariant.columnSpan);
622
+ const box = solveCompositionChromeBox({
623
+ slot,
624
+ item,
625
+ storyRole,
626
+ headlineScale,
627
+ localConfig,
628
+ y: stackTop,
629
+ globalYOffset: bodyTop,
630
+ chromeOverrides: blockSpec.chrome,
631
+ variant: slotVariant,
632
+ });
633
+ if (!box) continue;
634
+ leadBoxes.push(box.localBox);
635
+ renderLeadBoxes.push(box.globalBox);
636
+ updateOccupiedColumnBottoms(columnBottoms, box.localBox.columnStart, box.localBox.columnSpan, box.localReserveBottom);
637
+ }
638
+
639
+ const startCursor = { ...flow.currentCursor };
640
+ const textChromeBoxes = compositionMode === "offsetBody" ? leadBoxes : [...titleBoxes, ...leadBoxes];
641
+ const furnitureObstacleOffsetY = compositionMode === "offsetBody" ? -bodyTop : 0;
642
+ const columnCopyBandTops = getColumnCopyBandTops(
643
+ localColumnCount,
644
+ textChromeBoxes,
645
+ furniture,
646
+ furnitureObstacleOffsetY,
647
+ config.rhythm.rowHeight,
648
+ config.rhythm,
649
+ );
650
+ const copyBandTop = Math.max(...columnCopyBandTops, 0);
651
+ if (compositionMode === "stackedMedia" || compositionMode === "titleStackedMedia") {
652
+ bodySlotHeight = Math.max(bodySlotHeight, reserveRhythmRows(copyBandTop + getMinimumTextFrameHeight(config), config.rhythm));
653
+ }
654
+ if (textLimit?.mode === "bodyDepth") {
655
+ bodySlotHeight = Math.max(bodySlotHeight, reserveRhythmRows(copyBandTop + textLimit.height, config.rhythm));
656
+ }
657
+ const maxHeight = getComposedFrontTextMeasureHeight(textLimit, compositionMode, copyBandTop, bodySlotHeight);
658
+ const textResult = layoutTextColumns({
659
+ item,
660
+ cursor: startCursor,
661
+ columnCount: localColumnCount,
662
+ textHeight: maxHeight,
663
+ localConfig,
664
+ furniture,
665
+ furnitureObstacleOffsetY,
666
+ furnitureObstacleMarginBottom: config.rhythm.rowHeight,
667
+ minimumLineStartYByColumn: columnCopyBandTops,
668
+ chromeBoxes: textChromeBoxes,
669
+ });
670
+ const requiredRowHeight = Math.max(rowHeight, reserveRhythmRows(bodyTop + bodySlotHeight + jumpReserveHeight, config.rhythm));
671
+ const solvedRowHeight = options.enforcePlacementHeight ? rowHeight : requiredRowHeight;
672
+ const range = createTextRange({
673
+ flow,
674
+ pageId: pageIdFor(pageNumber),
675
+ blockId: blockSpec.id,
676
+ startCursor,
677
+ endCursor: textResult.cursor,
678
+ exhausted: !textResult.hasMore,
679
+ });
680
+ commitTextRange(flow, range);
681
+ commitUsedImageFurniture(flow, furniture);
682
+
683
+ return {
684
+ id: blockSpec.id,
685
+ type: "articleFrame",
686
+ presetId: blockSpec.presetId,
687
+ headlineScale,
688
+ editorialPriority: blockSpec.editorialPriority,
689
+ item,
690
+ article: item,
691
+ pageNumber,
692
+ jumpTargetPage: textResult.hasMore ? blockSpec.cutPolicy?.jumpTargetPage : undefined,
693
+ jumpLabel:
694
+ textResult.hasMore && blockSpec.cutPolicy?.jumpTargetPage
695
+ ? formatContinuationJumpLabel(item, blockSpec.cutPolicy.jumpTargetPage)
696
+ : undefined,
697
+ label: item.section,
698
+ title: item.headline,
699
+ deck: item.deck,
700
+ byline: item.byline,
701
+ dateline: item.dateline,
702
+ section: item.section,
703
+ x: 0,
704
+ y: 0,
705
+ width: blockWidth,
706
+ height: solvedRowHeight,
707
+ span,
708
+ columnCount: localColumnCount,
709
+ columns: textResult.columns,
710
+ chromeBoxes: [...titleBoxes, ...renderLeadBoxes],
711
+ furniture,
712
+ textRange: range,
713
+ hasMore: textResult.hasMore,
714
+ front: {
715
+ rowHeight: solvedRowHeight,
716
+ bodySlotHeight,
717
+ chromeHeight: bodyTop,
718
+ jumpReserveHeight,
719
+ chrome,
720
+ gridPlacement: placement.gridPlacement,
721
+ composition: {
722
+ mode: compositionMode,
723
+ bodyTop,
724
+ bodyHeight: bodySlotHeight,
725
+ columnCount: localColumnCount,
726
+ copyBandTop,
727
+ copyBandTops: columnCopyBandTops,
728
+ },
729
+ },
730
+ bodyHeight: bodySlotHeight,
731
+ };
732
+ }
733
+
734
+ function getFrontCompositionFlowMode(
735
+ placement: FrontArticlePlacement,
736
+ config: LayoutConfig,
737
+ localConfig: LayoutConfig,
738
+ topMediaVariant: PlacementVariant | null,
739
+ ): FrontCompositionFlowMode {
740
+ if (topMediaVariant && topMediaVariant.placement.vertical === "top" && !hasCompositionSideBesideMedia(localConfig, topMediaVariant)) {
741
+ return "stackedMedia";
742
+ }
743
+ if (
744
+ placement.gridPlacement &&
745
+ placement.gridPlacement.columnSpan === config.columnCount &&
746
+ localConfig.columnCount > 1 &&
747
+ topMediaVariant &&
748
+ topMediaVariant.columnSpan < localConfig.columnCount &&
749
+ topMediaVariant.placement.vertical === "top"
750
+ ) {
751
+ if (localConfig.columnCount <= 3) return "titleStackedMedia";
752
+ return "integrated";
753
+ }
754
+ return "offsetBody";
755
+ }
756
+
757
+ function resolveTopMediaCompositionVariant(blockSpec: ArticleFrameBlockSpec, localConfig: LayoutConfig): PlacementVariant | null {
758
+ const slot = blockSpec.composition?.lead.find((candidate) => (
759
+ candidate.slot === "media" && candidate.placement.vertical === "top"
760
+ ));
761
+ return slot ? resolvePreferredPlacementVariant(slot.placement, localConfig) : null;
762
+ }
763
+
764
+ function resolveCompositionChromeSlotVariant(
765
+ slot: ArticleFrameCompositionSlotSpec,
766
+ localConfig: LayoutConfig,
767
+ compositionMode: FrontCompositionFlowMode,
768
+ topMediaVariant: PlacementVariant | null,
769
+ ): PlacementVariant | null {
770
+ const variant = resolvePreferredPlacementVariant(slot.placement, localConfig);
771
+ if (!variant || !isTextCompositionSlot(slot.slot)) return variant;
772
+ if (compositionMode === "stackedMedia") return getFullWidthPlacementVariant(variant, localConfig, "stacked");
773
+ if (compositionMode === "titleStackedMedia" && isTitleCompositionSlot(slot.slot)) {
774
+ return getFullWidthPlacementVariant(variant, localConfig, "title-stacked");
775
+ }
776
+ if ((compositionMode !== "integrated" && compositionMode !== "titleStackedMedia") || !topMediaVariant) return variant;
777
+ if (!columnsOverlap(variant.columnStart, variant.columnSpan, topMediaVariant.columnStart, topMediaVariant.columnSpan)) {
778
+ return variant;
779
+ }
780
+
781
+ const leftSpan = topMediaVariant.columnStart;
782
+ const rightStart = topMediaVariant.columnStart + topMediaVariant.columnSpan;
783
+ const rightSpan = localConfig.columnCount - rightStart;
784
+ const preferLeft = variant.columnStart <= topMediaVariant.columnStart;
785
+ const side = preferLeft
786
+ ? getAvailableCompositionSide(0, leftSpan, rightStart, rightSpan)
787
+ : getAvailableCompositionSide(rightStart, rightSpan, 0, leftSpan);
788
+ if (!side) return variant;
789
+ return {
790
+ ...variant,
791
+ id: `${variant.id}-beside-media`,
792
+ columnStart: side.columnStart,
793
+ columnSpan: side.columnSpan,
794
+ };
795
+ }
796
+
797
+ function resolveCompositionMediaSlotVariant(
798
+ placement: ResponsivePlacementSpec,
799
+ localConfig: LayoutConfig,
800
+ compositionMode: FrontCompositionFlowMode,
801
+ ): PlacementVariant | null {
802
+ const variant = resolvePreferredPlacementVariant(placement, localConfig);
803
+ if (!variant) return null;
804
+ return compositionMode === "stackedMedia" ? getFullWidthPlacementVariant(variant, localConfig, "stacked") : variant;
805
+ }
806
+
807
+ function getFullWidthPlacementVariant(variant: PlacementVariant, localConfig: LayoutConfig, suffix: string): PlacementVariant {
808
+ return {
809
+ ...variant,
810
+ id: `${variant.id}-${suffix}`,
811
+ columnStart: 0,
812
+ columnSpan: localConfig.columnCount,
813
+ };
814
+ }
815
+
816
+ function getOrderedCompositionLeadSlots(
817
+ blockSpec: ArticleFrameBlockSpec,
818
+ compositionMode: FrontCompositionFlowMode,
819
+ ): ArticleFrameCompositionSlotSpec[] {
820
+ const slots = blockSpec.composition?.lead ?? [];
821
+ if (compositionMode !== "stackedMedia") return slots;
822
+ return [
823
+ ...slots.filter((slot) => slot.slot !== "media"),
824
+ ...slots.filter((slot) => slot.slot === "media"),
825
+ ];
826
+ }
827
+
828
+ function hasCompositionSideBesideMedia(localConfig: LayoutConfig, mediaVariant: PlacementVariant): boolean {
829
+ return mediaVariant.columnStart > 0 || mediaVariant.columnStart + mediaVariant.columnSpan < localConfig.columnCount;
830
+ }
831
+
832
+ function isTitleCompositionSlot(slot: SolvedChromeBox["slot"]): boolean {
833
+ return slot === "label" || slot === "headline";
834
+ }
835
+
836
+ function getAvailableCompositionSide(
837
+ primaryStart: number,
838
+ primarySpan: number,
839
+ fallbackStart: number,
840
+ fallbackSpan: number,
841
+ ): { columnStart: number; columnSpan: number } | null {
842
+ if (primarySpan > 0) return { columnStart: primaryStart, columnSpan: primarySpan };
843
+ if (fallbackSpan > 0) return { columnStart: fallbackStart, columnSpan: fallbackSpan };
844
+ return null;
845
+ }
846
+
847
+ function columnsOverlap(firstStart: number, firstSpan: number, secondStart: number, secondSpan: number): boolean {
848
+ return firstStart < secondStart + secondSpan && secondStart < firstStart + firstSpan;
849
+ }
850
+
851
+ export function getFrontGridHeight(targetPageHeight: number, narrow: boolean, medium: boolean): number {
852
+ const mastheadAllowance = narrow ? 190 : medium ? 230 : 270;
853
+ const bottomPadding = narrow ? 18 : 30;
854
+ return Math.max(420, targetPageHeight - mastheadAllowance - bottomPadding);
855
+ }
856
+
857
+ export function getFrontRows(columnCount: number, rowGap: number, gridHeight: number, rowMaxHeight: number, rhythm: VerticalRhythm): FrontRow[] {
858
+ if (columnCount === 1) {
859
+ const rowHeight = snapDownToRhythm((gridHeight - rowGap * 5) / 6, rhythm);
860
+ return Array.from({ length: 6 }, (_, index) => ({
861
+ startIndex: index,
862
+ endIndex: index,
863
+ height: clampRhythmHeight(rowHeight, 360, rowMaxHeight, rhythm),
864
+ }));
865
+ }
866
+ const first = clampRhythmHeight(snapDownToRhythm(gridHeight * 0.52, rhythm), 360, rowMaxHeight, rhythm);
867
+ const second = clampRhythmHeight(gridHeight - first - rowGap, 300, rowMaxHeight, rhythm);
868
+ return [
869
+ { startIndex: 0, endIndex: 2, height: first },
870
+ { startIndex: 3, endIndex: 5, height: second },
871
+ ];
872
+ }
873
+
874
+ function getFrontPageHeight(config: LayoutConfig): number {
875
+ return getFrontPageHeightForRegion(
876
+ config,
877
+ config.pageChrome.pagePaddingTop + config.pageChrome.mastheadHeight + config.pageChrome.frontGridMarginTop,
878
+ getFrontPageGridHeight(config),
879
+ );
880
+ }
881
+
882
+ function getFrontPageHeightForRegion(config: LayoutConfig, regionY: number, regionHeight: number, frontFooter?: SolvedFrontFooter): number {
883
+ return reserveRhythmRows(
884
+ regionY +
885
+ regionHeight +
886
+ (frontFooter ? frontFooter.marginTop + frontFooter.height : 0) +
887
+ config.pageChrome.pagePaddingBottom,
888
+ config.rhythm,
889
+ );
890
+ }
891
+
892
+ function getFrontPageGridHeight(config: LayoutConfig): number {
893
+ if (config.frontRows.length === 0) return 0;
894
+ return getFrontPageGridHeightFromRowHeights(config.frontRows.map((row) => row.height), config.rowGap);
895
+ }
896
+
897
+ function getFrontPageGridHeightFromRowHeights(rowHeights: number[], rowGap: number): number {
898
+ return rowHeights.reduce((total, height) => total + height, 0) + rowGap * Math.max(0, rowHeights.length - 1);
899
+ }
900
+
901
+ function getFrontRowHeight(config: LayoutConfig, articleIndex: number): number {
902
+ return config.frontRows.find((row) => articleIndex >= row.startIndex && articleIndex <= row.endIndex)?.height ?? reserveRhythmRows(420, config.rhythm);
903
+ }
904
+
905
+ function getFrontArticleRowHeight(placement: FrontArticlePlacement, config: LayoutConfig): number {
906
+ const baseHeight = placement.rowHeight ?? getFrontRowHeight(config, placement.solveIndex);
907
+ const requestedRows = placement.block.size?.defaultRows;
908
+ if (!requestedRows) return baseHeight;
909
+ return Math.max(baseHeight, reserveRhythmRows(requestedRows * config.rhythm.rowHeight, config.rhythm));
910
+ }
911
+
912
+ function shouldGrowFrontArticleToContent(blockSpec: ArticleFrameBlockSpec): boolean {
913
+ return !blockSpec.cutPolicy?.bodyDepthRows && !blockSpec.cutPolicy?.maxBodyLines && !blockSpec.cutPolicy?.jumpTargetPage;
914
+ }
915
+
916
+ function getFrontTeaserTextLimit(
917
+ blockSpec: ArticleFrameBlockSpec,
918
+ config: LayoutConfig,
919
+ ): { mode: "bodyDepth" | "lineCount"; height: number } | null {
920
+ if (blockSpec.cutPolicy?.bodyDepthRows) {
921
+ return {
922
+ mode: "bodyDepth",
923
+ height: getLineLimitHeight(blockSpec.cutPolicy.bodyDepthRows, config.lineHeight, config.linePaintHeight, config.rhythm),
924
+ };
925
+ }
926
+ if (blockSpec.cutPolicy?.maxBodyLines) {
927
+ return {
928
+ mode: "lineCount",
929
+ height: getLineLimitHeight(blockSpec.cutPolicy.maxBodyLines, config.lineHeight, config.linePaintHeight, config.rhythm),
930
+ };
931
+ }
932
+ return null;
933
+ }
934
+
935
+ function getFrontTeaserMeasureHeight(
936
+ textLimit: { mode: "bodyDepth" | "lineCount"; height: number } | null,
937
+ bodySlotHeight: number,
938
+ ): number {
939
+ if (!textLimit || textLimit.mode === "bodyDepth") return bodySlotHeight;
940
+ return Math.min(bodySlotHeight, textLimit.height);
941
+ }
942
+
943
+ function getComposedFrontTextMeasureHeight(
944
+ textLimit: { mode: "bodyDepth" | "lineCount"; height: number } | null,
945
+ compositionMode: FrontCompositionFlowMode,
946
+ copyBandTop: number,
947
+ bodySlotHeight: number,
948
+ ): number {
949
+ if (!textLimit || textLimit.mode === "bodyDepth") return bodySlotHeight;
950
+ const lineLimitHeight = compositionMode === "stackedMedia" || compositionMode === "titleStackedMedia"
951
+ ? copyBandTop + textLimit.height
952
+ : textLimit.height;
953
+ return Math.min(bodySlotHeight, lineLimitHeight);
954
+ }
955
+
956
+ function getOccupiedColumnBottom(bottoms: number[], columnStart: number, columnSpan: number): number {
957
+ return Math.max(...bottoms.slice(columnStart, columnStart + columnSpan), 0);
958
+ }
959
+
960
+ function getColumnCopyBandTops(
961
+ columnCount: number,
962
+ chromeBoxes: SolvedChromeBox[],
963
+ furniture: SolvedFurniture[],
964
+ furnitureOffsetY: number,
965
+ furnitureMarginBottom: number,
966
+ rhythm: VerticalRhythm,
967
+ ): number[] {
968
+ const minimums = Array.from({ length: columnCount }, () => 0);
969
+
970
+ for (const box of chromeBoxes) {
971
+ updateOccupiedColumnBottoms(minimums, box.columnStart, box.columnSpan, box.y + box.height);
972
+ }
973
+
974
+ for (const item of furniture) {
975
+ if (!item.wrapsText) continue;
976
+ updateOccupiedColumnBottoms(
977
+ minimums,
978
+ item.columnStart,
979
+ item.columnSpan,
980
+ item.y + furnitureOffsetY + item.height + furnitureMarginBottom,
981
+ );
982
+ }
983
+
984
+ return minimums.map((minimum) => reserveRhythmRows(Math.max(0, minimum), rhythm));
985
+ }
986
+
987
+ function updateOccupiedColumnBottoms(bottoms: number[], columnStart: number, columnSpan: number, bottom: number): void {
988
+ for (let index = columnStart; index < Math.min(bottoms.length, columnStart + columnSpan); index += 1) {
989
+ bottoms[index] = Math.max(bottoms[index], bottom);
990
+ }
991
+ }
992
+
993
+ function getLeadImageWrap(article: ArticlePublicationItem, width: number, config: LayoutConfig): TextObstacle | null {
994
+ if (!article.image) return null;
995
+ const layout = article.image.layout;
996
+ const minHeight = layout?.minHeight ?? config.lineHeight * 6;
997
+ const maxHeight = layout?.maxHeight ?? config.lineHeight * 12;
998
+ const targetWidth = getInlineFloatWidth(layout, width, config);
999
+ const imageWidth = clamp(
1000
+ Math.round(targetWidth),
1001
+ Math.min(layout?.inlineFloat?.minWidth ?? 72, Math.round(width * 0.24)),
1002
+ Math.round(width * (layout?.inlineFloat?.maxWidthRatio ?? 0.42)),
1003
+ );
1004
+ const naturalHeight = layout?.crop === "contain"
1005
+ ? Math.round(imageWidth / getImageAspectRatio(article.image))
1006
+ : Math.round(layout?.preferredHeight ?? config.lineHeight * 8);
1007
+ const height = layout?.crop === "contain"
1008
+ ? snapPreservedImageHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight)
1009
+ : snapPreferredHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight);
1010
+ return {
1011
+ x: Math.round(width - imageWidth),
1012
+ y: 0,
1013
+ width: imageWidth,
1014
+ height,
1015
+ };
1016
+ }
1017
+
1018
+ function getInlineFloatWidth(
1019
+ layout: NonNullable<ArticlePublicationItem["image"]>["layout"],
1020
+ width: number,
1021
+ config: LayoutConfig,
1022
+ ): number {
1023
+ const intent = layout?.inlineFloat;
1024
+ if (
1025
+ intent &&
1026
+ config.columnCount >= (intent.minColumnCount ?? 2) &&
1027
+ (intent.columnSpan ?? 0) > 0
1028
+ ) {
1029
+ return getSpanWidth(config, intent.columnSpan ?? 1);
1030
+ }
1031
+ const ratio = config.columnCount === 1
1032
+ ? intent?.narrowWidthRatio ?? 0.34
1033
+ : intent?.widthRatio ?? 0.42;
1034
+ return width * ratio;
1035
+ }
1036
+
1037
+ function createFrontPreludeImage(
1038
+ article: ArticlePublicationItem,
1039
+ blockSpec: ArticleFrameBlockSpec,
1040
+ width: number,
1041
+ config: LayoutConfig,
1042
+ usedImageAssetIds: ReadonlySet<string> = new Set(),
1043
+ ): SolvedImageFurniture | null {
1044
+ const mediaSpec = blockSpec.media[0];
1045
+ if (!mediaSpec) return null;
1046
+ const asset = getPreferredImage(article, mediaSpec.placement, mediaSpec.assetRole, usedImageAssetIds);
1047
+ if (!asset) return null;
1048
+ const aspectRatio = getImageAspectRatio(asset);
1049
+ const layout = asset.layout;
1050
+ const canCropFrame = mediaSpec.placement.crop === "cropAllowed";
1051
+ const shouldCropToFill = canCropFrame || layout?.crop === "cover";
1052
+ const minHeight = canCropFrame ? layout?.minHeight ?? 140 : config.rhythm.rowHeight;
1053
+ const maxHeight = canCropFrame ? layout?.maxHeight ?? 320 : Number.POSITIVE_INFINITY;
1054
+ const naturalHeight = Math.round(width / aspectRatio);
1055
+ const imageHeight = canCropFrame
1056
+ ? snapPreferredHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight)
1057
+ : snapPreservedImageHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight);
1058
+ const caption = getImageCaption(asset);
1059
+ const captionHeight = getImageCaptionHeight(caption, width, config.rhythm);
1060
+ const height = imageHeight + captionHeight;
1061
+ return {
1062
+ kind: "image",
1063
+ id: `${article.slug}-front-prelude-photo`,
1064
+ assetId: asset.id,
1065
+ src: asset.src,
1066
+ themeVariants: asset.themeVariants,
1067
+ alt: asset.alt,
1068
+ caption,
1069
+ credit: asset.credit,
1070
+ templateId: "front-prelude",
1071
+ columnStart: 0,
1072
+ columnSpan: 1,
1073
+ x: 0,
1074
+ y: 0,
1075
+ width,
1076
+ height,
1077
+ imageHeight,
1078
+ captionHeight,
1079
+ captionFontSize: IMAGE_CAPTION_FONT_SIZE,
1080
+ captionLineHeight: config.rhythm.rowHeight,
1081
+ aspectRatio,
1082
+ objectFit: shouldCropToFill ? "cover" : "contain",
1083
+ objectPosition: getObjectPosition(asset),
1084
+ wrapsText: false,
1085
+ preferredHeight: naturalHeight + captionHeight,
1086
+ };
1087
+ }
1088
+
1089
+ function leadImageToFurniture(article: ArticlePublicationItem, obstacle: TextObstacle, config: LayoutConfig): SolvedImageFurniture {
1090
+ if (!article.image) throw new Error(`Cannot create lead image furniture for ${article.slug} without an image.`);
1091
+ const [asset] = getPublicationItemImageAssets(article);
1092
+ const aspectRatio = getImageAspectRatio(article.image);
1093
+ const caption = getImageCaption(article.image);
1094
+ const captionHeight = getImageCaptionHeight(caption, obstacle.width, config.rhythm);
1095
+ const preserveImageAspect = article.image.layout?.crop === "contain";
1096
+ const naturalHeight = Math.round(obstacle.width / aspectRatio);
1097
+ const imageHeight = preserveImageAspect
1098
+ ? snapPreservedImageHeightToRhythm(
1099
+ naturalHeight,
1100
+ config.rhythm,
1101
+ article.image.layout?.minHeight ?? config.rhythm.rowHeight,
1102
+ article.image.layout?.maxHeight ?? Number.POSITIVE_INFINITY,
1103
+ )
1104
+ : Math.max(config.rhythm.rowHeight, obstacle.height - captionHeight);
1105
+ const height = imageHeight + captionHeight;
1106
+ return {
1107
+ kind: "image",
1108
+ id: `${article.slug}-lead-photo`,
1109
+ assetId: asset?.id ?? `${article.slug}-primary-image`,
1110
+ src: article.image.src,
1111
+ themeVariants: article.image.themeVariants,
1112
+ alt: article.image.alt,
1113
+ caption,
1114
+ credit: article.image.credit,
1115
+ templateId: "lead-wrap",
1116
+ columnStart: 0,
1117
+ columnSpan: 1,
1118
+ x: obstacle.x,
1119
+ y: obstacle.y,
1120
+ width: obstacle.width,
1121
+ height,
1122
+ imageHeight,
1123
+ captionHeight,
1124
+ captionFontSize: IMAGE_CAPTION_FONT_SIZE,
1125
+ captionLineHeight: config.rhythm.rowHeight,
1126
+ aspectRatio,
1127
+ objectFit: preserveImageAspect ? "contain" : "cover",
1128
+ objectPosition: "50% 50%",
1129
+ wrapsText: true,
1130
+ preferredHeight: height,
1131
+ };
1132
+ }