@open-press/core 1.1.3 → 1.2.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/engine/cli.mjs CHANGED
@@ -85,8 +85,8 @@ Commands:
85
85
  preview --renderer react [--host 127.0.0.1] [--port 5173] [--no-build] [--dry-run]
86
86
  dev --renderer react [--host 127.0.0.1] [--port 5173] [--no-build] [--dry-run]
87
87
  typecheck
88
- image [--output <outputDir>] [--no-build] [--dry-run]
89
- pdf [--output <outputDir>/<pdf.filename>] [--no-build] [--dry-run]
88
+ image [--output <outputDir>] [--press <slug>] [--pages <selector>] [--no-build] [--dry-run]
89
+ pdf [--output <outputDir>/<pdf.filename>] [--press <slug>] [--no-build] [--dry-run]
90
90
  deploy --confirm [--dry-run]
91
91
  doctor [--json] [--no-cache] # version + skill staleness check
92
92
  upgrade [--dry-run] [--no-deps] [--no-skills] [--json] # apply updates; agent-driven
@@ -40,6 +40,8 @@ export function parseOptions(argv) {
40
40
  else if (value === "--scope") options.scope = argv[++i];
41
41
  else if (value === "--source") options.source = argv[++i];
42
42
  else if (value === "--output") options.output = argv[++i];
43
+ else if (value === "--pages") options.pages = argv[++i];
44
+ else if (value === "--press") options.press = argv[++i];
43
45
  else if (value.startsWith("--")) throw new Error(`Unknown option: ${value}`);
44
46
  else positional.push(value);
45
47
  }
@@ -102,6 +104,52 @@ export async function buildReactStatic({ root, noBuild = false, recurse, silent
102
104
  return result.status ?? 1;
103
105
  }
104
106
 
107
+ export async function resolvePressSelection({ outputDir, slug }) {
108
+ const manifestPath = path.join(outputDir, "openpress", "workspace.json");
109
+ let manifest;
110
+ try {
111
+ const body = await fs.readFile(manifestPath, "utf8");
112
+ manifest = JSON.parse(body);
113
+ } catch (error) {
114
+ if (error?.code === "ENOENT") {
115
+ throw new Error(
116
+ `Cannot resolve --press: workspace manifest not found at ${manifestPath}. ` +
117
+ `Run a render first (or drop --no-build) so the manifest is regenerated.`,
118
+ );
119
+ }
120
+ throw error;
121
+ }
122
+ const presses = Array.isArray(manifest?.presses) ? manifest.presses : [];
123
+ if (presses.length === 0) {
124
+ throw new Error(`Workspace manifest at ${manifestPath} declares no Press entries.`);
125
+ }
126
+ const knownSlugs = presses.map((press) => press.slug || "").filter(Boolean);
127
+ const normalized = typeof slug === "string" ? slug.trim().replace(/^\/+|\/+$/g, "") : "";
128
+ if (!normalized) {
129
+ return { slug: presses[0].slug ?? "", title: presses[0].title ?? "", knownSlugs };
130
+ }
131
+ const match = presses.find((press) => (press.slug ?? "").replace(/^\/+|\/+$/g, "") === normalized);
132
+ if (!match) {
133
+ const listed = knownSlugs.length > 0 ? knownSlugs.join(", ") : "(none — workspace has no slugged presses)";
134
+ throw new Error(`Unknown --press "${slug}". Known slugs: ${listed}.`);
135
+ }
136
+ return { slug: match.slug ?? "", title: match.title ?? "", knownSlugs };
137
+ }
138
+
139
+ function pressPrintUrl(host, port, slug) {
140
+ const normalized = (slug ?? "").replace(/^\/+|\/+$/g, "");
141
+ if (!normalized) return `http://${host}:${port}/?print=1`;
142
+ return `http://${host}:${port}/${normalized}?print=1`;
143
+ }
144
+
145
+ function pressSuffixedFilename(baseFilename, slug) {
146
+ const normalized = (slug ?? "").replace(/^\/+|\/+$/g, "");
147
+ if (!normalized) return baseFilename;
148
+ const ext = path.extname(baseFilename);
149
+ const stem = ext ? baseFilename.slice(0, -ext.length) : baseFilename;
150
+ return `${stem}-${normalized}${ext}`;
151
+ }
152
+
105
153
  export async function buildReactPdf({
106
154
  root,
107
155
  config,
@@ -110,31 +158,44 @@ export async function buildReactPdf({
110
158
  port = "5185",
111
159
  noBuild = false,
112
160
  recurse,
161
+ pressSlug = null,
113
162
  }) {
114
163
  config ??= await loadConfig(root);
115
- outPath ??= config.paths.pdf;
116
164
  const renderCode = await buildReactStatic({ root, noBuild, recurse });
117
165
  if (renderCode !== 0) throw new Error(`React render failed with exit code ${renderCode}`);
118
166
  await optimizePdfMediaForStaticRoot(config.paths.outputDir);
167
+
168
+ const selection = pressSlug
169
+ ? await resolvePressSelection({ outputDir: config.paths.outputDir, slug: pressSlug })
170
+ : { slug: "", title: "", knownSlugs: [] };
171
+
172
+ if (!outPath) {
173
+ const filename = selection.slug
174
+ ? pressSuffixedFilename(config.pdf.filename, selection.slug)
175
+ : config.pdf.filename;
176
+ outPath = path.join(config.paths.outputDir, filename);
177
+ }
119
178
  await fs.mkdir(path.dirname(outPath), { recursive: true });
120
179
 
121
180
  const server = await startStaticServer(root, config, host, port);
122
181
  try {
123
- const pageCount = await printUrlToPdf({
182
+ const result = await printUrlToPdf({
124
183
  root,
125
- url: `http://${host}:${port}/?print=1`,
184
+ url: pressPrintUrl(host, port, selection.slug),
126
185
  outPath,
127
186
  waitForReady: waitForPrintReady,
128
187
  debuggingPortBase: 9300,
129
188
  debuggingPortRange: 600,
130
189
  profilePrefix: "chrome-pdf",
131
190
  });
132
- console.log(`${pageCount} OpenPress pages printed to PDF`);
191
+ const pageCount = result?.pageCount ?? result;
192
+ const pressLabel = selection.slug ? ` (press: ${selection.title || selection.slug})` : "";
193
+ console.log(`${pageCount} OpenPress pages printed to PDF${pressLabel}`);
133
194
  } finally {
134
195
  await stopChildProcess(server);
135
196
  }
136
197
 
137
- return { pdfPath: outPath };
198
+ return { pdfPath: outPath, pressSlug: selection.slug };
138
199
  }
139
200
 
140
201
  export async function buildReactImages({
@@ -145,26 +206,47 @@ export async function buildReactImages({
145
206
  port = "5186",
146
207
  noBuild = false,
147
208
  recurse,
209
+ pageSelector = null,
210
+ pressSlug = null,
148
211
  }) {
149
212
  config ??= await loadConfig(root);
150
- outDir ??= path.join(config.paths.outputDir, "images");
151
213
  const renderCode = await buildReactStatic({ root, noBuild, recurse });
152
214
  if (renderCode !== 0) throw new Error(`React render failed with exit code ${renderCode}`);
215
+
216
+ const selection = pressSlug
217
+ ? await resolvePressSelection({ outputDir: config.paths.outputDir, slug: pressSlug })
218
+ : { slug: "", title: "", knownSlugs: [] };
219
+
220
+ if (!outDir) {
221
+ const folder = selection.slug ? `images-${selection.slug}` : "images";
222
+ outDir = path.join(config.paths.outputDir, folder);
223
+ }
153
224
  await fs.mkdir(outDir, { recursive: true });
154
225
 
155
226
  const server = await startStaticServer(root, config, host, port);
156
227
  try {
157
228
  const result = await captureUrlPagesToPng({
158
229
  root,
159
- url: `http://${host}:${port}/?print=1`,
230
+ url: pressPrintUrl(host, port, selection.slug),
160
231
  outDir,
161
232
  waitForReady: waitForPrintReady,
162
233
  debuggingPortBase: 9700,
163
234
  debuggingPortRange: 600,
164
235
  profilePrefix: "chrome-image",
236
+ pageSelector,
165
237
  });
166
- console.log(`${result.files.length} OpenPress pages exported to PNG`);
167
- return { outDir, files: result.files, pageCount: result.pageCount };
238
+ const pressLabel = selection.slug ? ` (press: ${selection.title || selection.slug})` : "";
239
+ const countLabel = pageSelector
240
+ ? `${result.files.length}/${result.pageCount} OpenPress pages exported to PNG`
241
+ : `${result.files.length} OpenPress pages exported to PNG`;
242
+ console.log(`${countLabel}${pressLabel}`);
243
+ return {
244
+ outDir,
245
+ files: result.files,
246
+ pageCount: result.pageCount,
247
+ selectedPageNumbers: result.selectedPageNumbers,
248
+ pressSlug: selection.slug,
249
+ };
168
250
  } finally {
169
251
  await stopChildProcess(server);
170
252
  }
@@ -1,16 +1,27 @@
1
1
  import path from "node:path";
2
2
  import { STATIC_SERVER, buildReactImages, formatNodeScriptCommand, formatOpenPressCommand } from "./_shared.mjs";
3
+ import { parsePageSelector } from "../runtime/page-selector.mjs";
3
4
 
4
5
  export async function run({ root, config, options, recurse }) {
5
- const outputDir = options.output ? path.resolve(root, options.output) : path.join(config.paths.outputDir, "images");
6
+ const outputDir = options.output ? path.resolve(root, options.output) : undefined;
6
7
  const host = options.host ?? "127.0.0.1";
7
8
  const port = options.port ?? "5186";
8
9
 
10
+ const pageSelector = options.pages ? parsePageSelector(options.pages) : null;
11
+ const pressSlug = options.press ?? null;
12
+
9
13
  if (options.dryRun) {
14
+ const pressPath = pressSlug ? `/${String(pressSlug).replace(/^\/+|\/+$/g, "")}` : "";
15
+ const previewDir = outputDir
16
+ ?? path.join(config.paths.outputDir, pressSlug ? `images-${String(pressSlug).replace(/^\/+|\/+$/g, "")}` : "images");
10
17
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
11
18
  console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
12
- console.log(`Chrome image export URL: http://${host}:${port}/?print=1`);
13
- console.log(`Output: ${path.relative(root, path.join(outputDir, "page-001.png"))}`);
19
+ console.log(`Chrome image export URL: http://${host}:${port}${pressPath}/?print=1`);
20
+ if (pressSlug) console.log(`Press: ${pressSlug} (validated against workspace manifest at run time)`);
21
+ if (pageSelector) {
22
+ console.log(`Page selector: ${options.pages} (resolved at capture time against the rendered page count)`);
23
+ }
24
+ console.log(`Output: ${path.relative(root, path.join(previewDir, "page-001.png"))}`);
14
25
  return 0;
15
26
  }
16
27
 
@@ -22,8 +33,13 @@ export async function run({ root, config, options, recurse }) {
22
33
  port,
23
34
  noBuild: options.noBuild,
24
35
  recurse,
36
+ pageSelector,
37
+ pressSlug,
25
38
  });
26
39
 
27
- console.log(`OpenPress images: ${path.relative(root, result.outDir)} (${result.files.length} pages)`);
40
+ const suffix = pageSelector
41
+ ? ` (${result.files.length}/${result.pageCount} pages)`
42
+ : ` (${result.files.length} pages)`;
43
+ console.log(`OpenPress images: ${path.relative(root, result.outDir)}${suffix}`);
28
44
  return 0;
29
45
  }
@@ -7,9 +7,11 @@ export async function run({ root, config, options, recurse }) {
7
7
  const relOutput = path.relative(root, outputPath ?? config.paths.pdf);
8
8
  const host = options.host ?? "127.0.0.1";
9
9
  const port = options.port ?? "5185";
10
+ const pressPath = options.press ? `/${String(options.press).replace(/^\/+|\/+$/g, "")}` : "";
10
11
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
11
12
  console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
12
- console.log(`Command: Chrome --print-to-pdf=${relOutput} http://${host}:${port}/?print=1`);
13
+ console.log(`Command: Chrome --print-to-pdf=${relOutput} http://${host}:${port}${pressPath}/?print=1`);
14
+ if (options.press) console.log(`Press: ${options.press} (validated against workspace manifest at run time)`);
13
15
  return 0;
14
16
  }
15
17
  const result = await buildReactPdf({
@@ -20,6 +22,7 @@ export async function run({ root, config, options, recurse }) {
20
22
  port: options.port,
21
23
  noBuild: options.noBuild,
22
24
  recurse,
25
+ pressSlug: options.press ?? null,
23
26
  });
24
27
  console.log(`OpenPress PDF: ${path.relative(root, result.pdfPath)}`);
25
28
  return 0;
@@ -2,6 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { resolvePageSelector } from "../runtime/page-selector.mjs";
6
+
7
+ const defaultResolveSelector = resolvePageSelector;
5
8
 
6
9
  const CHROME_CANDIDATE_PATHS = {
7
10
  darwin: [
@@ -98,6 +101,100 @@ export const DEFAULT_PRINT_VIEWPORT = Object.freeze({
98
101
  mobile: false,
99
102
  });
100
103
 
104
+ export function pageGeometryProbeExpression() {
105
+ return `(() => {
106
+ if (!document.body) return null;
107
+ // OpenPressRuntime sets --openpress-page-width / -height on the
108
+ // PrintDocument <main> via inline style. The workspace's global
109
+ // theme also defines a *default* --openpress-page-width on :root
110
+ // (e.g. 210mm for the A4 preset). If we let getComputedStyle fall
111
+ // back to :root we will silently return that default before the
112
+ // print document has even rendered — and the per-document override
113
+ // never gets a chance to apply. Require the actual print surface
114
+ // so the probe waits until React paints it instead of locking in
115
+ // the wrong size from the workspace stylesheet.
116
+ const target = document.querySelector('[data-openpress-print-document="true"]')
117
+ || document.querySelector('.openpress-html-page');
118
+ if (!target) return null;
119
+ const cs = getComputedStyle(target);
120
+ const widthStr = cs.getPropertyValue('--openpress-page-width').trim();
121
+ const heightStr = cs.getPropertyValue('--openpress-page-height').trim();
122
+ if (!widthStr || !heightStr) return null;
123
+ const helper = document.createElement('div');
124
+ helper.style.position = 'absolute';
125
+ helper.style.left = '-99999px';
126
+ helper.style.top = '-99999px';
127
+ helper.style.visibility = 'hidden';
128
+ helper.style.pointerEvents = 'none';
129
+ helper.style.width = widthStr;
130
+ helper.style.height = heightStr;
131
+ document.body.appendChild(helper);
132
+ const rect = helper.getBoundingClientRect();
133
+ helper.remove();
134
+ if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) return null;
135
+ return { width: rect.width, height: rect.height };
136
+ })()`;
137
+ }
138
+
139
+ export async function measurePageGeometryPx(client, { timeoutMs = 5000, pollIntervalMs = 50 } = {}) {
140
+ const deadline = Date.now() + timeoutMs;
141
+ while (Date.now() < deadline) {
142
+ const result = await client.send("Runtime.evaluate", {
143
+ returnByValue: true,
144
+ expression: pageGeometryProbeExpression(),
145
+ });
146
+ const dims = result.result?.value;
147
+ if (dims && Number.isFinite(dims.width) && dims.width > 0) {
148
+ return { width: dims.width, height: dims.height };
149
+ }
150
+ await delay(pollIntervalMs);
151
+ }
152
+ return null;
153
+ }
154
+
155
+ export async function syncViewportToPageGeometry(client, viewport, options = {}) {
156
+ const dims = await measurePageGeometryPx(client, options);
157
+ if (!dims) return { viewport, pageDimensionsPx: null };
158
+ const targetWidth = Math.max(viewport.width, Math.ceil(dims.width));
159
+ const targetHeight = Math.max(viewport.height, Math.ceil(dims.height));
160
+ if (targetWidth === viewport.width && targetHeight === viewport.height) {
161
+ return { viewport, pageDimensionsPx: dims };
162
+ }
163
+ const next = { ...viewport, width: targetWidth, height: targetHeight };
164
+ await client.send("Emulation.setDeviceMetricsOverride", next);
165
+ return { viewport: next, pageDimensionsPx: dims };
166
+ }
167
+
168
+ // Chrome's Page.printToPDF takes paperWidth / paperHeight in inches and
169
+ // honors them when preferCSSPageSize falls through. Because our @page
170
+ // rule reads CSS custom properties scoped to <main> instead of :root,
171
+ // preferCSSPageSize cannot resolve the size in headless Chrome — we have
172
+ // to pass the inches explicitly. Convert from CSS px at the 1in = 96px
173
+ // rate the rest of the runtime already assumes.
174
+ //
175
+ // Wider-than-tall geometries (slide 16:9, landscape pages) also need
176
+ // `landscape: true`: with `landscape: false` Chrome silently rotates the
177
+ // MediaBox to portrait so the short side becomes the width, leaving the
178
+ // content laid out for the wide canvas but cropped against the short page.
179
+ export function pageDimensionsPxToPaperInches(dims) {
180
+ if (!dims || !Number.isFinite(dims.width) || dims.width <= 0) return null;
181
+ if (!Number.isFinite(dims.height) || dims.height <= 0) return null;
182
+ // Chrome's printToPDF semantics: paperWidth/paperHeight are taken as
183
+ // *portrait* page dimensions, and `landscape: true` then rotates the
184
+ // canvas 90°. So for a 1920×1080 slide we have to pass the short side
185
+ // as paperWidth (11.25"), the long side as paperHeight (20"), and
186
+ // landscape: true — Chrome will produce a 20"×11.25" landscape page
187
+ // whose content frame matches the original 1920×1080 layout.
188
+ const widthIn = dims.width / 96;
189
+ const heightIn = dims.height / 96;
190
+ const landscape = widthIn > heightIn;
191
+ return {
192
+ paperWidth: landscape ? heightIn : widthIn,
193
+ paperHeight: landscape ? widthIn : heightIn,
194
+ landscape,
195
+ };
196
+ }
197
+
101
198
  export async function printUrlToPdf({
102
199
  root,
103
200
  url,
@@ -136,7 +233,15 @@ export async function printUrlToPdf({
136
233
  try {
137
234
  await preparePdfPage(client, { viewport });
138
235
  await client.send("Page.navigate", { url });
236
+ // Widen the headless viewport when the document's page geometry is
237
+ // wider than the default A4 viewport, so layout uses the full page
238
+ // width before pagination. Paper size itself is driven by the
239
+ // @page rule in print-route.css, which now resolves
240
+ // --openpress-page-width / -height from :root (PrintDocument
241
+ // mirrors the per-document theme vars onto the document element).
242
+ await syncViewportToPageGeometry(client, viewport);
139
243
  const readyResult = await waitForReady(client);
244
+ warnAboutOverflowingPages("PDF", readyResult);
140
245
  const result = await client.send("Page.printToPDF", {
141
246
  ...DEFAULT_PRINT_OPTIONS,
142
247
  ...printOptions,
@@ -152,6 +257,16 @@ export async function printUrlToPdf({
152
257
  }
153
258
  }
154
259
 
260
+ function warnAboutOverflowingPages(label, readyResult) {
261
+ const overflowing = Array.isArray(readyResult?.overflowingPageNumbers) ? readyResult.overflowingPageNumbers : [];
262
+ if (overflowing.length === 0) return;
263
+ const preview = overflowing.slice(0, 12).join(", ") + (overflowing.length > 12 ? `, … (+${overflowing.length - 12} more)` : "");
264
+ console.warn(
265
+ `OpenPress ${label}: ${overflowing.length} page(s) exceed the page body bounds (pages ${preview}). ` +
266
+ `Output will still be generated but those pages may clip; run \`openpress inspect\` to locate the overflowing elements.`,
267
+ );
268
+ }
269
+
155
270
  export async function captureUrlPagesToPng({
156
271
  root,
157
272
  url,
@@ -162,6 +277,8 @@ export async function captureUrlPagesToPng({
162
277
  debuggingPortBase = 9700,
163
278
  debuggingPortRange = 300,
164
279
  profilePrefix = "chrome-image",
280
+ pageSelector = null,
281
+ resolveSelector = null,
165
282
  }) {
166
283
  chrome ??= resolveChromePath();
167
284
  await fs.mkdir(outDir, { recursive: true });
@@ -189,14 +306,26 @@ export async function captureUrlPagesToPng({
189
306
  try {
190
307
  await preparePdfPage(client, { viewport });
191
308
  await client.send("Page.navigate", { url });
192
- const pageCount = await waitForReady(client);
309
+ await syncViewportToPageGeometry(client, viewport);
310
+ const readyResult = await waitForReady(client);
311
+ warnAboutOverflowingPages("image", readyResult);
312
+ const pageCount = readyResult?.pageCount ?? 0;
193
313
  const rects = await getPrintPageRects(client);
194
314
  if (rects.length === 0) throw new Error("No OpenPress pages found for image export.");
195
315
 
316
+ const selectedPageNumbers = pageSelector
317
+ ? (resolveSelector ?? defaultResolveSelector)(pageSelector, rects.length)
318
+ : rects.map((_, index) => index + 1);
319
+ if (selectedPageNumbers.length === 0) {
320
+ throw new Error("Page selector resolved to zero pages; nothing to export.");
321
+ }
322
+
196
323
  const padWidth = Math.max(3, String(rects.length).length);
197
324
  const files = [];
198
- for (const [index, rect] of rects.entries()) {
199
- const filename = `page-${String(index + 1).padStart(padWidth, "0")}.png`;
325
+ for (const pageNumber of selectedPageNumbers) {
326
+ const rect = rects[pageNumber - 1];
327
+ if (!rect) continue;
328
+ const filename = `page-${String(pageNumber).padStart(padWidth, "0")}.png`;
200
329
  const filePath = path.join(outDir, filename);
201
330
  const result = await client.send("Page.captureScreenshot", {
202
331
  format: "png",
@@ -214,7 +343,7 @@ export async function captureUrlPagesToPng({
214
343
  files.push(filePath);
215
344
  }
216
345
 
217
- return { pageCount, files };
346
+ return { pageCount, files, selectedPageNumbers };
218
347
  } finally {
219
348
  client.close();
220
349
  }
@@ -282,59 +411,135 @@ export async function evaluateUrlWithChrome({
282
411
  }
283
412
  }
284
413
 
285
- export async function waitForPrintReady(client) {
286
- const deadline = Date.now() + 30000;
287
- while (Date.now() < deadline) {
414
+ export const PRINT_READY_DEFAULTS = Object.freeze({
415
+ totalTimeoutMs: 300_000,
416
+ idleTimeoutMs: 30_000,
417
+ pollIntervalMs: 100,
418
+ stableMs: 300,
419
+ });
420
+
421
+ export function resolvePrintReadyTiming(env = process.env) {
422
+ const total = Number(env.OPENPRESS_PRINT_READY_TIMEOUT_MS);
423
+ const idle = Number(env.OPENPRESS_PRINT_READY_IDLE_MS);
424
+ const stable = Number(env.OPENPRESS_PRINT_READY_STABLE_MS);
425
+ return {
426
+ totalTimeoutMs: Number.isFinite(total) && total > 0 ? total : PRINT_READY_DEFAULTS.totalTimeoutMs,
427
+ idleTimeoutMs: Number.isFinite(idle) && idle > 0 ? idle : PRINT_READY_DEFAULTS.idleTimeoutMs,
428
+ stableMs: Number.isFinite(stable) && stable >= 0 ? stable : PRINT_READY_DEFAULTS.stableMs,
429
+ pollIntervalMs: PRINT_READY_DEFAULTS.pollIntervalMs,
430
+ };
431
+ }
432
+
433
+ export function printReadinessExpression() {
434
+ return `Promise.resolve().then(async () => {
435
+ const root = document.querySelector('[data-openpress-print-document="true"]');
436
+ if (!root) return { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
437
+ const candidates = root.querySelectorAll('.openpress-html-page');
438
+ if (candidates.length === 0) return { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
439
+
440
+ await document.fonts?.ready;
441
+ await Promise.all(Array.from(document.images).map(async (img) => {
442
+ if (!img.complete) {
443
+ await new Promise((resolve) => {
444
+ const settle = () => {
445
+ img.removeEventListener('load', settle);
446
+ img.removeEventListener('error', settle);
447
+ resolve();
448
+ };
449
+ img.addEventListener('load', settle, { once: true });
450
+ img.addEventListener('error', settle, { once: true });
451
+ });
452
+ }
453
+ await img.decode?.().catch(() => undefined);
454
+ }));
455
+
456
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
457
+
458
+ const pages = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
459
+ const contentFitsPageBody = (body) => {
460
+ const bodyBottom = body.getBoundingClientRect().bottom;
461
+ const contentBottom = Array.from(body.children).reduce((bottom, child) => {
462
+ if (getComputedStyle(child).display === 'none') return bottom;
463
+ const marginBottom = Number.parseFloat(getComputedStyle(child).marginBottom) || 0;
464
+ return Math.max(bottom, child.getBoundingClientRect().bottom + marginBottom);
465
+ }, body.getBoundingClientRect().top);
466
+ return contentBottom <= bodyBottom + 1;
467
+ };
468
+ const overflowingPageNumbers = pages.reduce((nums, page, index) => {
469
+ const body = page.querySelector('.page-body');
470
+ if (body && !contentFitsPageBody(body)) nums.push(index + 1);
471
+ return nums;
472
+ }, []);
473
+
474
+ return {
475
+ pageCount: pages.length,
476
+ overflowingPages: overflowingPageNumbers.length,
477
+ overflowingPageNumbers,
478
+ };
479
+ })`;
480
+ }
481
+
482
+ function formatPrintReadyTimeoutMessage(reason, snapshot, timing, elapsedMs) {
483
+ const seconds = Math.round(elapsedMs / 1000);
484
+ const observed = `(observed ${snapshot.pageCount} page(s), ${snapshot.overflowingPages} overflowing)`;
485
+ if (reason === "idle") {
486
+ return (
487
+ `Timed out waiting for OpenPress pagination before PDF export. ` +
488
+ `No progress for ${seconds}s ${observed}. ` +
489
+ `Raise OPENPRESS_PRINT_READY_IDLE_MS (currently ${timing.idleTimeoutMs}ms) to extend the idle window.`
490
+ );
491
+ }
492
+ return (
493
+ `Timed out waiting for OpenPress pagination before PDF export. ` +
494
+ `Total ${seconds}s exceeded ${observed}. ` +
495
+ `Raise OPENPRESS_PRINT_READY_TIMEOUT_MS (currently ${timing.totalTimeoutMs}ms) to extend the hard cap.`
496
+ );
497
+ }
498
+
499
+ export async function waitForPrintReady(client, timing = resolvePrintReadyTiming()) {
500
+ const startedAt = Date.now();
501
+ let lastSignature = "";
502
+ let lastProgressAt = startedAt;
503
+ let stableSince = startedAt;
504
+ let lastSnapshot = { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
505
+
506
+ while (true) {
507
+ const totalElapsed = Date.now() - startedAt;
508
+ if (totalElapsed > timing.totalTimeoutMs) {
509
+ throw new Error(formatPrintReadyTimeoutMessage("total", lastSnapshot, timing, totalElapsed));
510
+ }
511
+
288
512
  const result = await client.send("Runtime.evaluate", {
289
513
  returnByValue: true,
290
514
  awaitPromise: true,
291
- expression: `Promise.resolve().then(async () => {
292
- const root = document.querySelector('[data-openpress-print-document="true"]');
293
- if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return 0;
294
-
295
- await document.fonts?.ready;
296
- await Promise.all(Array.from(document.images).map(async (img) => {
297
- if (!img.complete) {
298
- await new Promise((resolve) => {
299
- const settle = () => {
300
- img.removeEventListener('load', settle);
301
- img.removeEventListener('error', settle);
302
- resolve();
303
- };
304
-
305
- img.addEventListener('load', settle, { once: true });
306
- img.addEventListener('error', settle, { once: true });
307
- });
308
- }
309
-
310
- await img.decode?.().catch(() => undefined);
311
- }));
312
-
313
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
314
-
315
- const pages = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
316
- const contentFitsPageBody = (body) => {
317
- const bodyBottom = body.getBoundingClientRect().bottom;
318
- const contentBottom = Array.from(body.children).reduce((bottom, child) => {
319
- if (getComputedStyle(child).display === 'none') return bottom;
320
- const marginBottom = Number.parseFloat(getComputedStyle(child).marginBottom) || 0;
321
- return Math.max(bottom, child.getBoundingClientRect().bottom + marginBottom);
322
- }, body.getBoundingClientRect().top);
323
- return contentBottom <= bodyBottom + 1;
324
- };
325
- const bodyOverflowSafe = pages.every((page) => {
326
- const body = page.querySelector('.page-body');
327
- return !body || contentFitsPageBody(body);
328
- });
329
-
330
- return pages.length > 0 && bodyOverflowSafe ? pages.length : 0;
331
- })`,
515
+ expression: printReadinessExpression(),
332
516
  });
333
- const count = Number(result.result?.value ?? 0);
334
- if (count > 0) return count;
335
- await delay(100);
517
+ const value = result.result?.value ?? {};
518
+ const pageCount = Number.isFinite(Number(value.pageCount)) ? Number(value.pageCount) : 0;
519
+ const overflowingPages = Number.isFinite(Number(value.overflowingPages)) ? Number(value.overflowingPages) : 0;
520
+ const overflowingPageNumbers = Array.isArray(value.overflowingPageNumbers)
521
+ ? value.overflowingPageNumbers.map(Number).filter(Number.isFinite)
522
+ : [];
523
+ lastSnapshot = { pageCount, overflowingPages, overflowingPageNumbers };
524
+
525
+ const signature = `${pageCount}:${overflowingPages}`;
526
+ if (signature !== lastSignature) {
527
+ lastSignature = signature;
528
+ stableSince = Date.now();
529
+ lastProgressAt = Date.now();
530
+ }
531
+
532
+ if (pageCount > 0 && Date.now() - stableSince >= timing.stableMs) {
533
+ return { pageCount, overflowingPageNumbers };
534
+ }
535
+
536
+ const idleElapsed = Date.now() - lastProgressAt;
537
+ if (idleElapsed > timing.idleTimeoutMs) {
538
+ throw new Error(formatPrintReadyTimeoutMessage("idle", lastSnapshot, timing, idleElapsed));
539
+ }
540
+
541
+ await delay(timing.pollIntervalMs);
336
542
  }
337
- throw new Error("Timed out waiting for OpenPress pagination before PDF export.");
338
543
  }
339
544
 
340
545
  async function getPrintPageRects(client) {