@anthusai/papyrus 0.0.0 → 1.0.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/LICENSE +21 -0
- package/amplify/auth/resource.js +61 -0
- package/amplify/auth/resource.ts +65 -0
- package/amplify/data/resource.js +2037 -0
- package/amplify/data/resource.ts +2148 -0
- package/amplify/data/schema.js +1850 -0
- package/amplify/data/schema.ts +1953 -0
- package/amplify/functions/assignment-action/handler.ts +1416 -0
- package/amplify/functions/assignment-action/resource.js +8 -0
- package/amplify/functions/assignment-action/resource.ts +9 -0
- package/amplify/functions/category-action/handler.ts +2259 -0
- package/amplify/functions/category-action/resource.js +8 -0
- package/amplify/functions/category-action/resource.ts +9 -0
- package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
- package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
- package/amplify/functions/console-chat-responder/Dockerfile +19 -0
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
- package/amplify/functions/console-chat-responder/resource.js +153 -0
- package/amplify/functions/console-chat-responder/resource.ts +179 -0
- package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
- package/amplify/functions/email-submission-processor/handler.py +46 -0
- package/amplify/functions/email-submission-processor/requirements.txt +2 -0
- package/amplify/functions/email-submission-processor/resource.js +56 -0
- package/amplify/functions/email-submission-processor/resource.ts +69 -0
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
- package/amplify/functions/knowledge-query/handler.py +1 -0
- package/amplify/functions/knowledge-query/requirements.txt +3 -0
- package/amplify/functions/knowledge-query/resource.js +50 -0
- package/amplify/functions/knowledge-query/resource.ts +61 -0
- package/amplify/functions/manage-user-role/handler.ts +799 -0
- package/amplify/functions/manage-user-role/resource.js +8 -0
- package/amplify/functions/manage-user-role/resource.ts +9 -0
- package/amplify/functions/model-attachment-upload/handler.ts +387 -0
- package/amplify/functions/model-attachment-upload/resource.js +8 -0
- package/amplify/functions/model-attachment-upload/resource.ts +9 -0
- package/amplify/functions/newsroom-summary/handler.ts +321 -0
- package/amplify/functions/newsroom-summary/resource.js +8 -0
- package/amplify/functions/newsroom-summary/resource.ts +9 -0
- package/amplify/functions/procedure-action/handler.ts +621 -0
- package/amplify/functions/procedure-action/resource.js +8 -0
- package/amplify/functions/procedure-action/resource.ts +9 -0
- package/amplify/functions/reader-settings/handler.ts +322 -0
- package/amplify/functions/reader-settings/resource.js +8 -0
- package/amplify/functions/reader-settings/resource.ts +9 -0
- package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
- package/amplify/functions/ses-inbound-receive/resource.js +8 -0
- package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
- package/amplify/functions/shared/amplify-secrets.ts +65 -0
- package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
- package/amplify/functions/shared/email-mime-intake.ts +196 -0
- package/amplify/functions/shared/email-submission.ts +415 -0
- package/amplify/functions/shared/inbound-email-intake.ts +37 -0
- package/amplify/functions/shared/lambda-data-client.ts +21 -0
- package/amplify/functions/shared/model-payloads.ts +186 -0
- package/amplify/functions/shared/python-bundle.js +115 -0
- package/amplify/functions/shared/python-bundle.ts +141 -0
- package/amplify/functions/shared/slack-events.ts +111 -0
- package/amplify/functions/slack-delivery/handler.py +112 -0
- package/amplify/functions/slack-delivery/requirements.txt +2 -0
- package/amplify/functions/slack-delivery/resource.js +38 -0
- package/amplify/functions/slack-delivery/resource.ts +51 -0
- package/amplify/functions/slack-events/handler.ts +155 -0
- package/amplify/functions/slack-events/resource.js +11 -0
- package/amplify/functions/slack-events/resource.ts +12 -0
- package/amplify/inbound-email/stack.js +46 -0
- package/amplify/inbound-email/stack.ts +61 -0
- package/amplify/package.json +3 -0
- package/amplify/site-backend-config.js +1 -0
- package/amplify/site-backend-config.ts +30 -0
- package/amplify/site-backend.js +587 -0
- package/amplify/site-backend.ts +704 -0
- package/amplify/storage/resource.js +18 -0
- package/amplify/storage/resource.ts +19 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
- package/app/[year]/[month]/[day]/page.tsx +22 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
- package/app/api/archive/editions/route.ts +18 -0
- package/app/api/media/[...path]/route.ts +35 -0
- package/app/api/revalidate/route.ts +94 -0
- package/app/archive/page.tsx +24 -0
- package/app/articles/[slug]/page.tsx +35 -0
- package/app/globals.css +10329 -0
- package/app/layout.tsx +146 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
- package/app/newsroom/[section]/page.tsx +47 -0
- package/app/newsroom/forum/[threadId]/page.tsx +35 -0
- package/app/newsroom/insights/[threadId]/page.tsx +22 -0
- package/app/newsroom/insights/page.tsx +14 -0
- package/app/newsroom/layout.tsx +12 -0
- package/app/newsroom/page.tsx +31 -0
- package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
- package/app/page.tsx +272 -0
- package/app/settings/page.tsx +14 -0
- package/app/tailwind.css +65 -0
- package/bin/papyrus-app.mjs +10 -0
- package/bin/papyrus-infra.mjs +39 -0
- package/components/ai-elements/conversation.tsx +168 -0
- package/components/ai-elements/message.tsx +360 -0
- package/components/ai-elements/model-selector.tsx +30 -0
- package/components/ai-elements/prompt-input.tsx +1461 -0
- package/components/ai-elements/shimmer.tsx +76 -0
- package/components/ai-elements/suggestion.tsx +54 -0
- package/components/ai-elements/tool.tsx +173 -0
- package/components/amplify-client-provider.tsx +140 -0
- package/components/archive-grid.tsx +89 -0
- package/components/archive-shell.tsx +41 -0
- package/components/article-video.tsx +47 -0
- package/components/blog-page-background.tsx +20 -0
- package/components/dev-sandbox-editor-sign-in.tsx +60 -0
- package/components/news-desk-client-provider.tsx +211 -0
- package/components/news-desk-page.tsx +119 -0
- package/components/news-desk-taxonomy-client.ts +3684 -0
- package/components/newspaper.tsx +1929 -0
- package/components/newsroom-assignments-view.tsx +664 -0
- package/components/newsroom-ops-overview.tsx +226 -0
- package/components/newsroom-ops-shell.tsx +319 -0
- package/components/newsroom-references-view.tsx +598 -0
- package/components/newsroom-topics-view.tsx +425 -0
- package/components/papyrus-console-shell.tsx +1912 -0
- package/components/pictogram-figure.tsx +65 -0
- package/components/presentation-footer.tsx +146 -0
- package/components/reader-auth-control.tsx +162 -0
- package/components/reader-auth-state.ts +122 -0
- package/components/reader-settings.ts +332 -0
- package/components/reader-theme-control.tsx +85 -0
- package/components/reference-source-preview.tsx +163 -0
- package/components/settings-page.tsx +239 -0
- package/components/topic-steering-workspace.tsx +18063 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button-group.tsx +87 -0
- package/components/ui/button.tsx +58 -0
- package/components/ui/card.tsx +32 -0
- package/components/ui/command.tsx +194 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/dropdown-menu.tsx +266 -0
- package/components/ui/hover-card.tsx +51 -0
- package/components/ui/input-group.tsx +158 -0
- package/components/ui/input.tsx +20 -0
- package/components/ui/scroll-area.tsx +16 -0
- package/components/ui/select.tsx +199 -0
- package/components/ui/separator.tsx +23 -0
- package/components/ui/sheet.tsx +92 -0
- package/components/ui/spinner.tsx +10 -0
- package/components/ui/tabs.tsx +113 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/use-resolved-papyrus-theme.ts +47 -0
- package/infra/amplify-app-shell.js +109 -0
- package/infra/amplify-app-shell.ts +155 -0
- package/infra/index.js +12 -0
- package/lib/amplify-outputs-guard.ts +24 -0
- package/lib/amplify-outputs-path.ts +16 -0
- package/lib/amplify-server-runtime.ts +63 -0
- package/lib/archive-data.ts +30 -0
- package/lib/archive-types.ts +15 -0
- package/lib/articles.ts +320 -0
- package/lib/cached-content-repository.ts +89 -0
- package/lib/category-dashboard.ts +37 -0
- package/lib/category-repository.ts +1907 -0
- package/lib/console-chat-client.ts +1316 -0
- package/lib/console-web-context.ts +118 -0
- package/lib/content-load-timing.ts +23 -0
- package/lib/content-repository.ts +40 -0
- package/lib/content-types.ts +104 -0
- package/lib/define-site.js +3 -0
- package/lib/define-site.ts +21 -0
- package/lib/demo-amplify-outputs.ts +15 -0
- package/lib/dev-sandbox-editor-auth.ts +18 -0
- package/lib/doctrine.ts +128 -0
- package/lib/edition-routes.ts +155 -0
- package/lib/edition-sections.ts +111 -0
- package/lib/empty-edition-layout-plan.ts +109 -0
- package/lib/excerpts.ts +8 -0
- package/lib/graphql-content-repository.ts +986 -0
- package/lib/image-url.ts +10 -0
- package/lib/layout-plan.ts +981 -0
- package/lib/layout-scenarios.ts +564 -0
- package/lib/markus-body.ts +168 -0
- package/lib/markus-ir.ts +235 -0
- package/lib/markus-projection.ts +243 -0
- package/lib/markus-to-article.ts +93 -0
- package/lib/news-desk-session.ts +160 -0
- package/lib/newspaper-layout.ts +3459 -0
- package/lib/newsroom-assignments.ts +173 -0
- package/lib/newsroom-base-path.ts +48 -0
- package/lib/newsroom-card-layout.ts +102 -0
- package/lib/newsroom-category-drilldown.ts +133 -0
- package/lib/newsroom-demo-dashboard.ts +267 -0
- package/lib/newsroom-demo-profile.ts +46 -0
- package/lib/newsroom-forum-routes.ts +153 -0
- package/lib/newsroom-index-filters.ts +202 -0
- package/lib/newsroom-knowledge-query-request.ts +51 -0
- package/lib/newsroom-list-selection.ts +42 -0
- package/lib/newsroom-nav.ts +42 -0
- package/lib/newsroom-references.ts +116 -0
- package/lib/newsroom-sections.ts +52 -0
- package/lib/newsroom-topics.ts +118 -0
- package/lib/papyrus-object-kinds.ts +24 -0
- package/lib/papyrus-web-locations.ts +480 -0
- package/lib/pilobol-sample.ts +167 -0
- package/lib/presentation-footer.ts +92 -0
- package/lib/pretext-layout.ts +179 -0
- package/lib/public-placeholder-config.ts +102 -0
- package/lib/publication-items.ts +181 -0
- package/lib/reader-route-config.ts +1 -0
- package/lib/reader-static-params.ts +56 -0
- package/lib/reader-storage-url.ts +63 -0
- package/lib/reference-display.ts +74 -0
- package/lib/reference-policy.ts +97 -0
- package/lib/reference-source-preview.ts +175 -0
- package/lib/renderer-config.ts +50 -0
- package/lib/renderer.ts +87 -0
- package/lib/reporting-story-budget.ts +638 -0
- package/lib/semantic-graph.ts +554 -0
- package/lib/site-brand-tenant.ts +41 -0
- package/lib/site-brand.ts +179 -0
- package/lib/site-renderer.ts +17 -0
- package/lib/site-stack.ts +195 -0
- package/lib/themed-image.ts +30 -0
- package/lib/utils.ts +6 -0
- package/middleware.ts +78 -0
- package/package.json +296 -1
- package/publications/anth_us/brand.ts +68 -0
- package/publications/anth_us/theme.css +66 -0
- package/publications/papyrus/theme.css +11 -0
- package/publications/pilobol_us/brand.ts +37 -0
- package/publications/pilobol_us/theme.css +140 -0
- package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
- package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
- package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
- package/publications/threat_intelligence/brand.ts +40 -0
- package/publications/threat_intelligence/pictograms/art.tsx +608 -0
- package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
- package/publications/threat_intelligence/pictograms/registry.ts +30 -0
- package/publications/threat_intelligence/pictograms/system.tsx +305 -0
- package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
- package/publications/threat_intelligence/theme.css +1440 -0
- package/renderers/markus/index.ts +25 -0
- package/renderers/markus/stubs.tsx +33 -0
- package/renderers/pretext/article-page.tsx +174 -0
- package/renderers/pretext/index.ts +30 -0
- package/renderers/pretext/layouts.ts +16 -0
- package/renderers/pretext/presentation-shell.tsx +477 -0
- package/routes.manifest.json +182 -0
- package/scripts/fixtures/pilobol-sample.ast.json +234 -0
- package/src/backend.js +1 -0
- package/src/backend.ts +3 -0
- package/src/sync.mjs +87 -0
- package/src/with-papyrus.d.mts +2 -0
- package/src/with-papyrus.mjs +47 -0
|
@@ -0,0 +1,3459 @@
|
|
|
1
|
+
import type { ArticleImageAsset, ArticleImageThemeVariants } from "./articles";
|
|
2
|
+
import {
|
|
3
|
+
type ArticleFrameBlockSpec,
|
|
4
|
+
type ArticleFrameCompositionSlotSpec,
|
|
5
|
+
type ArticleFrameChromeSpec,
|
|
6
|
+
type ChromeTextSlotSpec,
|
|
7
|
+
type EditionLayoutPlan,
|
|
8
|
+
type EditorialPriorityId,
|
|
9
|
+
type FrontResponsiveLayoutOrder,
|
|
10
|
+
type FrontResponsiveLayoutSpec,
|
|
11
|
+
type FrontResponsiveSlotSpec,
|
|
12
|
+
type HeadlineScaleId,
|
|
13
|
+
type LayoutBlockSpec,
|
|
14
|
+
type LayoutMediaSpec,
|
|
15
|
+
type LayoutPageSpec,
|
|
16
|
+
type LayoutPullQuoteSpec,
|
|
17
|
+
type LayoutRegionSpec,
|
|
18
|
+
type ResponsivePlacementAnchor,
|
|
19
|
+
type ResponsivePlacementSpec,
|
|
20
|
+
type ResponsiveSpanPolicy,
|
|
21
|
+
type ResponsiveVerticalPlacement,
|
|
22
|
+
} from "./layout-plan";
|
|
23
|
+
import {
|
|
24
|
+
type ArticlePublicationItem,
|
|
25
|
+
type PublicationItem,
|
|
26
|
+
getArticlePublicationItems,
|
|
27
|
+
getPublicationItemImageAssets,
|
|
28
|
+
getPublicationItemText,
|
|
29
|
+
} from "./publication-items";
|
|
30
|
+
import {
|
|
31
|
+
layoutNextLine,
|
|
32
|
+
layoutTextLines,
|
|
33
|
+
prepareWithSegments,
|
|
34
|
+
type LayoutCursor,
|
|
35
|
+
type PreparedTextWithSegments,
|
|
36
|
+
type TextLine,
|
|
37
|
+
type TextObstacle,
|
|
38
|
+
} from "./pretext-layout";
|
|
39
|
+
|
|
40
|
+
export type { TextLine };
|
|
41
|
+
|
|
42
|
+
export type SolvedTextLine = TextLine;
|
|
43
|
+
|
|
44
|
+
export type SolvedFurniture = SolvedImageFurniture | SolvedPullQuoteFurniture | SolvedMediaClusterFurniture | SolvedAdFurniture;
|
|
45
|
+
|
|
46
|
+
export type SolvedImageFurniture = {
|
|
47
|
+
kind: "image";
|
|
48
|
+
id: string;
|
|
49
|
+
assetId: string;
|
|
50
|
+
src: string;
|
|
51
|
+
themeVariants?: ArticleImageThemeVariants;
|
|
52
|
+
alt: string;
|
|
53
|
+
caption: string;
|
|
54
|
+
credit: string;
|
|
55
|
+
templateId: string;
|
|
56
|
+
columnStart: number;
|
|
57
|
+
columnSpan: number;
|
|
58
|
+
x: number;
|
|
59
|
+
y: number;
|
|
60
|
+
width: number;
|
|
61
|
+
height: number;
|
|
62
|
+
imageHeight: number;
|
|
63
|
+
captionHeight: number;
|
|
64
|
+
captionFontSize: number;
|
|
65
|
+
captionLineHeight: number;
|
|
66
|
+
aspectRatio: number;
|
|
67
|
+
objectFit: "contain" | "cover";
|
|
68
|
+
objectPosition: string;
|
|
69
|
+
wrapsText: boolean;
|
|
70
|
+
preferredHeight: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type SolvedPullQuoteFurniture = {
|
|
74
|
+
kind: "pullQuote";
|
|
75
|
+
id: string;
|
|
76
|
+
text: string;
|
|
77
|
+
templateId: string;
|
|
78
|
+
columnStart: number;
|
|
79
|
+
columnSpan: number;
|
|
80
|
+
x: number;
|
|
81
|
+
y: number;
|
|
82
|
+
width: number;
|
|
83
|
+
height: number;
|
|
84
|
+
fontSize: number;
|
|
85
|
+
lineHeight: number;
|
|
86
|
+
wrapsText: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type SolvedMediaClusterFurniture = {
|
|
90
|
+
kind: "mediaCluster";
|
|
91
|
+
id: string;
|
|
92
|
+
templateId: string;
|
|
93
|
+
images: SolvedImageFurniture[];
|
|
94
|
+
caption: string;
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
width: number;
|
|
98
|
+
height: number;
|
|
99
|
+
wrapsText: false;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type SolvedAdFurniture = {
|
|
103
|
+
kind: "ad";
|
|
104
|
+
id: string;
|
|
105
|
+
src?: string;
|
|
106
|
+
label: string;
|
|
107
|
+
x: number;
|
|
108
|
+
y: number;
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
wrapsText: false;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type SolvedChromeBox = {
|
|
115
|
+
id: string;
|
|
116
|
+
slot: "label" | "headline" | "deck" | "byline";
|
|
117
|
+
text: string;
|
|
118
|
+
columnStart: number;
|
|
119
|
+
columnSpan: number;
|
|
120
|
+
x: number;
|
|
121
|
+
y: number;
|
|
122
|
+
width: number;
|
|
123
|
+
height: number;
|
|
124
|
+
fontSize: number;
|
|
125
|
+
lineHeight: number;
|
|
126
|
+
paintBuffer: number;
|
|
127
|
+
paddingTop?: number;
|
|
128
|
+
paddingBottom?: number;
|
|
129
|
+
ruleTopHeight?: number;
|
|
130
|
+
ruleBottomHeight?: number;
|
|
131
|
+
fontFamily: "serif" | "sans";
|
|
132
|
+
fontWeight?: number | string;
|
|
133
|
+
fontStyle?: "normal" | "italic";
|
|
134
|
+
textTransform?: "uppercase";
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type SolvedBlock = {
|
|
138
|
+
id: string;
|
|
139
|
+
type: LayoutBlockSpec["type"];
|
|
140
|
+
presetId?: string;
|
|
141
|
+
headlineScale?: HeadlineScaleId;
|
|
142
|
+
editorialPriority?: EditorialPriorityId;
|
|
143
|
+
item?: PublicationItem;
|
|
144
|
+
items?: PublicationItem[];
|
|
145
|
+
article?: ArticlePublicationItem;
|
|
146
|
+
pageNumber: number;
|
|
147
|
+
jumpTargetPage?: number;
|
|
148
|
+
jumpLabel?: string;
|
|
149
|
+
label?: string;
|
|
150
|
+
title?: string;
|
|
151
|
+
deck?: string;
|
|
152
|
+
byline?: string;
|
|
153
|
+
dateline?: string;
|
|
154
|
+
section?: string;
|
|
155
|
+
x: number;
|
|
156
|
+
y: number;
|
|
157
|
+
width: number;
|
|
158
|
+
height: number;
|
|
159
|
+
span: number;
|
|
160
|
+
columnCount: number;
|
|
161
|
+
columns: TextLine[][];
|
|
162
|
+
chromeBoxes?: SolvedChromeBox[];
|
|
163
|
+
furniture: SolvedFurniture[];
|
|
164
|
+
textRange?: PlacedTextRange;
|
|
165
|
+
hasMore?: boolean;
|
|
166
|
+
front?: SolvedFrontStoryMetrics;
|
|
167
|
+
titleChrome?: ContinuationTitleMetrics;
|
|
168
|
+
titleHeight?: number;
|
|
169
|
+
bodyHeight?: number;
|
|
170
|
+
furnitureSufficiency?: FurnitureSufficiencyReport;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export type SolvedFrontStoryMetrics = {
|
|
174
|
+
rowHeight: number;
|
|
175
|
+
bodySlotHeight: number;
|
|
176
|
+
chromeHeight: number;
|
|
177
|
+
jumpReserveHeight: number;
|
|
178
|
+
chrome: StoryChromeMetrics;
|
|
179
|
+
gridPlacement?: SolvedFrontGridPlacement;
|
|
180
|
+
composition?: {
|
|
181
|
+
mode: FrontCompositionFlowMode;
|
|
182
|
+
bodyTop: number;
|
|
183
|
+
bodyHeight: number;
|
|
184
|
+
columnCount: number;
|
|
185
|
+
copyBandTop: number;
|
|
186
|
+
copyBandTops: number[];
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export type SolvedFrontGridPlacement = {
|
|
191
|
+
columnStart: number;
|
|
192
|
+
columnSpan: number;
|
|
193
|
+
rowStart: number;
|
|
194
|
+
rowSpan: number;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export type SolvedRegion = {
|
|
198
|
+
id: string;
|
|
199
|
+
type: LayoutRegionSpec["type"];
|
|
200
|
+
role?: string;
|
|
201
|
+
x: number;
|
|
202
|
+
y: number;
|
|
203
|
+
width: number;
|
|
204
|
+
height: number;
|
|
205
|
+
rowHeights?: number[];
|
|
206
|
+
columnCount: number;
|
|
207
|
+
blocks: SolvedBlock[];
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export type SolvedFrontFooterEntry = {
|
|
211
|
+
section: string;
|
|
212
|
+
articleSlug: string;
|
|
213
|
+
articleTitle: string;
|
|
214
|
+
blockId: string;
|
|
215
|
+
pageNumber: number;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export type SolvedFrontFooterUtilityEntry =
|
|
219
|
+
| {
|
|
220
|
+
id: "archive";
|
|
221
|
+
label: string;
|
|
222
|
+
href: string;
|
|
223
|
+
disabled: false;
|
|
224
|
+
}
|
|
225
|
+
| {
|
|
226
|
+
id: "newsDesk";
|
|
227
|
+
label: string;
|
|
228
|
+
href: string;
|
|
229
|
+
disabled: false;
|
|
230
|
+
}
|
|
231
|
+
| {
|
|
232
|
+
id: "settings";
|
|
233
|
+
label: string;
|
|
234
|
+
href: string;
|
|
235
|
+
disabled: false;
|
|
236
|
+
}
|
|
237
|
+
| {
|
|
238
|
+
id: "login";
|
|
239
|
+
label: string;
|
|
240
|
+
disabled: true;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export type SolvedFrontFooter = {
|
|
244
|
+
rowHeight: number;
|
|
245
|
+
marginTop: number;
|
|
246
|
+
height: number;
|
|
247
|
+
sectionRows: number;
|
|
248
|
+
sectionColumns: number;
|
|
249
|
+
entries: SolvedFrontFooterEntry[];
|
|
250
|
+
utilityEntries: SolvedFrontFooterUtilityEntry[];
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export type SolvedPage = {
|
|
254
|
+
id: string;
|
|
255
|
+
pageNumber: number;
|
|
256
|
+
presetId: LayoutPageSpec["presetId"];
|
|
257
|
+
kind: string;
|
|
258
|
+
height: number;
|
|
259
|
+
columnCount: number;
|
|
260
|
+
regions: SolvedRegion[];
|
|
261
|
+
frontFooter?: SolvedFrontFooter;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export type NewspaperLayout = {
|
|
265
|
+
columnCount: number;
|
|
266
|
+
contentWidth: number;
|
|
267
|
+
gap: number;
|
|
268
|
+
rowGap: number;
|
|
269
|
+
rhythm: VerticalRhythm;
|
|
270
|
+
pageHeight: number;
|
|
271
|
+
frontPageHeight: number;
|
|
272
|
+
pageHeights: Record<number, number>;
|
|
273
|
+
pageChrome: PageChromeMetrics;
|
|
274
|
+
pages: SolvedPage[];
|
|
275
|
+
textRanges: PlacedTextRange[];
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export type VerticalRhythm = {
|
|
279
|
+
rowHeight: number;
|
|
280
|
+
paintHeight: number;
|
|
281
|
+
paintBuffer: number;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
export type PageChromeMetrics = {
|
|
285
|
+
pagePaddingTop: number;
|
|
286
|
+
pagePaddingX: number;
|
|
287
|
+
pagePaddingBottom: number;
|
|
288
|
+
mastheadHeight: number;
|
|
289
|
+
mastheadKickerLineHeight: number;
|
|
290
|
+
mastheadTitleFontSize: number;
|
|
291
|
+
mastheadTitleLineHeight: number;
|
|
292
|
+
mastheadTitleMarginTop: number;
|
|
293
|
+
mastheadTitleMarginBottom: number;
|
|
294
|
+
mastheadTitleOpticalShift: number;
|
|
295
|
+
mastheadMetaLineHeight: number;
|
|
296
|
+
mastheadMetaGap: number;
|
|
297
|
+
mastheadMetaPaddingTop: number;
|
|
298
|
+
frontGridMarginTop: number;
|
|
299
|
+
insideHeaderHeight: number;
|
|
300
|
+
continuedTitleHeight: number;
|
|
301
|
+
continuedTitleChromeHeight: number;
|
|
302
|
+
continuedTitleFontSize: number;
|
|
303
|
+
continuedTitleLineHeight: number;
|
|
304
|
+
continuationSectionSeparatorHeight: number;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export type ChromeTextBoxMetrics = {
|
|
308
|
+
fontSize: number;
|
|
309
|
+
lineHeight: number;
|
|
310
|
+
paintHeight: number;
|
|
311
|
+
paintBuffer: number;
|
|
312
|
+
contentHeight: number;
|
|
313
|
+
paddingTop: number;
|
|
314
|
+
paddingBottom: number;
|
|
315
|
+
ruleTopHeight: number;
|
|
316
|
+
ruleBottomHeight: number;
|
|
317
|
+
height: number;
|
|
318
|
+
lineCount: number;
|
|
319
|
+
marginBefore: number;
|
|
320
|
+
marginAfter: number;
|
|
321
|
+
totalHeight: number;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export type StoryChromeMetrics = {
|
|
325
|
+
borderTopHeight: number;
|
|
326
|
+
paddingTop: number;
|
|
327
|
+
mediaPreludeHeight: number;
|
|
328
|
+
mediaPreludeMarginBottom: number;
|
|
329
|
+
labelFontSize: number;
|
|
330
|
+
labelLineHeight: number;
|
|
331
|
+
labelPaintHeight: number;
|
|
332
|
+
labelPaintBuffer: number;
|
|
333
|
+
labelHeight: number;
|
|
334
|
+
headlineFontSize: number;
|
|
335
|
+
headlineLineHeight: number;
|
|
336
|
+
headlinePaintHeight: number;
|
|
337
|
+
headlinePaintBuffer: number;
|
|
338
|
+
headlineHeight: number;
|
|
339
|
+
headlineLineCount: number;
|
|
340
|
+
headlineMarginTop: number;
|
|
341
|
+
headlineMarginBottom: number;
|
|
342
|
+
deckFontSize: number;
|
|
343
|
+
deckLineHeight: number;
|
|
344
|
+
deckPaintHeight: number;
|
|
345
|
+
deckPaintBuffer: number;
|
|
346
|
+
deckPaddingTop: number;
|
|
347
|
+
deckPaddingBottom: number;
|
|
348
|
+
deckRuleTopHeight: number;
|
|
349
|
+
deckRuleBottomHeight: number;
|
|
350
|
+
deckHeight: number;
|
|
351
|
+
deckLineCount: number;
|
|
352
|
+
deckMarginBottom: number;
|
|
353
|
+
bylineFontSize: number;
|
|
354
|
+
bylineLineHeight: number;
|
|
355
|
+
bylinePaintHeight: number;
|
|
356
|
+
bylinePaintBuffer: number;
|
|
357
|
+
bylinePaddingTop: number;
|
|
358
|
+
bylinePaddingBottom: number;
|
|
359
|
+
bylineHeight: number;
|
|
360
|
+
bylineLineCount: number;
|
|
361
|
+
bylineMarginBottom: number;
|
|
362
|
+
measureChromeHeight: number;
|
|
363
|
+
jumpFontSize: number;
|
|
364
|
+
jumpLineHeight: number;
|
|
365
|
+
jumpPaintHeight: number;
|
|
366
|
+
jumpPaintBuffer: number;
|
|
367
|
+
jumpHeight: number;
|
|
368
|
+
jumpPaddingTop: number;
|
|
369
|
+
jumpPaddingBottom: number;
|
|
370
|
+
jumpBorderTopHeight: number;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
export type ContinuationTitleMetrics = {
|
|
374
|
+
label: ChromeTextBoxMetrics;
|
|
375
|
+
heading: ChromeTextBoxMetrics;
|
|
376
|
+
chromeHeight: number;
|
|
377
|
+
totalHeight: number;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
type FrontStoryRole = "feature" | "rail" | "standard";
|
|
381
|
+
|
|
382
|
+
type ChromeTextStyle = {
|
|
383
|
+
fontSize: number;
|
|
384
|
+
fontFamily: string;
|
|
385
|
+
fontWeight?: number | string;
|
|
386
|
+
fontStyle?: "normal" | "italic";
|
|
387
|
+
lineHeightEm: number;
|
|
388
|
+
paintHeightEm: number;
|
|
389
|
+
marginBeforeEm?: number;
|
|
390
|
+
marginAfterEm?: number;
|
|
391
|
+
minHeightEm?: number;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
type ChromeBoxTreatment = {
|
|
395
|
+
paddingTop: number;
|
|
396
|
+
paddingBottom: number;
|
|
397
|
+
ruleTopHeight?: number;
|
|
398
|
+
ruleBottomHeight?: number;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export type PlacedTextRange = {
|
|
402
|
+
articleId: string;
|
|
403
|
+
pageId: string;
|
|
404
|
+
blockId: string;
|
|
405
|
+
startCursor: LayoutCursor;
|
|
406
|
+
endCursor: LayoutCursor;
|
|
407
|
+
exhausted: boolean;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
export type ArticleFlow = {
|
|
411
|
+
article: ArticlePublicationItem;
|
|
412
|
+
currentCursor: LayoutCursor;
|
|
413
|
+
placedRanges: PlacedTextRange[];
|
|
414
|
+
usedImageAssetIds: Set<string>;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
type LayoutConfig = {
|
|
418
|
+
columnCount: number;
|
|
419
|
+
contentWidth: number;
|
|
420
|
+
gap: number;
|
|
421
|
+
rowGap: number;
|
|
422
|
+
rhythm: VerticalRhythm;
|
|
423
|
+
pageChrome: PageChromeMetrics;
|
|
424
|
+
lineHeight: number;
|
|
425
|
+
linePaintHeight: number;
|
|
426
|
+
bodyFontSize: number;
|
|
427
|
+
bodyFont: string;
|
|
428
|
+
frontRows: FrontRow[];
|
|
429
|
+
continuationHeight: number;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
type FrontRow = {
|
|
433
|
+
startIndex: number;
|
|
434
|
+
endIndex: number;
|
|
435
|
+
height: number;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
type FrontArticlePlacement = {
|
|
439
|
+
block: ArticleFrameBlockSpec;
|
|
440
|
+
originalIndex: number;
|
|
441
|
+
solveIndex: number;
|
|
442
|
+
gridPlacement?: SolvedFrontGridPlacement;
|
|
443
|
+
rowHeight?: number;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
type FrontArticleSolveOptions = {
|
|
447
|
+
enforcePlacementHeight?: boolean;
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
type FrontCompositionFlowMode = "offsetBody" | "integrated" | "titleStackedMedia" | "stackedMedia";
|
|
451
|
+
|
|
452
|
+
type PreparedTextCache = Map<string, PreparedTextWithSegments>;
|
|
453
|
+
|
|
454
|
+
type ArticleFrameCandidate = {
|
|
455
|
+
block: SolvedBlock;
|
|
456
|
+
range: PlacedTextRange;
|
|
457
|
+
score: number;
|
|
458
|
+
whitespace: number;
|
|
459
|
+
furnitureSufficiency?: FurnitureSufficiencyReport;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
export type FurnitureSufficiencyReport = {
|
|
463
|
+
accepted: boolean;
|
|
464
|
+
reason?: string;
|
|
465
|
+
visibleRows: number;
|
|
466
|
+
furnitureRows: number;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
type MediaVariant = {
|
|
470
|
+
id: string;
|
|
471
|
+
placement: ResponsivePlacementSpec | null;
|
|
472
|
+
assetRole?: string;
|
|
473
|
+
columnStart: number;
|
|
474
|
+
columnSpan: number;
|
|
475
|
+
fallbackPenalty: number;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
type PlacementVariant = {
|
|
479
|
+
id: string;
|
|
480
|
+
placement: ResponsivePlacementSpec;
|
|
481
|
+
columnStart: number;
|
|
482
|
+
columnSpan: number;
|
|
483
|
+
fallbackPenalty: number;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
type PullQuoteVariant = {
|
|
487
|
+
id: string;
|
|
488
|
+
placement: ResponsivePlacementSpec | null;
|
|
489
|
+
columnStart: number;
|
|
490
|
+
columnSpan: number;
|
|
491
|
+
fallbackPenalty: number;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const EMPTY_CURSOR: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
|
|
495
|
+
const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
|
|
496
|
+
const SANS_TEXT_FONT = "Arial, Helvetica, sans-serif";
|
|
497
|
+
const IMAGE_CAPTION_FONT_SIZE = 12;
|
|
498
|
+
const IMAGE_CAPTION_HORIZONTAL_PADDING_EM = 0.3;
|
|
499
|
+
const MASTHEAD_TITLE_GLYPH_HEIGHT_RATIO = 0.736;
|
|
500
|
+
const MASTHEAD_RULE_HEIGHT = 0;
|
|
501
|
+
const MASTHEAD_RULE_MARGIN_BOTTOM = 0;
|
|
502
|
+
const MASTHEAD_TITLE_MARGIN_TOP = 0;
|
|
503
|
+
const MASTHEAD_PADDING_BOTTOM = 0;
|
|
504
|
+
const MASTHEAD_BORDER_BOTTOM = 0;
|
|
505
|
+
const MASTHEAD_MARGIN_BOTTOM = 0;
|
|
506
|
+
const MASTHEAD_META_BORDER_TOP = 0;
|
|
507
|
+
const MASTHEAD_META_PADDING_TOP = 0;
|
|
508
|
+
const MASTHEAD_META_PADDING_BOTTOM = 0;
|
|
509
|
+
const MASTHEAD_META_BORDER_BOTTOM = 0;
|
|
510
|
+
const INSIDE_HEADER_PADDING_BOTTOM = 8;
|
|
511
|
+
const INSIDE_HEADER_BORDER_BOTTOM = 2;
|
|
512
|
+
const INSIDE_HEADER_MARGIN_BOTTOM = 14;
|
|
513
|
+
const CONTINUED_TITLE_KICKER_LINE_HEIGHT = 14;
|
|
514
|
+
const CONTINUED_TITLE_HEADING_MARGIN_TOP = 5;
|
|
515
|
+
const CONTINUED_TITLE_PADDING_BOTTOM = 0;
|
|
516
|
+
const CONTINUED_TITLE_BORDER_BOTTOM = 0;
|
|
517
|
+
const CONTINUED_TITLE_MARGIN_BOTTOM = 0;
|
|
518
|
+
const FURNITURE_COLLISION_GUTTER = 14;
|
|
519
|
+
const PULL_QUOTE_VERTICAL_PADDING = 24;
|
|
520
|
+
const KICKER_BASE_FONT_SIZE = 11.5;
|
|
521
|
+
const KICKER_BASE_ROW_HEIGHT = 19;
|
|
522
|
+
|
|
523
|
+
function getKickerFontSize(rhythmRowHeight: number): number {
|
|
524
|
+
return Math.round((KICKER_BASE_FONT_SIZE * rhythmRowHeight / KICKER_BASE_ROW_HEIGHT) * 10) / 10;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function createVerticalRhythm(rowHeight: number): VerticalRhythm {
|
|
528
|
+
const paintHeight = rowHeight + 4;
|
|
529
|
+
return {
|
|
530
|
+
rowHeight,
|
|
531
|
+
paintHeight,
|
|
532
|
+
paintBuffer: paintHeight - rowHeight,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function snapUpToRhythm(value: number, rhythm: VerticalRhythm): number {
|
|
537
|
+
if (value <= 0) return 0;
|
|
538
|
+
return Math.ceil(value / rhythm.rowHeight) * rhythm.rowHeight;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function snapDownToRhythm(value: number, rhythm: VerticalRhythm): number {
|
|
542
|
+
if (value <= 0) return 0;
|
|
543
|
+
return Math.floor(value / rhythm.rowHeight) * rhythm.rowHeight;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function snapToNearestRhythm(value: number, rhythm: VerticalRhythm): number {
|
|
547
|
+
if (value <= 0) return 0;
|
|
548
|
+
return Math.round(value / rhythm.rowHeight) * rhythm.rowHeight;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function reserveRhythmRows(value: number, rhythm: VerticalRhythm): number {
|
|
552
|
+
return snapUpToRhythm(value, rhythm);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function clampRhythmHeight(value: number, minimum: number, maximum: number, rhythm: VerticalRhythm): number {
|
|
556
|
+
const snappedMinimum = reserveRhythmRows(minimum, rhythm);
|
|
557
|
+
const snappedMaximum = Math.max(snappedMinimum, snapDownToRhythm(maximum, rhythm));
|
|
558
|
+
return clamp(reserveRhythmRows(value, rhythm), snappedMinimum, snappedMaximum);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function snapPreferredHeightToRhythm(value: number, rhythm: VerticalRhythm, minimum = rhythm.rowHeight, maximum = Number.POSITIVE_INFINITY): number {
|
|
562
|
+
const snappedMinimum = reserveRhythmRows(minimum, rhythm);
|
|
563
|
+
const snappedMaximum = Number.isFinite(maximum) ? Math.max(snappedMinimum, snapDownToRhythm(maximum, rhythm)) : Number.POSITIVE_INFINITY;
|
|
564
|
+
const down = snapDownToRhythm(value, rhythm);
|
|
565
|
+
const up = reserveRhythmRows(value, rhythm);
|
|
566
|
+
const candidates = [down, up].filter((candidate) => candidate >= snappedMinimum && candidate <= snappedMaximum);
|
|
567
|
+
if (candidates.length === 0) return snappedMinimum;
|
|
568
|
+
return candidates.sort((left, right) => Math.abs(left - value) - Math.abs(right - value) || left - right)[0];
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function snapPreservedImageHeightToRhythm(value: number, rhythm: VerticalRhythm, minimum = rhythm.rowHeight, maximum = Number.POSITIVE_INFINITY): number {
|
|
572
|
+
const snappedMinimum = reserveRhythmRows(minimum, rhythm);
|
|
573
|
+
const snappedMaximum = Number.isFinite(maximum) ? Math.max(snappedMinimum, snapDownToRhythm(maximum, rhythm)) : Number.POSITIVE_INFINITY;
|
|
574
|
+
return clamp(reserveRhythmRows(value, rhythm), snappedMinimum, snappedMaximum);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function getMinimumTextFrameHeight(config: LayoutConfig): number {
|
|
578
|
+
return reserveRhythmRows(config.linePaintHeight, config.rhythm);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function buildNewspaperLayout(
|
|
582
|
+
items: PublicationItem[],
|
|
583
|
+
pageWidth: number,
|
|
584
|
+
viewportHeight: number,
|
|
585
|
+
layoutPlan: EditionLayoutPlan,
|
|
586
|
+
): NewspaperLayout {
|
|
587
|
+
const config = getLayoutConfig(pageWidth, viewportHeight);
|
|
588
|
+
const itemsBySlug = new Map(items.map((item) => [item.slug, item]));
|
|
589
|
+
const flows = createArticleFlows(items);
|
|
590
|
+
const prepared: PreparedTextCache = new Map();
|
|
591
|
+
const pages: SolvedPage[] = [];
|
|
592
|
+
const textRanges: PlacedTextRange[] = [];
|
|
593
|
+
|
|
594
|
+
for (const pageSpec of layoutPlan.pages) {
|
|
595
|
+
const page = solvePage(pageSpec, itemsBySlug, flows, prepared, config);
|
|
596
|
+
pages.push(page);
|
|
597
|
+
textRanges.push(...collectPageTextRanges(page));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const pageHeights: Record<number, number> = {};
|
|
601
|
+
for (const page of pages) {
|
|
602
|
+
pageHeights[page.pageNumber] = page.height;
|
|
603
|
+
}
|
|
604
|
+
const maxPageHeight = Math.max(...pages.map((page) => page.height), 0);
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
columnCount: config.columnCount,
|
|
608
|
+
contentWidth: config.contentWidth,
|
|
609
|
+
gap: config.gap,
|
|
610
|
+
rowGap: config.rowGap,
|
|
611
|
+
rhythm: config.rhythm,
|
|
612
|
+
pageHeight: maxPageHeight,
|
|
613
|
+
frontPageHeight: pageHeights[1] ?? maxPageHeight,
|
|
614
|
+
pageHeights,
|
|
615
|
+
pageChrome: config.pageChrome,
|
|
616
|
+
pages,
|
|
617
|
+
textRanges,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function solvePage(
|
|
622
|
+
pageSpec: LayoutPageSpec,
|
|
623
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
624
|
+
flows: Map<string, ArticleFlow>,
|
|
625
|
+
prepared: PreparedTextCache,
|
|
626
|
+
config: LayoutConfig,
|
|
627
|
+
): SolvedPage {
|
|
628
|
+
if (pageSpec.presetId === "front.mosaic") {
|
|
629
|
+
return solveFrontMosaicPage(pageSpec, itemsBySlug, flows, prepared, config);
|
|
630
|
+
}
|
|
631
|
+
return solveStackedEditorialPage(pageSpec, itemsBySlug, flows, prepared, config);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function solveFrontMosaicPage(
|
|
635
|
+
pageSpec: LayoutPageSpec,
|
|
636
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
637
|
+
flows: Map<string, ArticleFlow>,
|
|
638
|
+
prepared: PreparedTextCache,
|
|
639
|
+
config: LayoutConfig,
|
|
640
|
+
): SolvedPage {
|
|
641
|
+
const regionSpec = pageSpec.regions[0];
|
|
642
|
+
const frontBlockSpecs = regionSpec.blocks.filter((block): block is ArticleFrameBlockSpec => block.type === "articleFrame");
|
|
643
|
+
const activeResponsiveLayout = getActiveFrontResponsiveLayout(regionSpec, config);
|
|
644
|
+
const placements = activeResponsiveLayout
|
|
645
|
+
? resolveResponsiveFrontArticlePlacements(frontBlockSpecs, activeResponsiveLayout, config)
|
|
646
|
+
: resolveDefaultFrontArticlePlacements(frontBlockSpecs, config);
|
|
647
|
+
const baseRowHeights = getResponsiveFrontRowHeights(config, placements);
|
|
648
|
+
const solvedPlacements = placements.map((placement) => ({
|
|
649
|
+
...placement,
|
|
650
|
+
rowHeight: getResponsiveFrontPlacementHeight(placement, baseRowHeights, config.rowGap),
|
|
651
|
+
}));
|
|
652
|
+
const previewFlows = new Map<string, ArticleFlow>();
|
|
653
|
+
const provisionalBlocks = solvedPlacements.map((placement) => (
|
|
654
|
+
solveFrontArticleFrame(placement, itemsBySlug, previewFlows, prepared, config, pageSpec.pageNumber)
|
|
655
|
+
));
|
|
656
|
+
const rowHeights = getSolvedResponsiveFrontRowHeights(baseRowHeights, provisionalBlocks, config);
|
|
657
|
+
const finalPlacements = solvedPlacements.map((placement) => ({
|
|
658
|
+
...placement,
|
|
659
|
+
rowHeight: getResponsiveFrontPlacementHeight(placement, rowHeights, config.rowGap),
|
|
660
|
+
}));
|
|
661
|
+
const blocks = finalPlacements.map((placement) => (
|
|
662
|
+
solveFrontArticleFrame(placement, itemsBySlug, flows, prepared, config, pageSpec.pageNumber, {
|
|
663
|
+
enforcePlacementHeight: true,
|
|
664
|
+
})
|
|
665
|
+
));
|
|
666
|
+
const gridHeight = getFrontPageGridHeightFromRowHeights(rowHeights, config.rowGap);
|
|
667
|
+
const frontFooter = solveFrontFooter(blocks, config, pageSpec.pageNumber);
|
|
668
|
+
const regionY = config.pageChrome.pagePaddingTop + config.pageChrome.mastheadHeight + config.pageChrome.frontGridMarginTop;
|
|
669
|
+
const pageHeight = getFrontPageHeightForRegion(config, regionY, gridHeight, frontFooter);
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
id: pageSpec.id ?? pageIdFor(pageSpec.pageNumber),
|
|
673
|
+
pageNumber: pageSpec.pageNumber,
|
|
674
|
+
presetId: pageSpec.presetId,
|
|
675
|
+
kind: "front",
|
|
676
|
+
height: pageHeight,
|
|
677
|
+
columnCount: config.columnCount,
|
|
678
|
+
frontFooter,
|
|
679
|
+
regions: [
|
|
680
|
+
{
|
|
681
|
+
id: regionSpec.id,
|
|
682
|
+
type: regionSpec.type,
|
|
683
|
+
role: regionSpec.role,
|
|
684
|
+
x: 0,
|
|
685
|
+
y: regionY,
|
|
686
|
+
width: config.contentWidth,
|
|
687
|
+
height: gridHeight,
|
|
688
|
+
rowHeights,
|
|
689
|
+
columnCount: config.columnCount,
|
|
690
|
+
blocks,
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function getActiveFrontResponsiveLayout(regionSpec: LayoutRegionSpec, config: LayoutConfig): FrontResponsiveLayoutSpec | null {
|
|
697
|
+
return regionSpec.responsiveLayouts?.find((layout) => (
|
|
698
|
+
config.columnCount >= layout.minColumns && config.columnCount <= layout.maxColumns
|
|
699
|
+
)) ?? null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function resolveResponsiveFrontArticlePlacements(
|
|
703
|
+
blocks: ArticleFrameBlockSpec[],
|
|
704
|
+
layout: FrontResponsiveLayoutSpec,
|
|
705
|
+
config: LayoutConfig,
|
|
706
|
+
): FrontArticlePlacement[] {
|
|
707
|
+
const orderedBlocks = orderFrontArticleBlocks(blocks, layout.order);
|
|
708
|
+
const placements: FrontArticlePlacement[] = [];
|
|
709
|
+
const assignedBlockIds = new Set<string>();
|
|
710
|
+
|
|
711
|
+
for (const slot of layout.slots) {
|
|
712
|
+
const block = findFrontSlotBlock(slot, blocks, orderedBlocks);
|
|
713
|
+
if (!block || assignedBlockIds.has(block.id)) continue;
|
|
714
|
+
assignedBlockIds.add(block.id);
|
|
715
|
+
placements.push({
|
|
716
|
+
block,
|
|
717
|
+
originalIndex: blocks.indexOf(block),
|
|
718
|
+
solveIndex: placements.length,
|
|
719
|
+
gridPlacement: resolveFrontSlotGridPlacement(slot, config),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const overflowBlocks = orderedBlocks.filter((block) => !assignedBlockIds.has(block.id));
|
|
724
|
+
const overflowPlacements = resolveFrontOverflowPlacements(
|
|
725
|
+
overflowBlocks,
|
|
726
|
+
blocks,
|
|
727
|
+
placements.length,
|
|
728
|
+
getNextFrontOverflowRow(placements),
|
|
729
|
+
layout.overflow.columnSpan,
|
|
730
|
+
layout.overflow.rowSpan,
|
|
731
|
+
config,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
return [...placements, ...overflowPlacements]
|
|
735
|
+
.sort((left, right) => (
|
|
736
|
+
(left.gridPlacement?.rowStart ?? 0) - (right.gridPlacement?.rowStart ?? 0) ||
|
|
737
|
+
(left.gridPlacement?.columnStart ?? 0) - (right.gridPlacement?.columnStart ?? 0) ||
|
|
738
|
+
left.solveIndex - right.solveIndex
|
|
739
|
+
))
|
|
740
|
+
.map((placement, solveIndex) => ({ ...placement, solveIndex }));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function resolveDefaultFrontArticlePlacements(blocks: ArticleFrameBlockSpec[], config: LayoutConfig): FrontArticlePlacement[] {
|
|
744
|
+
let columnStart = 0;
|
|
745
|
+
let rowStart = 0;
|
|
746
|
+
return blocks.map((block, index) => {
|
|
747
|
+
const columnSpan = getDefaultFrontArticlePlacementSpan(block, config);
|
|
748
|
+
if (columnStart + columnSpan > config.columnCount) {
|
|
749
|
+
rowStart += 1;
|
|
750
|
+
columnStart = 0;
|
|
751
|
+
}
|
|
752
|
+
const placement: FrontArticlePlacement = {
|
|
753
|
+
block,
|
|
754
|
+
originalIndex: index,
|
|
755
|
+
solveIndex: index,
|
|
756
|
+
gridPlacement: {
|
|
757
|
+
columnStart,
|
|
758
|
+
columnSpan,
|
|
759
|
+
rowStart,
|
|
760
|
+
rowSpan: 1,
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
columnStart += columnSpan;
|
|
764
|
+
return placement;
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getDefaultFrontArticlePlacementSpan(block: ArticleFrameBlockSpec, config: LayoutConfig): number {
|
|
769
|
+
return clamp(block.span?.preferred ?? 1, 1, config.columnCount);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function orderFrontArticleBlocks(blocks: ArticleFrameBlockSpec[], order: FrontResponsiveLayoutOrder): ArticleFrameBlockSpec[] {
|
|
773
|
+
if (order !== "editorialPriority") return blocks;
|
|
774
|
+
return blocks
|
|
775
|
+
.map((block, index) => ({ block, index }))
|
|
776
|
+
.sort((left, right) => (
|
|
777
|
+
getEditorialPriorityRank(left.block.editorialPriority) - getEditorialPriorityRank(right.block.editorialPriority) ||
|
|
778
|
+
left.index - right.index
|
|
779
|
+
))
|
|
780
|
+
.map(({ block }) => block);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function findFrontSlotBlock(
|
|
784
|
+
slot: FrontResponsiveSlotSpec,
|
|
785
|
+
blocks: ArticleFrameBlockSpec[],
|
|
786
|
+
orderedBlocks: ArticleFrameBlockSpec[],
|
|
787
|
+
): ArticleFrameBlockSpec | null {
|
|
788
|
+
if (slot.blockId) return blocks.find((block) => block.id === slot.blockId) ?? null;
|
|
789
|
+
if (!slot.editorialPriority) return null;
|
|
790
|
+
const occurrence = slot.priorityOccurrence ?? 1;
|
|
791
|
+
return orderedBlocks.filter((block) => block.editorialPriority === slot.editorialPriority)[occurrence - 1] ?? null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function resolveFrontSlotGridPlacement(slot: FrontResponsiveSlotSpec, config: LayoutConfig): SolvedFrontGridPlacement {
|
|
795
|
+
const columnStart = clamp(slot.columnStart - 1, 0, Math.max(0, config.columnCount - 1));
|
|
796
|
+
return {
|
|
797
|
+
columnStart,
|
|
798
|
+
columnSpan: clamp(slot.columnSpan, 1, Math.max(1, config.columnCount - columnStart)),
|
|
799
|
+
rowStart: slot.rowStart - 1,
|
|
800
|
+
rowSpan: slot.rowSpan,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function resolveFrontOverflowPlacements(
|
|
805
|
+
overflowBlocks: ArticleFrameBlockSpec[],
|
|
806
|
+
allBlocks: ArticleFrameBlockSpec[],
|
|
807
|
+
solveIndexStart: number,
|
|
808
|
+
rowStart: number,
|
|
809
|
+
requestedColumnSpan: number | "full",
|
|
810
|
+
rowSpan: number,
|
|
811
|
+
config: LayoutConfig,
|
|
812
|
+
): FrontArticlePlacement[] {
|
|
813
|
+
const columnSpan = requestedColumnSpan === "full"
|
|
814
|
+
? config.columnCount
|
|
815
|
+
: clamp(requestedColumnSpan, 1, config.columnCount);
|
|
816
|
+
let columnStart = 0;
|
|
817
|
+
let currentRow = rowStart;
|
|
818
|
+
return overflowBlocks.map((block, index) => {
|
|
819
|
+
if (columnStart + columnSpan > config.columnCount) {
|
|
820
|
+
currentRow += rowSpan;
|
|
821
|
+
columnStart = 0;
|
|
822
|
+
}
|
|
823
|
+
const placement: FrontArticlePlacement = {
|
|
824
|
+
block,
|
|
825
|
+
originalIndex: allBlocks.indexOf(block),
|
|
826
|
+
solveIndex: solveIndexStart + index,
|
|
827
|
+
gridPlacement: {
|
|
828
|
+
columnStart,
|
|
829
|
+
columnSpan,
|
|
830
|
+
rowStart: currentRow,
|
|
831
|
+
rowSpan,
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
columnStart += columnSpan;
|
|
835
|
+
return placement;
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function getNextFrontOverflowRow(placements: FrontArticlePlacement[]): number {
|
|
840
|
+
return placements.reduce((nextRow, placement) => {
|
|
841
|
+
const gridPlacement = placement.gridPlacement;
|
|
842
|
+
if (!gridPlacement) return nextRow;
|
|
843
|
+
return Math.max(nextRow, gridPlacement.rowStart + gridPlacement.rowSpan);
|
|
844
|
+
}, 0);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function getResponsiveFrontRowHeights(config: LayoutConfig, placements: FrontArticlePlacement[]): number[] {
|
|
848
|
+
const rowCount = placements.reduce((count, placement) => {
|
|
849
|
+
const gridPlacement = placement.gridPlacement;
|
|
850
|
+
if (!gridPlacement) return count;
|
|
851
|
+
return Math.max(count, gridPlacement.rowStart + gridPlacement.rowSpan);
|
|
852
|
+
}, 0);
|
|
853
|
+
const baseHeights = config.frontRows.map((row) => row.height);
|
|
854
|
+
const fallbackHeight = baseHeights[1] ?? baseHeights[0] ?? reserveRhythmRows(420, config.rhythm);
|
|
855
|
+
return Array.from({ length: rowCount }, (_, index) => baseHeights[index] ?? fallbackHeight);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function getResponsiveFrontPlacementHeight(placement: FrontArticlePlacement, rowHeights: number[], rowGap: number): number {
|
|
859
|
+
const gridPlacement = placement.gridPlacement;
|
|
860
|
+
if (!gridPlacement) return getFrontRowHeightFallback(rowHeights);
|
|
861
|
+
return getResponsiveFrontGridPlacementHeight(gridPlacement, rowHeights, rowGap);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function getResponsiveFrontGridPlacementHeight(gridPlacement: SolvedFrontGridPlacement, rowHeights: number[], rowGap: number): number {
|
|
865
|
+
return rowHeights
|
|
866
|
+
.slice(gridPlacement.rowStart, gridPlacement.rowStart + gridPlacement.rowSpan)
|
|
867
|
+
.reduce((total, height) => total + height, 0) + rowGap * Math.max(0, gridPlacement.rowSpan - 1);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function getFrontRowHeightFallback(rowHeights: number[]): number {
|
|
871
|
+
return rowHeights[0] ?? 0;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function solveFrontFooter(blocks: SolvedBlock[], config: LayoutConfig, pageNumber: number): SolvedFrontFooter {
|
|
875
|
+
const entries: SolvedFrontFooterEntry[] = [];
|
|
876
|
+
const seenSections = new Set<string>();
|
|
877
|
+
for (const block of blocks) {
|
|
878
|
+
const section = block.section ?? block.article?.section;
|
|
879
|
+
const articleSlug = block.article?.slug;
|
|
880
|
+
const articleTitle = block.title ?? block.article?.headline;
|
|
881
|
+
if (!section || !articleSlug || !articleTitle) continue;
|
|
882
|
+
const sectionKey = section.trim().toLowerCase();
|
|
883
|
+
if (!sectionKey || seenSections.has(sectionKey)) continue;
|
|
884
|
+
seenSections.add(sectionKey);
|
|
885
|
+
entries.push({
|
|
886
|
+
section,
|
|
887
|
+
articleSlug,
|
|
888
|
+
articleTitle,
|
|
889
|
+
blockId: block.id,
|
|
890
|
+
pageNumber,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const sectionColumns = entries.length === 0
|
|
895
|
+
? 1
|
|
896
|
+
: Math.min(entries.length, Math.max(1, config.columnCount <= 1 ? 1 : config.columnCount <= 3 ? 2 : 4));
|
|
897
|
+
const sectionRows = entries.length === 0 ? 0 : Math.ceil(entries.length / sectionColumns);
|
|
898
|
+
const utilityEntries: SolvedFrontFooterUtilityEntry[] = [
|
|
899
|
+
{ id: "archive", label: "Archive", href: "/archive", disabled: false },
|
|
900
|
+
{ id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
|
|
901
|
+
{ id: "settings", label: "Settings", href: "/settings", disabled: false },
|
|
902
|
+
{ id: "login", label: "LOGIN", disabled: true },
|
|
903
|
+
];
|
|
904
|
+
const rowCount = 1 + Math.max(sectionRows, utilityEntries.length);
|
|
905
|
+
return {
|
|
906
|
+
rowHeight: config.rhythm.rowHeight,
|
|
907
|
+
marginTop: config.rhythm.rowHeight,
|
|
908
|
+
height: reserveRhythmRows(rowCount * config.rhythm.rowHeight, config.rhythm),
|
|
909
|
+
sectionRows,
|
|
910
|
+
sectionColumns,
|
|
911
|
+
entries,
|
|
912
|
+
utilityEntries,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function getSolvedResponsiveFrontRowHeights(baseRowHeights: number[], blocks: SolvedBlock[], config: LayoutConfig): number[] {
|
|
917
|
+
const rowHeights = [...baseRowHeights];
|
|
918
|
+
for (const block of blocks) {
|
|
919
|
+
const placement = block.front?.gridPlacement;
|
|
920
|
+
if (!placement) continue;
|
|
921
|
+
while (rowHeights.length < placement.rowStart + placement.rowSpan) {
|
|
922
|
+
rowHeights.push(baseRowHeights[baseRowHeights.length - 1] ?? reserveRhythmRows(420, config.rhythm));
|
|
923
|
+
}
|
|
924
|
+
const currentHeight = getResponsiveFrontGridPlacementHeight(placement, rowHeights, config.rowGap);
|
|
925
|
+
if (block.height <= currentHeight) continue;
|
|
926
|
+
const lastRowIndex = placement.rowStart + placement.rowSpan - 1;
|
|
927
|
+
rowHeights[lastRowIndex] = reserveRhythmRows(rowHeights[lastRowIndex] + block.height - currentHeight, config.rhythm);
|
|
928
|
+
}
|
|
929
|
+
return rowHeights;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function getEditorialPriorityRank(priority: EditorialPriorityId): number {
|
|
933
|
+
if (priority === "primary") return 0;
|
|
934
|
+
if (priority === "secondary") return 1;
|
|
935
|
+
if (priority === "tertiary") return 2;
|
|
936
|
+
return 3;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function solveFrontArticleFrame(
|
|
940
|
+
placement: FrontArticlePlacement,
|
|
941
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
942
|
+
flows: Map<string, ArticleFlow>,
|
|
943
|
+
prepared: PreparedTextCache,
|
|
944
|
+
config: LayoutConfig,
|
|
945
|
+
pageNumber: number,
|
|
946
|
+
options: FrontArticleSolveOptions = {},
|
|
947
|
+
): SolvedBlock {
|
|
948
|
+
const { block: blockSpec } = placement;
|
|
949
|
+
const item = requireArticleItem(blockSpec.itemId, itemsBySlug);
|
|
950
|
+
const flow = getOrCreateFlow(flows, blockSpec.flowKey ?? blockSpec.itemId, item);
|
|
951
|
+
if (blockSpec.startCursor === "beginning") resetArticleFlow(flow);
|
|
952
|
+
|
|
953
|
+
const span = placement.gridPlacement?.columnSpan ?? Math.min(blockSpec.span?.preferred ?? 1, config.columnCount);
|
|
954
|
+
const blockWidth = getSpanWidth(config, span);
|
|
955
|
+
if (blockSpec.composition) {
|
|
956
|
+
return solveComposedFrontArticleFrame(placement, item, flow, prepared, config, pageNumber, span, blockWidth, options);
|
|
957
|
+
}
|
|
958
|
+
const preludeImage = createFrontPreludeImage(item, blockSpec, blockWidth, config, flow.usedImageAssetIds);
|
|
959
|
+
const storyRole = resolveFrontStoryRole(blockSpec, span, preludeImage !== null);
|
|
960
|
+
const headlineScale = resolveHeadlineScale(blockSpec, storyRole);
|
|
961
|
+
const chrome = getStoryChromeMetrics(config, item, storyRole, headlineScale, blockWidth, preludeImage?.height ?? 0, blockSpec.chrome);
|
|
962
|
+
const chromeHeight = getStoryChromeHeight(chrome);
|
|
963
|
+
const jumpReserveHeight = getStoryJumpReserveHeight(chrome);
|
|
964
|
+
const rowHeight = getFrontArticleRowHeight(placement, config);
|
|
965
|
+
const baseBodySlotHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(rowHeight - chromeHeight - jumpReserveHeight, config.rhythm));
|
|
966
|
+
const imageWrap = !preludeImage && storyRole === "feature" ? getLeadImageWrap(item, blockWidth, config) : null;
|
|
967
|
+
let bodySlotHeight = imageWrap
|
|
968
|
+
? Math.max(baseBodySlotHeight, reserveRhythmRows(imageWrap.height, config.rhythm))
|
|
969
|
+
: baseBodySlotHeight;
|
|
970
|
+
const textLimit = getFrontTeaserTextLimit(blockSpec, config);
|
|
971
|
+
if (textLimit?.mode === "bodyDepth") {
|
|
972
|
+
bodySlotHeight = Math.max(bodySlotHeight, textLimit.height);
|
|
973
|
+
}
|
|
974
|
+
const startCursor = { ...flow.currentCursor };
|
|
975
|
+
const text = getPrepared(prepared, item, config.bodyFont);
|
|
976
|
+
let maxHeight = getFrontTeaserMeasureHeight(textLimit, bodySlotHeight);
|
|
977
|
+
let result = layoutTextLines({
|
|
978
|
+
prepared: text,
|
|
979
|
+
cursor: startCursor,
|
|
980
|
+
maxHeight,
|
|
981
|
+
maxWidth: blockWidth,
|
|
982
|
+
lineHeight: config.lineHeight,
|
|
983
|
+
linePaintHeight: config.linePaintHeight,
|
|
984
|
+
fontSize: config.bodyFontSize,
|
|
985
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
986
|
+
obstacles: imageWrap ? [imageWrap] : [],
|
|
987
|
+
});
|
|
988
|
+
while (shouldGrowFrontArticleToContent(blockSpec) && result.hasMore) {
|
|
989
|
+
bodySlotHeight = reserveRhythmRows(bodySlotHeight + config.rhythm.rowHeight, config.rhythm);
|
|
990
|
+
maxHeight = bodySlotHeight;
|
|
991
|
+
result = layoutTextLines({
|
|
992
|
+
prepared: text,
|
|
993
|
+
cursor: startCursor,
|
|
994
|
+
maxHeight,
|
|
995
|
+
maxWidth: blockWidth,
|
|
996
|
+
lineHeight: config.lineHeight,
|
|
997
|
+
linePaintHeight: config.linePaintHeight,
|
|
998
|
+
fontSize: config.bodyFontSize,
|
|
999
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
1000
|
+
obstacles: imageWrap ? [imageWrap] : [],
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const requiredBlockHeight = Math.max(rowHeight, reserveRhythmRows(chromeHeight + bodySlotHeight + jumpReserveHeight, config.rhythm));
|
|
1004
|
+
const blockHeight = options.enforcePlacementHeight ? rowHeight : requiredBlockHeight;
|
|
1005
|
+
const range = createTextRange({
|
|
1006
|
+
flow,
|
|
1007
|
+
pageId: pageIdFor(pageNumber),
|
|
1008
|
+
blockId: blockSpec.id,
|
|
1009
|
+
startCursor,
|
|
1010
|
+
endCursor: result.cursor,
|
|
1011
|
+
exhausted: !result.hasMore,
|
|
1012
|
+
});
|
|
1013
|
+
const furniture = [preludeImage, imageWrap ? leadImageToFurniture(item, imageWrap, config) : null].filter(
|
|
1014
|
+
(furniture): furniture is SolvedImageFurniture => furniture !== null,
|
|
1015
|
+
);
|
|
1016
|
+
commitTextRange(flow, range);
|
|
1017
|
+
commitUsedImageFurniture(flow, furniture);
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
id: blockSpec.id,
|
|
1021
|
+
type: "articleFrame",
|
|
1022
|
+
presetId: blockSpec.presetId,
|
|
1023
|
+
headlineScale,
|
|
1024
|
+
editorialPriority: blockSpec.editorialPriority,
|
|
1025
|
+
item,
|
|
1026
|
+
article: item,
|
|
1027
|
+
pageNumber,
|
|
1028
|
+
jumpTargetPage: result.hasMore ? blockSpec.cutPolicy?.jumpTargetPage : undefined,
|
|
1029
|
+
jumpLabel:
|
|
1030
|
+
result.hasMore && blockSpec.cutPolicy?.jumpTargetPage
|
|
1031
|
+
? formatContinuationJumpLabel(item, blockSpec.cutPolicy.jumpTargetPage)
|
|
1032
|
+
: undefined,
|
|
1033
|
+
label: item.section,
|
|
1034
|
+
title: item.headline,
|
|
1035
|
+
deck: item.deck,
|
|
1036
|
+
byline: item.byline,
|
|
1037
|
+
dateline: item.dateline,
|
|
1038
|
+
section: item.section,
|
|
1039
|
+
x: 0,
|
|
1040
|
+
y: 0,
|
|
1041
|
+
width: blockWidth,
|
|
1042
|
+
height: blockHeight,
|
|
1043
|
+
span,
|
|
1044
|
+
columnCount: 1,
|
|
1045
|
+
columns: [result.lines],
|
|
1046
|
+
furniture,
|
|
1047
|
+
textRange: range,
|
|
1048
|
+
hasMore: result.hasMore,
|
|
1049
|
+
front: {
|
|
1050
|
+
rowHeight: blockHeight,
|
|
1051
|
+
bodySlotHeight,
|
|
1052
|
+
chromeHeight,
|
|
1053
|
+
jumpReserveHeight,
|
|
1054
|
+
chrome,
|
|
1055
|
+
gridPlacement: placement.gridPlacement,
|
|
1056
|
+
},
|
|
1057
|
+
bodyHeight: bodySlotHeight,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function solveComposedFrontArticleFrame(
|
|
1062
|
+
placement: FrontArticlePlacement,
|
|
1063
|
+
item: ArticlePublicationItem,
|
|
1064
|
+
flow: ArticleFlow,
|
|
1065
|
+
prepared: PreparedTextCache,
|
|
1066
|
+
config: LayoutConfig,
|
|
1067
|
+
pageNumber: number,
|
|
1068
|
+
span: number,
|
|
1069
|
+
blockWidth: number,
|
|
1070
|
+
options: FrontArticleSolveOptions = {},
|
|
1071
|
+
): SolvedBlock {
|
|
1072
|
+
const { block: blockSpec } = placement;
|
|
1073
|
+
const storyRole = resolveFrontStoryRole(blockSpec, span, false);
|
|
1074
|
+
const headlineScale = resolveHeadlineScale(blockSpec, storyRole);
|
|
1075
|
+
const localColumnCount = getLocalColumnCountCandidates(blockSpec.localGrid?.columns, config, blockWidth)[0] ?? 1;
|
|
1076
|
+
const localConfig = { ...config, columnCount: localColumnCount, contentWidth: blockWidth };
|
|
1077
|
+
const chrome = getStoryChromeMetrics(config, item, storyRole, headlineScale, blockWidth, 0, blockSpec.chrome);
|
|
1078
|
+
|
|
1079
|
+
const rowHeight = getFrontArticleRowHeight(placement, config);
|
|
1080
|
+
const jumpReserveHeight = getStoryJumpReserveHeight(chrome);
|
|
1081
|
+
const topMediaVariant = resolveTopMediaCompositionVariant(blockSpec, localConfig);
|
|
1082
|
+
const compositionMode = getFrontCompositionFlowMode(placement, config, localConfig, topMediaVariant);
|
|
1083
|
+
const titleBoxes: SolvedChromeBox[] = [];
|
|
1084
|
+
let titleBottom = reserveRhythmRows(chrome.paddingTop + chrome.borderTopHeight, config.rhythm);
|
|
1085
|
+
|
|
1086
|
+
for (const slot of blockSpec.composition?.title ?? []) {
|
|
1087
|
+
const variant = resolveCompositionChromeSlotVariant(slot, localConfig, compositionMode, topMediaVariant);
|
|
1088
|
+
const box = solveCompositionChromeBox({
|
|
1089
|
+
slot,
|
|
1090
|
+
item,
|
|
1091
|
+
storyRole,
|
|
1092
|
+
headlineScale,
|
|
1093
|
+
localConfig,
|
|
1094
|
+
y: titleBottom,
|
|
1095
|
+
globalYOffset: 0,
|
|
1096
|
+
chromeOverrides: blockSpec.chrome,
|
|
1097
|
+
variant,
|
|
1098
|
+
});
|
|
1099
|
+
if (!box) continue;
|
|
1100
|
+
titleBoxes.push(box.globalBox);
|
|
1101
|
+
titleBottom = Math.max(titleBottom, box.localReserveBottom);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const bodyTop = compositionMode === "offsetBody" ? reserveRhythmRows(titleBottom, config.rhythm) : 0;
|
|
1105
|
+
let bodySlotHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(rowHeight - bodyTop - jumpReserveHeight, config.rhythm));
|
|
1106
|
+
const textLimit = getFrontTeaserTextLimit(blockSpec, config);
|
|
1107
|
+
const leadBoxes: SolvedChromeBox[] = [];
|
|
1108
|
+
const renderLeadBoxes: SolvedChromeBox[] = [];
|
|
1109
|
+
const furniture: SolvedFurniture[] = [];
|
|
1110
|
+
const columnBottoms = Array.from({ length: localColumnCount }, () => 0);
|
|
1111
|
+
if (compositionMode !== "offsetBody") {
|
|
1112
|
+
for (const box of titleBoxes) {
|
|
1113
|
+
updateOccupiedColumnBottoms(columnBottoms, box.columnStart, box.columnSpan, box.y + box.height);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
for (const slot of getOrderedCompositionLeadSlots(blockSpec, compositionMode)) {
|
|
1118
|
+
const slotVariant = slot.slot === "media"
|
|
1119
|
+
? resolveCompositionMediaSlotVariant(slot.placement, localConfig, compositionMode)
|
|
1120
|
+
: resolveCompositionChromeSlotVariant(slot, localConfig, compositionMode, topMediaVariant);
|
|
1121
|
+
if (!slotVariant) continue;
|
|
1122
|
+
|
|
1123
|
+
if (slot.slot === "media") {
|
|
1124
|
+
const mediaSpec = blockSpec.media[slot.mediaIndex ?? 0];
|
|
1125
|
+
const image = createImageFurniture(
|
|
1126
|
+
item,
|
|
1127
|
+
{
|
|
1128
|
+
id: slotVariant.id,
|
|
1129
|
+
placement: slot.placement,
|
|
1130
|
+
assetRole: mediaSpec?.assetRole,
|
|
1131
|
+
columnStart: slotVariant.columnStart,
|
|
1132
|
+
columnSpan: slotVariant.columnSpan,
|
|
1133
|
+
fallbackPenalty: slotVariant.fallbackPenalty,
|
|
1134
|
+
},
|
|
1135
|
+
localConfig,
|
|
1136
|
+
bodySlotHeight,
|
|
1137
|
+
flow.usedImageAssetIds,
|
|
1138
|
+
);
|
|
1139
|
+
if (!image) {
|
|
1140
|
+
if (mediaSpec?.required) throw new Error(`${blockSpec.id} requires media slot ${slot.mediaIndex ?? 0} but no image could be solved`);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const stackTop = getOccupiedColumnBottom(columnBottoms, image.columnStart, image.columnSpan);
|
|
1144
|
+
const y = compositionMode === "stackedMedia"
|
|
1145
|
+
? reserveRhythmRows(stackTop, config.rhythm)
|
|
1146
|
+
: Math.max(image.y, stackTop);
|
|
1147
|
+
const placedImage = { ...image, y };
|
|
1148
|
+
furniture.push(placedImage);
|
|
1149
|
+
updateOccupiedColumnBottoms(columnBottoms, placedImage.columnStart, placedImage.columnSpan, placedImage.y + placedImage.height);
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const stackTop = getOccupiedColumnBottom(columnBottoms, slotVariant.columnStart, slotVariant.columnSpan);
|
|
1154
|
+
const box = solveCompositionChromeBox({
|
|
1155
|
+
slot,
|
|
1156
|
+
item,
|
|
1157
|
+
storyRole,
|
|
1158
|
+
headlineScale,
|
|
1159
|
+
localConfig,
|
|
1160
|
+
y: stackTop,
|
|
1161
|
+
globalYOffset: bodyTop,
|
|
1162
|
+
chromeOverrides: blockSpec.chrome,
|
|
1163
|
+
variant: slotVariant,
|
|
1164
|
+
});
|
|
1165
|
+
if (!box) continue;
|
|
1166
|
+
leadBoxes.push(box.localBox);
|
|
1167
|
+
renderLeadBoxes.push(box.globalBox);
|
|
1168
|
+
updateOccupiedColumnBottoms(columnBottoms, box.localBox.columnStart, box.localBox.columnSpan, box.localReserveBottom);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const startCursor = { ...flow.currentCursor };
|
|
1172
|
+
const textChromeBoxes = compositionMode === "offsetBody" ? leadBoxes : [...titleBoxes, ...leadBoxes];
|
|
1173
|
+
const furnitureObstacleOffsetY = compositionMode === "offsetBody" ? -bodyTop : 0;
|
|
1174
|
+
const columnCopyBandTops = getColumnCopyBandTops(
|
|
1175
|
+
localColumnCount,
|
|
1176
|
+
textChromeBoxes,
|
|
1177
|
+
furniture,
|
|
1178
|
+
furnitureObstacleOffsetY,
|
|
1179
|
+
config.rhythm.rowHeight,
|
|
1180
|
+
config.rhythm,
|
|
1181
|
+
);
|
|
1182
|
+
const copyBandTop = Math.max(...columnCopyBandTops, 0);
|
|
1183
|
+
if (compositionMode === "stackedMedia" || compositionMode === "titleStackedMedia") {
|
|
1184
|
+
bodySlotHeight = Math.max(bodySlotHeight, reserveRhythmRows(copyBandTop + getMinimumTextFrameHeight(config), config.rhythm));
|
|
1185
|
+
}
|
|
1186
|
+
if (textLimit?.mode === "bodyDepth") {
|
|
1187
|
+
bodySlotHeight = Math.max(bodySlotHeight, reserveRhythmRows(copyBandTop + textLimit.height, config.rhythm));
|
|
1188
|
+
}
|
|
1189
|
+
const maxHeight = getComposedFrontTextMeasureHeight(textLimit, compositionMode, copyBandTop, bodySlotHeight);
|
|
1190
|
+
const textResult = layoutTextColumns({
|
|
1191
|
+
item,
|
|
1192
|
+
prepared,
|
|
1193
|
+
cursor: startCursor,
|
|
1194
|
+
columnCount: localColumnCount,
|
|
1195
|
+
textHeight: maxHeight,
|
|
1196
|
+
localConfig,
|
|
1197
|
+
furniture,
|
|
1198
|
+
furnitureObstacleOffsetY,
|
|
1199
|
+
furnitureObstacleMarginBottom: config.rhythm.rowHeight,
|
|
1200
|
+
minimumLineStartYByColumn: columnCopyBandTops,
|
|
1201
|
+
chromeBoxes: textChromeBoxes,
|
|
1202
|
+
});
|
|
1203
|
+
const requiredRowHeight = Math.max(rowHeight, reserveRhythmRows(bodyTop + bodySlotHeight + jumpReserveHeight, config.rhythm));
|
|
1204
|
+
const solvedRowHeight = options.enforcePlacementHeight ? rowHeight : requiredRowHeight;
|
|
1205
|
+
const range = createTextRange({
|
|
1206
|
+
flow,
|
|
1207
|
+
pageId: pageIdFor(pageNumber),
|
|
1208
|
+
blockId: blockSpec.id,
|
|
1209
|
+
startCursor,
|
|
1210
|
+
endCursor: textResult.cursor,
|
|
1211
|
+
exhausted: !textResult.hasMore,
|
|
1212
|
+
});
|
|
1213
|
+
commitTextRange(flow, range);
|
|
1214
|
+
commitUsedImageFurniture(flow, furniture);
|
|
1215
|
+
|
|
1216
|
+
return {
|
|
1217
|
+
id: blockSpec.id,
|
|
1218
|
+
type: "articleFrame",
|
|
1219
|
+
presetId: blockSpec.presetId,
|
|
1220
|
+
headlineScale,
|
|
1221
|
+
editorialPriority: blockSpec.editorialPriority,
|
|
1222
|
+
item,
|
|
1223
|
+
article: item,
|
|
1224
|
+
pageNumber,
|
|
1225
|
+
jumpTargetPage: textResult.hasMore ? blockSpec.cutPolicy?.jumpTargetPage : undefined,
|
|
1226
|
+
jumpLabel:
|
|
1227
|
+
textResult.hasMore && blockSpec.cutPolicy?.jumpTargetPage
|
|
1228
|
+
? formatContinuationJumpLabel(item, blockSpec.cutPolicy.jumpTargetPage)
|
|
1229
|
+
: undefined,
|
|
1230
|
+
label: item.section,
|
|
1231
|
+
title: item.headline,
|
|
1232
|
+
deck: item.deck,
|
|
1233
|
+
byline: item.byline,
|
|
1234
|
+
dateline: item.dateline,
|
|
1235
|
+
section: item.section,
|
|
1236
|
+
x: 0,
|
|
1237
|
+
y: 0,
|
|
1238
|
+
width: blockWidth,
|
|
1239
|
+
height: solvedRowHeight,
|
|
1240
|
+
span,
|
|
1241
|
+
columnCount: localColumnCount,
|
|
1242
|
+
columns: textResult.columns,
|
|
1243
|
+
chromeBoxes: [...titleBoxes, ...renderLeadBoxes],
|
|
1244
|
+
furniture,
|
|
1245
|
+
textRange: range,
|
|
1246
|
+
hasMore: textResult.hasMore,
|
|
1247
|
+
front: {
|
|
1248
|
+
rowHeight: solvedRowHeight,
|
|
1249
|
+
bodySlotHeight,
|
|
1250
|
+
chromeHeight: bodyTop,
|
|
1251
|
+
jumpReserveHeight,
|
|
1252
|
+
chrome,
|
|
1253
|
+
gridPlacement: placement.gridPlacement,
|
|
1254
|
+
composition: {
|
|
1255
|
+
mode: compositionMode,
|
|
1256
|
+
bodyTop,
|
|
1257
|
+
bodyHeight: bodySlotHeight,
|
|
1258
|
+
columnCount: localColumnCount,
|
|
1259
|
+
copyBandTop,
|
|
1260
|
+
copyBandTops: columnCopyBandTops,
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
bodyHeight: bodySlotHeight,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function getFrontCompositionFlowMode(
|
|
1268
|
+
placement: FrontArticlePlacement,
|
|
1269
|
+
config: LayoutConfig,
|
|
1270
|
+
localConfig: LayoutConfig,
|
|
1271
|
+
topMediaVariant: PlacementVariant | null,
|
|
1272
|
+
): FrontCompositionFlowMode {
|
|
1273
|
+
if (topMediaVariant && topMediaVariant.placement.vertical === "top" && !hasCompositionSideBesideMedia(localConfig, topMediaVariant)) {
|
|
1274
|
+
return "stackedMedia";
|
|
1275
|
+
}
|
|
1276
|
+
if (
|
|
1277
|
+
placement.gridPlacement &&
|
|
1278
|
+
placement.gridPlacement.columnSpan === config.columnCount &&
|
|
1279
|
+
localConfig.columnCount > 1 &&
|
|
1280
|
+
topMediaVariant &&
|
|
1281
|
+
topMediaVariant.columnSpan < localConfig.columnCount &&
|
|
1282
|
+
topMediaVariant.placement.vertical === "top"
|
|
1283
|
+
) {
|
|
1284
|
+
if (localConfig.columnCount <= 3) return "titleStackedMedia";
|
|
1285
|
+
return "integrated";
|
|
1286
|
+
}
|
|
1287
|
+
return "offsetBody";
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function resolveTopMediaCompositionVariant(blockSpec: ArticleFrameBlockSpec, localConfig: LayoutConfig): PlacementVariant | null {
|
|
1291
|
+
const slot = blockSpec.composition?.lead.find((candidate) => (
|
|
1292
|
+
candidate.slot === "media" && candidate.placement.vertical === "top"
|
|
1293
|
+
));
|
|
1294
|
+
return slot ? resolvePreferredPlacementVariant(slot.placement, localConfig) : null;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function resolveCompositionChromeSlotVariant(
|
|
1298
|
+
slot: ArticleFrameCompositionSlotSpec,
|
|
1299
|
+
localConfig: LayoutConfig,
|
|
1300
|
+
compositionMode: FrontCompositionFlowMode,
|
|
1301
|
+
topMediaVariant: PlacementVariant | null,
|
|
1302
|
+
): PlacementVariant | null {
|
|
1303
|
+
const variant = resolvePreferredPlacementVariant(slot.placement, localConfig);
|
|
1304
|
+
if (!variant || !isTextCompositionSlot(slot.slot)) return variant;
|
|
1305
|
+
if (compositionMode === "stackedMedia") return getFullWidthPlacementVariant(variant, localConfig, "stacked");
|
|
1306
|
+
if (compositionMode === "titleStackedMedia" && isTitleCompositionSlot(slot.slot)) {
|
|
1307
|
+
return getFullWidthPlacementVariant(variant, localConfig, "title-stacked");
|
|
1308
|
+
}
|
|
1309
|
+
if ((compositionMode !== "integrated" && compositionMode !== "titleStackedMedia") || !topMediaVariant) return variant;
|
|
1310
|
+
if (!columnsOverlap(variant.columnStart, variant.columnSpan, topMediaVariant.columnStart, topMediaVariant.columnSpan)) {
|
|
1311
|
+
return variant;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const leftSpan = topMediaVariant.columnStart;
|
|
1315
|
+
const rightStart = topMediaVariant.columnStart + topMediaVariant.columnSpan;
|
|
1316
|
+
const rightSpan = localConfig.columnCount - rightStart;
|
|
1317
|
+
const preferLeft = variant.columnStart <= topMediaVariant.columnStart;
|
|
1318
|
+
const side = preferLeft
|
|
1319
|
+
? getAvailableCompositionSide(0, leftSpan, rightStart, rightSpan)
|
|
1320
|
+
: getAvailableCompositionSide(rightStart, rightSpan, 0, leftSpan);
|
|
1321
|
+
if (!side) return variant;
|
|
1322
|
+
return {
|
|
1323
|
+
...variant,
|
|
1324
|
+
id: `${variant.id}-beside-media`,
|
|
1325
|
+
columnStart: side.columnStart,
|
|
1326
|
+
columnSpan: side.columnSpan,
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function resolveCompositionMediaSlotVariant(
|
|
1331
|
+
placement: ResponsivePlacementSpec,
|
|
1332
|
+
localConfig: LayoutConfig,
|
|
1333
|
+
compositionMode: FrontCompositionFlowMode,
|
|
1334
|
+
): PlacementVariant | null {
|
|
1335
|
+
const variant = resolvePreferredPlacementVariant(placement, localConfig);
|
|
1336
|
+
if (!variant) return null;
|
|
1337
|
+
return compositionMode === "stackedMedia" ? getFullWidthPlacementVariant(variant, localConfig, "stacked") : variant;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function getFullWidthPlacementVariant(variant: PlacementVariant, localConfig: LayoutConfig, suffix: string): PlacementVariant {
|
|
1341
|
+
return {
|
|
1342
|
+
...variant,
|
|
1343
|
+
id: `${variant.id}-${suffix}`,
|
|
1344
|
+
columnStart: 0,
|
|
1345
|
+
columnSpan: localConfig.columnCount,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function getOrderedCompositionLeadSlots(
|
|
1350
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
1351
|
+
compositionMode: FrontCompositionFlowMode,
|
|
1352
|
+
): ArticleFrameCompositionSlotSpec[] {
|
|
1353
|
+
const slots = blockSpec.composition?.lead ?? [];
|
|
1354
|
+
if (compositionMode !== "stackedMedia") return slots;
|
|
1355
|
+
return [
|
|
1356
|
+
...slots.filter((slot) => slot.slot !== "media"),
|
|
1357
|
+
...slots.filter((slot) => slot.slot === "media"),
|
|
1358
|
+
];
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function hasCompositionSideBesideMedia(localConfig: LayoutConfig, mediaVariant: PlacementVariant): boolean {
|
|
1362
|
+
return mediaVariant.columnStart > 0 || mediaVariant.columnStart + mediaVariant.columnSpan < localConfig.columnCount;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function isTitleCompositionSlot(slot: SolvedChromeBox["slot"]): boolean {
|
|
1366
|
+
return slot === "label" || slot === "headline";
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function getAvailableCompositionSide(
|
|
1370
|
+
primaryStart: number,
|
|
1371
|
+
primarySpan: number,
|
|
1372
|
+
fallbackStart: number,
|
|
1373
|
+
fallbackSpan: number,
|
|
1374
|
+
): { columnStart: number; columnSpan: number } | null {
|
|
1375
|
+
if (primarySpan > 0) return { columnStart: primaryStart, columnSpan: primarySpan };
|
|
1376
|
+
if (fallbackSpan > 0) return { columnStart: fallbackStart, columnSpan: fallbackSpan };
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function columnsOverlap(firstStart: number, firstSpan: number, secondStart: number, secondSpan: number): boolean {
|
|
1381
|
+
return firstStart < secondStart + secondSpan && secondStart < firstStart + firstSpan;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function solveStackedEditorialPage(
|
|
1385
|
+
pageSpec: LayoutPageSpec,
|
|
1386
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
1387
|
+
flows: Map<string, ArticleFlow>,
|
|
1388
|
+
prepared: PreparedTextCache,
|
|
1389
|
+
config: LayoutConfig,
|
|
1390
|
+
): SolvedPage {
|
|
1391
|
+
const pageStartY = config.pageChrome.insideHeaderHeight;
|
|
1392
|
+
const regions: SolvedRegion[] = [];
|
|
1393
|
+
let currentY = pageStartY;
|
|
1394
|
+
const ratios = pageSpec.regions.map((region) => region.size?.ratio ?? 1 / pageSpec.regions.length);
|
|
1395
|
+
const ratioTotal = ratios.reduce((total, ratio) => total + ratio, 0) || 1;
|
|
1396
|
+
|
|
1397
|
+
for (let index = 0; index < pageSpec.regions.length; index += 1) {
|
|
1398
|
+
const regionSpec = pageSpec.regions[index];
|
|
1399
|
+
const allocatedHeight = Math.max(
|
|
1400
|
+
getMinimumTextFrameHeight(config),
|
|
1401
|
+
snapDownToRhythm(
|
|
1402
|
+
Math.floor((config.continuationHeight * ratios[index]) / ratioTotal) -
|
|
1403
|
+
(index > 0 ? config.pageChrome.continuationSectionSeparatorHeight : 0),
|
|
1404
|
+
config.rhythm,
|
|
1405
|
+
),
|
|
1406
|
+
);
|
|
1407
|
+
const region = solveRegion(regionSpec, pageSpec, allocatedHeight, itemsBySlug, flows, prepared, config, currentY);
|
|
1408
|
+
regions.push(region);
|
|
1409
|
+
currentY += region.height + (index < pageSpec.regions.length - 1 ? config.pageChrome.continuationSectionSeparatorHeight : 0);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const contentBottom = Math.max(...regions.map((region) => region.y + region.height), pageStartY);
|
|
1413
|
+
const height = reserveRhythmRows(
|
|
1414
|
+
config.pageChrome.pagePaddingTop +
|
|
1415
|
+
contentBottom +
|
|
1416
|
+
config.pageChrome.pagePaddingBottom,
|
|
1417
|
+
config.rhythm,
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
return {
|
|
1421
|
+
id: pageSpec.id ?? pageIdFor(pageSpec.pageNumber),
|
|
1422
|
+
pageNumber: pageSpec.pageNumber,
|
|
1423
|
+
presetId: pageSpec.presetId,
|
|
1424
|
+
kind: getPageKind(pageSpec),
|
|
1425
|
+
height,
|
|
1426
|
+
columnCount: config.columnCount,
|
|
1427
|
+
regions,
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function solveRegion(
|
|
1432
|
+
regionSpec: LayoutRegionSpec,
|
|
1433
|
+
pageSpec: LayoutPageSpec,
|
|
1434
|
+
allocatedHeight: number,
|
|
1435
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
1436
|
+
flows: Map<string, ArticleFlow>,
|
|
1437
|
+
prepared: PreparedTextCache,
|
|
1438
|
+
config: LayoutConfig,
|
|
1439
|
+
y: number,
|
|
1440
|
+
): SolvedRegion {
|
|
1441
|
+
const blocks: SolvedBlock[] = [];
|
|
1442
|
+
let currentY = 0;
|
|
1443
|
+
|
|
1444
|
+
for (const blockSpec of regionSpec.blocks) {
|
|
1445
|
+
const remainingHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(allocatedHeight - currentY, config.rhythm));
|
|
1446
|
+
const block = solveBlock(blockSpec, pageSpec, remainingHeight, itemsBySlug, flows, prepared, config);
|
|
1447
|
+
blocks.push({ ...block, y: currentY });
|
|
1448
|
+
currentY += block.height;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
return {
|
|
1452
|
+
id: regionSpec.id,
|
|
1453
|
+
type: regionSpec.type,
|
|
1454
|
+
role: regionSpec.role,
|
|
1455
|
+
x: 0,
|
|
1456
|
+
y,
|
|
1457
|
+
width: config.contentWidth,
|
|
1458
|
+
height: reserveRhythmRows(
|
|
1459
|
+
regionSpec.size?.shrinkToContent
|
|
1460
|
+
? Math.max(currentY, getMinimumTextFrameHeight(config))
|
|
1461
|
+
: Math.max(currentY, allocatedHeight),
|
|
1462
|
+
config.rhythm,
|
|
1463
|
+
),
|
|
1464
|
+
columnCount: config.columnCount,
|
|
1465
|
+
blocks,
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function solveBlock(
|
|
1470
|
+
blockSpec: LayoutBlockSpec,
|
|
1471
|
+
pageSpec: LayoutPageSpec,
|
|
1472
|
+
allocatedHeight: number,
|
|
1473
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
1474
|
+
flows: Map<string, ArticleFlow>,
|
|
1475
|
+
prepared: PreparedTextCache,
|
|
1476
|
+
config: LayoutConfig,
|
|
1477
|
+
): SolvedBlock {
|
|
1478
|
+
if (blockSpec.type === "articleFrame") {
|
|
1479
|
+
return solveArticleFrameBlock(blockSpec, pageSpec, allocatedHeight, itemsBySlug, flows, prepared, config);
|
|
1480
|
+
}
|
|
1481
|
+
return solveStaticBlock(blockSpec, pageSpec, allocatedHeight, itemsBySlug, config);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function solveArticleFrameBlock(
|
|
1485
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
1486
|
+
pageSpec: LayoutPageSpec,
|
|
1487
|
+
allocatedHeight: number,
|
|
1488
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
1489
|
+
flows: Map<string, ArticleFlow>,
|
|
1490
|
+
prepared: PreparedTextCache,
|
|
1491
|
+
config: LayoutConfig,
|
|
1492
|
+
): SolvedBlock {
|
|
1493
|
+
const item = requireArticleItem(blockSpec.itemId, itemsBySlug);
|
|
1494
|
+
const flow = getOrCreateFlow(flows, blockSpec.flowKey ?? blockSpec.itemId, item);
|
|
1495
|
+
if (blockSpec.startCursor === "beginning") resetArticleFlow(flow);
|
|
1496
|
+
const startCursor = blockSpec.startCursor === "beginning" ? { ...EMPTY_CURSOR } : { ...flow.currentCursor };
|
|
1497
|
+
const blockWidth = getBlockWidth(config, blockSpec.span);
|
|
1498
|
+
const label = getArticleFrameLabel(blockSpec, item);
|
|
1499
|
+
const headlineScale = blockSpec.typography?.headlineScale ?? "standard";
|
|
1500
|
+
const title = getContinuationTitleMetrics(
|
|
1501
|
+
config,
|
|
1502
|
+
item,
|
|
1503
|
+
blockWidth,
|
|
1504
|
+
label,
|
|
1505
|
+
headlineScale,
|
|
1506
|
+
Boolean(blockSpec.typography?.headlineScale),
|
|
1507
|
+
blockSpec.chrome,
|
|
1508
|
+
);
|
|
1509
|
+
const offeredHeight = getArticleFrameOfferedHeight(blockSpec, allocatedHeight, config);
|
|
1510
|
+
const bodyBudget = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(offeredHeight - title.totalHeight, config.rhythm));
|
|
1511
|
+
const mustExhaust = !blockSpec.cutPolicy?.maxBodyLines && !blockSpec.cutPolicy?.jumpTargetPage;
|
|
1512
|
+
const candidates = solveArticleFrameCandidates({
|
|
1513
|
+
blockSpec,
|
|
1514
|
+
pageSpec,
|
|
1515
|
+
item,
|
|
1516
|
+
flow,
|
|
1517
|
+
startCursor,
|
|
1518
|
+
blockWidth,
|
|
1519
|
+
label,
|
|
1520
|
+
headlineScale,
|
|
1521
|
+
title,
|
|
1522
|
+
bodyBudget,
|
|
1523
|
+
offeredHeight,
|
|
1524
|
+
mustExhaust,
|
|
1525
|
+
prepared,
|
|
1526
|
+
config,
|
|
1527
|
+
});
|
|
1528
|
+
const best = chooseBestCandidate(candidates, blockSpec.id);
|
|
1529
|
+
commitTextRange(flow, best.range);
|
|
1530
|
+
commitUsedImageFurniture(flow, best.block.furniture);
|
|
1531
|
+
return best.block;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function getArticleFrameOfferedHeight(
|
|
1535
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
1536
|
+
allocatedHeight: number,
|
|
1537
|
+
config: LayoutConfig,
|
|
1538
|
+
): number {
|
|
1539
|
+
if (!blockSpec.size?.defaultRows) return allocatedHeight;
|
|
1540
|
+
return Math.max(getMinimumTextFrameHeight(config), reserveRhythmRows(blockSpec.size.defaultRows * config.rhythm.rowHeight, config.rhythm));
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function solveArticleFrameCandidates({
|
|
1544
|
+
blockSpec,
|
|
1545
|
+
pageSpec,
|
|
1546
|
+
item,
|
|
1547
|
+
flow,
|
|
1548
|
+
startCursor,
|
|
1549
|
+
blockWidth,
|
|
1550
|
+
label,
|
|
1551
|
+
headlineScale,
|
|
1552
|
+
title,
|
|
1553
|
+
bodyBudget,
|
|
1554
|
+
offeredHeight,
|
|
1555
|
+
mustExhaust,
|
|
1556
|
+
prepared,
|
|
1557
|
+
config,
|
|
1558
|
+
}: {
|
|
1559
|
+
blockSpec: ArticleFrameBlockSpec;
|
|
1560
|
+
pageSpec: LayoutPageSpec;
|
|
1561
|
+
item: ArticlePublicationItem;
|
|
1562
|
+
flow: ArticleFlow;
|
|
1563
|
+
startCursor: LayoutCursor;
|
|
1564
|
+
blockWidth: number;
|
|
1565
|
+
label: string;
|
|
1566
|
+
headlineScale: HeadlineScaleId;
|
|
1567
|
+
title: ContinuationTitleMetrics;
|
|
1568
|
+
bodyBudget: number;
|
|
1569
|
+
offeredHeight: number;
|
|
1570
|
+
mustExhaust: boolean;
|
|
1571
|
+
prepared: PreparedTextCache;
|
|
1572
|
+
config: LayoutConfig;
|
|
1573
|
+
}): ArticleFrameCandidate[] {
|
|
1574
|
+
const localColumnCounts = getLocalColumnCountCandidates(blockSpec.localGrid?.columns, config, blockWidth);
|
|
1575
|
+
const candidates: ArticleFrameCandidate[] = [];
|
|
1576
|
+
|
|
1577
|
+
for (const columnCount of localColumnCounts) {
|
|
1578
|
+
const localConfig = { ...config, columnCount, contentWidth: blockWidth };
|
|
1579
|
+
const mediaSpec = blockSpec.media[0];
|
|
1580
|
+
const hasReusableMedia = mediaSpec
|
|
1581
|
+
? hasAvailablePreferredImage(item, mediaSpec.placement, mediaSpec.assetRole, flow.usedImageAssetIds)
|
|
1582
|
+
: false;
|
|
1583
|
+
const mediaVariants = getMediaVariants(
|
|
1584
|
+
mediaSpec?.required && !hasReusableMedia ? { ...mediaSpec, required: false } : mediaSpec,
|
|
1585
|
+
localConfig,
|
|
1586
|
+
);
|
|
1587
|
+
const effectiveMediaVariants = mediaSpec?.required && hasReusableMedia
|
|
1588
|
+
? [...mediaVariants, getNoMediaVariant(12_000)]
|
|
1589
|
+
: mediaVariants;
|
|
1590
|
+
const pullQuoteVariants = getPullQuoteVariants(item, blockSpec.pullQuote, localConfig);
|
|
1591
|
+
|
|
1592
|
+
for (const mediaVariant of effectiveMediaVariants) {
|
|
1593
|
+
const image = createImageFurniture(item, mediaVariant, localConfig, bodyBudget, flow.usedImageAssetIds);
|
|
1594
|
+
if (mediaSpec?.required && hasReusableMedia && !image) continue;
|
|
1595
|
+
|
|
1596
|
+
const minimumHeight = reserveRhythmRows(Math.max(config.linePaintHeight, image ? image.y + image.height : 0), config.rhythm);
|
|
1597
|
+
const textHeights = mustExhaust
|
|
1598
|
+
? getExhaustiveTextHeightVariants(Math.max(minimumHeight, bodyBudget), minimumHeight, localConfig)
|
|
1599
|
+
: getTextHeightVariants(Math.max(minimumHeight, bodyBudget), minimumHeight, localConfig);
|
|
1600
|
+
for (const textHeight of textHeights) {
|
|
1601
|
+
for (const pullQuoteVariant of pullQuoteVariants) {
|
|
1602
|
+
const pullQuote = createPullQuoteFurniture(item, pullQuoteVariant, localConfig, textHeight, image, blockSpec.chrome);
|
|
1603
|
+
if (blockSpec.pullQuote?.required && !pullQuote) continue;
|
|
1604
|
+
|
|
1605
|
+
const furniture: SolvedFurniture[] = [];
|
|
1606
|
+
if (image) furniture.push(image);
|
|
1607
|
+
if (pullQuote) furniture.push(pullQuote);
|
|
1608
|
+
const textResult = layoutTextColumns({
|
|
1609
|
+
item,
|
|
1610
|
+
prepared,
|
|
1611
|
+
cursor: startCursor,
|
|
1612
|
+
columnCount,
|
|
1613
|
+
textHeight,
|
|
1614
|
+
localConfig,
|
|
1615
|
+
furniture,
|
|
1616
|
+
furnitureObstacleMarginBottom: config.rhythm.rowHeight,
|
|
1617
|
+
});
|
|
1618
|
+
if (mustExhaust && textResult.hasMore) continue;
|
|
1619
|
+
if (!mustExhaust && textResult.hasMore && textHeight < bodyBudget - 0.75) continue;
|
|
1620
|
+
const furnitureSufficiency = getFurnitureSufficiencyReport({
|
|
1621
|
+
columns: textResult.columns,
|
|
1622
|
+
textHeight,
|
|
1623
|
+
furniture,
|
|
1624
|
+
hasMore: textResult.hasMore,
|
|
1625
|
+
config: localConfig,
|
|
1626
|
+
});
|
|
1627
|
+
if (!furnitureSufficiency.accepted) continue;
|
|
1628
|
+
const range = createTextRange({
|
|
1629
|
+
flow,
|
|
1630
|
+
pageId: pageIdFor(pageSpec.pageNumber),
|
|
1631
|
+
blockId: blockSpec.id,
|
|
1632
|
+
startCursor,
|
|
1633
|
+
endCursor: textResult.cursor,
|
|
1634
|
+
exhausted: !textResult.hasMore,
|
|
1635
|
+
});
|
|
1636
|
+
const linesHeight = Math.max(...textResult.columns.map(getLinesHeight), 0);
|
|
1637
|
+
const furnitureBottom = getFurnitureBottom(furniture);
|
|
1638
|
+
const contentBodyHeight = reserveRhythmRows(Math.max(furnitureBottom, textResult.hasMore ? textHeight : linesHeight, config.linePaintHeight), config.rhythm);
|
|
1639
|
+
const reservedBodyHeight = blockSpec.size?.defaultRows && !blockSpec.size.shrinkToContent
|
|
1640
|
+
? Math.max(contentBodyHeight, snapDownToRhythm(offeredHeight - title.totalHeight, config.rhythm))
|
|
1641
|
+
: contentBodyHeight;
|
|
1642
|
+
const bodyHeight = reserveRhythmRows(reservedBodyHeight, config.rhythm);
|
|
1643
|
+
const blockHeight = title.totalHeight + bodyHeight;
|
|
1644
|
+
const whitespace = getColumnWhitespace(textResult.columns, bodyHeight, furniture);
|
|
1645
|
+
const deadColumns = getDeadColumnCount(textResult.columns, bodyHeight, furniture);
|
|
1646
|
+
const score =
|
|
1647
|
+
50_000 -
|
|
1648
|
+
whitespace * 1.1 -
|
|
1649
|
+
deadColumns * 8_000 -
|
|
1650
|
+
(image ? Math.abs(image.height - image.preferredHeight) * 0.35 : 50_000) -
|
|
1651
|
+
(pullQuote ? 0 : 2_800) -
|
|
1652
|
+
mediaVariant.fallbackPenalty -
|
|
1653
|
+
pullQuoteVariant.fallbackPenalty -
|
|
1654
|
+
Math.max(0, config.columnCount - columnCount) * 240;
|
|
1655
|
+
|
|
1656
|
+
candidates.push({
|
|
1657
|
+
block: {
|
|
1658
|
+
id: blockSpec.id,
|
|
1659
|
+
type: "articleFrame",
|
|
1660
|
+
presetId: blockSpec.presetId,
|
|
1661
|
+
headlineScale,
|
|
1662
|
+
item,
|
|
1663
|
+
article: item,
|
|
1664
|
+
pageNumber: pageSpec.pageNumber,
|
|
1665
|
+
label,
|
|
1666
|
+
title: item.headline,
|
|
1667
|
+
deck: item.deck,
|
|
1668
|
+
byline: item.byline,
|
|
1669
|
+
dateline: item.dateline,
|
|
1670
|
+
section: item.section,
|
|
1671
|
+
x: 0,
|
|
1672
|
+
y: 0,
|
|
1673
|
+
width: blockWidth,
|
|
1674
|
+
height: blockHeight,
|
|
1675
|
+
span: getBlockSpan(config, blockSpec.span),
|
|
1676
|
+
columnCount,
|
|
1677
|
+
columns: textResult.columns,
|
|
1678
|
+
furniture,
|
|
1679
|
+
textRange: range,
|
|
1680
|
+
hasMore: textResult.hasMore,
|
|
1681
|
+
titleChrome: title,
|
|
1682
|
+
titleHeight: title.heading.height,
|
|
1683
|
+
bodyHeight,
|
|
1684
|
+
furnitureSufficiency,
|
|
1685
|
+
},
|
|
1686
|
+
range,
|
|
1687
|
+
score,
|
|
1688
|
+
whitespace,
|
|
1689
|
+
furnitureSufficiency,
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
return candidates;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function solveStaticBlock(
|
|
1700
|
+
blockSpec: LayoutBlockSpec,
|
|
1701
|
+
pageSpec: LayoutPageSpec,
|
|
1702
|
+
allocatedHeight: number,
|
|
1703
|
+
itemsBySlug: Map<string, PublicationItem>,
|
|
1704
|
+
config: LayoutConfig,
|
|
1705
|
+
): SolvedBlock {
|
|
1706
|
+
const width = config.contentWidth;
|
|
1707
|
+
if (blockSpec.type === "itemStack") {
|
|
1708
|
+
const items = blockSpec.itemIds.map((itemId) => requireKnownItem(itemId, itemsBySlug));
|
|
1709
|
+
const gridColumnCount = Math.max(1, Math.min(config.columnCount, 4));
|
|
1710
|
+
const rowCount = Math.ceil(items.length / gridColumnCount);
|
|
1711
|
+
const stackHeight = reserveRhythmRows(
|
|
1712
|
+
Math.max(getMinimumTextFrameHeight(config), 220 + rowCount * 170),
|
|
1713
|
+
config.rhythm,
|
|
1714
|
+
);
|
|
1715
|
+
return {
|
|
1716
|
+
id: blockSpec.id,
|
|
1717
|
+
type: blockSpec.type,
|
|
1718
|
+
pageNumber: pageSpec.pageNumber,
|
|
1719
|
+
title: blockSpec.title,
|
|
1720
|
+
items,
|
|
1721
|
+
x: 0,
|
|
1722
|
+
y: 0,
|
|
1723
|
+
width,
|
|
1724
|
+
height: stackHeight,
|
|
1725
|
+
span: config.columnCount,
|
|
1726
|
+
columnCount: gridColumnCount,
|
|
1727
|
+
columns: [],
|
|
1728
|
+
furniture: [],
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
if (blockSpec.type === "adBlock") {
|
|
1732
|
+
const adHeight = clampRhythmHeight(allocatedHeight, 260, 720, config.rhythm);
|
|
1733
|
+
return {
|
|
1734
|
+
id: blockSpec.id,
|
|
1735
|
+
type: blockSpec.type,
|
|
1736
|
+
presetId: blockSpec.presetId,
|
|
1737
|
+
item: blockSpec.itemId ? requireKnownItem(blockSpec.itemId, itemsBySlug) : undefined,
|
|
1738
|
+
pageNumber: pageSpec.pageNumber,
|
|
1739
|
+
title: "Advertisement",
|
|
1740
|
+
x: 0,
|
|
1741
|
+
y: 0,
|
|
1742
|
+
width,
|
|
1743
|
+
height: adHeight,
|
|
1744
|
+
span: config.columnCount,
|
|
1745
|
+
columnCount: config.columnCount,
|
|
1746
|
+
columns: [],
|
|
1747
|
+
furniture: [
|
|
1748
|
+
{
|
|
1749
|
+
kind: "ad",
|
|
1750
|
+
id: `${blockSpec.id}-ad`,
|
|
1751
|
+
src: blockSpec.imageUrl,
|
|
1752
|
+
label: blockSpec.label?.trim() || "Advertisement",
|
|
1753
|
+
x: 0,
|
|
1754
|
+
y: 0,
|
|
1755
|
+
width,
|
|
1756
|
+
height: adHeight,
|
|
1757
|
+
wrapsText: false,
|
|
1758
|
+
},
|
|
1759
|
+
],
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
const staticHeight = blockSpec.type === "rule"
|
|
1763
|
+
? config.rhythm.rowHeight
|
|
1764
|
+
: clampRhythmHeight(120, getMinimumTextFrameHeight(config), allocatedHeight, config.rhythm);
|
|
1765
|
+
return {
|
|
1766
|
+
id: blockSpec.id,
|
|
1767
|
+
type: blockSpec.type,
|
|
1768
|
+
pageNumber: pageSpec.pageNumber,
|
|
1769
|
+
x: 0,
|
|
1770
|
+
y: 0,
|
|
1771
|
+
width,
|
|
1772
|
+
height: staticHeight,
|
|
1773
|
+
span: config.columnCount,
|
|
1774
|
+
columnCount: config.columnCount,
|
|
1775
|
+
columns: [],
|
|
1776
|
+
furniture: [],
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function layoutTextColumns({
|
|
1781
|
+
item,
|
|
1782
|
+
prepared,
|
|
1783
|
+
cursor,
|
|
1784
|
+
columnCount,
|
|
1785
|
+
textHeight,
|
|
1786
|
+
maximumTextHeightByColumn = [],
|
|
1787
|
+
localConfig,
|
|
1788
|
+
furniture,
|
|
1789
|
+
chromeBoxes = [],
|
|
1790
|
+
furnitureObstacleOffsetY = 0,
|
|
1791
|
+
furnitureObstacleMarginBottom = 0,
|
|
1792
|
+
minimumLineStartYByColumn = [],
|
|
1793
|
+
}: {
|
|
1794
|
+
item: ArticlePublicationItem;
|
|
1795
|
+
prepared: PreparedTextCache;
|
|
1796
|
+
cursor: LayoutCursor;
|
|
1797
|
+
columnCount: number;
|
|
1798
|
+
textHeight: number;
|
|
1799
|
+
maximumTextHeightByColumn?: number[];
|
|
1800
|
+
localConfig: LayoutConfig;
|
|
1801
|
+
furniture: SolvedFurniture[];
|
|
1802
|
+
chromeBoxes?: SolvedChromeBox[];
|
|
1803
|
+
furnitureObstacleOffsetY?: number;
|
|
1804
|
+
furnitureObstacleMarginBottom?: number;
|
|
1805
|
+
minimumLineStartYByColumn?: number[];
|
|
1806
|
+
}): { columns: TextLine[][]; cursor: LayoutCursor; hasMore: boolean } {
|
|
1807
|
+
const columns: TextLine[][] = [];
|
|
1808
|
+
const preparedText = getPrepared(prepared, item, localConfig.bodyFont);
|
|
1809
|
+
const columnWidth = getSpanWidth(localConfig, 1);
|
|
1810
|
+
let current = { ...cursor };
|
|
1811
|
+
let hasMore = true;
|
|
1812
|
+
|
|
1813
|
+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
|
|
1814
|
+
const result = layoutTextLines({
|
|
1815
|
+
prepared: preparedText,
|
|
1816
|
+
cursor: current,
|
|
1817
|
+
maxHeight: maximumTextHeightByColumn[columnIndex] ?? textHeight,
|
|
1818
|
+
maxWidth: columnWidth,
|
|
1819
|
+
lineHeight: localConfig.lineHeight,
|
|
1820
|
+
linePaintHeight: localConfig.linePaintHeight,
|
|
1821
|
+
fontSize: localConfig.bodyFontSize,
|
|
1822
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
1823
|
+
obstacles: getColumnTextObstacles(
|
|
1824
|
+
furniture,
|
|
1825
|
+
columnIndex,
|
|
1826
|
+
chromeBoxes,
|
|
1827
|
+
furnitureObstacleOffsetY,
|
|
1828
|
+
furnitureObstacleMarginBottom,
|
|
1829
|
+
minimumLineStartYByColumn[columnIndex] ?? 0,
|
|
1830
|
+
),
|
|
1831
|
+
});
|
|
1832
|
+
columns.push(result.lines);
|
|
1833
|
+
current = result.cursor;
|
|
1834
|
+
hasMore = result.hasMore;
|
|
1835
|
+
if (!hasMore) {
|
|
1836
|
+
while (columns.length < columnCount) columns.push([]);
|
|
1837
|
+
break;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return { columns, cursor: current, hasMore };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function getMediaVariants(media: LayoutMediaSpec | undefined, config: LayoutConfig): MediaVariant[] {
|
|
1845
|
+
if (!media) return [getNoMediaVariant(900)];
|
|
1846
|
+
const variants = resolvePlacementVariants(media.placement, config).map((variant) => ({
|
|
1847
|
+
...variant,
|
|
1848
|
+
assetRole: media.assetRole,
|
|
1849
|
+
fallbackPenalty: variant.fallbackPenalty + Math.abs(variant.columnSpan - media.placement.span.preferred) * 900,
|
|
1850
|
+
}));
|
|
1851
|
+
if (media.required) return variants;
|
|
1852
|
+
return [...variants, getNoMediaVariant(24_000)];
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function getNoMediaVariant(fallbackPenalty: number): MediaVariant {
|
|
1856
|
+
return { id: "none", placement: null, columnStart: 0, columnSpan: 0, fallbackPenalty };
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function getPullQuoteVariants(
|
|
1860
|
+
article: ArticlePublicationItem,
|
|
1861
|
+
pullQuote: LayoutPullQuoteSpec | undefined,
|
|
1862
|
+
config: LayoutConfig,
|
|
1863
|
+
): PullQuoteVariant[] {
|
|
1864
|
+
const none = { id: "none", placement: null, columnStart: 0, columnSpan: 0, fallbackPenalty: 0 };
|
|
1865
|
+
if (!pullQuote || !article.pullQuotes?.[0]) return [none];
|
|
1866
|
+
const variants = pullQuote.placements.flatMap((placement, placementIndex) =>
|
|
1867
|
+
resolvePlacementVariants(placement, config).map((variant) => ({
|
|
1868
|
+
...variant,
|
|
1869
|
+
fallbackPenalty: variant.fallbackPenalty + placementIndex * 100,
|
|
1870
|
+
})),
|
|
1871
|
+
);
|
|
1872
|
+
return pullQuote.required ? variants : [none, ...variants];
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function resolvePlacementVariants(placement: ResponsivePlacementSpec, config: LayoutConfig): PlacementVariant[] {
|
|
1876
|
+
const effectiveSpan = getEffectivePlacementSpanPolicy(placement, config.columnCount);
|
|
1877
|
+
const collapsed = config.columnCount < effectiveSpan.min;
|
|
1878
|
+
if (collapsed && placement.collapse === "omit") return [];
|
|
1879
|
+
const spans = collapsed
|
|
1880
|
+
? [placement.collapse === "fullWidth" ? config.columnCount : 1]
|
|
1881
|
+
: getSpanCandidates(effectiveSpan, config.columnCount);
|
|
1882
|
+
return spans.flatMap((span, index) => {
|
|
1883
|
+
const anchor = collapsed && placement.collapse === "inline" ? "inline" : placement.anchor ?? "left";
|
|
1884
|
+
const columnStart = getColumnStartForPlacement(placement, resolveAnchor(anchor), span, config.columnCount, collapsed);
|
|
1885
|
+
if (columnStart === null) return [];
|
|
1886
|
+
return [{
|
|
1887
|
+
id: `${resolveAnchor(anchor)}-span${span}-${placement.vertical}`,
|
|
1888
|
+
placement,
|
|
1889
|
+
columnStart,
|
|
1890
|
+
columnSpan: span,
|
|
1891
|
+
fallbackPenalty: index * 120 + (collapsed ? 320 : 0),
|
|
1892
|
+
}];
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function getEffectivePlacementSpanPolicy(
|
|
1897
|
+
placement: ResponsivePlacementSpec,
|
|
1898
|
+
columnCount: number,
|
|
1899
|
+
): ResponsiveSpanPolicy {
|
|
1900
|
+
const override = placement.spanOverrides?.[String(columnCount)];
|
|
1901
|
+
if (!override) return placement.span;
|
|
1902
|
+
const clamped = clamp(override, placement.span.min, placement.span.max);
|
|
1903
|
+
return { min: clamped, preferred: clamped, max: clamped };
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function resolvePreferredPlacementVariant(placement: ResponsivePlacementSpec, config: LayoutConfig): PlacementVariant | null {
|
|
1907
|
+
return resolvePlacementVariants(placement, config)[0] ?? null;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function createImageFurniture(
|
|
1911
|
+
article: ArticlePublicationItem,
|
|
1912
|
+
variant: MediaVariant,
|
|
1913
|
+
config: LayoutConfig,
|
|
1914
|
+
textHeight: number,
|
|
1915
|
+
usedImageAssetIds: ReadonlySet<string> = new Set(),
|
|
1916
|
+
): SolvedImageFurniture | null {
|
|
1917
|
+
if (!variant.placement || variant.columnSpan === 0) return null;
|
|
1918
|
+
const asset = getPreferredImage(article, variant.placement, variant.assetRole, usedImageAssetIds);
|
|
1919
|
+
if (!asset) return null;
|
|
1920
|
+
const { x, width } = getSpanRect(config, variant.columnStart, variant.columnSpan);
|
|
1921
|
+
const aspectRatio = getImageAspectRatio(asset);
|
|
1922
|
+
const preferredHeight = Math.round(width / aspectRatio);
|
|
1923
|
+
const canCropFrame = variant.placement.crop === "cropAllowed";
|
|
1924
|
+
const shouldCropToFill = canCropFrame || asset.layout?.crop === "cover";
|
|
1925
|
+
const minHeight = canCropFrame ? asset.layout?.minHeight ?? 140 : config.rhythm.rowHeight;
|
|
1926
|
+
const maxHeight = canCropFrame ? asset.layout?.maxHeight ?? 420 : Number.POSITIVE_INFINITY;
|
|
1927
|
+
const rawHeight = canCropFrame ? clamp(preferredHeight, minHeight, maxHeight) : preferredHeight;
|
|
1928
|
+
const imageHeight = canCropFrame
|
|
1929
|
+
? snapPreferredHeightToRhythm(rawHeight, config.rhythm, minHeight, maxHeight)
|
|
1930
|
+
: snapPreservedImageHeightToRhythm(rawHeight, config.rhythm, minHeight, maxHeight);
|
|
1931
|
+
const caption = getImageCaption(asset);
|
|
1932
|
+
const captionHeight = getImageCaptionHeight(caption, width, config.rhythm);
|
|
1933
|
+
const height = imageHeight + captionHeight;
|
|
1934
|
+
const y = getFurnitureY(variant.placement.vertical, height, textHeight, config);
|
|
1935
|
+
const focalPoint = asset.layout?.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
1936
|
+
return {
|
|
1937
|
+
kind: "image",
|
|
1938
|
+
id: `${article.slug}-${variant.id}-image`,
|
|
1939
|
+
assetId: asset.id,
|
|
1940
|
+
src: asset.src,
|
|
1941
|
+
themeVariants: asset.themeVariants,
|
|
1942
|
+
alt: asset.alt,
|
|
1943
|
+
caption,
|
|
1944
|
+
credit: asset.credit,
|
|
1945
|
+
templateId: `image-${variant.id}`,
|
|
1946
|
+
columnStart: variant.columnStart,
|
|
1947
|
+
columnSpan: variant.columnSpan,
|
|
1948
|
+
x,
|
|
1949
|
+
y,
|
|
1950
|
+
width,
|
|
1951
|
+
height,
|
|
1952
|
+
imageHeight,
|
|
1953
|
+
captionHeight,
|
|
1954
|
+
captionFontSize: IMAGE_CAPTION_FONT_SIZE,
|
|
1955
|
+
captionLineHeight: config.rhythm.rowHeight,
|
|
1956
|
+
aspectRatio,
|
|
1957
|
+
objectFit: shouldCropToFill ? "cover" : "contain",
|
|
1958
|
+
objectPosition: `${Math.round(focalPoint.x * 100)}% ${Math.round(focalPoint.y * 100)}%`,
|
|
1959
|
+
wrapsText: variant.placement.wrapsText,
|
|
1960
|
+
preferredHeight: preferredHeight + captionHeight,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
function createPullQuoteFurniture(
|
|
1965
|
+
article: ArticlePublicationItem,
|
|
1966
|
+
variant: PullQuoteVariant,
|
|
1967
|
+
config: LayoutConfig,
|
|
1968
|
+
textHeight: number,
|
|
1969
|
+
image: SolvedImageFurniture | null,
|
|
1970
|
+
chrome?: ArticleFrameChromeSpec,
|
|
1971
|
+
): SolvedPullQuoteFurniture | null {
|
|
1972
|
+
if (!variant.placement || variant.columnSpan === 0) return null;
|
|
1973
|
+
const text = article.pullQuotes?.[0];
|
|
1974
|
+
if (!text) return null;
|
|
1975
|
+
const { x, width } = getSpanRect(config, variant.columnStart, variant.columnSpan);
|
|
1976
|
+
const metrics = getPullQuoteMetrics(text, width, config, chrome?.pullQuote);
|
|
1977
|
+
const y = getPullQuoteY({
|
|
1978
|
+
x,
|
|
1979
|
+
width,
|
|
1980
|
+
height: metrics.height,
|
|
1981
|
+
textHeight,
|
|
1982
|
+
anchorRatio: getVerticalAnchorRatio(variant.placement.vertical),
|
|
1983
|
+
image,
|
|
1984
|
+
config,
|
|
1985
|
+
});
|
|
1986
|
+
if (y === null) return null;
|
|
1987
|
+
return {
|
|
1988
|
+
kind: "pullQuote",
|
|
1989
|
+
id: `${article.slug}-${variant.id}-pullquote`,
|
|
1990
|
+
text,
|
|
1991
|
+
templateId: `pullquote-${variant.id}`,
|
|
1992
|
+
columnStart: variant.columnStart,
|
|
1993
|
+
columnSpan: variant.columnSpan,
|
|
1994
|
+
x,
|
|
1995
|
+
y,
|
|
1996
|
+
width,
|
|
1997
|
+
height: metrics.height,
|
|
1998
|
+
fontSize: metrics.fontSize,
|
|
1999
|
+
lineHeight: metrics.lineHeight,
|
|
2000
|
+
wrapsText: true,
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function getPreferredImage(
|
|
2005
|
+
article: ArticlePublicationItem,
|
|
2006
|
+
_placement: ResponsivePlacementSpec,
|
|
2007
|
+
assetRole: string | undefined,
|
|
2008
|
+
usedImageAssetIds: ReadonlySet<string> = new Set(),
|
|
2009
|
+
): ArticleImageAsset | null {
|
|
2010
|
+
const assets = getPublicationItemImageAssets(article).filter((asset) => !usedImageAssetIds.has(asset.id));
|
|
2011
|
+
if (assetRole) {
|
|
2012
|
+
const roleAsset = assets.find((asset) => asset.roles?.some((role) => role === assetRole));
|
|
2013
|
+
if (roleAsset) return roleAsset;
|
|
2014
|
+
}
|
|
2015
|
+
return (
|
|
2016
|
+
assets.find((asset) => asset.roles?.some((role) => role === "continuationInset")) ??
|
|
2017
|
+
assets.find((asset) => asset.roles?.some((role) => role === "continuation")) ??
|
|
2018
|
+
assets[0] ??
|
|
2019
|
+
null
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function hasAvailablePreferredImage(
|
|
2024
|
+
article: ArticlePublicationItem,
|
|
2025
|
+
placement: ResponsivePlacementSpec,
|
|
2026
|
+
assetRole: string | undefined,
|
|
2027
|
+
usedImageAssetIds: ReadonlySet<string>,
|
|
2028
|
+
): boolean {
|
|
2029
|
+
return getPreferredImage(article, placement, assetRole, usedImageAssetIds) !== null;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function chooseBestCandidate(candidates: ArticleFrameCandidate[], blockId: string): ArticleFrameCandidate {
|
|
2033
|
+
const best = candidates.reduce<ArticleFrameCandidate | null>(
|
|
2034
|
+
(winner, candidate) => (!winner || candidate.score > winner.score ? candidate : winner),
|
|
2035
|
+
null,
|
|
2036
|
+
);
|
|
2037
|
+
if (!best) throw new Error(`No valid articleFrame candidate solved for ${blockId}`);
|
|
2038
|
+
return best;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function getLocalColumnCountCandidates(
|
|
2042
|
+
policy: ResponsiveSpanPolicy | undefined,
|
|
2043
|
+
config: LayoutConfig,
|
|
2044
|
+
blockWidth: number,
|
|
2045
|
+
): number[] {
|
|
2046
|
+
const maxByWidth = getResponsiveColumnCount(blockWidth + config.pageChrome.pagePaddingX * 2, blockWidth, config.gap);
|
|
2047
|
+
const min = Math.min(policy?.min ?? 1, maxByWidth, config.columnCount);
|
|
2048
|
+
const max = Math.min(policy?.max ?? config.columnCount, maxByWidth, config.columnCount);
|
|
2049
|
+
const preferred = clamp(policy?.preferred ?? max, min, max);
|
|
2050
|
+
const candidates = [preferred, min, max];
|
|
2051
|
+
return Array.from(new Set(candidates)).sort((left, right) => (
|
|
2052
|
+
Math.abs(left - preferred) - Math.abs(right - preferred) || right - left
|
|
2053
|
+
));
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function getTextHeightVariants(maxHeight: number, minHeight: number, config: LayoutConfig): number[] {
|
|
2057
|
+
const snappedMinimum = reserveRhythmRows(minHeight, config.rhythm);
|
|
2058
|
+
const snappedMaximum = Math.max(snappedMinimum, snapDownToRhythm(maxHeight, config.rhythm));
|
|
2059
|
+
if (config.columnCount === 1) return [snappedMaximum];
|
|
2060
|
+
const candidates = [snappedMaximum, snappedMinimum].map((height) => clamp(snapDownToRhythm(height, config.rhythm), snappedMinimum, snappedMaximum));
|
|
2061
|
+
return Array.from(new Set(candidates)).sort((leftHeight, rightHeight) => rightHeight - leftHeight);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function getExhaustiveTextHeightVariants(startHeight: number, minHeight: number, config: LayoutConfig): number[] {
|
|
2065
|
+
const snappedMinimum = reserveRhythmRows(minHeight, config.rhythm);
|
|
2066
|
+
const snappedBudget = Math.max(snappedMinimum, reserveRhythmRows(startHeight, config.rhythm));
|
|
2067
|
+
const step = config.rhythm.rowHeight * 4;
|
|
2068
|
+
const maximum = snappedBudget + config.rhythm.rowHeight * 240;
|
|
2069
|
+
const heights: number[] = [];
|
|
2070
|
+
for (let height = snappedMinimum; height <= maximum; height += step) heights.push(height);
|
|
2071
|
+
return heights;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
function getPageKind(pageSpec: LayoutPageSpec): string {
|
|
2075
|
+
if (pageSpec.presetId === "page.regionStack" && pageSpec.regions.length > 1) return "regionStack";
|
|
2076
|
+
if (pageSpec.presetId === "page.railMain") return "railMain";
|
|
2077
|
+
return "articlePage";
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function collectPageTextRanges(page: SolvedPage): PlacedTextRange[] {
|
|
2081
|
+
return page.regions.flatMap((region) => region.blocks.flatMap((block) => (block.textRange ? [block.textRange] : [])));
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
function createArticleFlows(items: PublicationItem[]): Map<string, ArticleFlow> {
|
|
2085
|
+
return new Map(
|
|
2086
|
+
getArticlePublicationItems(items).map((article) => [
|
|
2087
|
+
article.slug,
|
|
2088
|
+
{
|
|
2089
|
+
article,
|
|
2090
|
+
currentCursor: { ...EMPTY_CURSOR },
|
|
2091
|
+
placedRanges: [],
|
|
2092
|
+
usedImageAssetIds: new Set<string>(),
|
|
2093
|
+
},
|
|
2094
|
+
]),
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function getOrCreateFlow(flows: Map<string, ArticleFlow>, flowKey: string, article: ArticlePublicationItem): ArticleFlow {
|
|
2099
|
+
const existing = flows.get(flowKey);
|
|
2100
|
+
if (existing) return existing;
|
|
2101
|
+
const flow = {
|
|
2102
|
+
article,
|
|
2103
|
+
currentCursor: { ...EMPTY_CURSOR },
|
|
2104
|
+
placedRanges: [],
|
|
2105
|
+
usedImageAssetIds: new Set<string>(),
|
|
2106
|
+
};
|
|
2107
|
+
flows.set(flowKey, flow);
|
|
2108
|
+
return flow;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function resetArticleFlow(flow: ArticleFlow): void {
|
|
2112
|
+
flow.currentCursor = { ...EMPTY_CURSOR };
|
|
2113
|
+
flow.placedRanges = [];
|
|
2114
|
+
flow.usedImageAssetIds.clear();
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function requireKnownItem(itemId: string, itemsBySlug: Map<string, PublicationItem>): PublicationItem {
|
|
2118
|
+
const item = itemsBySlug.get(itemId);
|
|
2119
|
+
if (!item) throw new Error(`Layout references missing item ${itemId}`);
|
|
2120
|
+
return item;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function requireArticleItem(itemId: string, itemsBySlug: Map<string, PublicationItem>): ArticlePublicationItem {
|
|
2124
|
+
const item = requireKnownItem(itemId, itemsBySlug);
|
|
2125
|
+
if (item.type !== "article") throw new Error(`Layout articleFrame requires article item ${itemId}`);
|
|
2126
|
+
return item;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function createTextRange({
|
|
2130
|
+
flow,
|
|
2131
|
+
pageId,
|
|
2132
|
+
blockId,
|
|
2133
|
+
startCursor,
|
|
2134
|
+
endCursor,
|
|
2135
|
+
exhausted,
|
|
2136
|
+
}: {
|
|
2137
|
+
flow: ArticleFlow;
|
|
2138
|
+
pageId: string;
|
|
2139
|
+
blockId: string;
|
|
2140
|
+
startCursor: LayoutCursor;
|
|
2141
|
+
endCursor: LayoutCursor;
|
|
2142
|
+
exhausted: boolean;
|
|
2143
|
+
}): PlacedTextRange {
|
|
2144
|
+
return {
|
|
2145
|
+
articleId: flow.article.slug,
|
|
2146
|
+
pageId,
|
|
2147
|
+
blockId,
|
|
2148
|
+
startCursor,
|
|
2149
|
+
endCursor,
|
|
2150
|
+
exhausted,
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function commitTextRange(flow: ArticleFlow, range: PlacedTextRange): void {
|
|
2155
|
+
flow.currentCursor = { ...range.endCursor };
|
|
2156
|
+
flow.placedRanges.push(range);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function commitUsedImageFurniture(flow: ArticleFlow, furniture: SolvedFurniture[]): void {
|
|
2160
|
+
for (const item of furniture) {
|
|
2161
|
+
if (item.kind === "image") {
|
|
2162
|
+
flow.usedImageAssetIds.add(item.assetId);
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
if (item.kind === "mediaCluster") {
|
|
2166
|
+
for (const image of item.images) flow.usedImageAssetIds.add(image.assetId);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function pageIdFor(pageNumber: number): string {
|
|
2172
|
+
return `page-${pageNumber}`;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function getLayoutConfig(pageWidth: number, viewportHeight: number): LayoutConfig {
|
|
2176
|
+
const narrow = pageWidth < 560;
|
|
2177
|
+
const medium = pageWidth >= 560 && pageWidth < 1040;
|
|
2178
|
+
const bodyFontSize = narrow ? 14 : 15;
|
|
2179
|
+
const bodyLineHeight = Math.ceil(bodyFontSize * 1.4);
|
|
2180
|
+
const rhythm = createVerticalRhythm(bodyLineHeight);
|
|
2181
|
+
const gap = narrow ? 14 : 18;
|
|
2182
|
+
const rowGap = rhythm.rowHeight;
|
|
2183
|
+
const sideMargin = narrow ? 18 : 30;
|
|
2184
|
+
const contentWidth = Math.max(280, pageWidth - sideMargin * 2);
|
|
2185
|
+
const columnCount = getResponsiveColumnCount(pageWidth, contentWidth, gap);
|
|
2186
|
+
const pageChrome = getPageChromeMetrics(pageWidth, narrow, rhythm, columnCount);
|
|
2187
|
+
const targetPageHeight = reserveRhythmRows(getTargetPageHeight(pageWidth, viewportHeight), rhythm);
|
|
2188
|
+
const frontGridHeight = snapDownToRhythm(getFrontGridHeight(targetPageHeight, narrow, medium), rhythm);
|
|
2189
|
+
const frontRowMaxHeight = snapDownToRhythm(narrow ? 520 : medium ? 560 : 620, rhythm);
|
|
2190
|
+
const continuationChrome =
|
|
2191
|
+
pageChrome.pagePaddingTop +
|
|
2192
|
+
pageChrome.insideHeaderHeight +
|
|
2193
|
+
pageChrome.continuedTitleHeight +
|
|
2194
|
+
pageChrome.pagePaddingBottom;
|
|
2195
|
+
const continuationHeight = reserveRhythmRows(Math.max(760, targetPageHeight - continuationChrome), rhythm);
|
|
2196
|
+
|
|
2197
|
+
return {
|
|
2198
|
+
columnCount,
|
|
2199
|
+
contentWidth,
|
|
2200
|
+
gap,
|
|
2201
|
+
rowGap,
|
|
2202
|
+
rhythm,
|
|
2203
|
+
pageChrome,
|
|
2204
|
+
lineHeight: rhythm.rowHeight,
|
|
2205
|
+
linePaintHeight: rhythm.paintHeight,
|
|
2206
|
+
bodyFontSize,
|
|
2207
|
+
bodyFont: `${bodyFontSize}px ${SERIF_TEXT_FONT}`,
|
|
2208
|
+
frontRows: getFrontRows(columnCount, rowGap, frontGridHeight, frontRowMaxHeight, rhythm),
|
|
2209
|
+
continuationHeight,
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
function getResponsiveColumnCount(pageWidth: number, contentWidth: number, gap: number): number {
|
|
2214
|
+
if (pageWidth < 560) return 1;
|
|
2215
|
+
const minimumColumnWidth = 168;
|
|
2216
|
+
for (const columnCount of [6, 5, 4, 3, 2] as const) {
|
|
2217
|
+
const columnWidth = (contentWidth - gap * (columnCount - 1)) / columnCount;
|
|
2218
|
+
if (columnWidth >= minimumColumnWidth) return columnCount;
|
|
2219
|
+
}
|
|
2220
|
+
return 1;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function getTargetPageHeight(pageWidth: number, viewportHeight: number): number {
|
|
2224
|
+
if (pageWidth < 720) return Math.max(3000, viewportHeight * 3.25);
|
|
2225
|
+
if (pageWidth < 1040) return Math.max(1900, viewportHeight * 2.1);
|
|
2226
|
+
return Math.max(1320, viewportHeight * 1.72);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function getMastheadRowPolicy(columnCount: number): { totalRows: number; titleRows: number } {
|
|
2230
|
+
if (columnCount <= 1) return { totalRows: 4, titleRows: 2 };
|
|
2231
|
+
if (columnCount <= 3) return { totalRows: 5, titleRows: 3 };
|
|
2232
|
+
return { totalRows: 6, titleRows: 4 };
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function getPageChromeMetrics(pageWidth: number, narrow: boolean, rhythm: VerticalRhythm, columnCount: number): PageChromeMetrics {
|
|
2236
|
+
const pagePaddingTop = rhythm.rowHeight;
|
|
2237
|
+
const pagePaddingX = narrow ? 18 : 30;
|
|
2238
|
+
const pagePaddingBottom = reserveRhythmRows(narrow ? 18 : 30, rhythm);
|
|
2239
|
+
const mastheadRows = getMastheadRowPolicy(columnCount);
|
|
2240
|
+
const mastheadKickerLineHeight = 14;
|
|
2241
|
+
const mastheadTitleLineHeight = rhythm.rowHeight * mastheadRows.titleRows;
|
|
2242
|
+
const mastheadTitleFontSize = mastheadTitleLineHeight / MASTHEAD_TITLE_GLYPH_HEIGHT_RATIO;
|
|
2243
|
+
const mastheadTitleMarginTop = rhythm.rowHeight / 2;
|
|
2244
|
+
const mastheadTitleMarginBottom = rhythm.rowHeight / 2;
|
|
2245
|
+
const mastheadTitleOpticalShift = -3;
|
|
2246
|
+
const mastheadMetaLineHeight = rhythm.rowHeight - MASTHEAD_META_BORDER_TOP - MASTHEAD_META_BORDER_BOTTOM;
|
|
2247
|
+
const mastheadMetaGap = narrow ? 5 : 0;
|
|
2248
|
+
const mastheadMetaContentHeight = mastheadMetaLineHeight;
|
|
2249
|
+
const rawMastheadHeight =
|
|
2250
|
+
MASTHEAD_RULE_HEIGHT +
|
|
2251
|
+
MASTHEAD_RULE_MARGIN_BOTTOM +
|
|
2252
|
+
MASTHEAD_TITLE_MARGIN_TOP +
|
|
2253
|
+
mastheadTitleMarginTop +
|
|
2254
|
+
mastheadTitleLineHeight +
|
|
2255
|
+
mastheadTitleMarginBottom +
|
|
2256
|
+
MASTHEAD_META_BORDER_TOP +
|
|
2257
|
+
MASTHEAD_META_PADDING_TOP +
|
|
2258
|
+
mastheadMetaContentHeight +
|
|
2259
|
+
MASTHEAD_META_PADDING_BOTTOM +
|
|
2260
|
+
MASTHEAD_META_BORDER_BOTTOM +
|
|
2261
|
+
MASTHEAD_PADDING_BOTTOM +
|
|
2262
|
+
MASTHEAD_BORDER_BOTTOM +
|
|
2263
|
+
MASTHEAD_MARGIN_BOTTOM;
|
|
2264
|
+
const mastheadHeight = rhythm.rowHeight * mastheadRows.totalRows;
|
|
2265
|
+
const mastheadMetaPaddingTop = MASTHEAD_META_PADDING_TOP + Math.max(0, mastheadHeight - rawMastheadHeight);
|
|
2266
|
+
const frontGridMarginTop = rhythm.rowHeight;
|
|
2267
|
+
const insideHeaderHeight = INSIDE_HEADER_PADDING_BOTTOM + INSIDE_HEADER_BORDER_BOTTOM + INSIDE_HEADER_MARGIN_BOTTOM + 18;
|
|
2268
|
+
const continuedTitleFontSize = narrow ? clamp(pageWidth * 0.075, 28, 42) : clamp(pageWidth * 0.035, 32, 56);
|
|
2269
|
+
const continuedTitleLineHeight = continuedTitleFontSize * 0.94;
|
|
2270
|
+
const continuedTitleHeight = Math.ceil(
|
|
2271
|
+
CONTINUED_TITLE_KICKER_LINE_HEIGHT +
|
|
2272
|
+
CONTINUED_TITLE_HEADING_MARGIN_TOP +
|
|
2273
|
+
continuedTitleLineHeight * 2 +
|
|
2274
|
+
CONTINUED_TITLE_PADDING_BOTTOM +
|
|
2275
|
+
CONTINUED_TITLE_BORDER_BOTTOM +
|
|
2276
|
+
CONTINUED_TITLE_MARGIN_BOTTOM,
|
|
2277
|
+
);
|
|
2278
|
+
|
|
2279
|
+
return {
|
|
2280
|
+
pagePaddingTop,
|
|
2281
|
+
pagePaddingX,
|
|
2282
|
+
pagePaddingBottom,
|
|
2283
|
+
mastheadHeight,
|
|
2284
|
+
mastheadKickerLineHeight,
|
|
2285
|
+
mastheadTitleFontSize,
|
|
2286
|
+
mastheadTitleLineHeight,
|
|
2287
|
+
mastheadTitleMarginTop,
|
|
2288
|
+
mastheadTitleMarginBottom,
|
|
2289
|
+
mastheadTitleOpticalShift,
|
|
2290
|
+
mastheadMetaLineHeight,
|
|
2291
|
+
mastheadMetaGap,
|
|
2292
|
+
mastheadMetaPaddingTop,
|
|
2293
|
+
frontGridMarginTop,
|
|
2294
|
+
insideHeaderHeight: reserveRhythmRows(insideHeaderHeight, rhythm),
|
|
2295
|
+
continuedTitleHeight: reserveRhythmRows(continuedTitleHeight, rhythm),
|
|
2296
|
+
continuedTitleChromeHeight: reserveRhythmRows(
|
|
2297
|
+
CONTINUED_TITLE_KICKER_LINE_HEIGHT +
|
|
2298
|
+
CONTINUED_TITLE_HEADING_MARGIN_TOP +
|
|
2299
|
+
CONTINUED_TITLE_PADDING_BOTTOM +
|
|
2300
|
+
CONTINUED_TITLE_BORDER_BOTTOM +
|
|
2301
|
+
CONTINUED_TITLE_MARGIN_BOTTOM,
|
|
2302
|
+
rhythm,
|
|
2303
|
+
),
|
|
2304
|
+
continuedTitleFontSize,
|
|
2305
|
+
continuedTitleLineHeight,
|
|
2306
|
+
continuationSectionSeparatorHeight: rhythm.rowHeight * 2,
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function getFrontGridHeight(targetPageHeight: number, narrow: boolean, medium: boolean): number {
|
|
2311
|
+
const mastheadAllowance = narrow ? 190 : medium ? 230 : 270;
|
|
2312
|
+
const bottomPadding = narrow ? 18 : 30;
|
|
2313
|
+
return Math.max(420, targetPageHeight - mastheadAllowance - bottomPadding);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function getFrontRows(columnCount: number, rowGap: number, gridHeight: number, rowMaxHeight: number, rhythm: VerticalRhythm): FrontRow[] {
|
|
2317
|
+
if (columnCount === 1) {
|
|
2318
|
+
const rowHeight = snapDownToRhythm((gridHeight - rowGap * 5) / 6, rhythm);
|
|
2319
|
+
return Array.from({ length: 6 }, (_, index) => ({
|
|
2320
|
+
startIndex: index,
|
|
2321
|
+
endIndex: index,
|
|
2322
|
+
height: clampRhythmHeight(rowHeight, 360, rowMaxHeight, rhythm),
|
|
2323
|
+
}));
|
|
2324
|
+
}
|
|
2325
|
+
const first = clampRhythmHeight(snapDownToRhythm(gridHeight * 0.52, rhythm), 360, rowMaxHeight, rhythm);
|
|
2326
|
+
const second = clampRhythmHeight(gridHeight - first - rowGap, 300, rowMaxHeight, rhythm);
|
|
2327
|
+
return [
|
|
2328
|
+
{ startIndex: 0, endIndex: 2, height: first },
|
|
2329
|
+
{ startIndex: 3, endIndex: 5, height: second },
|
|
2330
|
+
];
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
function getFrontPageHeight(config: LayoutConfig): number {
|
|
2334
|
+
return getFrontPageHeightForRegion(
|
|
2335
|
+
config,
|
|
2336
|
+
config.pageChrome.pagePaddingTop + config.pageChrome.mastheadHeight + config.pageChrome.frontGridMarginTop,
|
|
2337
|
+
getFrontPageGridHeight(config),
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function getFrontPageHeightForRegion(config: LayoutConfig, regionY: number, regionHeight: number, frontFooter?: SolvedFrontFooter): number {
|
|
2342
|
+
return reserveRhythmRows(
|
|
2343
|
+
regionY +
|
|
2344
|
+
regionHeight +
|
|
2345
|
+
(frontFooter ? frontFooter.marginTop + frontFooter.height : 0) +
|
|
2346
|
+
config.pageChrome.pagePaddingBottom,
|
|
2347
|
+
config.rhythm,
|
|
2348
|
+
);
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function getFrontPageGridHeight(config: LayoutConfig): number {
|
|
2352
|
+
if (config.frontRows.length === 0) return 0;
|
|
2353
|
+
return getFrontPageGridHeightFromRowHeights(config.frontRows.map((row) => row.height), config.rowGap);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
function getFrontPageGridHeightFromRowHeights(rowHeights: number[], rowGap: number): number {
|
|
2357
|
+
return rowHeights.reduce((total, height) => total + height, 0) + rowGap * Math.max(0, rowHeights.length - 1);
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
function getFrontRowHeight(config: LayoutConfig, articleIndex: number): number {
|
|
2361
|
+
return config.frontRows.find((row) => articleIndex >= row.startIndex && articleIndex <= row.endIndex)?.height ?? reserveRhythmRows(420, config.rhythm);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function getFrontArticleRowHeight(placement: FrontArticlePlacement, config: LayoutConfig): number {
|
|
2365
|
+
const baseHeight = placement.rowHeight ?? getFrontRowHeight(config, placement.solveIndex);
|
|
2366
|
+
const requestedRows = placement.block.size?.defaultRows;
|
|
2367
|
+
if (!requestedRows) return baseHeight;
|
|
2368
|
+
return Math.max(baseHeight, reserveRhythmRows(requestedRows * config.rhythm.rowHeight, config.rhythm));
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function shouldGrowFrontArticleToContent(blockSpec: ArticleFrameBlockSpec): boolean {
|
|
2372
|
+
return Boolean(
|
|
2373
|
+
blockSpec.size?.shrinkToContent &&
|
|
2374
|
+
!blockSpec.cutPolicy?.bodyDepthRows &&
|
|
2375
|
+
!blockSpec.cutPolicy?.maxBodyLines &&
|
|
2376
|
+
!blockSpec.cutPolicy?.jumpTargetPage,
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
function getFrontTeaserTextLimit(
|
|
2381
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
2382
|
+
config: LayoutConfig,
|
|
2383
|
+
): { mode: "bodyDepth" | "lineCount"; height: number } | null {
|
|
2384
|
+
if (blockSpec.cutPolicy?.bodyDepthRows) {
|
|
2385
|
+
return {
|
|
2386
|
+
mode: "bodyDepth",
|
|
2387
|
+
height: getLineLimitHeight(blockSpec.cutPolicy.bodyDepthRows, config.lineHeight, config.linePaintHeight, config.rhythm),
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
if (blockSpec.cutPolicy?.maxBodyLines) {
|
|
2391
|
+
return {
|
|
2392
|
+
mode: "lineCount",
|
|
2393
|
+
height: getLineLimitHeight(blockSpec.cutPolicy.maxBodyLines, config.lineHeight, config.linePaintHeight, config.rhythm),
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
return null;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function getFrontTeaserMeasureHeight(
|
|
2400
|
+
textLimit: { mode: "bodyDepth" | "lineCount"; height: number } | null,
|
|
2401
|
+
bodySlotHeight: number,
|
|
2402
|
+
): number {
|
|
2403
|
+
if (!textLimit || textLimit.mode === "bodyDepth") return bodySlotHeight;
|
|
2404
|
+
return Math.min(bodySlotHeight, textLimit.height);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function getComposedFrontTextMeasureHeight(
|
|
2408
|
+
textLimit: { mode: "bodyDepth" | "lineCount"; height: number } | null,
|
|
2409
|
+
compositionMode: FrontCompositionFlowMode,
|
|
2410
|
+
copyBandTop: number,
|
|
2411
|
+
bodySlotHeight: number,
|
|
2412
|
+
): number {
|
|
2413
|
+
if (!textLimit || textLimit.mode === "bodyDepth") return bodySlotHeight;
|
|
2414
|
+
const lineLimitHeight = compositionMode === "stackedMedia" || compositionMode === "titleStackedMedia"
|
|
2415
|
+
? copyBandTop + textLimit.height
|
|
2416
|
+
: textLimit.height;
|
|
2417
|
+
return Math.min(bodySlotHeight, lineLimitHeight);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
function getBlockSpan(config: LayoutConfig, span: ResponsiveSpanPolicy | undefined): number {
|
|
2421
|
+
if (config.columnCount === 1) return 1;
|
|
2422
|
+
return clamp(span?.preferred ?? config.columnCount, span?.min ?? 1, Math.min(span?.max ?? config.columnCount, config.columnCount));
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function getBlockWidth(config: LayoutConfig, span: ResponsiveSpanPolicy | undefined): number {
|
|
2426
|
+
return getSpanWidth(config, getBlockSpan(config, span));
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function getSpanWidth(config: LayoutConfig, span: number): number {
|
|
2430
|
+
const safeSpan = clamp(span, 1, config.columnCount);
|
|
2431
|
+
const singleColumn = (config.contentWidth - config.gap * (config.columnCount - 1)) / config.columnCount;
|
|
2432
|
+
return singleColumn * safeSpan + config.gap * (safeSpan - 1);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
function getSpanRect(config: LayoutConfig, columnStart: number, columnSpan: number): { x: number; width: number } {
|
|
2436
|
+
const safeStart = clamp(columnStart, 0, Math.max(0, config.columnCount - 1));
|
|
2437
|
+
const safeSpan = clamp(columnSpan, 1, config.columnCount - safeStart);
|
|
2438
|
+
const columnWidth = getSpanWidth(config, 1);
|
|
2439
|
+
const rawX = safeStart * (columnWidth + config.gap);
|
|
2440
|
+
const rawRight = rawX + getSpanWidth(config, safeSpan);
|
|
2441
|
+
const x = Math.round(rawX);
|
|
2442
|
+
const right = Math.min(Math.round(rawRight), Math.floor(config.contentWidth));
|
|
2443
|
+
return { x, width: Math.max(1, right - x) };
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function solveCompositionChromeBox({
|
|
2447
|
+
slot,
|
|
2448
|
+
item,
|
|
2449
|
+
storyRole,
|
|
2450
|
+
headlineScale,
|
|
2451
|
+
localConfig,
|
|
2452
|
+
y,
|
|
2453
|
+
globalYOffset,
|
|
2454
|
+
chromeOverrides,
|
|
2455
|
+
variant = resolvePreferredPlacementVariant(slot.placement, localConfig),
|
|
2456
|
+
}: {
|
|
2457
|
+
slot: ArticleFrameCompositionSlotSpec;
|
|
2458
|
+
item: ArticlePublicationItem;
|
|
2459
|
+
storyRole: FrontStoryRole;
|
|
2460
|
+
headlineScale: HeadlineScaleId;
|
|
2461
|
+
localConfig: LayoutConfig;
|
|
2462
|
+
y: number;
|
|
2463
|
+
globalYOffset: number;
|
|
2464
|
+
chromeOverrides?: ArticleFrameChromeSpec;
|
|
2465
|
+
variant?: PlacementVariant | null;
|
|
2466
|
+
}): { localBox: SolvedChromeBox; globalBox: SolvedChromeBox; localReserveBottom: number } | null {
|
|
2467
|
+
if (!variant || !isTextCompositionSlot(slot.slot)) return null;
|
|
2468
|
+
const text = getCompositionSlotText(slot.slot, item);
|
|
2469
|
+
if (!text) return null;
|
|
2470
|
+
const { x, width } = getSpanRect(localConfig, variant.columnStart, variant.columnSpan);
|
|
2471
|
+
const style = getCompositionSlotStyle(slot.slot, localConfig, storyRole, headlineScale, width);
|
|
2472
|
+
const metrics = solveChromeTextBox(text, width, style, localConfig.rhythm, chromeOverrides?.[slot.slot]);
|
|
2473
|
+
const treatedMetrics = applyFrontChromeTreatment(slot.slot, metrics, localConfig, storyRole);
|
|
2474
|
+
const boxY = snapUpToRhythm(y + treatedMetrics.marginBefore, localConfig.rhythm);
|
|
2475
|
+
const localBox: SolvedChromeBox = {
|
|
2476
|
+
id: `${item.slug}-${slot.slot}-${variant.id}`,
|
|
2477
|
+
slot: slot.slot,
|
|
2478
|
+
text,
|
|
2479
|
+
columnStart: variant.columnStart,
|
|
2480
|
+
columnSpan: variant.columnSpan,
|
|
2481
|
+
x,
|
|
2482
|
+
y: boxY,
|
|
2483
|
+
width,
|
|
2484
|
+
height: treatedMetrics.height,
|
|
2485
|
+
fontSize: treatedMetrics.fontSize,
|
|
2486
|
+
lineHeight: treatedMetrics.lineHeight,
|
|
2487
|
+
paintBuffer: treatedMetrics.paintBuffer,
|
|
2488
|
+
paddingTop: treatedMetrics.paddingTop,
|
|
2489
|
+
paddingBottom: treatedMetrics.paddingBottom,
|
|
2490
|
+
ruleTopHeight: treatedMetrics.ruleTopHeight,
|
|
2491
|
+
ruleBottomHeight: treatedMetrics.ruleBottomHeight,
|
|
2492
|
+
fontFamily: style.fontFamily === SANS_TEXT_FONT ? "sans" : "serif",
|
|
2493
|
+
fontWeight: style.fontWeight,
|
|
2494
|
+
fontStyle: style.fontStyle,
|
|
2495
|
+
textTransform: slot.slot === "label" || slot.slot === "byline" ? "uppercase" : undefined,
|
|
2496
|
+
};
|
|
2497
|
+
return {
|
|
2498
|
+
localBox,
|
|
2499
|
+
globalBox: { ...localBox, y: localBox.y + globalYOffset },
|
|
2500
|
+
localReserveBottom: reserveRhythmRows(boxY + treatedMetrics.height + treatedMetrics.marginAfter, localConfig.rhythm),
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function isTextCompositionSlot(slot: ArticleFrameCompositionSlotSpec["slot"]): slot is SolvedChromeBox["slot"] {
|
|
2505
|
+
return slot === "label" || slot === "headline" || slot === "deck" || slot === "byline";
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function getCompositionSlotText(slot: SolvedChromeBox["slot"], article: ArticlePublicationItem): string {
|
|
2509
|
+
if (slot === "label") return article.section;
|
|
2510
|
+
if (slot === "headline") return article.headline;
|
|
2511
|
+
if (slot === "deck") return article.deck;
|
|
2512
|
+
return formatByline(article);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function getCompositionSlotStyle(
|
|
2516
|
+
slot: SolvedChromeBox["slot"],
|
|
2517
|
+
config: LayoutConfig,
|
|
2518
|
+
storyRole: FrontStoryRole,
|
|
2519
|
+
headlineScale: HeadlineScaleId,
|
|
2520
|
+
slotWidth: number,
|
|
2521
|
+
): ChromeTextStyle {
|
|
2522
|
+
if (slot === "label") {
|
|
2523
|
+
const labelFontSize = getKickerFontSize(config.rhythm.rowHeight);
|
|
2524
|
+
return {
|
|
2525
|
+
fontSize: labelFontSize,
|
|
2526
|
+
fontFamily: SANS_TEXT_FONT,
|
|
2527
|
+
fontWeight: 800,
|
|
2528
|
+
lineHeightEm: config.rhythm.rowHeight / labelFontSize,
|
|
2529
|
+
paintHeightEm: config.rhythm.rowHeight / labelFontSize,
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
if (slot === "headline") {
|
|
2533
|
+
const displayHeadline = isDisplayHeadlineScale(headlineScale);
|
|
2534
|
+
const fontSize = getHeadlineFontSize(config, headlineScale, slotWidth);
|
|
2535
|
+
return {
|
|
2536
|
+
fontSize,
|
|
2537
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
2538
|
+
fontWeight: 900,
|
|
2539
|
+
lineHeightEm: displayHeadline ? 0.96 : 1,
|
|
2540
|
+
paintHeightEm: displayHeadline ? 1.1 : 1.12,
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
if (slot === "deck") {
|
|
2544
|
+
const feature = storyRole === "feature";
|
|
2545
|
+
const fontSize = config.columnCount === 1 ? 17 : feature ? 19 : 16;
|
|
2546
|
+
const lineHeight = feature ? Math.round(fontSize * 1.16) : config.rhythm.rowHeight;
|
|
2547
|
+
return {
|
|
2548
|
+
fontSize,
|
|
2549
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
2550
|
+
fontStyle: "italic",
|
|
2551
|
+
lineHeightEm: lineHeight / fontSize,
|
|
2552
|
+
paintHeightEm: lineHeight / fontSize,
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
return {
|
|
2556
|
+
fontSize: 12,
|
|
2557
|
+
fontFamily: SANS_TEXT_FONT,
|
|
2558
|
+
fontWeight: 800,
|
|
2559
|
+
lineHeightEm: config.rhythm.rowHeight / 12,
|
|
2560
|
+
paintHeightEm: config.rhythm.rowHeight / 12,
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function getOccupiedColumnBottom(bottoms: number[], columnStart: number, columnSpan: number): number {
|
|
2565
|
+
return Math.max(...bottoms.slice(columnStart, columnStart + columnSpan), 0);
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
function getColumnCopyBandTops(
|
|
2569
|
+
columnCount: number,
|
|
2570
|
+
chromeBoxes: SolvedChromeBox[],
|
|
2571
|
+
furniture: SolvedFurniture[],
|
|
2572
|
+
furnitureOffsetY: number,
|
|
2573
|
+
furnitureMarginBottom: number,
|
|
2574
|
+
rhythm: VerticalRhythm,
|
|
2575
|
+
): number[] {
|
|
2576
|
+
const minimums = Array.from({ length: columnCount }, () => 0);
|
|
2577
|
+
|
|
2578
|
+
for (const box of chromeBoxes) {
|
|
2579
|
+
updateOccupiedColumnBottoms(minimums, box.columnStart, box.columnSpan, box.y + box.height);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
for (const item of furniture) {
|
|
2583
|
+
if (!item.wrapsText) continue;
|
|
2584
|
+
updateOccupiedColumnBottoms(
|
|
2585
|
+
minimums,
|
|
2586
|
+
item.columnStart,
|
|
2587
|
+
item.columnSpan,
|
|
2588
|
+
item.y + furnitureOffsetY + item.height + furnitureMarginBottom,
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
return minimums.map((minimum) => reserveRhythmRows(Math.max(0, minimum), rhythm));
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
function updateOccupiedColumnBottoms(bottoms: number[], columnStart: number, columnSpan: number, bottom: number): void {
|
|
2596
|
+
for (let index = columnStart; index < Math.min(bottoms.length, columnStart + columnSpan); index += 1) {
|
|
2597
|
+
bottoms[index] = Math.max(bottoms[index], bottom);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
function applyFrontChromeTreatment(
|
|
2602
|
+
slot: SolvedChromeBox["slot"],
|
|
2603
|
+
metrics: ChromeTextBoxMetrics,
|
|
2604
|
+
config: LayoutConfig,
|
|
2605
|
+
storyRole: FrontStoryRole,
|
|
2606
|
+
): ChromeTextBoxMetrics {
|
|
2607
|
+
if (slot === "deck") {
|
|
2608
|
+
const padding = Math.round(config.rhythm.rowHeight * (storyRole === "feature" ? 0.48 : 0.34));
|
|
2609
|
+
return applyChromeBoxTreatment(metrics, {
|
|
2610
|
+
paddingTop: padding,
|
|
2611
|
+
paddingBottom: padding,
|
|
2612
|
+
ruleTopHeight: 1,
|
|
2613
|
+
ruleBottomHeight: 1,
|
|
2614
|
+
}, config.rhythm);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
if (slot === "byline") {
|
|
2618
|
+
return applyChromeBoxTreatment(metrics, {
|
|
2619
|
+
paddingTop: config.rhythm.rowHeight,
|
|
2620
|
+
paddingBottom: config.rhythm.rowHeight,
|
|
2621
|
+
}, config.rhythm);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
return metrics;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function applyChromeBoxTreatment(
|
|
2628
|
+
metrics: ChromeTextBoxMetrics,
|
|
2629
|
+
treatment: ChromeBoxTreatment,
|
|
2630
|
+
rhythm: VerticalRhythm,
|
|
2631
|
+
): ChromeTextBoxMetrics {
|
|
2632
|
+
const ruleTopHeight = treatment.ruleTopHeight ?? 0;
|
|
2633
|
+
const ruleBottomHeight = treatment.ruleBottomHeight ?? 0;
|
|
2634
|
+
const rawHeight =
|
|
2635
|
+
ruleTopHeight +
|
|
2636
|
+
treatment.paddingTop +
|
|
2637
|
+
metrics.contentHeight +
|
|
2638
|
+
treatment.paddingBottom +
|
|
2639
|
+
ruleBottomHeight;
|
|
2640
|
+
const height = reserveRhythmRows(rawHeight, rhythm);
|
|
2641
|
+
const paddingBottom = treatment.paddingBottom + Math.max(0, height - rawHeight);
|
|
2642
|
+
const rawTotalHeight = metrics.marginBefore + height + metrics.marginAfter;
|
|
2643
|
+
const totalHeight = reserveRhythmRows(rawTotalHeight, rhythm);
|
|
2644
|
+
|
|
2645
|
+
return {
|
|
2646
|
+
...metrics,
|
|
2647
|
+
height,
|
|
2648
|
+
paddingTop: treatment.paddingTop,
|
|
2649
|
+
paddingBottom,
|
|
2650
|
+
ruleTopHeight,
|
|
2651
|
+
ruleBottomHeight,
|
|
2652
|
+
totalHeight,
|
|
2653
|
+
marginAfter: metrics.marginAfter + (totalHeight - rawTotalHeight),
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function getStoryChromeMetrics(
|
|
2658
|
+
config: LayoutConfig,
|
|
2659
|
+
article: ArticlePublicationItem,
|
|
2660
|
+
storyRole: FrontStoryRole,
|
|
2661
|
+
headlineScale: HeadlineScaleId,
|
|
2662
|
+
blockWidth: number,
|
|
2663
|
+
mediaPreludeHeight = 0,
|
|
2664
|
+
overrides?: ArticleFrameChromeSpec,
|
|
2665
|
+
): StoryChromeMetrics {
|
|
2666
|
+
const feature = storyRole === "feature";
|
|
2667
|
+
const displayHeadline = isDisplayHeadlineScale(headlineScale);
|
|
2668
|
+
const headlineFontSize = getHeadlineFontSize(config, headlineScale, blockWidth);
|
|
2669
|
+
const labelFontSize = getKickerFontSize(config.rhythm.rowHeight);
|
|
2670
|
+
const label = solveChromeTextBox(article.section, blockWidth, {
|
|
2671
|
+
fontSize: labelFontSize,
|
|
2672
|
+
fontFamily: SANS_TEXT_FONT,
|
|
2673
|
+
fontWeight: 800,
|
|
2674
|
+
lineHeightEm: config.rhythm.rowHeight / labelFontSize,
|
|
2675
|
+
paintHeightEm: config.rhythm.rowHeight / labelFontSize,
|
|
2676
|
+
}, config.rhythm, overrides?.label);
|
|
2677
|
+
const headline = solveChromeTextBox(article.headline, blockWidth, {
|
|
2678
|
+
fontSize: headlineFontSize,
|
|
2679
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
2680
|
+
fontWeight: 900,
|
|
2681
|
+
lineHeightEm: displayHeadline ? 0.96 : 1,
|
|
2682
|
+
paintHeightEm: displayHeadline ? 1.1 : 1.12,
|
|
2683
|
+
}, config.rhythm, overrides?.headline);
|
|
2684
|
+
const deckFontSize = config.columnCount === 1 ? 17 : feature ? 19 : 16;
|
|
2685
|
+
const deckLineHeight = feature ? Math.round(deckFontSize * 1.16) : config.rhythm.rowHeight;
|
|
2686
|
+
const deck = applyFrontChromeTreatment("deck", solveChromeTextBox(article.deck, blockWidth, {
|
|
2687
|
+
fontSize: deckFontSize,
|
|
2688
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
2689
|
+
fontStyle: "italic",
|
|
2690
|
+
lineHeightEm: deckLineHeight / deckFontSize,
|
|
2691
|
+
paintHeightEm: deckLineHeight / deckFontSize,
|
|
2692
|
+
}, config.rhythm, overrides?.deck), config, storyRole);
|
|
2693
|
+
const bylineFontSize = 12;
|
|
2694
|
+
const byline = applyFrontChromeTreatment("byline", solveChromeTextBox(formatByline(article), blockWidth, {
|
|
2695
|
+
fontSize: bylineFontSize,
|
|
2696
|
+
fontFamily: SANS_TEXT_FONT,
|
|
2697
|
+
fontWeight: 800,
|
|
2698
|
+
lineHeightEm: config.rhythm.rowHeight / bylineFontSize,
|
|
2699
|
+
paintHeightEm: config.rhythm.rowHeight / bylineFontSize,
|
|
2700
|
+
}, config.rhythm, overrides?.byline), config, storyRole);
|
|
2701
|
+
const jump = solveChromeTextBox("SEE MORE", blockWidth, {
|
|
2702
|
+
fontSize: 12.2,
|
|
2703
|
+
fontFamily: SANS_TEXT_FONT,
|
|
2704
|
+
fontWeight: 800,
|
|
2705
|
+
lineHeightEm: config.rhythm.rowHeight / 12.2,
|
|
2706
|
+
paintHeightEm: config.rhythm.rowHeight / 12.2,
|
|
2707
|
+
}, config.rhythm, overrides?.jumpLine);
|
|
2708
|
+
|
|
2709
|
+
return {
|
|
2710
|
+
borderTopHeight: 0,
|
|
2711
|
+
paddingTop: 0,
|
|
2712
|
+
mediaPreludeHeight,
|
|
2713
|
+
mediaPreludeMarginBottom: mediaPreludeHeight > 0 ? config.rhythm.rowHeight : 0,
|
|
2714
|
+
labelFontSize: label.fontSize,
|
|
2715
|
+
labelLineHeight: label.lineHeight,
|
|
2716
|
+
labelPaintHeight: label.paintHeight,
|
|
2717
|
+
labelPaintBuffer: label.paintBuffer,
|
|
2718
|
+
labelHeight: label.height,
|
|
2719
|
+
headlineFontSize: headline.fontSize,
|
|
2720
|
+
headlineLineHeight: headline.lineHeight,
|
|
2721
|
+
headlinePaintHeight: headline.paintHeight,
|
|
2722
|
+
headlinePaintBuffer: headline.paintBuffer,
|
|
2723
|
+
headlineHeight: headline.height,
|
|
2724
|
+
headlineLineCount: headline.lineCount,
|
|
2725
|
+
headlineMarginTop: headline.marginBefore,
|
|
2726
|
+
headlineMarginBottom: headline.marginAfter,
|
|
2727
|
+
deckFontSize: deck.fontSize,
|
|
2728
|
+
deckLineHeight: deck.lineHeight,
|
|
2729
|
+
deckPaintHeight: deck.paintHeight,
|
|
2730
|
+
deckPaintBuffer: deck.paintBuffer,
|
|
2731
|
+
deckPaddingTop: deck.paddingTop,
|
|
2732
|
+
deckPaddingBottom: deck.paddingBottom,
|
|
2733
|
+
deckRuleTopHeight: deck.ruleTopHeight,
|
|
2734
|
+
deckRuleBottomHeight: deck.ruleBottomHeight,
|
|
2735
|
+
deckHeight: deck.height,
|
|
2736
|
+
deckLineCount: deck.lineCount,
|
|
2737
|
+
deckMarginBottom: deck.marginAfter,
|
|
2738
|
+
bylineFontSize: byline.fontSize,
|
|
2739
|
+
bylineLineHeight: byline.lineHeight,
|
|
2740
|
+
bylinePaintHeight: byline.paintHeight,
|
|
2741
|
+
bylinePaintBuffer: byline.paintBuffer,
|
|
2742
|
+
bylinePaddingTop: byline.paddingTop,
|
|
2743
|
+
bylinePaddingBottom: byline.paddingBottom,
|
|
2744
|
+
bylineHeight: byline.height,
|
|
2745
|
+
bylineLineCount: byline.lineCount,
|
|
2746
|
+
bylineMarginBottom: byline.marginAfter,
|
|
2747
|
+
measureChromeHeight: 0,
|
|
2748
|
+
jumpFontSize: jump.fontSize,
|
|
2749
|
+
jumpLineHeight: jump.lineHeight,
|
|
2750
|
+
jumpPaintHeight: jump.paintHeight,
|
|
2751
|
+
jumpPaintBuffer: jump.paintBuffer,
|
|
2752
|
+
jumpHeight: jump.height,
|
|
2753
|
+
jumpPaddingTop: 0,
|
|
2754
|
+
jumpPaddingBottom: config.rhythm.rowHeight,
|
|
2755
|
+
jumpBorderTopHeight: 0,
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function resolveFrontStoryRole(blockSpec: ArticleFrameBlockSpec, span: number, hasFeatureCue: boolean): FrontStoryRole {
|
|
2760
|
+
if (blockSpec.role === "feature" || blockSpec.role === "primary") return "feature";
|
|
2761
|
+
if (blockSpec.role === "rail") return "rail";
|
|
2762
|
+
if (span >= 4 || hasFeatureCue) return "feature";
|
|
2763
|
+
return "standard";
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
function resolveHeadlineScale(blockSpec: ArticleFrameBlockSpec, storyRole: FrontStoryRole): HeadlineScaleId {
|
|
2767
|
+
if (blockSpec.typography?.headlineScale) return blockSpec.typography.headlineScale;
|
|
2768
|
+
if (storyRole === "feature") return "feature";
|
|
2769
|
+
if (storyRole === "rail") return "rail";
|
|
2770
|
+
return "standard";
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
function isDisplayHeadlineScale(headlineScale: HeadlineScaleId): boolean {
|
|
2774
|
+
return headlineScale === "banner" || headlineScale === "feature";
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
function getHeadlineFontSize(config: LayoutConfig, headlineScale: HeadlineScaleId, blockWidth: number): number {
|
|
2778
|
+
const width = Math.max(blockWidth, 1);
|
|
2779
|
+
if (config.columnCount === 1) {
|
|
2780
|
+
if (headlineScale === "banner") return clamp(width * 0.13, 42, 82);
|
|
2781
|
+
if (headlineScale === "feature") return clamp(width * 0.11, 34, 54);
|
|
2782
|
+
if (headlineScale === "standard") return clamp(width * 0.095, 29, 46);
|
|
2783
|
+
if (headlineScale === "rail") return clamp(width * 0.09, 27, 42);
|
|
2784
|
+
return clamp(width * 0.075, 22, 32);
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
if (headlineScale === "banner") return clamp(width * 0.105, 54, 112);
|
|
2788
|
+
if (headlineScale === "feature") return clamp(width * 0.052, 34, 58);
|
|
2789
|
+
if (headlineScale === "standard") return clamp(width * 0.07, 27, 40);
|
|
2790
|
+
if (headlineScale === "rail") return clamp(width * 0.15, 24, 34);
|
|
2791
|
+
return clamp(width * 0.055, 18, 26);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function solveChromeTextBox(
|
|
2795
|
+
text: string,
|
|
2796
|
+
maxWidth: number,
|
|
2797
|
+
defaults: ChromeTextStyle,
|
|
2798
|
+
rhythm: VerticalRhythm,
|
|
2799
|
+
overrides?: ChromeTextSlotSpec,
|
|
2800
|
+
): ChromeTextBoxMetrics {
|
|
2801
|
+
const fontSize = defaults.fontSize;
|
|
2802
|
+
const lineHeight = Math.ceil(emToPx(parseEmOverride(overrides?.lineHeight, defaults.lineHeightEm), fontSize));
|
|
2803
|
+
const paintHeight = Math.ceil(emToPx(parseEmOverride(overrides?.paintHeight, defaults.paintHeightEm), fontSize));
|
|
2804
|
+
const marginBefore = Math.round(emToPx(parseEmOverride(overrides?.marginBefore, defaults.marginBeforeEm ?? 0), fontSize));
|
|
2805
|
+
const rawMarginAfter = Math.round(emToPx(parseEmOverride(overrides?.marginAfter, defaults.marginAfterEm ?? 0), fontSize));
|
|
2806
|
+
const minHeight = Math.ceil(emToPx(parseEmOverride(overrides?.minHeight, defaults.minHeightEm ?? 0), fontSize));
|
|
2807
|
+
const measured = measureWrappedTextBlock(text, buildChromeFont(defaults), maxWidth, lineHeight, paintHeight);
|
|
2808
|
+
const height = reserveRhythmRows(Math.max(measured.height, minHeight), rhythm);
|
|
2809
|
+
const rawTotalHeight = marginBefore + height + rawMarginAfter;
|
|
2810
|
+
const totalHeight = reserveRhythmRows(rawTotalHeight, rhythm);
|
|
2811
|
+
const marginAfter = rawMarginAfter + (totalHeight - rawTotalHeight);
|
|
2812
|
+
|
|
2813
|
+
return {
|
|
2814
|
+
fontSize,
|
|
2815
|
+
lineHeight,
|
|
2816
|
+
paintHeight,
|
|
2817
|
+
paintBuffer: Math.max(0, paintHeight - lineHeight),
|
|
2818
|
+
contentHeight: measured.height,
|
|
2819
|
+
paddingTop: 0,
|
|
2820
|
+
paddingBottom: 0,
|
|
2821
|
+
ruleTopHeight: 0,
|
|
2822
|
+
ruleBottomHeight: 0,
|
|
2823
|
+
height,
|
|
2824
|
+
lineCount: measured.lineCount,
|
|
2825
|
+
marginBefore,
|
|
2826
|
+
marginAfter,
|
|
2827
|
+
totalHeight,
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function buildChromeFont(style: ChromeTextStyle): string {
|
|
2832
|
+
const pieces: string[] = [];
|
|
2833
|
+
if (style.fontStyle && style.fontStyle !== "normal") pieces.push(style.fontStyle);
|
|
2834
|
+
if (style.fontWeight) pieces.push(String(style.fontWeight));
|
|
2835
|
+
pieces.push(`${style.fontSize}px`);
|
|
2836
|
+
pieces.push(style.fontFamily);
|
|
2837
|
+
return pieces.join(" ");
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
function parseEmOverride(value: ChromeTextSlotSpec[keyof ChromeTextSlotSpec] | undefined, fallback: number): number {
|
|
2841
|
+
if (!value) return fallback;
|
|
2842
|
+
return parseEmValue(value);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
function parseEmValue(value: string): number {
|
|
2846
|
+
return Number(value.slice(0, -2));
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
function emToPx(value: number, fontSize: number): number {
|
|
2850
|
+
return value * fontSize;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
function getLineStackHeight(lineCount: number, lineHeight: number, paintHeight: number): number {
|
|
2854
|
+
if (lineCount <= 0) return 0;
|
|
2855
|
+
return Math.ceil((lineCount - 1) * lineHeight + paintHeight);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
function getStoryChromeHeight(chrome: StoryChromeMetrics): number {
|
|
2859
|
+
return (
|
|
2860
|
+
chrome.borderTopHeight +
|
|
2861
|
+
chrome.paddingTop +
|
|
2862
|
+
chrome.mediaPreludeHeight +
|
|
2863
|
+
chrome.mediaPreludeMarginBottom +
|
|
2864
|
+
chrome.labelHeight +
|
|
2865
|
+
chrome.headlineMarginTop +
|
|
2866
|
+
chrome.headlineHeight +
|
|
2867
|
+
chrome.headlineMarginBottom +
|
|
2868
|
+
chrome.deckHeight +
|
|
2869
|
+
chrome.deckMarginBottom +
|
|
2870
|
+
chrome.bylineHeight +
|
|
2871
|
+
chrome.bylineMarginBottom +
|
|
2872
|
+
chrome.measureChromeHeight
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
function getStoryJumpReserveHeight(chrome: StoryChromeMetrics): number {
|
|
2877
|
+
return chrome.jumpBorderTopHeight + chrome.jumpPaddingTop + chrome.jumpHeight + chrome.jumpPaddingBottom;
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
function getContinuationTitleMetrics(
|
|
2881
|
+
config: LayoutConfig,
|
|
2882
|
+
article: ArticlePublicationItem,
|
|
2883
|
+
blockWidth: number,
|
|
2884
|
+
labelText: string,
|
|
2885
|
+
headlineScale: HeadlineScaleId,
|
|
2886
|
+
explicitHeadlineScale: boolean,
|
|
2887
|
+
overrides?: ArticleFrameChromeSpec,
|
|
2888
|
+
): ContinuationTitleMetrics {
|
|
2889
|
+
const labelFontSize = getKickerFontSize(config.rhythm.rowHeight);
|
|
2890
|
+
const label = solveChromeTextBox(labelText, blockWidth, {
|
|
2891
|
+
fontSize: labelFontSize,
|
|
2892
|
+
fontFamily: SANS_TEXT_FONT,
|
|
2893
|
+
fontWeight: 800,
|
|
2894
|
+
lineHeightEm: config.rhythm.rowHeight / labelFontSize,
|
|
2895
|
+
paintHeightEm: config.rhythm.rowHeight / labelFontSize,
|
|
2896
|
+
}, config.rhythm, overrides?.label);
|
|
2897
|
+
const headingWidth = Math.min(blockWidth, 980);
|
|
2898
|
+
const headingFontSize = explicitHeadlineScale
|
|
2899
|
+
? getHeadlineFontSize(config, headlineScale, headingWidth)
|
|
2900
|
+
: config.pageChrome.continuedTitleFontSize;
|
|
2901
|
+
const headingLineHeight = explicitHeadlineScale
|
|
2902
|
+
? headingFontSize * (isDisplayHeadlineScale(headlineScale) ? 0.94 : 1)
|
|
2903
|
+
: config.pageChrome.continuedTitleLineHeight;
|
|
2904
|
+
const heading = solveChromeTextBox(article.headline, headingWidth, {
|
|
2905
|
+
fontSize: headingFontSize,
|
|
2906
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
2907
|
+
fontWeight: 900,
|
|
2908
|
+
lineHeightEm: headingLineHeight / headingFontSize,
|
|
2909
|
+
paintHeightEm: Math.max(1.08, headingLineHeight / headingFontSize),
|
|
2910
|
+
marginBeforeEm: CONTINUED_TITLE_HEADING_MARGIN_TOP / headingFontSize,
|
|
2911
|
+
}, config.rhythm, overrides?.headline);
|
|
2912
|
+
const chromeHeight = reserveRhythmRows(
|
|
2913
|
+
label.totalHeight +
|
|
2914
|
+
heading.totalHeight +
|
|
2915
|
+
CONTINUED_TITLE_PADDING_BOTTOM +
|
|
2916
|
+
CONTINUED_TITLE_BORDER_BOTTOM +
|
|
2917
|
+
CONTINUED_TITLE_MARGIN_BOTTOM,
|
|
2918
|
+
config.rhythm,
|
|
2919
|
+
);
|
|
2920
|
+
return {
|
|
2921
|
+
label,
|
|
2922
|
+
heading,
|
|
2923
|
+
chromeHeight,
|
|
2924
|
+
totalHeight: chromeHeight,
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
function measureWrappedTextBlock(
|
|
2929
|
+
text: string,
|
|
2930
|
+
font: string,
|
|
2931
|
+
maxWidth: number,
|
|
2932
|
+
lineHeight: number,
|
|
2933
|
+
paintHeight = lineHeight,
|
|
2934
|
+
): { lineCount: number; height: number } {
|
|
2935
|
+
const prepared = prepareWithSegments(text, font);
|
|
2936
|
+
let cursor = { ...EMPTY_CURSOR };
|
|
2937
|
+
let lineCount = 0;
|
|
2938
|
+
while (lineCount < 20) {
|
|
2939
|
+
const line = layoutNextLine(prepared, cursor, maxWidth);
|
|
2940
|
+
if (!line) break;
|
|
2941
|
+
cursor = line.end;
|
|
2942
|
+
lineCount += 1;
|
|
2943
|
+
}
|
|
2944
|
+
return {
|
|
2945
|
+
lineCount,
|
|
2946
|
+
height: getLineStackHeight(lineCount, lineHeight, paintHeight),
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
function getLeadImageWrap(article: ArticlePublicationItem, width: number, config: LayoutConfig): TextObstacle | null {
|
|
2951
|
+
if (!article.image) return null;
|
|
2952
|
+
const layout = article.image.layout;
|
|
2953
|
+
const minHeight = layout?.minHeight ?? config.lineHeight * 6;
|
|
2954
|
+
const maxHeight = layout?.maxHeight ?? config.lineHeight * 12;
|
|
2955
|
+
const targetWidth = getInlineFloatWidth(layout, width, config);
|
|
2956
|
+
const imageWidth = clamp(
|
|
2957
|
+
Math.round(targetWidth),
|
|
2958
|
+
Math.min(layout?.inlineFloat?.minWidth ?? 72, Math.round(width * 0.24)),
|
|
2959
|
+
Math.round(width * (layout?.inlineFloat?.maxWidthRatio ?? 0.42)),
|
|
2960
|
+
);
|
|
2961
|
+
const naturalHeight = layout?.crop === "contain"
|
|
2962
|
+
? Math.round(imageWidth / getImageAspectRatio(article.image))
|
|
2963
|
+
: Math.round(layout?.preferredHeight ?? config.lineHeight * 8);
|
|
2964
|
+
const height = layout?.crop === "contain"
|
|
2965
|
+
? snapPreservedImageHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight)
|
|
2966
|
+
: snapPreferredHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight);
|
|
2967
|
+
return {
|
|
2968
|
+
x: Math.round(width - imageWidth),
|
|
2969
|
+
y: 0,
|
|
2970
|
+
width: imageWidth,
|
|
2971
|
+
height,
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
function getInlineFloatWidth(
|
|
2976
|
+
layout: NonNullable<ArticlePublicationItem["image"]>["layout"],
|
|
2977
|
+
width: number,
|
|
2978
|
+
config: LayoutConfig,
|
|
2979
|
+
): number {
|
|
2980
|
+
const intent = layout?.inlineFloat;
|
|
2981
|
+
if (
|
|
2982
|
+
intent &&
|
|
2983
|
+
config.columnCount >= (intent.minColumnCount ?? 2) &&
|
|
2984
|
+
(intent.columnSpan ?? 0) > 0
|
|
2985
|
+
) {
|
|
2986
|
+
return getSpanWidth(config, intent.columnSpan ?? 1);
|
|
2987
|
+
}
|
|
2988
|
+
const ratio = config.columnCount === 1
|
|
2989
|
+
? intent?.narrowWidthRatio ?? 0.34
|
|
2990
|
+
: intent?.widthRatio ?? 0.42;
|
|
2991
|
+
return width * ratio;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
function createFrontPreludeImage(
|
|
2995
|
+
article: ArticlePublicationItem,
|
|
2996
|
+
blockSpec: ArticleFrameBlockSpec,
|
|
2997
|
+
width: number,
|
|
2998
|
+
config: LayoutConfig,
|
|
2999
|
+
usedImageAssetIds: ReadonlySet<string> = new Set(),
|
|
3000
|
+
): SolvedImageFurniture | null {
|
|
3001
|
+
const mediaSpec = blockSpec.media[0];
|
|
3002
|
+
if (!mediaSpec) return null;
|
|
3003
|
+
const asset = getPreferredImage(article, mediaSpec.placement, mediaSpec.assetRole, usedImageAssetIds);
|
|
3004
|
+
if (!asset) return null;
|
|
3005
|
+
const aspectRatio = getImageAspectRatio(asset);
|
|
3006
|
+
const layout = asset.layout;
|
|
3007
|
+
const canCropFrame = mediaSpec.placement.crop === "cropAllowed";
|
|
3008
|
+
const shouldCropToFill = canCropFrame || layout?.crop === "cover";
|
|
3009
|
+
const minHeight = canCropFrame ? layout?.minHeight ?? 140 : config.rhythm.rowHeight;
|
|
3010
|
+
const maxHeight = canCropFrame ? layout?.maxHeight ?? 320 : Number.POSITIVE_INFINITY;
|
|
3011
|
+
const naturalHeight = Math.round(width / aspectRatio);
|
|
3012
|
+
const imageHeight = canCropFrame
|
|
3013
|
+
? snapPreferredHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight)
|
|
3014
|
+
: snapPreservedImageHeightToRhythm(naturalHeight, config.rhythm, minHeight, maxHeight);
|
|
3015
|
+
const caption = getImageCaption(asset);
|
|
3016
|
+
const captionHeight = getImageCaptionHeight(caption, width, config.rhythm);
|
|
3017
|
+
const height = imageHeight + captionHeight;
|
|
3018
|
+
return {
|
|
3019
|
+
kind: "image",
|
|
3020
|
+
id: `${article.slug}-front-prelude-photo`,
|
|
3021
|
+
assetId: asset.id,
|
|
3022
|
+
src: asset.src,
|
|
3023
|
+
themeVariants: asset.themeVariants,
|
|
3024
|
+
alt: asset.alt,
|
|
3025
|
+
caption,
|
|
3026
|
+
credit: asset.credit,
|
|
3027
|
+
templateId: "front-prelude",
|
|
3028
|
+
columnStart: 0,
|
|
3029
|
+
columnSpan: 1,
|
|
3030
|
+
x: 0,
|
|
3031
|
+
y: 0,
|
|
3032
|
+
width,
|
|
3033
|
+
height,
|
|
3034
|
+
imageHeight,
|
|
3035
|
+
captionHeight,
|
|
3036
|
+
captionFontSize: IMAGE_CAPTION_FONT_SIZE,
|
|
3037
|
+
captionLineHeight: config.rhythm.rowHeight,
|
|
3038
|
+
aspectRatio,
|
|
3039
|
+
objectFit: shouldCropToFill ? "cover" : "contain",
|
|
3040
|
+
objectPosition: getObjectPosition(asset),
|
|
3041
|
+
wrapsText: false,
|
|
3042
|
+
preferredHeight: naturalHeight + captionHeight,
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
function leadImageToFurniture(article: ArticlePublicationItem, obstacle: TextObstacle, config: LayoutConfig): SolvedImageFurniture {
|
|
3047
|
+
if (!article.image) throw new Error(`Cannot create lead image furniture for ${article.slug} without an image.`);
|
|
3048
|
+
const [asset] = getPublicationItemImageAssets(article);
|
|
3049
|
+
const aspectRatio = getImageAspectRatio(article.image);
|
|
3050
|
+
const caption = getImageCaption(article.image);
|
|
3051
|
+
const captionHeight = getImageCaptionHeight(caption, obstacle.width, config.rhythm);
|
|
3052
|
+
const preserveImageAspect = article.image.layout?.crop === "contain";
|
|
3053
|
+
const naturalHeight = Math.round(obstacle.width / aspectRatio);
|
|
3054
|
+
const imageHeight = preserveImageAspect
|
|
3055
|
+
? snapPreservedImageHeightToRhythm(
|
|
3056
|
+
naturalHeight,
|
|
3057
|
+
config.rhythm,
|
|
3058
|
+
article.image.layout?.minHeight ?? config.rhythm.rowHeight,
|
|
3059
|
+
article.image.layout?.maxHeight ?? Number.POSITIVE_INFINITY,
|
|
3060
|
+
)
|
|
3061
|
+
: Math.max(config.rhythm.rowHeight, obstacle.height - captionHeight);
|
|
3062
|
+
const height = imageHeight + captionHeight;
|
|
3063
|
+
return {
|
|
3064
|
+
kind: "image",
|
|
3065
|
+
id: `${article.slug}-lead-photo`,
|
|
3066
|
+
assetId: asset?.id ?? `${article.slug}-primary-image`,
|
|
3067
|
+
src: article.image.src,
|
|
3068
|
+
themeVariants: article.image.themeVariants,
|
|
3069
|
+
alt: article.image.alt,
|
|
3070
|
+
caption,
|
|
3071
|
+
credit: article.image.credit,
|
|
3072
|
+
templateId: "lead-wrap",
|
|
3073
|
+
columnStart: 0,
|
|
3074
|
+
columnSpan: 1,
|
|
3075
|
+
x: obstacle.x,
|
|
3076
|
+
y: obstacle.y,
|
|
3077
|
+
width: obstacle.width,
|
|
3078
|
+
height,
|
|
3079
|
+
imageHeight,
|
|
3080
|
+
captionHeight,
|
|
3081
|
+
captionFontSize: IMAGE_CAPTION_FONT_SIZE,
|
|
3082
|
+
captionLineHeight: config.rhythm.rowHeight,
|
|
3083
|
+
aspectRatio,
|
|
3084
|
+
objectFit: preserveImageAspect ? "contain" : "cover",
|
|
3085
|
+
objectPosition: "50% 50%",
|
|
3086
|
+
wrapsText: true,
|
|
3087
|
+
preferredHeight: height,
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
function getPrepared(
|
|
3092
|
+
cache: PreparedTextCache,
|
|
3093
|
+
article: ArticlePublicationItem,
|
|
3094
|
+
font: string,
|
|
3095
|
+
): PreparedTextWithSegments {
|
|
3096
|
+
const key = `${article.slug}:${font}`;
|
|
3097
|
+
const cached = cache.get(key);
|
|
3098
|
+
if (cached) return cached;
|
|
3099
|
+
const prepared = prepareWithSegments(getPublicationItemText(article), font, { whiteSpace: "pre-wrap" });
|
|
3100
|
+
cache.set(key, prepared);
|
|
3101
|
+
return prepared;
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
function getLineLimitHeight(maxLines: number, lineHeight: number, linePaintHeight: number, rhythm: VerticalRhythm): number {
|
|
3105
|
+
if (maxLines <= 0) return 0;
|
|
3106
|
+
return reserveRhythmRows((maxLines - 1) * lineHeight + linePaintHeight, rhythm);
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function getColumnTextObstacles(
|
|
3110
|
+
furniture: SolvedFurniture[],
|
|
3111
|
+
columnIndex: number,
|
|
3112
|
+
chromeBoxes: SolvedChromeBox[] = [],
|
|
3113
|
+
furnitureOffsetY = 0,
|
|
3114
|
+
furnitureMarginBottom = 0,
|
|
3115
|
+
minimumLineStartY = 0,
|
|
3116
|
+
): TextObstacle[] {
|
|
3117
|
+
const topObstacle: TextObstacle[] = minimumLineStartY > 0
|
|
3118
|
+
? [{ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: minimumLineStartY }]
|
|
3119
|
+
: [];
|
|
3120
|
+
const furnitureObstacles = furniture
|
|
3121
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
3122
|
+
.map((item) => ({
|
|
3123
|
+
x: 0,
|
|
3124
|
+
y: item.y + furnitureOffsetY,
|
|
3125
|
+
width: Number.POSITIVE_INFINITY,
|
|
3126
|
+
height: item.height + getFurnitureObstacleMarginBottom(item, furnitureMarginBottom),
|
|
3127
|
+
}));
|
|
3128
|
+
const imageClearanceObstacles = furniture
|
|
3129
|
+
.filter((item) => (
|
|
3130
|
+
item.kind === "image" &&
|
|
3131
|
+
item.wrapsText &&
|
|
3132
|
+
furnitureMarginBottom > 0 &&
|
|
3133
|
+
columnIndex >= item.columnStart &&
|
|
3134
|
+
columnIndex < item.columnStart + item.columnSpan
|
|
3135
|
+
))
|
|
3136
|
+
.map((item) => ({
|
|
3137
|
+
x: 0,
|
|
3138
|
+
y: item.y + furnitureOffsetY + item.height,
|
|
3139
|
+
width: Number.POSITIVE_INFINITY,
|
|
3140
|
+
height: furnitureMarginBottom,
|
|
3141
|
+
}));
|
|
3142
|
+
const chromeObstacles = chromeBoxes
|
|
3143
|
+
.filter((box) => columnIndex >= box.columnStart && columnIndex < box.columnStart + box.columnSpan)
|
|
3144
|
+
.map((box) => ({
|
|
3145
|
+
x: 0,
|
|
3146
|
+
y: box.y,
|
|
3147
|
+
width: Number.POSITIVE_INFINITY,
|
|
3148
|
+
height: box.height,
|
|
3149
|
+
}));
|
|
3150
|
+
return [...topObstacle, ...furnitureObstacles, ...imageClearanceObstacles, ...chromeObstacles];
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
function getFurnitureObstacleMarginBottom(item: SolvedFurniture, marginBottom: number): number {
|
|
3154
|
+
return item.kind === "image" || item.kind === "pullQuote" ? marginBottom : 0;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function getColumnWhitespace(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
3158
|
+
return columns.reduce(
|
|
3159
|
+
(total, column, columnIndex) =>
|
|
3160
|
+
total + Math.max(0, textHeight - getLinesHeight(column) - getColumnObstacleHeight(furniture, columnIndex, textHeight)),
|
|
3161
|
+
0,
|
|
3162
|
+
);
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
function getFurnitureSufficiencyReport({
|
|
3166
|
+
columns,
|
|
3167
|
+
textHeight,
|
|
3168
|
+
furniture,
|
|
3169
|
+
hasMore,
|
|
3170
|
+
config,
|
|
3171
|
+
}: {
|
|
3172
|
+
columns: TextLine[][];
|
|
3173
|
+
textHeight: number;
|
|
3174
|
+
furniture: SolvedFurniture[];
|
|
3175
|
+
hasMore: boolean;
|
|
3176
|
+
config: LayoutConfig;
|
|
3177
|
+
}): FurnitureSufficiencyReport {
|
|
3178
|
+
const visibleRows = columns.reduce((total, column) => total + column.length, 0);
|
|
3179
|
+
const furnitureRows = furniture.reduce(
|
|
3180
|
+
(total, item) => total + getFurnitureRowBurden(item, config.rhythm.rowHeight),
|
|
3181
|
+
0,
|
|
3182
|
+
);
|
|
3183
|
+
const accepted = (reason?: string): FurnitureSufficiencyReport => ({
|
|
3184
|
+
accepted: !reason,
|
|
3185
|
+
reason,
|
|
3186
|
+
visibleRows,
|
|
3187
|
+
furnitureRows,
|
|
3188
|
+
});
|
|
3189
|
+
|
|
3190
|
+
if (furniture.length === 0 || furnitureRows === 0) return accepted();
|
|
3191
|
+
const overlapReason = getFurnitureOverlapReason(furniture);
|
|
3192
|
+
if (overlapReason) return accepted(overlapReason);
|
|
3193
|
+
const minimumReadableRows = columns.length * (furniture.some((item) => item.kind === "pullQuote") ? 6 : 3);
|
|
3194
|
+
if (visibleRows * 4 < furnitureRows) {
|
|
3195
|
+
return accepted("visible-copy-far-below-furniture-burden");
|
|
3196
|
+
}
|
|
3197
|
+
if (visibleRows < furnitureRows && visibleRows < minimumReadableRows) {
|
|
3198
|
+
return accepted("visible-copy-below-furniture-burden");
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
const finalTextColumnIndex = columns.reduce((lastIndex, column, columnIndex) => (
|
|
3202
|
+
column.length > 0 ? columnIndex : lastIndex
|
|
3203
|
+
), -1);
|
|
3204
|
+
const exhausted = !hasMore;
|
|
3205
|
+
for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
|
|
3206
|
+
const visibleColumnRows = columns[columnIndex].length;
|
|
3207
|
+
const obstacleRows = Math.ceil(getColumnObstacleHeight(furniture, columnIndex, textHeight) / config.rhythm.rowHeight);
|
|
3208
|
+
const availableRows = Math.floor(textHeight / config.rhythm.rowHeight) - obstacleRows;
|
|
3209
|
+
const hasSubstantialFurniture = obstacleRows >= 6;
|
|
3210
|
+
const isAfterExhaustedText = exhausted && columnIndex > finalTextColumnIndex;
|
|
3211
|
+
if (!hasSubstantialFurniture && !isAfterExhaustedText && availableRows >= 3 && visibleColumnRows < 3) {
|
|
3212
|
+
return accepted("open-column-below-minimum-copy");
|
|
3213
|
+
}
|
|
3214
|
+
if (!hasSubstantialFurniture && visibleColumnRows === 1 && columnIndex < finalTextColumnIndex) {
|
|
3215
|
+
return accepted("non-final-column-single-copy-row");
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
return accepted();
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function getFurnitureRowBurden(item: SolvedFurniture, rowHeight: number): number {
|
|
3223
|
+
if (item.kind !== "image" && item.kind !== "pullQuote") return 0;
|
|
3224
|
+
return Math.ceil(item.height / rowHeight) * item.columnSpan;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
function getFurnitureOverlapReason(furniture: SolvedFurniture[]): string | undefined {
|
|
3228
|
+
for (let firstIndex = 0; firstIndex < furniture.length; firstIndex += 1) {
|
|
3229
|
+
for (let secondIndex = firstIndex + 1; secondIndex < furniture.length; secondIndex += 1) {
|
|
3230
|
+
if (rectsOverlap(furniture[firstIndex], furniture[secondIndex])) return "furniture-overlap";
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
return undefined;
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function getColumnObstacleHeight(furniture: SolvedFurniture[], columnIndex: number, textHeight: number): number {
|
|
3237
|
+
const intervals = furniture
|
|
3238
|
+
.filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
|
|
3239
|
+
.map((item) => ({
|
|
3240
|
+
start: clamp(item.y, 0, textHeight),
|
|
3241
|
+
end: clamp(item.y + item.height, 0, textHeight),
|
|
3242
|
+
}))
|
|
3243
|
+
.filter((interval) => interval.end > interval.start)
|
|
3244
|
+
.sort((a, b) => a.start - b.start);
|
|
3245
|
+
|
|
3246
|
+
const merged: Array<{ start: number; end: number }> = [];
|
|
3247
|
+
for (const interval of intervals) {
|
|
3248
|
+
const previous = merged[merged.length - 1];
|
|
3249
|
+
if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
|
|
3250
|
+
else merged.push({ ...interval });
|
|
3251
|
+
}
|
|
3252
|
+
return merged.reduce((total, interval) => total + interval.end - interval.start, 0);
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function getDeadColumnCount(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
|
|
3256
|
+
return columns.reduce((count, column, columnIndex) => {
|
|
3257
|
+
if (column.length > 0) return count;
|
|
3258
|
+
const obstacleHeight = getColumnObstacleHeight(furniture, columnIndex, textHeight);
|
|
3259
|
+
return obstacleHeight >= Math.min(96, textHeight * 0.18) ? count : count + 1;
|
|
3260
|
+
}, 0);
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function getLinesHeight(lines: TextLine[]): number {
|
|
3264
|
+
return lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
function getFurnitureBottom(furniture: SolvedFurniture[]): number {
|
|
3268
|
+
return furniture.reduce((bottom, item) => Math.max(bottom, item.y + item.height), 0);
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
function getSpanCandidates(span: ResponsiveSpanPolicy, columnCount: number): number[] {
|
|
3272
|
+
const min = Math.min(span.min, columnCount);
|
|
3273
|
+
const max = Math.min(span.max, columnCount);
|
|
3274
|
+
const preferred = clamp(span.preferred, min, max);
|
|
3275
|
+
const candidates = [preferred, min, max];
|
|
3276
|
+
return Array.from(new Set(candidates)).sort((left, right) => (
|
|
3277
|
+
Math.abs(left - preferred) - Math.abs(right - preferred) || right - left
|
|
3278
|
+
));
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
function resolveAnchor(anchor: ResponsivePlacementAnchor): "left" | "right" | "center" | "inline" {
|
|
3282
|
+
if (anchor === "outer") return "right";
|
|
3283
|
+
if (anchor === "inner") return "left";
|
|
3284
|
+
if (anchor === "inline") return "left";
|
|
3285
|
+
return anchor;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
function getColumnStart(anchor: "left" | "right" | "center" | "inline", span: number, columnCount: number): number {
|
|
3289
|
+
if (anchor === "right") return Math.max(0, columnCount - span);
|
|
3290
|
+
if (anchor === "center") return Math.max(0, Math.floor((columnCount - span) / 2));
|
|
3291
|
+
return 0;
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
function getColumnStartForPlacement(
|
|
3295
|
+
placement: ResponsivePlacementSpec,
|
|
3296
|
+
anchor: "left" | "right" | "center" | "inline",
|
|
3297
|
+
span: number,
|
|
3298
|
+
columnCount: number,
|
|
3299
|
+
collapsed: boolean,
|
|
3300
|
+
): number | null {
|
|
3301
|
+
if (!placement.columnStart || collapsed) return getColumnStart(anchor, span, columnCount);
|
|
3302
|
+
const requested = placement.columnStart - 1;
|
|
3303
|
+
if (requested + span <= columnCount) return requested;
|
|
3304
|
+
if (placement.collapse === "omit") return null;
|
|
3305
|
+
if (placement.collapse === "fullWidth") return 0;
|
|
3306
|
+
return getColumnStart("inline", Math.min(span, columnCount), columnCount);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
function getFurnitureY(
|
|
3310
|
+
verticalAnchor: ResponsiveVerticalPlacement,
|
|
3311
|
+
height: number,
|
|
3312
|
+
textHeight: number,
|
|
3313
|
+
config: LayoutConfig,
|
|
3314
|
+
): number {
|
|
3315
|
+
if (verticalAnchor === "top") return 0;
|
|
3316
|
+
const minY = config.lineHeight * 2;
|
|
3317
|
+
const maxY = Math.max(0, textHeight - height);
|
|
3318
|
+
const ratio = getVerticalAnchorRatio(verticalAnchor);
|
|
3319
|
+
const minimumY = Math.min(reserveRhythmRows(minY, config.rhythm), maxY);
|
|
3320
|
+
const maximumY = snapDownToRhythm(maxY, config.rhythm);
|
|
3321
|
+
if (maximumY < minimumY) return maximumY;
|
|
3322
|
+
return clamp(snapToNearestRhythm(textHeight * ratio - height / 2, config.rhythm), minimumY, maximumY);
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
function getVerticalAnchorRatio(verticalAnchor: ResponsiveVerticalPlacement): number {
|
|
3326
|
+
if (verticalAnchor === "top") return 0.12;
|
|
3327
|
+
if (verticalAnchor === "upperThird") return 0.32;
|
|
3328
|
+
if (verticalAnchor === "lowerThird") return 0.66;
|
|
3329
|
+
return 0.5;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
function getPullQuoteMetrics(
|
|
3333
|
+
text: string,
|
|
3334
|
+
width: number,
|
|
3335
|
+
config: LayoutConfig,
|
|
3336
|
+
overrides?: ChromeTextSlotSpec,
|
|
3337
|
+
): { height: number; fontSize: number; lineHeight: number } {
|
|
3338
|
+
const narrow = config.columnCount === 1;
|
|
3339
|
+
const fontSize = narrow ? 20 : 22;
|
|
3340
|
+
const lineHeight = Math.ceil(emToPx(parseEmOverride(overrides?.lineHeight, 1.12), fontSize));
|
|
3341
|
+
const paintHeight = Math.ceil(emToPx(parseEmOverride(overrides?.paintHeight, 1.18), fontSize));
|
|
3342
|
+
const metrics = measureWrappedTextBlock(text, buildChromeFont({
|
|
3343
|
+
fontSize,
|
|
3344
|
+
fontFamily: SERIF_TEXT_FONT,
|
|
3345
|
+
fontStyle: "italic",
|
|
3346
|
+
lineHeightEm: lineHeight / fontSize,
|
|
3347
|
+
paintHeightEm: paintHeight / fontSize,
|
|
3348
|
+
}), width, lineHeight, paintHeight);
|
|
3349
|
+
const marginBefore = Math.round(emToPx(parseEmOverride(overrides?.marginBefore, PULL_QUOTE_VERTICAL_PADDING / 2 / fontSize), fontSize));
|
|
3350
|
+
const marginAfter = Math.round(emToPx(parseEmOverride(overrides?.marginAfter, PULL_QUOTE_VERTICAL_PADDING / 2 / fontSize), fontSize));
|
|
3351
|
+
const minHeight = Math.ceil(emToPx(parseEmOverride(overrides?.minHeight, 0), fontSize));
|
|
3352
|
+
const height = reserveRhythmRows(Math.max(metrics.height + marginBefore + marginAfter, minHeight), config.rhythm);
|
|
3353
|
+
return { height, fontSize, lineHeight };
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
function getPullQuoteY({
|
|
3357
|
+
x,
|
|
3358
|
+
width,
|
|
3359
|
+
height,
|
|
3360
|
+
textHeight,
|
|
3361
|
+
anchorRatio,
|
|
3362
|
+
image,
|
|
3363
|
+
config,
|
|
3364
|
+
}: {
|
|
3365
|
+
x: number;
|
|
3366
|
+
width: number;
|
|
3367
|
+
height: number;
|
|
3368
|
+
textHeight: number;
|
|
3369
|
+
anchorRatio: number;
|
|
3370
|
+
image: SolvedImageFurniture | null;
|
|
3371
|
+
config: LayoutConfig;
|
|
3372
|
+
}): number | null {
|
|
3373
|
+
const minY = config.lineHeight * 2;
|
|
3374
|
+
const maxY = textHeight - height;
|
|
3375
|
+
if (maxY < minY) return null;
|
|
3376
|
+
const minimumY = reserveRhythmRows(minY, config.rhythm);
|
|
3377
|
+
const maximumY = snapDownToRhythm(maxY, config.rhythm);
|
|
3378
|
+
if (maximumY < minimumY) return null;
|
|
3379
|
+
const preferredY = clamp(snapToNearestRhythm(textHeight * anchorRatio - height / 2, config.rhythm), minimumY, maximumY);
|
|
3380
|
+
const preferredRect = { x, y: preferredY, width, height };
|
|
3381
|
+
if (!image || !rectsOverlap(preferredRect, image, FURNITURE_COLLISION_GUTTER)) return preferredY;
|
|
3382
|
+
const candidateYs = [
|
|
3383
|
+
reserveRhythmRows(image.y + image.height + FURNITURE_COLLISION_GUTTER, config.rhythm),
|
|
3384
|
+
snapDownToRhythm(image.y - height - FURNITURE_COLLISION_GUTTER, config.rhythm),
|
|
3385
|
+
]
|
|
3386
|
+
.filter((candidateY) => candidateY >= minimumY && candidateY <= maximumY)
|
|
3387
|
+
.sort((leftY, rightY) => Math.abs(leftY - preferredY) - Math.abs(rightY - preferredY));
|
|
3388
|
+
return candidateYs.find((candidateY) => !rectsOverlap({ x, y: candidateY, width, height }, image, FURNITURE_COLLISION_GUTTER)) ?? null;
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
function rectsOverlap(
|
|
3392
|
+
a: { x: number; y: number; width: number; height: number },
|
|
3393
|
+
b: { x: number; y: number; width: number; height: number },
|
|
3394
|
+
gutter = 0,
|
|
3395
|
+
): boolean {
|
|
3396
|
+
return (
|
|
3397
|
+
a.x < b.x + b.width + gutter &&
|
|
3398
|
+
a.x + a.width + gutter > b.x &&
|
|
3399
|
+
a.y < b.y + b.height + gutter &&
|
|
3400
|
+
a.y + a.height + gutter > b.y
|
|
3401
|
+
);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function getImageAspectRatio(asset: Pick<ArticleImageAsset, "layout">): number {
|
|
3405
|
+
return asset.layout?.aspectRatio ?? 1.5;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
function getImageCaption(asset: Pick<ArticleImageAsset, "caption" | "credit">): string {
|
|
3409
|
+
return (asset.caption ?? asset.credit).trim();
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function getImageCaptionHeight(caption: string, width: number, rhythm: VerticalRhythm): number {
|
|
3413
|
+
if (!caption) return 0;
|
|
3414
|
+
const contentWidth = Math.max(1, width - IMAGE_CAPTION_FONT_SIZE * IMAGE_CAPTION_HORIZONTAL_PADDING_EM);
|
|
3415
|
+
const metrics = measureWrappedTextBlock(
|
|
3416
|
+
caption,
|
|
3417
|
+
`italic ${IMAGE_CAPTION_FONT_SIZE}px ${SERIF_TEXT_FONT}`,
|
|
3418
|
+
contentWidth,
|
|
3419
|
+
rhythm.rowHeight,
|
|
3420
|
+
rhythm.rowHeight,
|
|
3421
|
+
);
|
|
3422
|
+
return reserveRhythmRows(Math.max(rhythm.rowHeight, metrics.height), rhythm);
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function getObjectPosition(asset: Pick<ArticleImageAsset, "layout">): string {
|
|
3426
|
+
const focalPoint = asset.layout?.focalPoint ?? { x: 0.5, y: 0.5 };
|
|
3427
|
+
return `${Math.round(focalPoint.x * 100)}% ${Math.round(focalPoint.y * 100)}%`;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
function formatContinuationJumpLabel(article: ArticlePublicationItem, pageNumber: number): string {
|
|
3431
|
+
return `SEE ${getShortSlug(article) ?? "MORE"} ON ${formatSectionPage(pageNumber)}`;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
function getArticleFrameLabel(blockSpec: ArticleFrameBlockSpec, article: ArticlePublicationItem): string {
|
|
3435
|
+
return blockSpec.startCursor === "current" ? formatContinuationSourceLabel(article, 1) : article.section;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
function formatContinuationSourceLabel(article: ArticlePublicationItem, sourcePageNumber: number): string {
|
|
3439
|
+
const shortSlug = getShortSlug(article);
|
|
3440
|
+
const source = `FROM ${formatSectionPage(sourcePageNumber)}`;
|
|
3441
|
+
return shortSlug ? `${shortSlug} ${source}` : source;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
function getShortSlug(article: ArticlePublicationItem): string | null {
|
|
3445
|
+
const shortSlug = article.shortSlug?.trim().toUpperCase();
|
|
3446
|
+
return shortSlug || null;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
function formatSectionPage(pageNumber: number): string {
|
|
3450
|
+
return `A${pageNumber}`;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function formatByline(article: ArticlePublicationItem): string {
|
|
3454
|
+
return `${article.byline} / ${article.dateline}`.toUpperCase();
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
function clamp(value: number, min: number, max: number): number {
|
|
3458
|
+
return Math.min(max, Math.max(min, value));
|
|
3459
|
+
}
|