@open-press/core 0.6.0 → 0.7.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 (71) hide show
  1. package/README.md +9 -5
  2. package/engine/cli.mjs +2 -5
  3. package/engine/commands/_shared.mjs +4 -4
  4. package/engine/commands/deploy.mjs +1 -1
  5. package/engine/commands/inspect.mjs +3 -3
  6. package/engine/commands/replace.mjs +1 -1
  7. package/engine/commands/search.mjs +1 -1
  8. package/engine/commands/upgrade.mjs +47 -5
  9. package/engine/commands/validate.mjs +2 -2
  10. package/engine/document-export.mjs +1 -1
  11. package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
  12. package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
  13. package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
  14. package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
  15. package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
  16. package/engine/react/caption-numbering.mjs +73 -0
  17. package/engine/react/comment-marker.mjs +54 -10
  18. package/engine/react/document-entry.mjs +124 -64
  19. package/engine/react/document-export.mjs +266 -310
  20. package/engine/react/mdx-compile.mjs +214 -3
  21. package/engine/react/measurement-css.mjs +3 -3
  22. package/engine/react/pagination/allocator.mjs +122 -0
  23. package/engine/react/pagination/regions.mjs +81 -0
  24. package/engine/react/pagination.mjs +9 -121
  25. package/engine/react/pipeline/allocate.mjs +248 -0
  26. package/engine/react/pipeline/final-render.mjs +94 -0
  27. package/engine/react/pipeline/frame-measurement.mjs +300 -0
  28. package/engine/react/pipeline/press-tree.mjs +135 -0
  29. package/engine/react/project-asset-endpoint.mjs +2 -2
  30. package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
  31. package/engine/react/sources/heading-numbering.mjs +132 -0
  32. package/engine/react/sources/mdx-resolver.mjs +441 -0
  33. package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
  34. package/engine/{config.mjs → runtime/config.mjs} +15 -0
  35. package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
  36. package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
  37. package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
  38. package/engine/runtime/source-workspace.mjs +186 -0
  39. package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
  40. package/package.json +5 -2
  41. package/src/openpress/anchorMap.ts +27 -0
  42. package/src/openpress/core/Frame.tsx +80 -0
  43. package/src/openpress/core/FrameContext.tsx +19 -0
  44. package/src/openpress/core/MdxArea.tsx +35 -0
  45. package/src/openpress/core/Press.tsx +34 -0
  46. package/src/openpress/core/index.tsx +34 -15
  47. package/src/openpress/core/primitives.tsx +23 -0
  48. package/src/openpress/core/types.ts +131 -19
  49. package/src/openpress/core/useSource.ts +28 -0
  50. package/src/openpress/manuscript/index.tsx +196 -0
  51. package/src/openpress/mdx/index.ts +88 -0
  52. package/src/openpress/numbering/index.ts +294 -0
  53. package/src/openpress/publicPage.tsx +4 -186
  54. package/src/openpress/reactDocumentMetadata.ts +2 -16
  55. package/src/openpress/types.ts +0 -16
  56. package/src/openpress/workbench.tsx +2 -36
  57. package/src/styles/openpress/responsive.css +0 -14
  58. package/tsconfig.json +4 -1
  59. package/vite.config.ts +10 -3
  60. package/engine/commands/migrate-to-react.mjs +0 -27
  61. package/engine/page-renderer.mjs +0 -217
  62. package/engine/react/migrate-to-react.mjs +0 -355
  63. package/engine/source-workspace.mjs +0 -76
  64. package/src/openpress/core/basePages.tsx +0 -87
  65. package/src/openpress/pagination.ts +0 -845
  66. /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
  67. /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
  68. /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
  69. /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
  70. /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
  71. /package/engine/{issue-report.mjs → runtime/issue-report.mjs} +0 -0
@@ -31,6 +31,7 @@ export async function compileMdx({
31
31
  components = {},
32
32
  chapterSlug = "document",
33
33
  includeBlockIds = null,
34
+ blockAttributes = null,
34
35
  } = {}) {
35
36
  if (typeof source !== "string") throw new Error("compileMdx requires a string `source`.");
36
37
  if (typeof filePath !== "string" || !filePath.trim()) throw new Error("compileMdx requires `filePath`.");
@@ -39,7 +40,7 @@ export async function compileMdx({
39
40
 
40
41
  const blocks = [];
41
42
  const remarkPlugins = [[remarkMath, { singleDollarTextMath: true }], remarkGfm, [remarkBlockOnlyMdx, { filePath }]];
42
- const rehypePlugins = [rehypeKatex, rehypeTableCaptions, [rehypeBlockIds, { blocks, filePath, chapterSlug, includeBlockIds }]];
43
+ const rehypePlugins = [rehypeKatex, rehypeTableCaptions, [rehypeBlockIds, { blocks, filePath, chapterSlug, includeBlockIds, blockAttributes }]];
43
44
  const mod = await evaluate(mdxSource, {
44
45
  ...jsxRuntime,
45
46
  baseUrl: pathToFileURL(filePath).href,
@@ -78,6 +79,7 @@ export function rehypeBlockIds(options = {}) {
78
79
  const chapterSlug = slugPart(options.chapterSlug ?? "document");
79
80
  const sourceSlug = slugPart(path.basename(filePath, path.extname(filePath)));
80
81
  const includeBlockIds = Array.isArray(options.includeBlockIds) ? new Set(options.includeBlockIds) : null;
82
+ const blockAttributes = normalizeBlockAttributes(options.blockAttributes);
81
83
  let counter = 0;
82
84
 
83
85
  return (tree) => {
@@ -87,22 +89,106 @@ export function rehypeBlockIds(options = {}) {
87
89
 
88
90
  const id = `b-${chapterSlug}-${sourceSlug}-${counter}`;
89
91
  counter += 1;
92
+ if (block.name === "table") {
93
+ return applyTableRowBlocks({
94
+ node,
95
+ id,
96
+ blocks,
97
+ filePath,
98
+ chapterSlug,
99
+ includeBlockIds,
100
+ });
101
+ }
102
+ if (block.name === "ul" || block.name === "ol") {
103
+ return applyListItemBlocks({
104
+ node,
105
+ id,
106
+ blocks,
107
+ filePath,
108
+ chapterSlug,
109
+ includeBlockIds,
110
+ });
111
+ }
90
112
  if (includeBlockIds && !includeBlockIds.has(id)) return false;
91
113
 
92
114
  setDataAttribute(node, "data-openpress-block-id", id);
115
+ const extraAttributes = blockAttributes.get(id);
116
+ if (extraAttributes) {
117
+ for (const [name, value] of Object.entries(extraAttributes)) {
118
+ if (value == null || value === "") continue;
119
+ setDataAttribute(node, name, String(value));
120
+ }
121
+ }
93
122
  blocks.push({
94
123
  id,
95
124
  kind: block.kind,
96
125
  name: block.name,
126
+ text: block.text,
97
127
  filePath,
98
128
  chapterSlug,
99
129
  source: sourcePosition(node.position),
100
130
  });
101
- return true;
131
+ return "skip";
102
132
  });
103
133
  };
104
134
  }
105
135
 
136
+ function applyTableRowBlocks({
137
+ node,
138
+ id,
139
+ blocks,
140
+ filePath,
141
+ chapterSlug,
142
+ includeBlockIds,
143
+ }) {
144
+ const rows = tableBodyRows(node);
145
+ if (rows.length === 0) {
146
+ if (includeBlockIds && !includeBlockIds.has(id)) return false;
147
+ setDataAttribute(node, "data-openpress-block-id", id);
148
+ blocks.push({
149
+ id,
150
+ kind: "element",
151
+ name: "table",
152
+ text: textContent(node).trim() || undefined,
153
+ filePath,
154
+ chapterSlug,
155
+ source: sourcePosition(node.position),
156
+ });
157
+ return "skip";
158
+ }
159
+
160
+ const rowRecords = rows.map((row, index) => ({
161
+ id: `${id}-r${index}`,
162
+ node: row,
163
+ index,
164
+ }));
165
+ const selected = includeBlockIds
166
+ ? rowRecords.filter((row) => includeBlockIds.has(row.id))
167
+ : rowRecords;
168
+ if (selected.length === 0) return false;
169
+
170
+ setDataAttribute(node, "data-openpress-table-id", id);
171
+ const selectedNodes = new Set(selected.map((row) => row.node));
172
+ pruneUnselectedTableRows(node, new Set(rowRecords.map((row) => row.node)), selectedNodes);
173
+ if (selected[0]?.index > 0) stripTableHeader(node);
174
+
175
+ for (const row of selected) {
176
+ setDataAttribute(row.node, "data-openpress-block-id", row.id);
177
+ blocks.push({
178
+ id: row.id,
179
+ kind: "table-row",
180
+ name: "table-row",
181
+ text: textContent(row.node).trim() || undefined,
182
+ filePath,
183
+ chapterSlug,
184
+ tableId: id,
185
+ rowIndex: row.index,
186
+ source: sourcePosition(row.node.position ?? node.position),
187
+ });
188
+ }
189
+ return "skip";
190
+ }
191
+
106
192
  export function remarkBlockOnlyMdx(options = {}) {
107
193
  const filePath = String(options.filePath ?? "document.mdx");
108
194
 
@@ -227,7 +313,7 @@ function normalizeSingleLineDisplayMath(source) {
227
313
 
228
314
  function blockInfo(node) {
229
315
  if (node?.type === "element" && PAGINABLE_TAGS.has(node.tagName)) {
230
- return { kind: "element", name: node.tagName };
316
+ return { kind: "element", name: node.tagName, text: headingText(node) };
231
317
  }
232
318
  if (node?.type === "element" && node.tagName === "span" && hasClassName(node, "katex-display")) {
233
319
  return { kind: "element", name: "math" };
@@ -238,6 +324,130 @@ function blockInfo(node) {
238
324
  return null;
239
325
  }
240
326
 
327
+ function applyListItemBlocks({
328
+ node,
329
+ id,
330
+ blocks,
331
+ filePath,
332
+ chapterSlug,
333
+ includeBlockIds,
334
+ }) {
335
+ const items = listItems(node);
336
+ if (items.length === 0) {
337
+ if (includeBlockIds && !includeBlockIds.has(id)) return false;
338
+ setDataAttribute(node, "data-openpress-block-id", id);
339
+ blocks.push({
340
+ id,
341
+ kind: "element",
342
+ name: node.tagName,
343
+ text: textContent(node).trim() || undefined,
344
+ filePath,
345
+ chapterSlug,
346
+ source: sourcePosition(node.position),
347
+ });
348
+ return "skip";
349
+ }
350
+
351
+ const itemRecords = items.map((item, index) => ({
352
+ id: `${id}-i${index}`,
353
+ node: item,
354
+ index,
355
+ }));
356
+ const selected = includeBlockIds
357
+ ? itemRecords.filter((item) => includeBlockIds.has(item.id))
358
+ : itemRecords;
359
+ if (selected.length === 0) return false;
360
+
361
+ setDataAttribute(node, "data-openpress-list-id", id);
362
+
363
+ // For ordered lists, continuation pages must keep numbering picking up
364
+ // from the first surviving item. `start` is the 1-based number of the
365
+ // first `<li>` rendered, so if the original list had `start="5"` and we
366
+ // dropped the first three items, continuation starts at 5 + 3 = 8.
367
+ if (node.tagName === "ol" && selected[0]?.index > 0) {
368
+ const baseStart = Number(node.properties?.start ?? 1);
369
+ const continuationStart = baseStart + selected[0].index;
370
+ node.properties = { ...node.properties, start: continuationStart };
371
+ }
372
+
373
+ const selectedNodes = new Set(selected.map((item) => item.node));
374
+ pruneUnselectedListItems(node, new Set(itemRecords.map((item) => item.node)), selectedNodes);
375
+
376
+ for (const item of selected) {
377
+ setDataAttribute(item.node, "data-openpress-block-id", item.id);
378
+ blocks.push({
379
+ id: item.id,
380
+ kind: "list-item",
381
+ name: "list-item",
382
+ text: textContent(item.node).trim() || undefined,
383
+ filePath,
384
+ chapterSlug,
385
+ listId: id,
386
+ listTag: node.tagName,
387
+ itemIndex: item.index,
388
+ source: sourcePosition(item.node.position ?? node.position),
389
+ });
390
+ }
391
+ return "skip";
392
+ }
393
+
394
+ function listItems(list) {
395
+ if (list?.type !== "element") return [];
396
+ if (list.tagName !== "ul" && list.tagName !== "ol") return [];
397
+ return (list.children ?? []).filter((child) => child?.type === "element" && child.tagName === "li");
398
+ }
399
+
400
+ function pruneUnselectedListItems(node, itemNodes, selectedNodes) {
401
+ if (!Array.isArray(node?.children)) return;
402
+ node.children = node.children.filter((child) => {
403
+ if (!itemNodes.has(child)) return true;
404
+ return selectedNodes.has(child);
405
+ });
406
+ }
407
+
408
+ function tableBodyRows(table) {
409
+ if (table?.type !== "element" || table.tagName !== "table") return [];
410
+ const rows = [];
411
+ for (const child of table.children ?? []) {
412
+ if (child?.type === "element" && child.tagName === "tbody") {
413
+ for (const row of child.children ?? []) {
414
+ if (row?.type === "element" && row.tagName === "tr") rows.push(row);
415
+ }
416
+ }
417
+ }
418
+ if (rows.length > 0) return rows;
419
+ return (table.children ?? []).filter((child) => child?.type === "element" && child.tagName === "tr");
420
+ }
421
+
422
+ function pruneUnselectedTableRows(node, rowNodes, selectedNodes) {
423
+ if (!Array.isArray(node?.children)) return;
424
+ node.children = node.children.filter((child) => {
425
+ if (!rowNodes.has(child)) return true;
426
+ return selectedNodes.has(child);
427
+ });
428
+ for (const child of node.children) pruneUnselectedTableRows(child, rowNodes, selectedNodes);
429
+ }
430
+
431
+ function stripTableHeader(table) {
432
+ if (!Array.isArray(table?.children)) return;
433
+ table.children = table.children.filter((child) => {
434
+ if (child?.type !== "element") return true;
435
+ return child.tagName !== "caption" && child.tagName !== "thead";
436
+ });
437
+ }
438
+
439
+ function headingText(node) {
440
+ if (!/^h[1-6]$/.test(String(node?.tagName ?? ""))) return undefined;
441
+ return textContent(node).trim() || undefined;
442
+ }
443
+
444
+ function normalizeBlockAttributes(value) {
445
+ if (!value) return new Map();
446
+ if (value instanceof Map) return value;
447
+ if (typeof value === "object") return new Map(Object.entries(value));
448
+ return new Map();
449
+ }
450
+
241
451
  function hasClassName(node, className) {
242
452
  const raw = node?.properties?.className;
243
453
  if (Array.isArray(raw)) return raw.includes(className);
@@ -269,6 +479,7 @@ function visit(node, visitor) {
269
479
  function filterTree(node, visitor) {
270
480
  const keep = visitor(node);
271
481
  if (!keep) return false;
482
+ if (keep === "skip") return true;
272
483
  if (!Array.isArray(node?.children)) return true;
273
484
  node.children = node.children.filter((child) => filterTree(child, visitor));
274
485
  return true;
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { buildComponentsCss, buildContentCss } from "../file-utils.mjs";
6
- import { buildChapterScopedCss } from "./chapter-css.mjs";
5
+ import { buildComponentsCss, buildContentCss } from "../runtime/file-utils.mjs";
6
+ import { buildSectionScopedCss } from "./section-css.mjs";
7
7
 
8
8
  const require = createRequire(import.meta.url);
9
9
 
@@ -15,7 +15,7 @@ export async function buildReactMeasurementCss(root, config, workspace) {
15
15
  parts.push(await buildContentCss(root, config));
16
16
  parts.push("\n/* === public/openpress/components.css === */\n");
17
17
  parts.push(await buildComponentsCss(root, config));
18
- const chapterCss = await buildChapterScopedCss(workspace);
18
+ const chapterCss = await buildSectionScopedCss(workspace);
19
19
  if (chapterCss.trim()) {
20
20
  parts.push("\n/* === public/openpress/chapter-scoped.css === */\n");
21
21
  parts.push(chapterCss);
@@ -0,0 +1,122 @@
1
+ import { DEFAULT_PAGE_SAFE_HEIGHT_PX } from "../pagination-constants.mjs";
2
+ import { singleColumnRegionStream } from "./regions.mjs";
3
+
4
+ // Pure region-based block allocator.
5
+ //
6
+ // Greedy bin-packing: walk measured blocks in order, append to the current
7
+ // region until adding the next block would exceed capacity, then advance to
8
+ // the next region. Pages are a derived view (grouping by pageIndex), so the
9
+ // same code paginates single-column, multi-column, and heterogeneous layouts.
10
+ export function allocateBlocksToRegions(measuredBlocks, regionStream) {
11
+ const filled = [];
12
+ const warnings = [];
13
+ let current = regionStream.next();
14
+ if (!current) {
15
+ return { regions: filled, warnings: [{ code: "out-of-regions" }] };
16
+ }
17
+ let currentBlockIds = [];
18
+ let currentHeight = 0;
19
+
20
+ const flush = () => {
21
+ if (currentBlockIds.length === 0) return;
22
+ filled.push({
23
+ regionId: current.id,
24
+ pageIndex: current.pageIndex,
25
+ columnIndex: current.columnIndex,
26
+ blockIds: currentBlockIds,
27
+ });
28
+ currentBlockIds = [];
29
+ currentHeight = 0;
30
+ };
31
+
32
+ for (const block of measuredBlocks ?? []) {
33
+ const id = String(block?.id ?? "");
34
+ if (!id) continue;
35
+ const height = Math.max(0, Number(block.height) || 0);
36
+
37
+ if (height > current.capacity) {
38
+ warnings.push({
39
+ code: "block-overflows-region",
40
+ blockId: id,
41
+ height,
42
+ regionCapacity: current.capacity,
43
+ regionId: current.id,
44
+ pageIndex: current.pageIndex,
45
+ });
46
+ }
47
+
48
+ if (currentBlockIds.length > 0 && currentHeight + height > current.capacity) {
49
+ flush();
50
+ const next = regionStream.next();
51
+ if (!next) {
52
+ warnings.push({ code: "out-of-regions", blockId: id });
53
+ break;
54
+ }
55
+ current = next;
56
+ }
57
+
58
+ currentBlockIds.push(id);
59
+ currentHeight += height;
60
+ }
61
+
62
+ flush();
63
+ return { regions: filled, warnings };
64
+ }
65
+
66
+ // Derive a flat pages[] view from filled regions. Blocks within a page are
67
+ // emitted in column order (col 0, col 1, ...) — matching how readers consume
68
+ // a multi-column page (left-to-right, top-to-bottom).
69
+ export function pagesFromRegions(filledRegions) {
70
+ const byPage = new Map();
71
+ for (const region of filledRegions) {
72
+ if (!byPage.has(region.pageIndex)) byPage.set(region.pageIndex, []);
73
+ byPage.get(region.pageIndex).push(region);
74
+ }
75
+ const pages = [];
76
+ for (const [pageIndex, regionsOnPage] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
77
+ const sorted = regionsOnPage.slice().sort((a, b) => a.columnIndex - b.columnIndex);
78
+ const blockIds = sorted.flatMap((r) => r.blockIds);
79
+ pages.push({
80
+ pageIndex,
81
+ blockIds,
82
+ breakAfter: blockIds.at(-1),
83
+ });
84
+ }
85
+ return pages;
86
+ }
87
+
88
+ // Public wrapper preserving the legacy (blocks, { pageSafeHeightPx }) signature.
89
+ // New code can pass a `regions` stream directly to opt into multi-column or
90
+ // heterogeneous layouts.
91
+ export function paginateMeasuredBlocks(measuredBlocks, options = {}) {
92
+ const { pageSafeHeightPx = DEFAULT_PAGE_SAFE_HEIGHT_PX, regions } = options;
93
+ const safeHeight = positiveNumber(pageSafeHeightPx, DEFAULT_PAGE_SAFE_HEIGHT_PX);
94
+ const stream = regions ?? singleColumnRegionStream({ pageSafeHeightPx: safeHeight });
95
+ const { regions: filledRegions, warnings } = allocateBlocksToRegions(measuredBlocks, stream);
96
+ const pages = pagesFromRegions(filledRegions);
97
+ return {
98
+ pages,
99
+ regions: filledRegions,
100
+ warnings: warnings.map((w) => mapWarning(w, safeHeight)),
101
+ };
102
+ }
103
+
104
+ // Translate the new region-shaped warnings back to the legacy
105
+ // `block-overflows-page` schema that document-export.mjs and downstream
106
+ // consumers expect. Once consumers migrate, this can drop.
107
+ function mapWarning(warning, pageSafeHeightPx) {
108
+ if (warning.code === "block-overflows-region") {
109
+ return {
110
+ code: "block-overflows-page",
111
+ blockId: warning.blockId,
112
+ height: warning.height,
113
+ pageSafeHeightPx,
114
+ };
115
+ }
116
+ return warning;
117
+ }
118
+
119
+ function positiveNumber(value, fallback) {
120
+ const number = Number(value);
121
+ return Number.isFinite(number) && number > 0 ? number : fallback;
122
+ }
@@ -0,0 +1,81 @@
1
+ // A Region is a fillable area on a page — the engine treats pagination as a
2
+ // stream of regions consumed in order. One single-column page is "one region";
3
+ // a two-column page is two regions on the same pageIndex; a newspaper page
4
+ // could be three (main-left, main-right, sidebar).
5
+ //
6
+ // A RegionStream is a lazy iterator that yields regions on demand. The
7
+ // allocator pulls the next region when the current one is full. This makes
8
+ // "multi-column" and "newspaper-style mixed layout" the same code path as
9
+ // single-column — only the stream differs.
10
+ //
11
+ // Shape:
12
+ // Region = { id: string, capacity: number, pageIndex: number, columnIndex: number }
13
+ // RegionStream = { next(): Region } | Iterable<Region>
14
+
15
+ /**
16
+ * Default region stream: an infinite sequence of single-column pages.
17
+ * Equivalent to today's "one safe-height page after another" behavior.
18
+ */
19
+ export function singleColumnRegionStream({ pageSafeHeightPx }) {
20
+ return iteratorFromGenerator(function* () {
21
+ let pageIndex = 0;
22
+ while (true) {
23
+ yield {
24
+ id: `page-${pageIndex}-col-0`,
25
+ capacity: pageSafeHeightPx,
26
+ pageIndex,
27
+ columnIndex: 0,
28
+ };
29
+ pageIndex++;
30
+ }
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Multi-column region stream: each page yields `columnCount` regions in order,
36
+ * all sharing the same pageIndex but with increasing columnIndex.
37
+ *
38
+ * Blocks fill column 0 first, then column 1, then advance to the next page's
39
+ * column 0 — same greedy semantics as single-column, just more regions per page.
40
+ */
41
+ export function multiColumnRegionStream({ pageSafeHeightPx, columnCount }) {
42
+ const cols = Math.max(1, Math.floor(columnCount) || 1);
43
+ return iteratorFromGenerator(function* () {
44
+ let pageIndex = 0;
45
+ while (true) {
46
+ for (let col = 0; col < cols; col++) {
47
+ yield {
48
+ id: `page-${pageIndex}-col-${col}`,
49
+ capacity: pageSafeHeightPx,
50
+ pageIndex,
51
+ columnIndex: col,
52
+ };
53
+ }
54
+ pageIndex++;
55
+ }
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Build a region stream from an explicit list of regions. Useful for
61
+ * heterogeneous layouts (e.g. a research-article first page with a wide
62
+ * abstract region on top + two narrow columns below). Stream ends when
63
+ * the list is exhausted — the caller is responsible for providing enough
64
+ * regions; the allocator emits an `out-of-regions` warning otherwise.
65
+ */
66
+ export function fixedRegionStream(regions) {
67
+ const list = Array.isArray(regions) ? regions : [];
68
+ return iteratorFromGenerator(function* () {
69
+ for (const region of list) yield region;
70
+ });
71
+ }
72
+
73
+ function iteratorFromGenerator(genFn) {
74
+ const iter = genFn();
75
+ return {
76
+ next() {
77
+ const { value, done } = iter.next();
78
+ return done ? null : value;
79
+ },
80
+ };
81
+ }
@@ -1,121 +1,9 @@
1
- import { chromium } from "playwright";
2
- import {
3
- DEFAULT_PAGE_SAFE_HEIGHT_PX,
4
- PAGE_BODY_FIT_SAFETY_MAX_PX,
5
- PAGE_BODY_FIT_SAFETY_RATIO,
6
- } from "./pagination-constants.mjs";
7
-
8
- const DEFAULT_VIEWPORT = { width: 794, height: 1123 };
9
-
10
- export function paginateMeasuredBlocks(measuredBlocks, { pageSafeHeightPx = DEFAULT_PAGE_SAFE_HEIGHT_PX } = {}) {
11
- const safeHeight = positiveNumber(pageSafeHeightPx, DEFAULT_PAGE_SAFE_HEIGHT_PX);
12
- const pages = [];
13
- const warnings = [];
14
- let currentBlockIds = [];
15
- let currentHeight = 0;
16
-
17
- for (const block of measuredBlocks ?? []) {
18
- const id = String(block?.id ?? "");
19
- if (!id) continue;
20
- const height = Math.max(0, Number(block.height) || 0);
21
-
22
- if (height > safeHeight) {
23
- warnings.push({
24
- code: "block-overflows-page",
25
- blockId: id,
26
- height,
27
- pageSafeHeightPx: safeHeight,
28
- });
29
- }
30
-
31
- if (currentBlockIds.length > 0 && currentHeight + height > safeHeight) {
32
- pages.push(pageRecord(pages.length, currentBlockIds));
33
- currentBlockIds = [];
34
- currentHeight = 0;
35
- }
36
-
37
- currentBlockIds.push(id);
38
- currentHeight += height;
39
- }
40
-
41
- if (currentBlockIds.length > 0) {
42
- pages.push(pageRecord(pages.length, currentBlockIds));
43
- }
44
-
45
- return { pages, warnings };
46
- }
47
-
48
- export async function measureBlocksInChromium({
49
- html,
50
- css = "",
51
- pageSafeHeightPx,
52
- viewport = DEFAULT_VIEWPORT,
53
- } = {}) {
54
- const browser = await chromium.launch();
55
- try {
56
- const page = await browser.newPage({ viewport });
57
- await page.setContent(measurementDocument(html, css), { waitUntil: "load" });
58
- await page.evaluate(async () => {
59
- if (document.fonts?.ready) await document.fonts.ready;
60
- });
61
- const measurements = await page.evaluate(() => (
62
- Array.from(document.querySelectorAll("[data-openpress-block-id]")).map((element) => ({
63
- id: element.getAttribute("data-openpress-block-id"),
64
- height: element.getBoundingClientRect().height,
65
- }))
66
- ));
67
- const safeHeight = positiveNumber(pageSafeHeightPx, null)
68
- ?? await measurePageSafeHeight(page)
69
- ?? DEFAULT_PAGE_SAFE_HEIGHT_PX;
70
- return {
71
- measurements,
72
- pageSafeHeightPx: safeHeight,
73
- ...paginateMeasuredBlocks(measurements, { pageSafeHeightPx: safeHeight }),
74
- };
75
- } finally {
76
- await browser.close();
77
- }
78
- }
79
-
80
- async function measurePageSafeHeight(page) {
81
- return page.evaluate(({ ratio, maxPx }) => {
82
- const body = document.querySelector(".reader-page[data-page-kind='content'] .page-body")
83
- ?? document.querySelector(".reader-page--content .page-body")
84
- ?? document.querySelector(".page-body");
85
- if (!body) return null;
86
- const height = body.getBoundingClientRect().height;
87
- if (!Number.isFinite(height) || height <= 0) return null;
88
- const safetyInset = Math.min(maxPx, Math.max(0, height * ratio));
89
- return Math.max(1, height - safetyInset);
90
- }, {
91
- ratio: PAGE_BODY_FIT_SAFETY_RATIO,
92
- maxPx: PAGE_BODY_FIT_SAFETY_MAX_PX,
93
- });
94
- }
95
-
96
- function pageRecord(pageIndex, blockIds) {
97
- return {
98
- pageIndex,
99
- blockIds,
100
- breakAfter: blockIds.at(-1),
101
- };
102
- }
103
-
104
- function positiveNumber(value, fallback) {
105
- const number = Number(value);
106
- return Number.isFinite(number) && number > 0 ? number : fallback;
107
- }
108
-
109
- function measurementDocument(html = "", css = "") {
110
- return `<!doctype html>
111
- <html>
112
- <head>
113
- <meta charset="utf-8">
114
- <style>
115
- body { margin: 0; }
116
- ${css}
117
- </style>
118
- </head>
119
- <body>${html}</body>
120
- </html>`;
121
- }
1
+ // Public surface for the build-time region allocator.
2
+ //
3
+ // The Press Tree pipeline measures MdxArea capacities and block heights in
4
+ // `engine/react/pipeline/frame-measurement.mjs` and runs allocation through
5
+ // these helpers. The region kernel is also usable on its own for custom
6
+ // pipelines or unit tests.
7
+
8
+ export { paginateMeasuredBlocks, allocateBlocksToRegions, pagesFromRegions } from "./pagination/allocator.mjs";
9
+ export { singleColumnRegionStream, multiColumnRegionStream, fixedRegionStream } from "./pagination/regions.mjs";