@open-press/core 0.5.0 → 0.7.0
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/README.md +9 -5
- package/engine/cli.mjs +2 -5
- package/engine/commands/_shared.mjs +4 -4
- package/engine/commands/deploy.mjs +1 -1
- package/engine/commands/inspect.mjs +3 -3
- package/engine/commands/replace.mjs +1 -1
- package/engine/commands/search.mjs +1 -1
- package/engine/commands/validate.mjs +2 -2
- package/engine/document-export.mjs +1 -1
- package/engine/{chrome-pdf.mjs → output/chrome-pdf.mjs} +1 -2
- package/engine/{deploy-sync.mjs → output/deploy-sync.mjs} +2 -2
- package/engine/{fonts.mjs → output/fonts.mjs} +1 -1
- package/engine/{public-assets.mjs → output/public-assets.mjs} +2 -2
- package/engine/{static-server.mjs → output/static-server.mjs} +2 -2
- package/engine/react/caption-numbering.mjs +73 -0
- package/engine/react/comment-marker.mjs +54 -10
- package/engine/react/document-entry.mjs +124 -64
- package/engine/react/document-export.mjs +252 -311
- package/engine/react/mdx-compile.mjs +123 -3
- package/engine/react/measurement-css.mjs +3 -3
- package/engine/react/pagination/allocator.mjs +122 -0
- package/engine/react/pagination/regions.mjs +81 -0
- package/engine/react/pagination.mjs +9 -121
- package/engine/react/pipeline/allocate.mjs +248 -0
- package/engine/react/pipeline/final-render.mjs +94 -0
- package/engine/react/pipeline/frame-measurement.mjs +271 -0
- package/engine/react/pipeline/press-tree.mjs +135 -0
- package/engine/react/project-asset-endpoint.mjs +2 -2
- package/engine/react/{chapter-css.mjs → section-css.mjs} +12 -9
- package/engine/react/sources/heading-numbering.mjs +132 -0
- package/engine/react/sources/mdx-resolver.mjs +441 -0
- package/engine/react/{workspace-discovery.mjs → style-discovery.mjs} +29 -40
- package/engine/{config.mjs → runtime/config.mjs} +15 -0
- package/engine/{file-utils.mjs → runtime/file-utils.mjs} +1 -1
- package/engine/{inspection.mjs → runtime/inspection.mjs} +3 -4
- package/engine/{source-text-tools.mjs → runtime/source-text-tools.mjs} +24 -7
- package/engine/runtime/source-workspace.mjs +186 -0
- package/engine/{validation.mjs → runtime/validation.mjs} +19 -17
- package/package.json +5 -2
- package/src/openpress/anchorMap.ts +27 -0
- package/src/openpress/core/Frame.tsx +80 -0
- package/src/openpress/core/FrameContext.tsx +19 -0
- package/src/openpress/core/MdxArea.tsx +35 -0
- package/src/openpress/core/Press.tsx +34 -0
- package/src/openpress/core/index.tsx +34 -15
- package/src/openpress/core/primitives.tsx +23 -0
- package/src/openpress/core/types.ts +131 -19
- package/src/openpress/core/useSource.ts +28 -0
- package/src/openpress/manuscript/index.tsx +196 -0
- package/src/openpress/mdx/index.ts +88 -0
- package/src/openpress/numbering/index.ts +294 -0
- package/src/openpress/publicPage.tsx +4 -186
- package/src/openpress/reactDocumentMetadata.ts +2 -16
- package/src/openpress/types.ts +0 -16
- package/src/openpress/workbench.tsx +2 -36
- package/src/styles/openpress/responsive.css +0 -14
- package/tsconfig.json +4 -1
- package/vite.config.ts +10 -3
- package/engine/commands/migrate-to-react.mjs +0 -27
- package/engine/page-renderer.mjs +0 -217
- package/engine/react/migrate-to-react.mjs +0 -355
- package/engine/source-workspace.mjs +0 -76
- package/src/openpress/core/basePages.tsx +0 -87
- package/src/openpress/pagination.ts +0 -845
- /package/engine/{chrome-pdf.d.mts → output/chrome-pdf.d.mts} +0 -0
- /package/engine/{katex-assets.mjs → output/katex-assets.mjs} +0 -0
- /package/engine/{page-block.mjs → output/page-block.mjs} +0 -0
- /package/engine/{pdf-media.mjs → output/pdf-media.mjs} +0 -0
- /package/engine/{config.d.mts → runtime/config.d.mts} +0 -0
- /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,96 @@ 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
|
+
}
|
|
90
102
|
if (includeBlockIds && !includeBlockIds.has(id)) return false;
|
|
91
103
|
|
|
92
104
|
setDataAttribute(node, "data-openpress-block-id", id);
|
|
105
|
+
const extraAttributes = blockAttributes.get(id);
|
|
106
|
+
if (extraAttributes) {
|
|
107
|
+
for (const [name, value] of Object.entries(extraAttributes)) {
|
|
108
|
+
if (value == null || value === "") continue;
|
|
109
|
+
setDataAttribute(node, name, String(value));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
93
112
|
blocks.push({
|
|
94
113
|
id,
|
|
95
114
|
kind: block.kind,
|
|
96
115
|
name: block.name,
|
|
116
|
+
text: block.text,
|
|
97
117
|
filePath,
|
|
98
118
|
chapterSlug,
|
|
99
119
|
source: sourcePosition(node.position),
|
|
100
120
|
});
|
|
101
|
-
return
|
|
121
|
+
return "skip";
|
|
102
122
|
});
|
|
103
123
|
};
|
|
104
124
|
}
|
|
105
125
|
|
|
126
|
+
function applyTableRowBlocks({
|
|
127
|
+
node,
|
|
128
|
+
id,
|
|
129
|
+
blocks,
|
|
130
|
+
filePath,
|
|
131
|
+
chapterSlug,
|
|
132
|
+
includeBlockIds,
|
|
133
|
+
}) {
|
|
134
|
+
const rows = tableBodyRows(node);
|
|
135
|
+
if (rows.length === 0) {
|
|
136
|
+
if (includeBlockIds && !includeBlockIds.has(id)) return false;
|
|
137
|
+
setDataAttribute(node, "data-openpress-block-id", id);
|
|
138
|
+
blocks.push({
|
|
139
|
+
id,
|
|
140
|
+
kind: "element",
|
|
141
|
+
name: "table",
|
|
142
|
+
text: textContent(node).trim() || undefined,
|
|
143
|
+
filePath,
|
|
144
|
+
chapterSlug,
|
|
145
|
+
source: sourcePosition(node.position),
|
|
146
|
+
});
|
|
147
|
+
return "skip";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rowRecords = rows.map((row, index) => ({
|
|
151
|
+
id: `${id}-r${index}`,
|
|
152
|
+
node: row,
|
|
153
|
+
index,
|
|
154
|
+
}));
|
|
155
|
+
const selected = includeBlockIds
|
|
156
|
+
? rowRecords.filter((row) => includeBlockIds.has(row.id))
|
|
157
|
+
: rowRecords;
|
|
158
|
+
if (selected.length === 0) return false;
|
|
159
|
+
|
|
160
|
+
setDataAttribute(node, "data-openpress-table-id", id);
|
|
161
|
+
const selectedNodes = new Set(selected.map((row) => row.node));
|
|
162
|
+
pruneUnselectedTableRows(node, new Set(rowRecords.map((row) => row.node)), selectedNodes);
|
|
163
|
+
if (selected[0]?.index > 0) stripTableHeader(node);
|
|
164
|
+
|
|
165
|
+
for (const row of selected) {
|
|
166
|
+
setDataAttribute(row.node, "data-openpress-block-id", row.id);
|
|
167
|
+
blocks.push({
|
|
168
|
+
id: row.id,
|
|
169
|
+
kind: "table-row",
|
|
170
|
+
name: "table-row",
|
|
171
|
+
text: textContent(row.node).trim() || undefined,
|
|
172
|
+
filePath,
|
|
173
|
+
chapterSlug,
|
|
174
|
+
tableId: id,
|
|
175
|
+
rowIndex: row.index,
|
|
176
|
+
source: sourcePosition(row.node.position ?? node.position),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return "skip";
|
|
180
|
+
}
|
|
181
|
+
|
|
106
182
|
export function remarkBlockOnlyMdx(options = {}) {
|
|
107
183
|
const filePath = String(options.filePath ?? "document.mdx");
|
|
108
184
|
|
|
@@ -227,7 +303,7 @@ function normalizeSingleLineDisplayMath(source) {
|
|
|
227
303
|
|
|
228
304
|
function blockInfo(node) {
|
|
229
305
|
if (node?.type === "element" && PAGINABLE_TAGS.has(node.tagName)) {
|
|
230
|
-
return { kind: "element", name: node.tagName };
|
|
306
|
+
return { kind: "element", name: node.tagName, text: headingText(node) };
|
|
231
307
|
}
|
|
232
308
|
if (node?.type === "element" && node.tagName === "span" && hasClassName(node, "katex-display")) {
|
|
233
309
|
return { kind: "element", name: "math" };
|
|
@@ -238,6 +314,49 @@ function blockInfo(node) {
|
|
|
238
314
|
return null;
|
|
239
315
|
}
|
|
240
316
|
|
|
317
|
+
function tableBodyRows(table) {
|
|
318
|
+
if (table?.type !== "element" || table.tagName !== "table") return [];
|
|
319
|
+
const rows = [];
|
|
320
|
+
for (const child of table.children ?? []) {
|
|
321
|
+
if (child?.type === "element" && child.tagName === "tbody") {
|
|
322
|
+
for (const row of child.children ?? []) {
|
|
323
|
+
if (row?.type === "element" && row.tagName === "tr") rows.push(row);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (rows.length > 0) return rows;
|
|
328
|
+
return (table.children ?? []).filter((child) => child?.type === "element" && child.tagName === "tr");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function pruneUnselectedTableRows(node, rowNodes, selectedNodes) {
|
|
332
|
+
if (!Array.isArray(node?.children)) return;
|
|
333
|
+
node.children = node.children.filter((child) => {
|
|
334
|
+
if (!rowNodes.has(child)) return true;
|
|
335
|
+
return selectedNodes.has(child);
|
|
336
|
+
});
|
|
337
|
+
for (const child of node.children) pruneUnselectedTableRows(child, rowNodes, selectedNodes);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function stripTableHeader(table) {
|
|
341
|
+
if (!Array.isArray(table?.children)) return;
|
|
342
|
+
table.children = table.children.filter((child) => {
|
|
343
|
+
if (child?.type !== "element") return true;
|
|
344
|
+
return child.tagName !== "caption" && child.tagName !== "thead";
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function headingText(node) {
|
|
349
|
+
if (!/^h[1-6]$/.test(String(node?.tagName ?? ""))) return undefined;
|
|
350
|
+
return textContent(node).trim() || undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function normalizeBlockAttributes(value) {
|
|
354
|
+
if (!value) return new Map();
|
|
355
|
+
if (value instanceof Map) return value;
|
|
356
|
+
if (typeof value === "object") return new Map(Object.entries(value));
|
|
357
|
+
return new Map();
|
|
358
|
+
}
|
|
359
|
+
|
|
241
360
|
function hasClassName(node, className) {
|
|
242
361
|
const raw = node?.properties?.className;
|
|
243
362
|
if (Array.isArray(raw)) return raw.includes(className);
|
|
@@ -269,6 +388,7 @@ function visit(node, visitor) {
|
|
|
269
388
|
function filterTree(node, visitor) {
|
|
270
389
|
const keep = visitor(node);
|
|
271
390
|
if (!keep) return false;
|
|
391
|
+
if (keep === "skip") return true;
|
|
272
392
|
if (!Array.isArray(node?.children)) return true;
|
|
273
393
|
node.children = node.children.filter((child) => filterTree(child, visitor));
|
|
274
394
|
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 {
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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";
|