@anthusai/papyrus 1.2.0 → 1.4.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  2. package/app/newsroom/[section]/page.tsx +6 -0
  3. package/app/newsroom/articles/page.tsx +3 -0
  4. package/components/news-desk-client-provider.tsx +17 -2
  5. package/components/news-desk-taxonomy-client.ts +133 -2
  6. package/components/newspaper.tsx +4 -4
  7. package/components/newsroom-board-view.tsx +74 -50
  8. package/components/newsroom-client-shell.tsx +8 -1
  9. package/components/newsroom-organizer-shell.tsx +12 -7
  10. package/components/newsroom-organizer.css +4 -3
  11. package/components/newsroom-references-view.tsx +18 -0
  12. package/components/reference-cyclotron-priming.tsx +85 -0
  13. package/components/topic-steering-workspace.tsx +35 -1
  14. package/lib/article-flow.ts +95 -0
  15. package/lib/article-frame-solver.ts +519 -0
  16. package/lib/chrome-text.ts +504 -0
  17. package/lib/content-item-lifecycle.ts +25 -26
  18. package/lib/front-mosaic.ts +1132 -0
  19. package/lib/furniture.ts +385 -0
  20. package/lib/labels.ts +37 -0
  21. package/lib/layout-config.ts +237 -0
  22. package/lib/layout-plan.ts +37 -9
  23. package/lib/layout-scenarios.ts +21 -11
  24. package/lib/layout-types.ts +457 -0
  25. package/lib/local-preview-seed.ts +97 -0
  26. package/lib/local-preview.ts +3 -0
  27. package/lib/newspaper-layout.ts +17 -3410
  28. package/lib/newsroom-board.ts +63 -62
  29. package/lib/newsroom-organizer-nav.ts +3 -4
  30. package/lib/pretext-layout.ts +22 -0
  31. package/lib/relevance-status.ts +73 -0
  32. package/lib/site-brand.ts +15 -1
  33. package/lib/text-columns.ts +251 -0
  34. package/package.json +13 -2
  35. package/renderers/pretext/presentation-shell.tsx +1 -1
  36. package/routes.manifest.json +1 -9
  37. package/app/newsroom/work/page.tsx +0 -10
@@ -1,14 +1,15 @@
1
1
  import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
2
- import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
2
+ import type { AssignmentRecord } from "./category-repository";
3
3
  import {
4
- CONTENT_ITEM_STAGES,
4
+ contentItemStageKeys,
5
5
  contentItemStageLabel,
6
6
  latestVersionPerContentItem,
7
7
  type ContentItemVersionRecord,
8
8
  } from "./content-item-lifecycle";
9
- import { getContentItemName } from "./site-brand";
9
+ import { assignmentStatusLabel } from "./newsroom-assignments";
10
+ import { getContentItemName, getContentItemStageDefinitions } from "./site-brand";
10
11
 
11
- export type NativeBoardKind = "contentItem" | "reference" | "assignment";
12
+ export type NativeBoardKind = "contentItem" | "assignment";
12
13
  export interface NativeBoardIssue extends KanbanIssue {
13
14
  kind: NativeBoardKind;
14
15
  nativeId: string;
@@ -20,18 +21,14 @@ export function contentItemBoardType(): string {
20
21
  return getContentItemName().singular.toLowerCase();
21
22
  }
22
23
 
23
- function boardTypeForKind(kind: NativeBoardKind): string {
24
- return kind === "contentItem" ? contentItemBoardType() : kind;
25
- }
24
+ export const ASSIGNMENT_BOARD_TYPE = "assignment";
25
+
26
+ const ASSIGNMENT_STATUSES_BEFORE_OTHERS = ["open", "claimed"];
27
+ const ASSIGNMENT_STATUSES_AFTER_OTHERS = ["completed", "canceled"];
26
28
 
27
- /**
28
- * One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
29
- * are counted on that item's card rather than shown as separate cards. References and other work keep their own
30
- * identity and native status.
31
- */
32
- export function projectNewsroomBoard(records: {
33
- contentItemVersions?: readonly ContentItemVersionRecord[];
34
- references?: readonly ReferenceRecord[];
29
+ /** One card per content item, in the column for its lifecycle stage; its assignments are counted on the card. */
30
+ export function projectContentItemBoard(records: {
31
+ contentItemVersions: readonly ContentItemVersionRecord[];
35
32
  assignments?: readonly AssignmentRecord[];
36
33
  }): NativeBoardIssue[] {
37
34
  const assignmentCountByContentItem = new Map<string, number>();
@@ -41,62 +38,66 @@ export function projectNewsroomBoard(records: {
41
38
  }
42
39
  }
43
40
  const contentItemType = contentItemBoardType();
44
- return [
45
- ...latestVersionPerContentItem(records.contentItemVersions ?? []).map(version => {
46
- const stage = version.stage ?? "idea";
47
- const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
48
- return {
49
- id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
50
- title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
51
- priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
52
- updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
53
- };
54
- }),
55
- ...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
56
- id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
57
- title: row.title || row.externalItemId || row.id,
58
- nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
59
- priority: 2, description: row.curationStatusReason || undefined,
60
- updated_at: row.updatedAt || row.importedAt || undefined,
61
- href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
62
- })),
63
- ...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
64
- id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
65
- title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
66
- priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
67
- description: row.summary || row.brief || undefined,
68
- created_at: row.createdAt, updated_at: row.updatedAt,
69
- href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
70
- })),
71
- ];
41
+ return latestVersionPerContentItem(records.contentItemVersions).map(version => {
42
+ const stage = version.stage ?? "idea";
43
+ const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
44
+ return {
45
+ id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
46
+ title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
47
+ priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
48
+ updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
49
+ };
50
+ });
72
51
  }
73
52
 
74
- export function contentItemColumnOrder(stagesPresentOnBoard: readonly string[]): string[] {
75
- const lastStage = CONTENT_ITEM_STAGES[CONTENT_ITEM_STAGES.length - 1];
76
- const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !(CONTENT_ITEM_STAGES as readonly string[]).includes(stage));
77
- return [...CONTENT_ITEM_STAGES.filter(stage => stage !== lastStage), ...unrecognizedStages, lastStage];
53
+ /** One card per assignment, in the column for its status. */
54
+ export function projectAssignmentBoard(assignments: readonly AssignmentRecord[]): NativeBoardIssue[] {
55
+ return assignments.map(row => ({
56
+ id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: ASSIGNMENT_BOARD_TYPE,
57
+ title: row.title, nativeStatus: row.status, status: `${ASSIGNMENT_BOARD_TYPE}/${row.status}`,
58
+ priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
59
+ description: row.summary || row.brief || undefined,
60
+ created_at: row.createdAt, updated_at: row.updatedAt,
61
+ href: `/newsroom/assignments/${encodeURIComponent(row.id)}`,
62
+ }));
78
63
  }
79
64
 
80
- /** Content items read left to right through the lifecycle with the final stage last; other record kinds follow it. */
81
- export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
82
- const contentItemType = contentItemBoardType();
83
- const kinds: NativeBoardKind[] = ["contentItem", "reference", "assignment"];
84
- const contentItemStatuses = contentItemColumnOrder(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus))
85
- .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage), category: "native" }));
86
- const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
87
- .map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
88
- const statuses = [...contentItemStatuses, ...otherStatuses];
65
+ export function contentItemColumnOrder(
66
+ stagesPresentOnBoard: readonly string[],
67
+ stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
68
+ ): string[] {
69
+ const lastStage = stageKeys[stageKeys.length - 1];
70
+ const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
71
+ return [...stageKeys.filter(stage => stage !== lastStage), ...unrecognizedStages, lastStage];
72
+ }
73
+
74
+ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[]): string[] {
75
+ const knownStatuses = [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
76
+ const otherStatuses = [...new Set(statusesPresentOnBoard)].filter(status => !knownStatuses.includes(status));
77
+ return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
78
+ }
79
+
80
+ function boardConfigForType(type: string, columns: readonly { key: string; name: string }[]): KanbanConfig {
81
+ const statuses = columns.map(column => ({ ...column, category: "native" }));
89
82
  return {
90
- hierarchy: [], types: kinds.map(boardTypeForKind),
91
- statuses,
83
+ hierarchy: [], types: [type], statuses,
92
84
  categories: [{ name: "native" }], priorities: {}, type_colors: {},
93
- workflows: Object.fromEntries(kinds.map(kind => {
94
- const type = boardTypeForKind(kind);
95
- return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
96
- })),
85
+ workflows: { [type]: Object.fromEntries(statuses.map(status => [status.key, []])) },
97
86
  };
98
87
  }
99
88
 
89
+ /** Content items read left to right through the lifecycle with the final stage last. */
90
+ export function contentItemBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
91
+ const contentItemType = contentItemBoardType();
92
+ return boardConfigForType(contentItemType, contentItemColumnOrder(issues.map(issue => issue.nativeStatus))
93
+ .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage, getContentItemStageDefinitions()) })));
94
+ }
95
+
96
+ export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
97
+ return boardConfigForType(ASSIGNMENT_BOARD_TYPE, assignmentColumnOrder(issues.map(issue => issue.nativeStatus))
98
+ .map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
99
+ }
100
+
100
101
  export function canReadNewsroomBoard(phase: string | undefined): boolean {
101
102
  return phase === "ready";
102
103
  }
@@ -8,9 +8,8 @@ export type OrganizerDestination = {
8
8
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
9
9
  { title: "Editorial work", items: [
10
10
  { key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
11
- { key: "board", label: "Board", description: `Follow ${contentItemPluralLowercase}, sources and assignments across their stages.`, path: "/newsroom/work", primary: true },
12
- { key: "articles", label: getContentItemName().plural, description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
13
- { key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
11
+ { key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: "/newsroom/articles", primary: true },
12
+ { key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
14
13
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
15
14
  { key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
16
15
  ] },
@@ -30,7 +29,7 @@ export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly Org
30
29
  ] },
31
30
  ];
32
31
  const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
33
- export const ORGANIZER_PRIMARY_NAV = ["overview", "board", "references", "messages"].map(key => all.find(item => item.key === key)!);
32
+ export const ORGANIZER_PRIMARY_NAV = ["overview", "articles", "references", "messages"].map(key => all.find(item => item.key === key)!);
34
33
 
35
34
  export const ORGANIZER_SETTINGS_DESTINATION = {
36
35
  label: "Settings", path: "/newsroom/settings", description: "Theme, motion and interface preferences.",
@@ -9,6 +9,27 @@ import {
9
9
 
10
10
  export { layoutNextLine, prepareWithSegments, type LayoutCursor, type PreparedTextWithSegments };
11
11
 
12
+ const preparedTextCache = new Map<string, PreparedTextWithSegments>();
13
+ let preparedTextCacheMissCount = 0;
14
+
15
+ export function getPreparedTextCacheMissCount(): number {
16
+ return preparedTextCacheMissCount;
17
+ }
18
+
19
+ export function prepareWithSegmentsCached(
20
+ text: string,
21
+ font: string,
22
+ options?: { whiteSpace?: "normal" | "pre-wrap" },
23
+ ): PreparedTextWithSegments {
24
+ const key = `${options?.whiteSpace ?? "normal"}\u0000${font}\u0000${text}`;
25
+ const cached = preparedTextCache.get(key);
26
+ if (cached) return cached;
27
+ preparedTextCacheMissCount += 1;
28
+ const prepared = prepareWithSegments(text, font, options);
29
+ preparedTextCache.set(key, prepared);
30
+ return prepared;
31
+ }
32
+
12
33
  export type TextLine = {
13
34
  text: string;
14
35
  width: number;
@@ -184,5 +205,6 @@ function clamp(value: number, min: number, max: number): number {
184
205
  }
185
206
 
186
207
  export function clearPretextMeasurementCache(): void {
208
+ preparedTextCache.clear();
187
209
  clearCache();
188
210
  }
@@ -61,3 +61,76 @@ export const DEMO_CYCLOTRON_STATUS: CyclotronStatus = {
61
61
  lastChange: null,
62
62
  pending: { decisionsAwaitingReview: 1, staleSince: null },
63
63
  };
64
+
65
+ /**
66
+ * What the Newsroom assumes until a decision names the cyclotron: the defaults
67
+ * of the steering config's relevanceCyclotron block (src/papyrus_content/relevance_cyclotron.py).
68
+ */
69
+ export const DEFAULT_RELEVANCE_CYCLOTRON = { id: "papyrus-relevance", labels: ["include", "exclude"] } as const;
70
+
71
+ /** The payload kind of a priming request; the sweep lists the cyclotron's payloads and filters on it. */
72
+ export const PRIMING_REQUEST_KIND = "cyclotron-priming-request";
73
+
74
+ /** The attachment role the sweep writes a request's outcome under. */
75
+ export const PRIMING_OUTCOME_ROLE = "priming_outcome";
76
+
77
+ /** The KnowledgeRawPayload an editor writes to ask the next sweep to prime the cyclotron with one URL. */
78
+ export function primingRequestPayloadId(cyclotronId: string, requestId: string): string {
79
+ return `knowledge-raw-payload-cyclotron-priming-request-${safeId(cyclotronId)}-${safeId(requestId)}`;
80
+ }
81
+
82
+ export type PrimingRequest = {
83
+ requestId: string;
84
+ url: string;
85
+ label: string;
86
+ explanation: string;
87
+ requestedBy: string;
88
+ requestedAt: string;
89
+ };
90
+
91
+ export type PrimingOutcome =
92
+ | { state: "applied"; version: number; at: string; itemId?: string }
93
+ | { state: "failed"; message: string; at: string };
94
+
95
+ /** A saved request with the sweep's outcome, once there is one. */
96
+ export type PrimingRequestView = { request: PrimingRequest; outcome: PrimingOutcome | null };
97
+
98
+ export function buildPrimingRequest(
99
+ input: { url: string; label: string; explanation?: string },
100
+ requestedBy: string,
101
+ labels: readonly string[],
102
+ now: Date = new Date(),
103
+ requestId: string = `${now.getTime().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
104
+ ): PrimingRequest {
105
+ let parsed: URL | null = null;
106
+ try {
107
+ parsed = new URL(input.url.trim());
108
+ } catch {
109
+ parsed = null;
110
+ }
111
+ if (!parsed || parsed.protocol !== "https:") throw new Error("Paste an https URL.");
112
+ if (!labels.includes(input.label)) throw new Error(`Choose a label: ${labels.join(" or ")}.`);
113
+ return {
114
+ requestId,
115
+ url: parsed.toString(),
116
+ label: input.label,
117
+ explanation: (input.explanation ?? "").trim(),
118
+ requestedBy,
119
+ requestedAt: now.toISOString(),
120
+ };
121
+ }
122
+
123
+ export function isPrimingOutcome(value: unknown): value is PrimingOutcome {
124
+ if (!value || typeof value !== "object") return false;
125
+ const outcome = value as Record<string, unknown>;
126
+ return (outcome.state === "applied" && typeof outcome.version === "number")
127
+ || (outcome.state === "failed" && typeof outcome.message === "string");
128
+ }
129
+
130
+ /** In words: waiting for the next sweep, applied as version N, or failed with the reason. */
131
+ export function primingStateText(view: PrimingRequestView): string {
132
+ if (!view.outcome) return "Waiting for the next sweep";
133
+ return view.outcome.state === "applied"
134
+ ? `Applied as version ${view.outcome.version}`
135
+ : `Failed: ${view.outcome.message}`;
136
+ }
package/lib/site-brand.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isLocalPreviewEnabled } from "./local-preview";
1
2
  import type { ComponentType } from "react";
2
3
  import type { EditionPresentationFormat } from "./content-types";
3
4
  import type { Article, ArticleVideoAsset } from "./articles";
@@ -5,6 +6,7 @@ import type { BlogPageBackgroundProps } from "../components/blog-page-background
5
6
  import type { SiteBrandFont } from "./brand-fonts";
6
7
  import type { PictogramFigureProps } from "../components/pictogram-figure";
7
8
  import papyrusSite from "papyrus-site";
9
+ import { DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS, type ContentItemStageDefinition } from "./content-item-lifecycle";
8
10
  import {
9
11
  DEFAULT_THEME_PACK_TOKENS,
10
12
  type HostingConfig,
@@ -92,6 +94,8 @@ export type SiteBrand = {
92
94
  articleTitleSuffix: string;
93
95
  /** Defaults to Story / Stories, the newsroom term. */
94
96
  contentItemName?: ContentItemName;
97
+ /** The lifecycle stages in board order, last one being publication. Defaults to the standard newsroom lifecycle. */
98
+ contentItemStages?: readonly ContentItemStageDefinition[];
95
99
  placeholderByline: string;
96
100
  defaultPresentation: EditionPresentationFormat;
97
101
  forcedPresentation?: EditionPresentationFormat;
@@ -181,11 +185,17 @@ const REFERENCE_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
181
185
  * The Papyrus reference brand plus whatever the publication registered in its
182
186
  * own `papyrus.config.ts` (resolved through the `papyrus-site` alias).
183
187
  */
184
- const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
188
+ const REGISTERED_SITE_BRANDS: Record<SiteBrandId, SiteBrand> = {
185
189
  ...REFERENCE_SITE_BRANDS,
186
190
  ...Object.fromEntries((papyrusSite.brands ?? []).map((brand) => [brand.id, brand])),
187
191
  };
188
192
 
193
+ const SITE_BRANDS: Record<SiteBrandId, SiteBrand> = isLocalPreviewEnabled()
194
+ ? Object.fromEntries(
195
+ Object.entries(REGISTERED_SITE_BRANDS).map(([id, brand]) => [id, { ...brand, newsroomLayout: "organizer" as const }]),
196
+ )
197
+ : REGISTERED_SITE_BRANDS;
198
+
189
199
  export function normalizeSiteBrandId(value: string | undefined | null): SiteBrandId | null {
190
200
  if (!value) return null;
191
201
  const trimmed = value.trim();
@@ -223,6 +233,10 @@ export function getContentItemName(brand: SiteBrand = SITE_BRAND): ContentItemNa
223
233
  return brand.contentItemName ?? DEFAULT_CONTENT_ITEM_NAME;
224
234
  }
225
235
 
236
+ export function getContentItemStageDefinitions(brand: SiteBrand = SITE_BRAND): readonly ContentItemStageDefinition[] {
237
+ return brand.contentItemStages?.length ? brand.contentItemStages : DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS;
238
+ }
239
+
226
240
  /** Root-route config for a brand, defaulting to `{ kind: "reader" }`. */
227
241
  export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
228
242
  return brand.rootRoute ?? { kind: "reader" };
@@ -0,0 +1,251 @@
1
+ import { type ArticlePublicationItem, getPublicationItemText } from "./publication-items";
2
+ import {
3
+ type LayoutCursor,
4
+ layoutTextLines,
5
+ type PreparedTextWithSegments,
6
+ prepareWithSegmentsCached,
7
+ type TextLine,
8
+ type TextObstacle,
9
+ } from "./pretext-layout";
10
+ import { reserveRhythmRows, type VerticalRhythm } from "./blog-rhythm";
11
+ import {
12
+ type FurnitureSufficiencyReport,
13
+ type LayoutConfig,
14
+ type SolvedChromeBox,
15
+ type SolvedFurniture,
16
+ } from "./layout-types";
17
+ import { SERIF_TEXT_FONT, clamp, getSpanWidth } from "./layout-config";
18
+ import { rectsOverlap } from "./furniture";
19
+
20
+ export function layoutTextColumns({
21
+ item,
22
+ cursor,
23
+ columnCount,
24
+ textHeight,
25
+ maximumTextHeightByColumn = [],
26
+ localConfig,
27
+ furniture,
28
+ chromeBoxes = [],
29
+ furnitureObstacleOffsetY = 0,
30
+ furnitureObstacleMarginBottom = 0,
31
+ minimumLineStartYByColumn = [],
32
+ }: {
33
+ item: ArticlePublicationItem;
34
+ cursor: LayoutCursor;
35
+ columnCount: number;
36
+ textHeight: number;
37
+ maximumTextHeightByColumn?: number[];
38
+ localConfig: LayoutConfig;
39
+ furniture: SolvedFurniture[];
40
+ chromeBoxes?: SolvedChromeBox[];
41
+ furnitureObstacleOffsetY?: number;
42
+ furnitureObstacleMarginBottom?: number;
43
+ minimumLineStartYByColumn?: number[];
44
+ }): { columns: TextLine[][]; cursor: LayoutCursor; hasMore: boolean } {
45
+ const columns: TextLine[][] = [];
46
+ const preparedText = getPrepared(item, localConfig.bodyFont);
47
+ const columnWidth = getSpanWidth(localConfig, 1);
48
+ let current = { ...cursor };
49
+ let hasMore = true;
50
+
51
+ for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
52
+ const result = layoutTextLines({
53
+ prepared: preparedText,
54
+ cursor: current,
55
+ maxHeight: maximumTextHeightByColumn[columnIndex] ?? textHeight,
56
+ maxWidth: columnWidth,
57
+ lineHeight: localConfig.lineHeight,
58
+ linePaintHeight: localConfig.linePaintHeight,
59
+ fontSize: localConfig.bodyFontSize,
60
+ fontFamily: SERIF_TEXT_FONT,
61
+ obstacles: getColumnTextObstacles(
62
+ furniture,
63
+ columnIndex,
64
+ chromeBoxes,
65
+ furnitureObstacleOffsetY,
66
+ furnitureObstacleMarginBottom,
67
+ minimumLineStartYByColumn[columnIndex] ?? 0,
68
+ ),
69
+ });
70
+ columns.push(result.lines);
71
+ current = result.cursor;
72
+ hasMore = result.hasMore;
73
+ if (!hasMore) {
74
+ while (columns.length < columnCount) columns.push([]);
75
+ break;
76
+ }
77
+ }
78
+
79
+ return { columns, cursor: current, hasMore };
80
+ }
81
+
82
+ export function getPrepared(article: ArticlePublicationItem, font: string): PreparedTextWithSegments {
83
+ return prepareWithSegmentsCached(getPublicationItemText(article), font, { whiteSpace: "pre-wrap" });
84
+ }
85
+
86
+ export function getLineLimitHeight(maxLines: number, lineHeight: number, linePaintHeight: number, rhythm: VerticalRhythm): number {
87
+ if (maxLines <= 0) return 0;
88
+ return reserveRhythmRows((maxLines - 1) * lineHeight + linePaintHeight, rhythm);
89
+ }
90
+
91
+ function getColumnTextObstacles(
92
+ furniture: SolvedFurniture[],
93
+ columnIndex: number,
94
+ chromeBoxes: SolvedChromeBox[] = [],
95
+ furnitureOffsetY = 0,
96
+ furnitureMarginBottom = 0,
97
+ minimumLineStartY = 0,
98
+ ): TextObstacle[] {
99
+ const topObstacle: TextObstacle[] = minimumLineStartY > 0
100
+ ? [{ x: 0, y: 0, width: Number.POSITIVE_INFINITY, height: minimumLineStartY }]
101
+ : [];
102
+ const furnitureObstacles = furniture
103
+ .filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
104
+ .map((item) => ({
105
+ x: 0,
106
+ y: item.y + furnitureOffsetY,
107
+ width: Number.POSITIVE_INFINITY,
108
+ height: item.height + getFurnitureObstacleMarginBottom(item, furnitureMarginBottom),
109
+ }));
110
+ const imageClearanceObstacles = furniture
111
+ .filter((item) => (
112
+ item.kind === "image" &&
113
+ item.wrapsText &&
114
+ furnitureMarginBottom > 0 &&
115
+ columnIndex >= item.columnStart &&
116
+ columnIndex < item.columnStart + item.columnSpan
117
+ ))
118
+ .map((item) => ({
119
+ x: 0,
120
+ y: item.y + furnitureOffsetY + item.height,
121
+ width: Number.POSITIVE_INFINITY,
122
+ height: furnitureMarginBottom,
123
+ }));
124
+ const chromeObstacles = chromeBoxes
125
+ .filter((box) => columnIndex >= box.columnStart && columnIndex < box.columnStart + box.columnSpan)
126
+ .map((box) => ({
127
+ x: 0,
128
+ y: box.y,
129
+ width: Number.POSITIVE_INFINITY,
130
+ height: box.height,
131
+ }));
132
+ return [...topObstacle, ...furnitureObstacles, ...imageClearanceObstacles, ...chromeObstacles];
133
+ }
134
+
135
+ function getFurnitureObstacleMarginBottom(item: SolvedFurniture, marginBottom: number): number {
136
+ return item.kind === "image" || item.kind === "pullQuote" ? marginBottom : 0;
137
+ }
138
+
139
+ export function getColumnWhitespace(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
140
+ return columns.reduce(
141
+ (total, column, columnIndex) =>
142
+ total + Math.max(0, textHeight - getLinesHeight(column) - getColumnObstacleHeight(furniture, columnIndex, textHeight)),
143
+ 0,
144
+ );
145
+ }
146
+
147
+ export function getFurnitureSufficiencyReport({
148
+ columns,
149
+ textHeight,
150
+ furniture,
151
+ hasMore,
152
+ config,
153
+ }: {
154
+ columns: TextLine[][];
155
+ textHeight: number;
156
+ furniture: SolvedFurniture[];
157
+ hasMore: boolean;
158
+ config: LayoutConfig;
159
+ }): FurnitureSufficiencyReport {
160
+ const visibleRows = columns.reduce((total, column) => total + column.length, 0);
161
+ const furnitureRows = furniture.reduce(
162
+ (total, item) => total + getFurnitureRowBurden(item, config.rhythm.rowHeight),
163
+ 0,
164
+ );
165
+ const accepted = (reason?: string): FurnitureSufficiencyReport => ({
166
+ accepted: !reason,
167
+ reason,
168
+ visibleRows,
169
+ furnitureRows,
170
+ });
171
+
172
+ if (furniture.length === 0 || furnitureRows === 0) return accepted();
173
+ const overlapReason = getFurnitureOverlapReason(furniture);
174
+ if (overlapReason) return accepted(overlapReason);
175
+ const minimumReadableRows = columns.length * (furniture.some((item) => item.kind === "pullQuote") ? 6 : 3);
176
+ if (visibleRows * 4 < furnitureRows) {
177
+ return accepted("visible-copy-far-below-furniture-burden");
178
+ }
179
+ if (visibleRows < furnitureRows && visibleRows < minimumReadableRows) {
180
+ return accepted("visible-copy-below-furniture-burden");
181
+ }
182
+
183
+ const finalTextColumnIndex = columns.reduce((lastIndex, column, columnIndex) => (
184
+ column.length > 0 ? columnIndex : lastIndex
185
+ ), -1);
186
+ const exhausted = !hasMore;
187
+ for (let columnIndex = 0; columnIndex < columns.length; columnIndex += 1) {
188
+ const visibleColumnRows = columns[columnIndex].length;
189
+ const obstacleRows = Math.ceil(getColumnObstacleHeight(furniture, columnIndex, textHeight) / config.rhythm.rowHeight);
190
+ const availableRows = Math.floor(textHeight / config.rhythm.rowHeight) - obstacleRows;
191
+ const hasSubstantialFurniture = obstacleRows >= 6;
192
+ const isAfterExhaustedText = exhausted && columnIndex > finalTextColumnIndex;
193
+ if (!hasSubstantialFurniture && !isAfterExhaustedText && availableRows >= 3 && visibleColumnRows < 3) {
194
+ return accepted("open-column-below-minimum-copy");
195
+ }
196
+ if (!hasSubstantialFurniture && visibleColumnRows === 1 && columnIndex < finalTextColumnIndex) {
197
+ return accepted("non-final-column-single-copy-row");
198
+ }
199
+ }
200
+
201
+ return accepted();
202
+ }
203
+
204
+ function getFurnitureRowBurden(item: SolvedFurniture, rowHeight: number): number {
205
+ if (item.kind !== "image" && item.kind !== "pullQuote") return 0;
206
+ return Math.ceil(item.height / rowHeight) * item.columnSpan;
207
+ }
208
+
209
+ function getFurnitureOverlapReason(furniture: SolvedFurniture[]): string | undefined {
210
+ for (let firstIndex = 0; firstIndex < furniture.length; firstIndex += 1) {
211
+ for (let secondIndex = firstIndex + 1; secondIndex < furniture.length; secondIndex += 1) {
212
+ if (rectsOverlap(furniture[firstIndex], furniture[secondIndex])) return "furniture-overlap";
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ function getColumnObstacleHeight(furniture: SolvedFurniture[], columnIndex: number, textHeight: number): number {
219
+ const intervals = furniture
220
+ .filter((item) => item.wrapsText && columnIndex >= item.columnStart && columnIndex < item.columnStart + item.columnSpan)
221
+ .map((item) => ({
222
+ start: clamp(item.y, 0, textHeight),
223
+ end: clamp(item.y + item.height, 0, textHeight),
224
+ }))
225
+ .filter((interval) => interval.end > interval.start)
226
+ .sort((a, b) => a.start - b.start);
227
+
228
+ const merged: Array<{ start: number; end: number }> = [];
229
+ for (const interval of intervals) {
230
+ const previous = merged[merged.length - 1];
231
+ if (previous && interval.start <= previous.end) previous.end = Math.max(previous.end, interval.end);
232
+ else merged.push({ ...interval });
233
+ }
234
+ return merged.reduce((total, interval) => total + interval.end - interval.start, 0);
235
+ }
236
+
237
+ export function getDeadColumnCount(columns: TextLine[][], textHeight: number, furniture: SolvedFurniture[]): number {
238
+ return columns.reduce((count, column, columnIndex) => {
239
+ if (column.length > 0) return count;
240
+ const obstacleHeight = getColumnObstacleHeight(furniture, columnIndex, textHeight);
241
+ return obstacleHeight >= Math.min(96, textHeight * 0.18) ? count : count + 1;
242
+ }, 0);
243
+ }
244
+
245
+ export function getLinesHeight(lines: TextLine[]): number {
246
+ return lines.length === 0 ? 0 : Math.max(...lines.map((line) => line.y + line.paintHeight));
247
+ }
248
+
249
+ export function getFurnitureBottom(furniture: SolvedFurniture[]): number {
250
+ return furniture.reduce((bottom, item) => Math.max(bottom, item.y + item.height), 0);
251
+ }