@anthusai/papyrus 1.3.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.
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/app/newsroom/[section]/page.tsx +6 -0
- package/app/newsroom/articles/page.tsx +3 -0
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-taxonomy-client.ts +74 -0
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-board-view.tsx +59 -26
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-organizer-shell.tsx +12 -7
- package/components/newsroom-organizer.css +2 -1
- package/components/newsroom-references-view.tsx +18 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/topic-steering-workspace.tsx +35 -1
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/chrome-text.ts +504 -0
- package/lib/front-mosaic.ts +1132 -0
- package/lib/furniture.ts +385 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +37 -9
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/local-preview-seed.ts +97 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-board.ts +52 -45
- package/lib/newsroom-organizer-nav.ts +3 -4
- package/lib/pretext-layout.ts +22 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/site-brand.ts +8 -1
- package/lib/text-columns.ts +251 -0
- package/package.json +13 -2
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/routes.manifest.json +1 -9
- package/app/newsroom/work/page.tsx +0 -10
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArticlePublicationItem,
|
|
3
|
+
getArticlePublicationItems,
|
|
4
|
+
type PublicationItem,
|
|
5
|
+
} from "./publication-items";
|
|
6
|
+
import { type LayoutCursor } from "./pretext-layout";
|
|
7
|
+
import { type ArticleFlow, type PlacedTextRange, type SolvedFurniture } from "./layout-types";
|
|
8
|
+
import { EMPTY_CURSOR } from "./layout-config";
|
|
9
|
+
|
|
10
|
+
export function createArticleFlows(items: PublicationItem[]): Map<string, ArticleFlow> {
|
|
11
|
+
return new Map(
|
|
12
|
+
getArticlePublicationItems(items).map((article) => [
|
|
13
|
+
article.slug,
|
|
14
|
+
{
|
|
15
|
+
article,
|
|
16
|
+
currentCursor: { ...EMPTY_CURSOR },
|
|
17
|
+
placedRanges: [],
|
|
18
|
+
usedImageAssetIds: new Set<string>(),
|
|
19
|
+
},
|
|
20
|
+
]),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getOrCreateFlow(flows: Map<string, ArticleFlow>, flowKey: string, article: ArticlePublicationItem): ArticleFlow {
|
|
25
|
+
const existing = flows.get(flowKey);
|
|
26
|
+
if (existing) return existing;
|
|
27
|
+
const flow = {
|
|
28
|
+
article,
|
|
29
|
+
currentCursor: { ...EMPTY_CURSOR },
|
|
30
|
+
placedRanges: [],
|
|
31
|
+
usedImageAssetIds: new Set<string>(),
|
|
32
|
+
};
|
|
33
|
+
flows.set(flowKey, flow);
|
|
34
|
+
return flow;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resetArticleFlow(flow: ArticleFlow): void {
|
|
38
|
+
flow.currentCursor = { ...EMPTY_CURSOR };
|
|
39
|
+
flow.placedRanges = [];
|
|
40
|
+
flow.usedImageAssetIds.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requireKnownItem(itemId: string, itemsBySlug: Map<string, PublicationItem>): PublicationItem {
|
|
44
|
+
const item = itemsBySlug.get(itemId);
|
|
45
|
+
if (!item) throw new Error(`Layout references missing item ${itemId}`);
|
|
46
|
+
return item;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function requireArticleItem(itemId: string, itemsBySlug: Map<string, PublicationItem>): ArticlePublicationItem {
|
|
50
|
+
const item = requireKnownItem(itemId, itemsBySlug);
|
|
51
|
+
if (item.type !== "article") throw new Error(`Layout articleFrame requires article item ${itemId}`);
|
|
52
|
+
return item;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createTextRange({
|
|
56
|
+
flow,
|
|
57
|
+
pageId,
|
|
58
|
+
blockId,
|
|
59
|
+
startCursor,
|
|
60
|
+
endCursor,
|
|
61
|
+
exhausted,
|
|
62
|
+
}: {
|
|
63
|
+
flow: ArticleFlow;
|
|
64
|
+
pageId: string;
|
|
65
|
+
blockId: string;
|
|
66
|
+
startCursor: LayoutCursor;
|
|
67
|
+
endCursor: LayoutCursor;
|
|
68
|
+
exhausted: boolean;
|
|
69
|
+
}): PlacedTextRange {
|
|
70
|
+
return {
|
|
71
|
+
articleId: flow.article.slug,
|
|
72
|
+
pageId,
|
|
73
|
+
blockId,
|
|
74
|
+
startCursor,
|
|
75
|
+
endCursor,
|
|
76
|
+
exhausted,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function commitTextRange(flow: ArticleFlow, range: PlacedTextRange): void {
|
|
81
|
+
flow.currentCursor = { ...range.endCursor };
|
|
82
|
+
flow.placedRanges.push(range);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function commitUsedImageFurniture(flow: ArticleFlow, furniture: SolvedFurniture[]): void {
|
|
86
|
+
for (const item of furniture) {
|
|
87
|
+
if (item.kind === "image") {
|
|
88
|
+
flow.usedImageAssetIds.add(item.assetId);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (item.kind === "mediaCluster") {
|
|
92
|
+
for (const image of item.images) flow.usedImageAssetIds.add(image.assetId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArticleFrameBlockSpec,
|
|
3
|
+
type HeadlineScaleId,
|
|
4
|
+
type LayoutBlockSpec,
|
|
5
|
+
type LayoutPageSpec,
|
|
6
|
+
type LayoutRegionSpec,
|
|
7
|
+
type ResponsiveSpanPolicy,
|
|
8
|
+
} from "./layout-plan";
|
|
9
|
+
import { type ArticlePublicationItem, type PublicationItem } from "./publication-items";
|
|
10
|
+
import { clampRhythmHeight, reserveRhythmRows, snapDownToRhythm } from "./blog-rhythm";
|
|
11
|
+
import { type LayoutCursor } from "./pretext-layout";
|
|
12
|
+
import {
|
|
13
|
+
type ArticleFlow,
|
|
14
|
+
type ArticleFrameCandidate,
|
|
15
|
+
type ContinuationTitleMetrics,
|
|
16
|
+
type LayoutConfig,
|
|
17
|
+
type PlacedTextRange,
|
|
18
|
+
type SolvedBlock,
|
|
19
|
+
type SolvedFurniture,
|
|
20
|
+
type SolvedPage,
|
|
21
|
+
type SolvedRegion,
|
|
22
|
+
} from "./layout-types";
|
|
23
|
+
import {
|
|
24
|
+
EMPTY_CURSOR,
|
|
25
|
+
clamp,
|
|
26
|
+
getBlockSpan,
|
|
27
|
+
getBlockWidth,
|
|
28
|
+
getMinimumTextFrameHeight,
|
|
29
|
+
getResponsiveColumnCount,
|
|
30
|
+
pageIdFor,
|
|
31
|
+
} from "./layout-config";
|
|
32
|
+
import {
|
|
33
|
+
commitTextRange,
|
|
34
|
+
commitUsedImageFurniture,
|
|
35
|
+
createTextRange,
|
|
36
|
+
getOrCreateFlow,
|
|
37
|
+
requireArticleItem,
|
|
38
|
+
requireKnownItem,
|
|
39
|
+
resetArticleFlow,
|
|
40
|
+
} from "./article-flow";
|
|
41
|
+
import { formatContinuationJumpLabel, getArticleFrameLabel } from "./labels";
|
|
42
|
+
import { getContinuationTitleMetrics } from "./chrome-text";
|
|
43
|
+
import {
|
|
44
|
+
createImageFurniture,
|
|
45
|
+
createPullQuoteFurniture,
|
|
46
|
+
getMediaVariants,
|
|
47
|
+
getNoMediaVariant,
|
|
48
|
+
getPullQuoteVariants,
|
|
49
|
+
hasAvailablePreferredImage,
|
|
50
|
+
} from "./furniture";
|
|
51
|
+
import {
|
|
52
|
+
getColumnWhitespace,
|
|
53
|
+
getDeadColumnCount,
|
|
54
|
+
getFurnitureBottom,
|
|
55
|
+
getFurnitureSufficiencyReport,
|
|
56
|
+
getLinesHeight,
|
|
57
|
+
layoutTextColumns,
|
|
58
|
+
} from "./text-columns";
|
|
59
|
+
|
|
60
|
+
const ARTICLE_FRAME_SCORE_WEIGHTS = {
|
|
61
|
+
baseScore: 50_000,
|
|
62
|
+
whitespacePerPixel: 1.1,
|
|
63
|
+
deadColumnPenalty: 8_000,
|
|
64
|
+
imageHeightDeviationPerPixel: 0.35,
|
|
65
|
+
missingImagePenalty: 50_000,
|
|
66
|
+
missingPullQuotePenalty: 2_800,
|
|
67
|
+
narrowedColumnPenaltyPerColumn: 240,
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
export function solveStackedEditorialPage(
|
|
71
|
+
pageSpec: LayoutPageSpec,
|
|
72
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
73
|
+
flows: Map<string, ArticleFlow>,
|
|
74
|
+
config: LayoutConfig,
|
|
75
|
+
): SolvedPage {
|
|
76
|
+
const pageStartY = config.pageChrome.insideHeaderHeight;
|
|
77
|
+
const regions: SolvedRegion[] = [];
|
|
78
|
+
let currentY = pageStartY;
|
|
79
|
+
const ratios = pageSpec.regions.map((region) => region.size?.ratio ?? 1 / pageSpec.regions.length);
|
|
80
|
+
const ratioTotal = ratios.reduce((total, ratio) => total + ratio, 0) || 1;
|
|
81
|
+
|
|
82
|
+
for (let index = 0; index < pageSpec.regions.length; index += 1) {
|
|
83
|
+
const regionSpec = pageSpec.regions[index];
|
|
84
|
+
const allocatedHeight = Math.max(
|
|
85
|
+
getMinimumTextFrameHeight(config),
|
|
86
|
+
snapDownToRhythm(
|
|
87
|
+
Math.floor((config.continuationHeight * ratios[index]) / ratioTotal) -
|
|
88
|
+
(index > 0 ? config.pageChrome.continuationSectionSeparatorHeight : 0),
|
|
89
|
+
config.rhythm,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
const region = solveRegion(regionSpec, pageSpec, allocatedHeight, itemsBySlug, flows, config, currentY);
|
|
93
|
+
regions.push(region);
|
|
94
|
+
currentY += region.height + (index < pageSpec.regions.length - 1 ? config.pageChrome.continuationSectionSeparatorHeight : 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const contentBottom = Math.max(...regions.map((region) => region.y + region.height), pageStartY);
|
|
98
|
+
const height = reserveRhythmRows(
|
|
99
|
+
config.pageChrome.pagePaddingTop +
|
|
100
|
+
contentBottom +
|
|
101
|
+
config.pageChrome.pagePaddingBottom,
|
|
102
|
+
config.rhythm,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: pageSpec.id ?? pageIdFor(pageSpec.pageNumber),
|
|
107
|
+
pageNumber: pageSpec.pageNumber,
|
|
108
|
+
presetId: pageSpec.presetId,
|
|
109
|
+
kind: getPageKind(pageSpec),
|
|
110
|
+
height,
|
|
111
|
+
columnCount: config.columnCount,
|
|
112
|
+
regions,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function solveRegion(
|
|
117
|
+
regionSpec: LayoutRegionSpec,
|
|
118
|
+
pageSpec: LayoutPageSpec,
|
|
119
|
+
allocatedHeight: number,
|
|
120
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
121
|
+
flows: Map<string, ArticleFlow>,
|
|
122
|
+
config: LayoutConfig,
|
|
123
|
+
y: number,
|
|
124
|
+
): SolvedRegion {
|
|
125
|
+
const blocks: SolvedBlock[] = [];
|
|
126
|
+
let currentY = 0;
|
|
127
|
+
|
|
128
|
+
for (const blockSpec of regionSpec.blocks) {
|
|
129
|
+
const remainingHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(allocatedHeight - currentY, config.rhythm));
|
|
130
|
+
const block = solveBlock(blockSpec, pageSpec, remainingHeight, itemsBySlug, flows, config);
|
|
131
|
+
blocks.push({ ...block, y: currentY });
|
|
132
|
+
currentY += block.height;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
id: regionSpec.id,
|
|
137
|
+
type: regionSpec.type,
|
|
138
|
+
role: regionSpec.role,
|
|
139
|
+
x: 0,
|
|
140
|
+
y,
|
|
141
|
+
width: config.contentWidth,
|
|
142
|
+
height: reserveRhythmRows(
|
|
143
|
+
regionSpec.size?.shrinkToContent
|
|
144
|
+
? Math.max(currentY, getMinimumTextFrameHeight(config))
|
|
145
|
+
: Math.max(currentY, allocatedHeight),
|
|
146
|
+
config.rhythm,
|
|
147
|
+
),
|
|
148
|
+
columnCount: config.columnCount,
|
|
149
|
+
blocks,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function solveBlock(
|
|
154
|
+
blockSpec: LayoutBlockSpec,
|
|
155
|
+
pageSpec: LayoutPageSpec,
|
|
156
|
+
allocatedHeight: number,
|
|
157
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
158
|
+
flows: Map<string, ArticleFlow>,
|
|
159
|
+
config: LayoutConfig,
|
|
160
|
+
): SolvedBlock {
|
|
161
|
+
if (blockSpec.type === "articleFrame") {
|
|
162
|
+
return solveArticleFrameBlock(blockSpec, pageSpec, allocatedHeight, itemsBySlug, flows, config);
|
|
163
|
+
}
|
|
164
|
+
return solveStaticBlock(blockSpec, pageSpec, allocatedHeight, itemsBySlug, config);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function solveArticleFrameBlock(
|
|
168
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
169
|
+
pageSpec: LayoutPageSpec,
|
|
170
|
+
allocatedHeight: number,
|
|
171
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
172
|
+
flows: Map<string, ArticleFlow>,
|
|
173
|
+
config: LayoutConfig,
|
|
174
|
+
): SolvedBlock {
|
|
175
|
+
const item = requireArticleItem(blockSpec.itemId, itemsBySlug);
|
|
176
|
+
const flow = getOrCreateFlow(flows, blockSpec.flowKey ?? blockSpec.itemId, item);
|
|
177
|
+
if (blockSpec.startCursor === "beginning") resetArticleFlow(flow);
|
|
178
|
+
const startCursor = blockSpec.startCursor === "beginning" ? { ...EMPTY_CURSOR } : { ...flow.currentCursor };
|
|
179
|
+
const blockWidth = getBlockWidth(config, blockSpec.span);
|
|
180
|
+
const label = getArticleFrameLabel(blockSpec, item, flow);
|
|
181
|
+
const headlineScale = blockSpec.typography?.headlineScale ?? "standard";
|
|
182
|
+
const title = getContinuationTitleMetrics(
|
|
183
|
+
config,
|
|
184
|
+
item,
|
|
185
|
+
blockWidth,
|
|
186
|
+
label,
|
|
187
|
+
headlineScale,
|
|
188
|
+
Boolean(blockSpec.typography?.headlineScale),
|
|
189
|
+
blockSpec.chrome,
|
|
190
|
+
);
|
|
191
|
+
const offeredHeight = getArticleFrameOfferedHeight(blockSpec, allocatedHeight, config);
|
|
192
|
+
const bodyBudget = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(offeredHeight - title.totalHeight, config.rhythm));
|
|
193
|
+
const mustExhaust = !blockSpec.cutPolicy?.maxBodyLines && !blockSpec.cutPolicy?.jumpTargetPage;
|
|
194
|
+
const candidates = solveArticleFrameCandidates({
|
|
195
|
+
blockSpec,
|
|
196
|
+
pageSpec,
|
|
197
|
+
item,
|
|
198
|
+
flow,
|
|
199
|
+
startCursor,
|
|
200
|
+
blockWidth,
|
|
201
|
+
label,
|
|
202
|
+
headlineScale,
|
|
203
|
+
title,
|
|
204
|
+
bodyBudget,
|
|
205
|
+
offeredHeight,
|
|
206
|
+
mustExhaust,
|
|
207
|
+
config,
|
|
208
|
+
});
|
|
209
|
+
const best = chooseBestCandidate(candidates, blockSpec.id);
|
|
210
|
+
commitTextRange(flow, best.range);
|
|
211
|
+
commitUsedImageFurniture(flow, best.block.furniture);
|
|
212
|
+
return best.block;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function getArticleFrameOfferedHeight(
|
|
216
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
217
|
+
allocatedHeight: number,
|
|
218
|
+
config: LayoutConfig,
|
|
219
|
+
): number {
|
|
220
|
+
if (!blockSpec.size?.defaultRows) return allocatedHeight;
|
|
221
|
+
return Math.max(getMinimumTextFrameHeight(config), reserveRhythmRows(blockSpec.size.defaultRows * config.rhythm.rowHeight, config.rhythm));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function solveArticleFrameCandidates({
|
|
225
|
+
blockSpec,
|
|
226
|
+
pageSpec,
|
|
227
|
+
item,
|
|
228
|
+
flow,
|
|
229
|
+
startCursor,
|
|
230
|
+
blockWidth,
|
|
231
|
+
label,
|
|
232
|
+
headlineScale,
|
|
233
|
+
title,
|
|
234
|
+
bodyBudget,
|
|
235
|
+
offeredHeight,
|
|
236
|
+
mustExhaust,
|
|
237
|
+
config,
|
|
238
|
+
}: {
|
|
239
|
+
blockSpec: ArticleFrameBlockSpec;
|
|
240
|
+
pageSpec: LayoutPageSpec;
|
|
241
|
+
item: ArticlePublicationItem;
|
|
242
|
+
flow: ArticleFlow;
|
|
243
|
+
startCursor: LayoutCursor;
|
|
244
|
+
blockWidth: number;
|
|
245
|
+
label: string;
|
|
246
|
+
headlineScale: HeadlineScaleId;
|
|
247
|
+
title: ContinuationTitleMetrics;
|
|
248
|
+
bodyBudget: number;
|
|
249
|
+
offeredHeight: number;
|
|
250
|
+
mustExhaust: boolean;
|
|
251
|
+
config: LayoutConfig;
|
|
252
|
+
}): ArticleFrameCandidate[] {
|
|
253
|
+
const localColumnCounts = getLocalColumnCountCandidates(blockSpec.localGrid?.columns, config, blockWidth);
|
|
254
|
+
const candidates: ArticleFrameCandidate[] = [];
|
|
255
|
+
|
|
256
|
+
for (const columnCount of localColumnCounts) {
|
|
257
|
+
const localConfig = { ...config, columnCount, contentWidth: blockWidth };
|
|
258
|
+
const mediaSpec = blockSpec.media[0];
|
|
259
|
+
const hasReusableMedia = mediaSpec
|
|
260
|
+
? hasAvailablePreferredImage(item, mediaSpec.placement, mediaSpec.assetRole, flow.usedImageAssetIds)
|
|
261
|
+
: false;
|
|
262
|
+
const mediaVariants = getMediaVariants(
|
|
263
|
+
mediaSpec?.required && !hasReusableMedia ? { ...mediaSpec, required: false } : mediaSpec,
|
|
264
|
+
localConfig,
|
|
265
|
+
);
|
|
266
|
+
const effectiveMediaVariants = mediaSpec?.required && hasReusableMedia
|
|
267
|
+
? [...mediaVariants, getNoMediaVariant(12_000)]
|
|
268
|
+
: mediaVariants;
|
|
269
|
+
const pullQuoteVariants = getPullQuoteVariants(item, blockSpec.pullQuote, localConfig);
|
|
270
|
+
|
|
271
|
+
for (const mediaVariant of effectiveMediaVariants) {
|
|
272
|
+
const image = createImageFurniture(item, mediaVariant, localConfig, bodyBudget, flow.usedImageAssetIds);
|
|
273
|
+
if (mediaSpec?.required && hasReusableMedia && !image) continue;
|
|
274
|
+
|
|
275
|
+
const minimumHeight = reserveRhythmRows(Math.max(config.linePaintHeight, image ? image.y + image.height : 0), config.rhythm);
|
|
276
|
+
const textHeights = mustExhaust
|
|
277
|
+
? getExhaustiveTextHeightVariants(Math.max(minimumHeight, bodyBudget), minimumHeight, localConfig)
|
|
278
|
+
: getTextHeightVariants(Math.max(minimumHeight, bodyBudget), minimumHeight, localConfig);
|
|
279
|
+
const furnitureLayoutsAlreadyExhausted = new Set<string>();
|
|
280
|
+
for (const textHeight of textHeights) {
|
|
281
|
+
for (const pullQuoteVariant of pullQuoteVariants) {
|
|
282
|
+
const pullQuote = createPullQuoteFurniture(item, pullQuoteVariant, localConfig, textHeight, image, blockSpec.chrome);
|
|
283
|
+
if (blockSpec.pullQuote?.required && !pullQuote) continue;
|
|
284
|
+
const furnitureLayoutKey = JSON.stringify(pullQuote);
|
|
285
|
+
if (furnitureLayoutsAlreadyExhausted.has(furnitureLayoutKey)) continue;
|
|
286
|
+
|
|
287
|
+
const furniture: SolvedFurniture[] = [];
|
|
288
|
+
if (image) furniture.push(image);
|
|
289
|
+
if (pullQuote) furniture.push(pullQuote);
|
|
290
|
+
const textResult = layoutTextColumns({
|
|
291
|
+
item,
|
|
292
|
+
cursor: startCursor,
|
|
293
|
+
columnCount,
|
|
294
|
+
textHeight,
|
|
295
|
+
localConfig,
|
|
296
|
+
furniture,
|
|
297
|
+
furnitureObstacleMarginBottom: config.rhythm.rowHeight,
|
|
298
|
+
});
|
|
299
|
+
if (mustExhaust && textResult.hasMore) continue;
|
|
300
|
+
if (!mustExhaust && textResult.hasMore && textHeight < bodyBudget - 0.75) continue;
|
|
301
|
+
const furnitureSufficiency = getFurnitureSufficiencyReport({
|
|
302
|
+
columns: textResult.columns,
|
|
303
|
+
textHeight,
|
|
304
|
+
furniture,
|
|
305
|
+
hasMore: textResult.hasMore,
|
|
306
|
+
config: localConfig,
|
|
307
|
+
});
|
|
308
|
+
if (!furnitureSufficiency.accepted) continue;
|
|
309
|
+
if (mustExhaust) furnitureLayoutsAlreadyExhausted.add(furnitureLayoutKey);
|
|
310
|
+
const range = createTextRange({
|
|
311
|
+
flow,
|
|
312
|
+
pageId: pageIdFor(pageSpec.pageNumber),
|
|
313
|
+
blockId: blockSpec.id,
|
|
314
|
+
startCursor,
|
|
315
|
+
endCursor: textResult.cursor,
|
|
316
|
+
exhausted: !textResult.hasMore,
|
|
317
|
+
});
|
|
318
|
+
const linesHeight = Math.max(...textResult.columns.map(getLinesHeight), 0);
|
|
319
|
+
const furnitureBottom = getFurnitureBottom(furniture);
|
|
320
|
+
const contentBodyHeight = reserveRhythmRows(Math.max(furnitureBottom, textResult.hasMore ? textHeight : linesHeight, config.linePaintHeight), config.rhythm);
|
|
321
|
+
const reservedBodyHeight = blockSpec.size?.defaultRows && !blockSpec.size.shrinkToContent
|
|
322
|
+
? Math.max(contentBodyHeight, snapDownToRhythm(offeredHeight - title.totalHeight, config.rhythm))
|
|
323
|
+
: contentBodyHeight;
|
|
324
|
+
const bodyHeight = reserveRhythmRows(reservedBodyHeight, config.rhythm);
|
|
325
|
+
const blockHeight = title.totalHeight + bodyHeight;
|
|
326
|
+
const whitespace = getColumnWhitespace(textResult.columns, bodyHeight, furniture);
|
|
327
|
+
const deadColumns = getDeadColumnCount(textResult.columns, bodyHeight, furniture);
|
|
328
|
+
const score =
|
|
329
|
+
ARTICLE_FRAME_SCORE_WEIGHTS.baseScore -
|
|
330
|
+
whitespace * ARTICLE_FRAME_SCORE_WEIGHTS.whitespacePerPixel -
|
|
331
|
+
deadColumns * ARTICLE_FRAME_SCORE_WEIGHTS.deadColumnPenalty -
|
|
332
|
+
(image
|
|
333
|
+
? Math.abs(image.height - image.preferredHeight) * ARTICLE_FRAME_SCORE_WEIGHTS.imageHeightDeviationPerPixel
|
|
334
|
+
: ARTICLE_FRAME_SCORE_WEIGHTS.missingImagePenalty) -
|
|
335
|
+
(pullQuote ? 0 : ARTICLE_FRAME_SCORE_WEIGHTS.missingPullQuotePenalty) -
|
|
336
|
+
mediaVariant.fallbackPenalty -
|
|
337
|
+
pullQuoteVariant.fallbackPenalty -
|
|
338
|
+
Math.max(0, config.columnCount - columnCount) * ARTICLE_FRAME_SCORE_WEIGHTS.narrowedColumnPenaltyPerColumn;
|
|
339
|
+
|
|
340
|
+
candidates.push({
|
|
341
|
+
block: {
|
|
342
|
+
id: blockSpec.id,
|
|
343
|
+
type: "articleFrame",
|
|
344
|
+
presetId: blockSpec.presetId,
|
|
345
|
+
headlineScale,
|
|
346
|
+
item,
|
|
347
|
+
article: item,
|
|
348
|
+
pageNumber: pageSpec.pageNumber,
|
|
349
|
+
jumpTargetPage: textResult.hasMore ? blockSpec.cutPolicy?.jumpTargetPage : undefined,
|
|
350
|
+
jumpLabel:
|
|
351
|
+
textResult.hasMore && blockSpec.cutPolicy?.jumpTargetPage
|
|
352
|
+
? formatContinuationJumpLabel(item, blockSpec.cutPolicy.jumpTargetPage)
|
|
353
|
+
: undefined,
|
|
354
|
+
label,
|
|
355
|
+
title: item.headline,
|
|
356
|
+
deck: item.deck,
|
|
357
|
+
byline: item.byline,
|
|
358
|
+
dateline: item.dateline,
|
|
359
|
+
section: item.section,
|
|
360
|
+
x: 0,
|
|
361
|
+
y: 0,
|
|
362
|
+
width: blockWidth,
|
|
363
|
+
height: blockHeight,
|
|
364
|
+
span: getBlockSpan(config, blockSpec.span),
|
|
365
|
+
columnCount,
|
|
366
|
+
columns: textResult.columns,
|
|
367
|
+
furniture,
|
|
368
|
+
textRange: range,
|
|
369
|
+
hasMore: textResult.hasMore,
|
|
370
|
+
titleChrome: title,
|
|
371
|
+
titleHeight: title.heading.height,
|
|
372
|
+
bodyHeight,
|
|
373
|
+
furnitureSufficiency,
|
|
374
|
+
},
|
|
375
|
+
range,
|
|
376
|
+
score,
|
|
377
|
+
whitespace,
|
|
378
|
+
furnitureSufficiency,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return candidates;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function solveStaticBlock(
|
|
389
|
+
blockSpec: LayoutBlockSpec,
|
|
390
|
+
pageSpec: LayoutPageSpec,
|
|
391
|
+
allocatedHeight: number,
|
|
392
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
393
|
+
config: LayoutConfig,
|
|
394
|
+
): SolvedBlock {
|
|
395
|
+
const width = config.contentWidth;
|
|
396
|
+
if (blockSpec.type === "itemStack") {
|
|
397
|
+
const items = blockSpec.itemIds.map((itemId) => requireKnownItem(itemId, itemsBySlug));
|
|
398
|
+
const gridColumnCount = Math.max(1, Math.min(config.columnCount, 4));
|
|
399
|
+
const rowCount = Math.ceil(items.length / gridColumnCount);
|
|
400
|
+
const stackHeight = reserveRhythmRows(
|
|
401
|
+
Math.max(getMinimumTextFrameHeight(config), 220 + rowCount * 170),
|
|
402
|
+
config.rhythm,
|
|
403
|
+
);
|
|
404
|
+
return {
|
|
405
|
+
id: blockSpec.id,
|
|
406
|
+
type: blockSpec.type,
|
|
407
|
+
pageNumber: pageSpec.pageNumber,
|
|
408
|
+
title: blockSpec.title,
|
|
409
|
+
items,
|
|
410
|
+
x: 0,
|
|
411
|
+
y: 0,
|
|
412
|
+
width,
|
|
413
|
+
height: stackHeight,
|
|
414
|
+
span: config.columnCount,
|
|
415
|
+
columnCount: gridColumnCount,
|
|
416
|
+
columns: [],
|
|
417
|
+
furniture: [],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (blockSpec.type === "adBlock") {
|
|
421
|
+
const adHeight = clampRhythmHeight(allocatedHeight, 260, 720, config.rhythm);
|
|
422
|
+
return {
|
|
423
|
+
id: blockSpec.id,
|
|
424
|
+
type: blockSpec.type,
|
|
425
|
+
presetId: blockSpec.presetId,
|
|
426
|
+
item: blockSpec.itemId ? requireKnownItem(blockSpec.itemId, itemsBySlug) : undefined,
|
|
427
|
+
pageNumber: pageSpec.pageNumber,
|
|
428
|
+
title: "Advertisement",
|
|
429
|
+
x: 0,
|
|
430
|
+
y: 0,
|
|
431
|
+
width,
|
|
432
|
+
height: adHeight,
|
|
433
|
+
span: config.columnCount,
|
|
434
|
+
columnCount: config.columnCount,
|
|
435
|
+
columns: [],
|
|
436
|
+
furniture: [
|
|
437
|
+
{
|
|
438
|
+
kind: "ad",
|
|
439
|
+
id: `${blockSpec.id}-ad`,
|
|
440
|
+
src: blockSpec.imageUrl,
|
|
441
|
+
label: blockSpec.label?.trim() || "Advertisement",
|
|
442
|
+
x: 0,
|
|
443
|
+
y: 0,
|
|
444
|
+
width,
|
|
445
|
+
height: adHeight,
|
|
446
|
+
wrapsText: false,
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const staticHeight = blockSpec.type === "rule"
|
|
452
|
+
? config.rhythm.rowHeight
|
|
453
|
+
: clampRhythmHeight(120, getMinimumTextFrameHeight(config), allocatedHeight, config.rhythm);
|
|
454
|
+
return {
|
|
455
|
+
id: blockSpec.id,
|
|
456
|
+
type: blockSpec.type,
|
|
457
|
+
pageNumber: pageSpec.pageNumber,
|
|
458
|
+
x: 0,
|
|
459
|
+
y: 0,
|
|
460
|
+
width,
|
|
461
|
+
height: staticHeight,
|
|
462
|
+
span: config.columnCount,
|
|
463
|
+
columnCount: config.columnCount,
|
|
464
|
+
columns: [],
|
|
465
|
+
furniture: [],
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function chooseBestCandidate(candidates: ArticleFrameCandidate[], blockId: string): ArticleFrameCandidate {
|
|
470
|
+
const best = candidates.reduce<ArticleFrameCandidate | null>(
|
|
471
|
+
(winner, candidate) => (!winner || candidate.score > winner.score ? candidate : winner),
|
|
472
|
+
null,
|
|
473
|
+
);
|
|
474
|
+
if (!best) throw new Error(`No valid articleFrame candidate solved for ${blockId}`);
|
|
475
|
+
return best;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function getLocalColumnCountCandidates(
|
|
479
|
+
policy: ResponsiveSpanPolicy | undefined,
|
|
480
|
+
config: LayoutConfig,
|
|
481
|
+
blockWidth: number,
|
|
482
|
+
): number[] {
|
|
483
|
+
const maxByWidth = getResponsiveColumnCount(blockWidth + config.pageChrome.pagePaddingX * 2, blockWidth, config.gap);
|
|
484
|
+
const min = Math.min(policy?.min ?? 1, maxByWidth, config.columnCount);
|
|
485
|
+
const max = Math.min(policy?.max ?? config.columnCount, maxByWidth, config.columnCount);
|
|
486
|
+
const preferred = clamp(policy?.preferred ?? max, min, max);
|
|
487
|
+
const candidates = [preferred, min, max];
|
|
488
|
+
return Array.from(new Set(candidates)).sort((left, right) => (
|
|
489
|
+
Math.abs(left - preferred) - Math.abs(right - preferred) || right - left
|
|
490
|
+
));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function getTextHeightVariants(maxHeight: number, minHeight: number, config: LayoutConfig): number[] {
|
|
494
|
+
const snappedMinimum = reserveRhythmRows(minHeight, config.rhythm);
|
|
495
|
+
const snappedMaximum = Math.max(snappedMinimum, snapDownToRhythm(maxHeight, config.rhythm));
|
|
496
|
+
if (config.columnCount === 1) return [snappedMaximum];
|
|
497
|
+
const candidates = [snappedMaximum, snappedMinimum].map((height) => clamp(snapDownToRhythm(height, config.rhythm), snappedMinimum, snappedMaximum));
|
|
498
|
+
return Array.from(new Set(candidates)).sort((leftHeight, rightHeight) => rightHeight - leftHeight);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getExhaustiveTextHeightVariants(startHeight: number, minHeight: number, config: LayoutConfig): number[] {
|
|
502
|
+
const snappedMinimum = reserveRhythmRows(minHeight, config.rhythm);
|
|
503
|
+
const snappedBudget = Math.max(snappedMinimum, reserveRhythmRows(startHeight, config.rhythm));
|
|
504
|
+
const step = config.rhythm.rowHeight * 4;
|
|
505
|
+
const maximum = snappedBudget + config.rhythm.rowHeight * 240;
|
|
506
|
+
const heights: number[] = [];
|
|
507
|
+
for (let height = snappedMinimum; height <= maximum; height += step) heights.push(height);
|
|
508
|
+
return heights;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function getPageKind(pageSpec: LayoutPageSpec): string {
|
|
512
|
+
if (pageSpec.presetId === "page.regionStack" && pageSpec.regions.length > 1) return "regionStack";
|
|
513
|
+
if (pageSpec.presetId === "page.railMain") return "railMain";
|
|
514
|
+
return "articlePage";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function collectPageTextRanges(page: SolvedPage): PlacedTextRange[] {
|
|
518
|
+
return page.regions.flatMap((region) => region.blocks.flatMap((block) => (block.textRange ? [block.textRange] : [])));
|
|
519
|
+
}
|