@anthusai/papyrus 1.3.0 → 1.4.0-next.2
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 +8 -0
- package/app/newsroom/articles/page.tsx +10 -0
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-taxonomy-client.ts +86 -3
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-board-view.tsx +70 -29
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-organizer-shell.tsx +13 -8
- package/components/newsroom-organizer.css +5 -5
- 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/content-item-lifecycle.ts +32 -5
- 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 +95 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-board.ts +70 -47
- package/lib/newsroom-organizer-nav.ts +5 -5
- 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
package/lib/furniture.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ArticleFrameChromeSpec,
|
|
3
|
+
type ChromeTextSlotSpec,
|
|
4
|
+
type LayoutMediaSpec,
|
|
5
|
+
type LayoutPullQuoteSpec,
|
|
6
|
+
type ResponsivePlacementAnchor,
|
|
7
|
+
type ResponsivePlacementSpec,
|
|
8
|
+
type ResponsiveSpanPolicy,
|
|
9
|
+
type ResponsiveVerticalPlacement,
|
|
10
|
+
} from "./layout-plan";
|
|
11
|
+
import { type ArticlePublicationItem, getPublicationItemImageAssets } from "./publication-items";
|
|
12
|
+
import {
|
|
13
|
+
reserveRhythmRows,
|
|
14
|
+
snapDownToRhythm,
|
|
15
|
+
snapPreferredHeightToRhythm,
|
|
16
|
+
snapPreservedImageHeightToRhythm,
|
|
17
|
+
snapToNearestRhythm,
|
|
18
|
+
type VerticalRhythm,
|
|
19
|
+
} from "./blog-rhythm";
|
|
20
|
+
import { type ArticleImageAsset } from "./articles";
|
|
21
|
+
import {
|
|
22
|
+
type LayoutConfig,
|
|
23
|
+
type MediaVariant,
|
|
24
|
+
type PlacementVariant,
|
|
25
|
+
type PullQuoteVariant,
|
|
26
|
+
type SolvedImageFurniture,
|
|
27
|
+
type SolvedPullQuoteFurniture,
|
|
28
|
+
} from "./layout-types";
|
|
29
|
+
import {
|
|
30
|
+
FURNITURE_COLLISION_GUTTER,
|
|
31
|
+
IMAGE_CAPTION_FONT_SIZE,
|
|
32
|
+
IMAGE_CAPTION_HORIZONTAL_PADDING_EM,
|
|
33
|
+
PULL_QUOTE_VERTICAL_PADDING,
|
|
34
|
+
SERIF_TEXT_FONT,
|
|
35
|
+
clamp,
|
|
36
|
+
getSpanRect,
|
|
37
|
+
} from "./layout-config";
|
|
38
|
+
import { buildChromeFont, emToPx, measureWrappedTextBlock, parseEmOverride } from "./chrome-text";
|
|
39
|
+
|
|
40
|
+
export function getMediaVariants(media: LayoutMediaSpec | undefined, config: LayoutConfig): MediaVariant[] {
|
|
41
|
+
if (!media) return [getNoMediaVariant(900)];
|
|
42
|
+
const variants = resolvePlacementVariants(media.placement, config).map((variant) => ({
|
|
43
|
+
...variant,
|
|
44
|
+
assetRole: media.assetRole,
|
|
45
|
+
fallbackPenalty: variant.fallbackPenalty + Math.abs(variant.columnSpan - media.placement.span.preferred) * 900,
|
|
46
|
+
}));
|
|
47
|
+
if (media.required) return variants;
|
|
48
|
+
return [...variants, getNoMediaVariant(24_000)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getNoMediaVariant(fallbackPenalty: number): MediaVariant {
|
|
52
|
+
return { id: "none", placement: null, columnStart: 0, columnSpan: 0, fallbackPenalty };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getPullQuoteVariants(
|
|
56
|
+
article: ArticlePublicationItem,
|
|
57
|
+
pullQuote: LayoutPullQuoteSpec | undefined,
|
|
58
|
+
config: LayoutConfig,
|
|
59
|
+
): PullQuoteVariant[] {
|
|
60
|
+
const none = { id: "none", placement: null, columnStart: 0, columnSpan: 0, fallbackPenalty: 0 };
|
|
61
|
+
if (!pullQuote || !article.pullQuotes?.[0]) return [none];
|
|
62
|
+
const variants = pullQuote.placements.flatMap((placement, placementIndex) =>
|
|
63
|
+
resolvePlacementVariants(placement, config).map((variant) => ({
|
|
64
|
+
...variant,
|
|
65
|
+
fallbackPenalty: variant.fallbackPenalty + placementIndex * 100,
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
68
|
+
return pullQuote.required ? variants : [none, ...variants];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolvePlacementVariants(placement: ResponsivePlacementSpec, config: LayoutConfig): PlacementVariant[] {
|
|
72
|
+
const effectiveSpan = getEffectivePlacementSpanPolicy(placement, config.columnCount);
|
|
73
|
+
const collapsed = config.columnCount < effectiveSpan.min;
|
|
74
|
+
if (collapsed && placement.collapse === "omit") return [];
|
|
75
|
+
const spans = collapsed
|
|
76
|
+
? [placement.collapse === "fullWidth" ? config.columnCount : 1]
|
|
77
|
+
: getSpanCandidates(effectiveSpan, config.columnCount);
|
|
78
|
+
return spans.flatMap((span, index) => {
|
|
79
|
+
const anchor = collapsed && placement.collapse === "inline" ? "inline" : placement.anchor ?? "left";
|
|
80
|
+
const columnStart = getColumnStartForPlacement(placement, resolveAnchor(anchor), span, config.columnCount, collapsed);
|
|
81
|
+
if (columnStart === null) return [];
|
|
82
|
+
return [{
|
|
83
|
+
id: `${resolveAnchor(anchor)}-span${span}-${placement.vertical}`,
|
|
84
|
+
placement,
|
|
85
|
+
columnStart,
|
|
86
|
+
columnSpan: span,
|
|
87
|
+
fallbackPenalty: index * 120 + (collapsed ? 320 : 0),
|
|
88
|
+
}];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getEffectivePlacementSpanPolicy(
|
|
93
|
+
placement: ResponsivePlacementSpec,
|
|
94
|
+
columnCount: number,
|
|
95
|
+
): ResponsiveSpanPolicy {
|
|
96
|
+
const override = placement.spanOverrides?.[String(columnCount)];
|
|
97
|
+
if (!override) return placement.span;
|
|
98
|
+
const clamped = clamp(override, placement.span.min, placement.span.max);
|
|
99
|
+
return { min: clamped, preferred: clamped, max: clamped };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolvePreferredPlacementVariant(placement: ResponsivePlacementSpec, config: LayoutConfig): PlacementVariant | null {
|
|
103
|
+
return resolvePlacementVariants(placement, config)[0] ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createImageFurniture(
|
|
107
|
+
article: ArticlePublicationItem,
|
|
108
|
+
variant: MediaVariant,
|
|
109
|
+
config: LayoutConfig,
|
|
110
|
+
textHeight: number,
|
|
111
|
+
usedImageAssetIds: ReadonlySet<string> = new Set(),
|
|
112
|
+
): SolvedImageFurniture | null {
|
|
113
|
+
if (!variant.placement || variant.columnSpan === 0) return null;
|
|
114
|
+
const asset = getPreferredImage(article, variant.placement, variant.assetRole, usedImageAssetIds);
|
|
115
|
+
if (!asset) return null;
|
|
116
|
+
const { x, width } = getSpanRect(config, variant.columnStart, variant.columnSpan);
|
|
117
|
+
const aspectRatio = getImageAspectRatio(asset);
|
|
118
|
+
const preferredHeight = Math.round(width / aspectRatio);
|
|
119
|
+
const canCropFrame = variant.placement.crop === "cropAllowed";
|
|
120
|
+
const shouldCropToFill = canCropFrame || asset.layout?.crop === "cover";
|
|
121
|
+
const minHeight = canCropFrame ? asset.layout?.minHeight ?? 140 : config.rhythm.rowHeight;
|
|
122
|
+
const maxHeight = canCropFrame ? asset.layout?.maxHeight ?? 420 : Number.POSITIVE_INFINITY;
|
|
123
|
+
const rawHeight = canCropFrame ? clamp(preferredHeight, minHeight, maxHeight) : preferredHeight;
|
|
124
|
+
const imageHeight = canCropFrame
|
|
125
|
+
? snapPreferredHeightToRhythm(rawHeight, config.rhythm, minHeight, maxHeight)
|
|
126
|
+
: snapPreservedImageHeightToRhythm(rawHeight, config.rhythm, minHeight, maxHeight);
|
|
127
|
+
const caption = getImageCaption(asset);
|
|
128
|
+
const captionHeight = getImageCaptionHeight(caption, width, config.rhythm);
|
|
129
|
+
const height = imageHeight + captionHeight;
|
|
130
|
+
const y = getFurnitureY(variant.placement.vertical, height, textHeight, config);
|
|
131
|
+
const focalPoint = asset.layout?.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
132
|
+
return {
|
|
133
|
+
kind: "image",
|
|
134
|
+
id: `${article.slug}-${variant.id}-image`,
|
|
135
|
+
assetId: asset.id,
|
|
136
|
+
src: asset.src,
|
|
137
|
+
themeVariants: asset.themeVariants,
|
|
138
|
+
alt: asset.alt,
|
|
139
|
+
caption,
|
|
140
|
+
credit: asset.credit,
|
|
141
|
+
templateId: `image-${variant.id}`,
|
|
142
|
+
columnStart: variant.columnStart,
|
|
143
|
+
columnSpan: variant.columnSpan,
|
|
144
|
+
x,
|
|
145
|
+
y,
|
|
146
|
+
width,
|
|
147
|
+
height,
|
|
148
|
+
imageHeight,
|
|
149
|
+
captionHeight,
|
|
150
|
+
captionFontSize: IMAGE_CAPTION_FONT_SIZE,
|
|
151
|
+
captionLineHeight: config.rhythm.rowHeight,
|
|
152
|
+
aspectRatio,
|
|
153
|
+
objectFit: shouldCropToFill ? "cover" : "contain",
|
|
154
|
+
objectPosition: `${Math.round(focalPoint.x * 100)}% ${Math.round(focalPoint.y * 100)}%`,
|
|
155
|
+
wrapsText: variant.placement.wrapsText,
|
|
156
|
+
preferredHeight: preferredHeight + captionHeight,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function createPullQuoteFurniture(
|
|
161
|
+
article: ArticlePublicationItem,
|
|
162
|
+
variant: PullQuoteVariant,
|
|
163
|
+
config: LayoutConfig,
|
|
164
|
+
textHeight: number,
|
|
165
|
+
image: SolvedImageFurniture | null,
|
|
166
|
+
chrome?: ArticleFrameChromeSpec,
|
|
167
|
+
): SolvedPullQuoteFurniture | null {
|
|
168
|
+
if (!variant.placement || variant.columnSpan === 0) return null;
|
|
169
|
+
const text = article.pullQuotes?.[0];
|
|
170
|
+
if (!text) return null;
|
|
171
|
+
const { x, width } = getSpanRect(config, variant.columnStart, variant.columnSpan);
|
|
172
|
+
const metrics = getPullQuoteMetrics(text, width, config, chrome?.pullQuote);
|
|
173
|
+
const y = getPullQuoteY({
|
|
174
|
+
x,
|
|
175
|
+
width,
|
|
176
|
+
height: metrics.height,
|
|
177
|
+
textHeight,
|
|
178
|
+
anchorRatio: getVerticalAnchorRatio(variant.placement.vertical),
|
|
179
|
+
image,
|
|
180
|
+
config,
|
|
181
|
+
});
|
|
182
|
+
if (y === null) return null;
|
|
183
|
+
return {
|
|
184
|
+
kind: "pullQuote",
|
|
185
|
+
id: `${article.slug}-${variant.id}-pullquote`,
|
|
186
|
+
text,
|
|
187
|
+
templateId: `pullquote-${variant.id}`,
|
|
188
|
+
columnStart: variant.columnStart,
|
|
189
|
+
columnSpan: variant.columnSpan,
|
|
190
|
+
x,
|
|
191
|
+
y,
|
|
192
|
+
width,
|
|
193
|
+
height: metrics.height,
|
|
194
|
+
fontSize: metrics.fontSize,
|
|
195
|
+
lineHeight: metrics.lineHeight,
|
|
196
|
+
wrapsText: true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function getPreferredImage(
|
|
201
|
+
article: ArticlePublicationItem,
|
|
202
|
+
_placement: ResponsivePlacementSpec,
|
|
203
|
+
assetRole: string | undefined,
|
|
204
|
+
usedImageAssetIds: ReadonlySet<string> = new Set(),
|
|
205
|
+
): ArticleImageAsset | null {
|
|
206
|
+
const assets = getPublicationItemImageAssets(article).filter((asset) => !usedImageAssetIds.has(asset.id));
|
|
207
|
+
if (assetRole) {
|
|
208
|
+
const roleAsset = assets.find((asset) => asset.roles?.some((role) => role === assetRole));
|
|
209
|
+
if (roleAsset) return roleAsset;
|
|
210
|
+
}
|
|
211
|
+
return (
|
|
212
|
+
assets.find((asset) => asset.roles?.some((role) => role === "continuationInset")) ??
|
|
213
|
+
assets.find((asset) => asset.roles?.some((role) => role === "continuation")) ??
|
|
214
|
+
assets[0] ??
|
|
215
|
+
null
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function hasAvailablePreferredImage(
|
|
220
|
+
article: ArticlePublicationItem,
|
|
221
|
+
placement: ResponsivePlacementSpec,
|
|
222
|
+
assetRole: string | undefined,
|
|
223
|
+
usedImageAssetIds: ReadonlySet<string>,
|
|
224
|
+
): boolean {
|
|
225
|
+
return getPreferredImage(article, placement, assetRole, usedImageAssetIds) !== null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getSpanCandidates(span: ResponsiveSpanPolicy, columnCount: number): number[] {
|
|
229
|
+
const min = Math.min(span.min, columnCount);
|
|
230
|
+
const max = Math.min(span.max, columnCount);
|
|
231
|
+
const preferred = clamp(span.preferred, min, max);
|
|
232
|
+
const candidates = [preferred, min, max];
|
|
233
|
+
return Array.from(new Set(candidates)).sort((left, right) => (
|
|
234
|
+
Math.abs(left - preferred) - Math.abs(right - preferred) || right - left
|
|
235
|
+
));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function resolveAnchor(anchor: ResponsivePlacementAnchor): "left" | "right" | "center" | "inline" {
|
|
239
|
+
if (anchor === "outer") return "right";
|
|
240
|
+
if (anchor === "inner") return "left";
|
|
241
|
+
if (anchor === "inline") return "left";
|
|
242
|
+
return anchor;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getColumnStart(anchor: "left" | "right" | "center" | "inline", span: number, columnCount: number): number {
|
|
246
|
+
if (anchor === "right") return Math.max(0, columnCount - span);
|
|
247
|
+
if (anchor === "center") return Math.max(0, Math.floor((columnCount - span) / 2));
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getColumnStartForPlacement(
|
|
252
|
+
placement: ResponsivePlacementSpec,
|
|
253
|
+
anchor: "left" | "right" | "center" | "inline",
|
|
254
|
+
span: number,
|
|
255
|
+
columnCount: number,
|
|
256
|
+
collapsed: boolean,
|
|
257
|
+
): number | null {
|
|
258
|
+
if (!placement.columnStart || collapsed) return getColumnStart(anchor, span, columnCount);
|
|
259
|
+
const requested = placement.columnStart - 1;
|
|
260
|
+
if (requested + span <= columnCount) return requested;
|
|
261
|
+
if (placement.collapse === "omit") return null;
|
|
262
|
+
if (placement.collapse === "fullWidth") return 0;
|
|
263
|
+
return getColumnStart("inline", Math.min(span, columnCount), columnCount);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function getFurnitureY(
|
|
267
|
+
verticalAnchor: ResponsiveVerticalPlacement,
|
|
268
|
+
height: number,
|
|
269
|
+
textHeight: number,
|
|
270
|
+
config: LayoutConfig,
|
|
271
|
+
): number {
|
|
272
|
+
if (verticalAnchor === "top") return 0;
|
|
273
|
+
const minY = config.lineHeight * 2;
|
|
274
|
+
const maxY = Math.max(0, textHeight - height);
|
|
275
|
+
const ratio = getVerticalAnchorRatio(verticalAnchor);
|
|
276
|
+
const minimumY = Math.min(reserveRhythmRows(minY, config.rhythm), maxY);
|
|
277
|
+
const maximumY = snapDownToRhythm(maxY, config.rhythm);
|
|
278
|
+
if (maximumY < minimumY) return maximumY;
|
|
279
|
+
return clamp(snapToNearestRhythm(textHeight * ratio - height / 2, config.rhythm), minimumY, maximumY);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function getVerticalAnchorRatio(verticalAnchor: ResponsiveVerticalPlacement): number {
|
|
283
|
+
if (verticalAnchor === "top") return 0.12;
|
|
284
|
+
if (verticalAnchor === "upperThird") return 0.32;
|
|
285
|
+
if (verticalAnchor === "lowerThird") return 0.66;
|
|
286
|
+
return 0.5;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getPullQuoteMetrics(
|
|
290
|
+
text: string,
|
|
291
|
+
width: number,
|
|
292
|
+
config: LayoutConfig,
|
|
293
|
+
overrides?: ChromeTextSlotSpec,
|
|
294
|
+
): { height: number; fontSize: number; lineHeight: number } {
|
|
295
|
+
const narrow = config.columnCount === 1;
|
|
296
|
+
const fontSize = narrow ? 20 : 22;
|
|
297
|
+
const lineHeight = Math.ceil(emToPx(parseEmOverride(overrides?.lineHeight, 1.12), fontSize));
|
|
298
|
+
const paintHeight = Math.ceil(emToPx(parseEmOverride(overrides?.paintHeight, 1.18), fontSize));
|
|
299
|
+
const metrics = measureWrappedTextBlock(text, buildChromeFont({
|
|
300
|
+
fontSize,
|
|
301
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
302
|
+
fontStyle: "italic",
|
|
303
|
+
lineHeightEm: lineHeight / fontSize,
|
|
304
|
+
paintHeightEm: paintHeight / fontSize,
|
|
305
|
+
}), width, lineHeight, paintHeight);
|
|
306
|
+
const marginBefore = Math.round(emToPx(parseEmOverride(overrides?.marginBefore, PULL_QUOTE_VERTICAL_PADDING / 2 / fontSize), fontSize));
|
|
307
|
+
const marginAfter = Math.round(emToPx(parseEmOverride(overrides?.marginAfter, PULL_QUOTE_VERTICAL_PADDING / 2 / fontSize), fontSize));
|
|
308
|
+
const minHeight = Math.ceil(emToPx(parseEmOverride(overrides?.minHeight, 0), fontSize));
|
|
309
|
+
const height = reserveRhythmRows(Math.max(metrics.height + marginBefore + marginAfter, minHeight), config.rhythm);
|
|
310
|
+
return { height, fontSize, lineHeight };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function getPullQuoteY({
|
|
314
|
+
x,
|
|
315
|
+
width,
|
|
316
|
+
height,
|
|
317
|
+
textHeight,
|
|
318
|
+
anchorRatio,
|
|
319
|
+
image,
|
|
320
|
+
config,
|
|
321
|
+
}: {
|
|
322
|
+
x: number;
|
|
323
|
+
width: number;
|
|
324
|
+
height: number;
|
|
325
|
+
textHeight: number;
|
|
326
|
+
anchorRatio: number;
|
|
327
|
+
image: SolvedImageFurniture | null;
|
|
328
|
+
config: LayoutConfig;
|
|
329
|
+
}): number | null {
|
|
330
|
+
const minY = config.lineHeight * 2;
|
|
331
|
+
const maxY = textHeight - height;
|
|
332
|
+
if (maxY < minY) return null;
|
|
333
|
+
const minimumY = reserveRhythmRows(minY, config.rhythm);
|
|
334
|
+
const maximumY = snapDownToRhythm(maxY, config.rhythm);
|
|
335
|
+
if (maximumY < minimumY) return null;
|
|
336
|
+
const preferredY = clamp(snapToNearestRhythm(textHeight * anchorRatio - height / 2, config.rhythm), minimumY, maximumY);
|
|
337
|
+
const preferredRect = { x, y: preferredY, width, height };
|
|
338
|
+
if (!image || !rectsOverlap(preferredRect, image, FURNITURE_COLLISION_GUTTER)) return preferredY;
|
|
339
|
+
const candidateYs = [
|
|
340
|
+
reserveRhythmRows(image.y + image.height + FURNITURE_COLLISION_GUTTER, config.rhythm),
|
|
341
|
+
snapDownToRhythm(image.y - height - FURNITURE_COLLISION_GUTTER, config.rhythm),
|
|
342
|
+
]
|
|
343
|
+
.filter((candidateY) => candidateY >= minimumY && candidateY <= maximumY)
|
|
344
|
+
.sort((leftY, rightY) => Math.abs(leftY - preferredY) - Math.abs(rightY - preferredY));
|
|
345
|
+
return candidateYs.find((candidateY) => !rectsOverlap({ x, y: candidateY, width, height }, image, FURNITURE_COLLISION_GUTTER)) ?? null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function rectsOverlap(
|
|
349
|
+
a: { x: number; y: number; width: number; height: number },
|
|
350
|
+
b: { x: number; y: number; width: number; height: number },
|
|
351
|
+
gutter = 0,
|
|
352
|
+
): boolean {
|
|
353
|
+
return (
|
|
354
|
+
a.x < b.x + b.width + gutter &&
|
|
355
|
+
a.x + a.width + gutter > b.x &&
|
|
356
|
+
a.y < b.y + b.height + gutter &&
|
|
357
|
+
a.y + a.height + gutter > b.y
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function getImageAspectRatio(asset: Pick<ArticleImageAsset, "layout">): number {
|
|
362
|
+
return asset.layout?.aspectRatio ?? 1.5;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function getImageCaption(asset: Pick<ArticleImageAsset, "caption" | "credit">): string {
|
|
366
|
+
return (asset.caption ?? asset.credit).trim();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function getImageCaptionHeight(caption: string, width: number, rhythm: VerticalRhythm): number {
|
|
370
|
+
if (!caption) return 0;
|
|
371
|
+
const contentWidth = Math.max(1, width - IMAGE_CAPTION_FONT_SIZE * IMAGE_CAPTION_HORIZONTAL_PADDING_EM);
|
|
372
|
+
const metrics = measureWrappedTextBlock(
|
|
373
|
+
caption,
|
|
374
|
+
`italic ${IMAGE_CAPTION_FONT_SIZE}px ${SERIF_TEXT_FONT}`,
|
|
375
|
+
contentWidth,
|
|
376
|
+
rhythm.rowHeight,
|
|
377
|
+
rhythm.rowHeight,
|
|
378
|
+
);
|
|
379
|
+
return reserveRhythmRows(Math.max(rhythm.rowHeight, metrics.height), rhythm);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function getObjectPosition(asset: Pick<ArticleImageAsset, "layout">): string {
|
|
383
|
+
const focalPoint = asset.layout?.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
384
|
+
return `${Math.round(focalPoint.x * 100)}% ${Math.round(focalPoint.y * 100)}%`;
|
|
385
|
+
}
|
package/lib/labels.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ArticlePublicationItem } from "./publication-items";
|
|
2
|
+
import { type ArticleFrameBlockSpec } from "./layout-plan";
|
|
3
|
+
import { type ArticleFlow } from "./layout-types";
|
|
4
|
+
|
|
5
|
+
export function formatContinuationJumpLabel(article: ArticlePublicationItem, pageNumber: number): string {
|
|
6
|
+
return `SEE ${getShortSlug(article) ?? "MORE"} ON ${formatSectionPage(pageNumber)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getArticleFrameLabel(blockSpec: ArticleFrameBlockSpec, article: ArticlePublicationItem, flow: ArticleFlow): string {
|
|
10
|
+
if (blockSpec.startCursor !== "current") return article.section;
|
|
11
|
+
const previousRange = flow.placedRanges[flow.placedRanges.length - 1];
|
|
12
|
+
if (!previousRange) throw new Error(`Continuation block ${blockSpec.id} has no earlier text range to continue from`);
|
|
13
|
+
return formatContinuationSourceLabel(article, getPageNumberFromPageId(previousRange.pageId));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getPageNumberFromPageId(pageId: string): number {
|
|
17
|
+
return Number(pageId.replace(/^page-/, ""));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatContinuationSourceLabel(article: ArticlePublicationItem, sourcePageNumber: number): string {
|
|
21
|
+
const shortSlug = getShortSlug(article);
|
|
22
|
+
const source = `FROM ${formatSectionPage(sourcePageNumber)}`;
|
|
23
|
+
return shortSlug ? `${shortSlug} ${source}` : source;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getShortSlug(article: ArticlePublicationItem): string | null {
|
|
27
|
+
const shortSlug = article.shortSlug?.trim().toUpperCase();
|
|
28
|
+
return shortSlug || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatSectionPage(pageNumber: number): string {
|
|
32
|
+
return `A${pageNumber}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatByline(article: ArticlePublicationItem): string {
|
|
36
|
+
return `${article.byline} / ${article.dateline}`.toUpperCase();
|
|
37
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { type LayoutCursor } from "./pretext-layout";
|
|
2
|
+
import {
|
|
3
|
+
createVerticalRhythm,
|
|
4
|
+
reserveRhythmRows,
|
|
5
|
+
snapDownToRhythm,
|
|
6
|
+
type VerticalRhythm,
|
|
7
|
+
} from "./blog-rhythm";
|
|
8
|
+
import { type ResponsiveSpanPolicy } from "./layout-plan";
|
|
9
|
+
import { type LayoutConfig, type PageChromeMetrics } from "./layout-types";
|
|
10
|
+
import { getFrontGridHeight, getFrontRows } from "./front-mosaic";
|
|
11
|
+
|
|
12
|
+
export const EMPTY_CURSOR: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
|
|
13
|
+
|
|
14
|
+
export const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|
|
15
|
+
|
|
16
|
+
export const SANS_TEXT_FONT = "Arial, Helvetica, sans-serif";
|
|
17
|
+
|
|
18
|
+
export const IMAGE_CAPTION_FONT_SIZE = 12;
|
|
19
|
+
|
|
20
|
+
export const IMAGE_CAPTION_HORIZONTAL_PADDING_EM = 0.3;
|
|
21
|
+
|
|
22
|
+
const MASTHEAD_TITLE_GLYPH_HEIGHT_RATIO = 0.736;
|
|
23
|
+
|
|
24
|
+
const MASTHEAD_RULE_HEIGHT = 0;
|
|
25
|
+
|
|
26
|
+
const MASTHEAD_RULE_MARGIN_BOTTOM = 0;
|
|
27
|
+
|
|
28
|
+
const MASTHEAD_TITLE_MARGIN_TOP = 0;
|
|
29
|
+
|
|
30
|
+
const MASTHEAD_PADDING_BOTTOM = 0;
|
|
31
|
+
|
|
32
|
+
const MASTHEAD_BORDER_BOTTOM = 0;
|
|
33
|
+
|
|
34
|
+
const MASTHEAD_MARGIN_BOTTOM = 0;
|
|
35
|
+
|
|
36
|
+
const MASTHEAD_META_BORDER_TOP = 0;
|
|
37
|
+
|
|
38
|
+
const MASTHEAD_META_PADDING_TOP = 0;
|
|
39
|
+
|
|
40
|
+
const MASTHEAD_META_PADDING_BOTTOM = 0;
|
|
41
|
+
|
|
42
|
+
const MASTHEAD_META_BORDER_BOTTOM = 0;
|
|
43
|
+
|
|
44
|
+
const INSIDE_HEADER_PADDING_BOTTOM = 8;
|
|
45
|
+
|
|
46
|
+
const INSIDE_HEADER_BORDER_BOTTOM = 2;
|
|
47
|
+
|
|
48
|
+
const INSIDE_HEADER_MARGIN_BOTTOM = 14;
|
|
49
|
+
|
|
50
|
+
const CONTINUED_TITLE_KICKER_LINE_HEIGHT = 14;
|
|
51
|
+
|
|
52
|
+
export const CONTINUED_TITLE_HEADING_MARGIN_TOP = 5;
|
|
53
|
+
|
|
54
|
+
export const CONTINUED_TITLE_PADDING_BOTTOM = 0;
|
|
55
|
+
|
|
56
|
+
export const CONTINUED_TITLE_BORDER_BOTTOM = 0;
|
|
57
|
+
|
|
58
|
+
export const CONTINUED_TITLE_MARGIN_BOTTOM = 0;
|
|
59
|
+
|
|
60
|
+
export const FURNITURE_COLLISION_GUTTER = 14;
|
|
61
|
+
|
|
62
|
+
export const PULL_QUOTE_VERTICAL_PADDING = 24;
|
|
63
|
+
|
|
64
|
+
const NEWSPAPER_PAINT_BUFFER_PX = 4;
|
|
65
|
+
|
|
66
|
+
export function getMinimumTextFrameHeight(config: LayoutConfig): number {
|
|
67
|
+
return reserveRhythmRows(config.linePaintHeight, config.rhythm);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pageIdFor(pageNumber: number): string {
|
|
71
|
+
return `page-${pageNumber}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getLayoutConfig(pageWidth: number, viewportHeight: number): LayoutConfig {
|
|
75
|
+
const narrow = pageWidth < 560;
|
|
76
|
+
const medium = pageWidth >= 560 && pageWidth < 1040;
|
|
77
|
+
const bodyFontSize = narrow ? 14 : 15;
|
|
78
|
+
const bodyLineHeight = Math.ceil(bodyFontSize * 1.4);
|
|
79
|
+
const rhythm = createVerticalRhythm(bodyLineHeight, NEWSPAPER_PAINT_BUFFER_PX);
|
|
80
|
+
const gap = narrow ? 14 : 18;
|
|
81
|
+
const rowGap = rhythm.rowHeight;
|
|
82
|
+
const sideMargin = narrow ? 18 : 30;
|
|
83
|
+
const contentWidth = Math.max(280, pageWidth - sideMargin * 2);
|
|
84
|
+
const columnCount = getResponsiveColumnCount(pageWidth, contentWidth, gap);
|
|
85
|
+
const pageChrome = getPageChromeMetrics(pageWidth, narrow, rhythm, columnCount);
|
|
86
|
+
const targetPageHeight = reserveRhythmRows(getTargetPageHeight(pageWidth, viewportHeight), rhythm);
|
|
87
|
+
const frontGridHeight = snapDownToRhythm(getFrontGridHeight(targetPageHeight, narrow, medium), rhythm);
|
|
88
|
+
const frontRowMaxHeight = snapDownToRhythm(narrow ? 520 : medium ? 560 : 620, rhythm);
|
|
89
|
+
const continuationChrome =
|
|
90
|
+
pageChrome.pagePaddingTop +
|
|
91
|
+
pageChrome.insideHeaderHeight +
|
|
92
|
+
pageChrome.continuedTitleHeight +
|
|
93
|
+
pageChrome.pagePaddingBottom;
|
|
94
|
+
const continuationHeight = reserveRhythmRows(Math.max(760, targetPageHeight - continuationChrome), rhythm);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
columnCount,
|
|
98
|
+
contentWidth,
|
|
99
|
+
gap,
|
|
100
|
+
rowGap,
|
|
101
|
+
rhythm,
|
|
102
|
+
pageChrome,
|
|
103
|
+
lineHeight: rhythm.rowHeight,
|
|
104
|
+
linePaintHeight: rhythm.paintHeight,
|
|
105
|
+
bodyFontSize,
|
|
106
|
+
bodyFont: `${bodyFontSize}px ${SERIF_TEXT_FONT}`,
|
|
107
|
+
frontRows: getFrontRows(columnCount, rowGap, frontGridHeight, frontRowMaxHeight, rhythm),
|
|
108
|
+
continuationHeight,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getResponsiveColumnCount(pageWidth: number, contentWidth: number, gap: number): number {
|
|
113
|
+
if (pageWidth < 560) return 1;
|
|
114
|
+
const minimumColumnWidth = 168;
|
|
115
|
+
for (const columnCount of [6, 5, 4, 3, 2] as const) {
|
|
116
|
+
const columnWidth = (contentWidth - gap * (columnCount - 1)) / columnCount;
|
|
117
|
+
if (columnWidth >= minimumColumnWidth) return columnCount;
|
|
118
|
+
}
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getTargetPageHeight(pageWidth: number, viewportHeight: number): number {
|
|
123
|
+
if (pageWidth < 720) return Math.max(3000, viewportHeight * 3.25);
|
|
124
|
+
if (pageWidth < 1040) return Math.max(1900, viewportHeight * 2.1);
|
|
125
|
+
return Math.max(1320, viewportHeight * 1.72);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getMastheadRowPolicy(columnCount: number): { totalRows: number; titleRows: number } {
|
|
129
|
+
if (columnCount <= 1) return { totalRows: 4, titleRows: 2 };
|
|
130
|
+
if (columnCount <= 3) return { totalRows: 5, titleRows: 3 };
|
|
131
|
+
return { totalRows: 6, titleRows: 4 };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getPageChromeMetrics(pageWidth: number, narrow: boolean, rhythm: VerticalRhythm, columnCount: number): PageChromeMetrics {
|
|
135
|
+
const pagePaddingTop = rhythm.rowHeight;
|
|
136
|
+
const pagePaddingX = narrow ? 18 : 30;
|
|
137
|
+
const pagePaddingBottom = reserveRhythmRows(narrow ? 18 : 30, rhythm);
|
|
138
|
+
const mastheadRows = getMastheadRowPolicy(columnCount);
|
|
139
|
+
const mastheadKickerLineHeight = 14;
|
|
140
|
+
const mastheadTitleLineHeight = rhythm.rowHeight * mastheadRows.titleRows;
|
|
141
|
+
const mastheadTitleFontSize = mastheadTitleLineHeight / MASTHEAD_TITLE_GLYPH_HEIGHT_RATIO;
|
|
142
|
+
const mastheadTitleMarginTop = rhythm.rowHeight / 2;
|
|
143
|
+
const mastheadTitleMarginBottom = rhythm.rowHeight / 2;
|
|
144
|
+
const mastheadTitleOpticalShift = -3;
|
|
145
|
+
const mastheadMetaLineHeight = rhythm.rowHeight - MASTHEAD_META_BORDER_TOP - MASTHEAD_META_BORDER_BOTTOM;
|
|
146
|
+
const mastheadMetaGap = narrow ? 5 : 0;
|
|
147
|
+
const mastheadMetaContentHeight = mastheadMetaLineHeight;
|
|
148
|
+
const rawMastheadHeight =
|
|
149
|
+
MASTHEAD_RULE_HEIGHT +
|
|
150
|
+
MASTHEAD_RULE_MARGIN_BOTTOM +
|
|
151
|
+
MASTHEAD_TITLE_MARGIN_TOP +
|
|
152
|
+
mastheadTitleMarginTop +
|
|
153
|
+
mastheadTitleLineHeight +
|
|
154
|
+
mastheadTitleMarginBottom +
|
|
155
|
+
MASTHEAD_META_BORDER_TOP +
|
|
156
|
+
MASTHEAD_META_PADDING_TOP +
|
|
157
|
+
mastheadMetaContentHeight +
|
|
158
|
+
MASTHEAD_META_PADDING_BOTTOM +
|
|
159
|
+
MASTHEAD_META_BORDER_BOTTOM +
|
|
160
|
+
MASTHEAD_PADDING_BOTTOM +
|
|
161
|
+
MASTHEAD_BORDER_BOTTOM +
|
|
162
|
+
MASTHEAD_MARGIN_BOTTOM;
|
|
163
|
+
const mastheadHeight = rhythm.rowHeight * mastheadRows.totalRows;
|
|
164
|
+
const mastheadMetaPaddingTop = MASTHEAD_META_PADDING_TOP + Math.max(0, mastheadHeight - rawMastheadHeight);
|
|
165
|
+
const frontGridMarginTop = rhythm.rowHeight;
|
|
166
|
+
const insideHeaderHeight = INSIDE_HEADER_PADDING_BOTTOM + INSIDE_HEADER_BORDER_BOTTOM + INSIDE_HEADER_MARGIN_BOTTOM + 18;
|
|
167
|
+
const continuedTitleFontSize = narrow ? clamp(pageWidth * 0.075, 28, 42) : clamp(pageWidth * 0.035, 32, 56);
|
|
168
|
+
const continuedTitleLineHeight = continuedTitleFontSize * 0.94;
|
|
169
|
+
const continuedTitleHeight = Math.ceil(
|
|
170
|
+
CONTINUED_TITLE_KICKER_LINE_HEIGHT +
|
|
171
|
+
CONTINUED_TITLE_HEADING_MARGIN_TOP +
|
|
172
|
+
continuedTitleLineHeight * 2 +
|
|
173
|
+
CONTINUED_TITLE_PADDING_BOTTOM +
|
|
174
|
+
CONTINUED_TITLE_BORDER_BOTTOM +
|
|
175
|
+
CONTINUED_TITLE_MARGIN_BOTTOM,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
pagePaddingTop,
|
|
180
|
+
pagePaddingX,
|
|
181
|
+
pagePaddingBottom,
|
|
182
|
+
mastheadHeight,
|
|
183
|
+
mastheadKickerLineHeight,
|
|
184
|
+
mastheadTitleFontSize,
|
|
185
|
+
mastheadTitleLineHeight,
|
|
186
|
+
mastheadTitleMarginTop,
|
|
187
|
+
mastheadTitleMarginBottom,
|
|
188
|
+
mastheadTitleOpticalShift,
|
|
189
|
+
mastheadMetaLineHeight,
|
|
190
|
+
mastheadMetaGap,
|
|
191
|
+
mastheadMetaPaddingTop,
|
|
192
|
+
frontGridMarginTop,
|
|
193
|
+
insideHeaderHeight: reserveRhythmRows(insideHeaderHeight, rhythm),
|
|
194
|
+
continuedTitleHeight: reserveRhythmRows(continuedTitleHeight, rhythm),
|
|
195
|
+
continuedTitleChromeHeight: reserveRhythmRows(
|
|
196
|
+
CONTINUED_TITLE_KICKER_LINE_HEIGHT +
|
|
197
|
+
CONTINUED_TITLE_HEADING_MARGIN_TOP +
|
|
198
|
+
CONTINUED_TITLE_PADDING_BOTTOM +
|
|
199
|
+
CONTINUED_TITLE_BORDER_BOTTOM +
|
|
200
|
+
CONTINUED_TITLE_MARGIN_BOTTOM,
|
|
201
|
+
rhythm,
|
|
202
|
+
),
|
|
203
|
+
continuedTitleFontSize,
|
|
204
|
+
continuedTitleLineHeight,
|
|
205
|
+
continuationSectionSeparatorHeight: rhythm.rowHeight * 2,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getBlockSpan(config: LayoutConfig, span: ResponsiveSpanPolicy | undefined): number {
|
|
210
|
+
if (config.columnCount === 1) return 1;
|
|
211
|
+
return clamp(span?.preferred ?? config.columnCount, span?.min ?? 1, Math.min(span?.max ?? config.columnCount, config.columnCount));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getBlockWidth(config: LayoutConfig, span: ResponsiveSpanPolicy | undefined): number {
|
|
215
|
+
return getSpanWidth(config, getBlockSpan(config, span));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function getSpanWidth(config: LayoutConfig, span: number): number {
|
|
219
|
+
const safeSpan = clamp(span, 1, config.columnCount);
|
|
220
|
+
const singleColumn = (config.contentWidth - config.gap * (config.columnCount - 1)) / config.columnCount;
|
|
221
|
+
return singleColumn * safeSpan + config.gap * (safeSpan - 1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function getSpanRect(config: LayoutConfig, columnStart: number, columnSpan: number): { x: number; width: number } {
|
|
225
|
+
const safeStart = clamp(columnStart, 0, Math.max(0, config.columnCount - 1));
|
|
226
|
+
const safeSpan = clamp(columnSpan, 1, config.columnCount - safeStart);
|
|
227
|
+
const columnWidth = getSpanWidth(config, 1);
|
|
228
|
+
const rawX = safeStart * (columnWidth + config.gap);
|
|
229
|
+
const rawRight = rawX + getSpanWidth(config, safeSpan);
|
|
230
|
+
const x = Math.round(rawX);
|
|
231
|
+
const right = Math.min(Math.round(rawRight), Math.floor(config.contentWidth));
|
|
232
|
+
return { x, width: Math.max(1, right - x) };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
236
|
+
return Math.min(max, Math.max(min, value));
|
|
237
|
+
}
|