@open-press/core 1.1.3 → 1.1.4

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,7 +85,7 @@ 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]
88
+ image [--output <outputDir>] [--pages <selector>] [--no-build] [--dry-run]
89
89
  pdf [--output <outputDir>/<pdf.filename>] [--no-build] [--dry-run]
90
90
  deploy --confirm [--dry-run]
91
91
  doctor [--json] [--no-cache] # version + skill staleness check
@@ -40,6 +40,7 @@ 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];
43
44
  else if (value.startsWith("--")) throw new Error(`Unknown option: ${value}`);
44
45
  else positional.push(value);
45
46
  }
@@ -145,6 +146,7 @@ export async function buildReactImages({
145
146
  port = "5186",
146
147
  noBuild = false,
147
148
  recurse,
149
+ pageSelector = null,
148
150
  }) {
149
151
  config ??= await loadConfig(root);
150
152
  outDir ??= path.join(config.paths.outputDir, "images");
@@ -162,9 +164,18 @@ export async function buildReactImages({
162
164
  debuggingPortBase: 9700,
163
165
  debuggingPortRange: 600,
164
166
  profilePrefix: "chrome-image",
167
+ pageSelector,
165
168
  });
166
- console.log(`${result.files.length} OpenPress pages exported to PNG`);
167
- return { outDir, files: result.files, pageCount: result.pageCount };
169
+ const label = pageSelector
170
+ ? `${result.files.length}/${result.pageCount} OpenPress pages exported to PNG`
171
+ : `${result.files.length} OpenPress pages exported to PNG`;
172
+ console.log(label);
173
+ return {
174
+ outDir,
175
+ files: result.files,
176
+ pageCount: result.pageCount,
177
+ selectedPageNumbers: result.selectedPageNumbers,
178
+ };
168
179
  } finally {
169
180
  await stopChildProcess(server);
170
181
  }
@@ -1,15 +1,21 @@
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
6
  const outputDir = options.output ? path.resolve(root, options.output) : path.join(config.paths.outputDir, "images");
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
+
9
12
  if (options.dryRun) {
10
13
  console.log(`Command: ${formatOpenPressCommand(["render", ".", "--renderer", "react"])}`);
11
14
  console.log(`Command: ${formatNodeScriptCommand(root, STATIC_SERVER)} ${config.outputDir} --host ${host} --port ${port} --workspace .`);
12
15
  console.log(`Chrome image export URL: http://${host}:${port}/?print=1`);
16
+ if (pageSelector) {
17
+ console.log(`Page selector: ${options.pages} (resolved at capture time against the rendered page count)`);
18
+ }
13
19
  console.log(`Output: ${path.relative(root, path.join(outputDir, "page-001.png"))}`);
14
20
  return 0;
15
21
  }
@@ -22,8 +28,12 @@ export async function run({ root, config, options, recurse }) {
22
28
  port,
23
29
  noBuild: options.noBuild,
24
30
  recurse,
31
+ pageSelector,
25
32
  });
26
33
 
27
- console.log(`OpenPress images: ${path.relative(root, result.outDir)} (${result.files.length} pages)`);
34
+ const suffix = pageSelector
35
+ ? ` (${result.files.length}/${result.pageCount} pages)`
36
+ : ` (${result.files.length} pages)`;
37
+ console.log(`OpenPress images: ${path.relative(root, result.outDir)}${suffix}`);
28
38
  return 0;
29
39
  }
@@ -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: [
@@ -137,6 +140,7 @@ export async function printUrlToPdf({
137
140
  await preparePdfPage(client, { viewport });
138
141
  await client.send("Page.navigate", { url });
139
142
  const readyResult = await waitForReady(client);
143
+ warnAboutOverflowingPages("PDF", readyResult);
140
144
  const result = await client.send("Page.printToPDF", {
141
145
  ...DEFAULT_PRINT_OPTIONS,
142
146
  ...printOptions,
@@ -152,6 +156,16 @@ export async function printUrlToPdf({
152
156
  }
153
157
  }
154
158
 
159
+ function warnAboutOverflowingPages(label, readyResult) {
160
+ const overflowing = Array.isArray(readyResult?.overflowingPageNumbers) ? readyResult.overflowingPageNumbers : [];
161
+ if (overflowing.length === 0) return;
162
+ const preview = overflowing.slice(0, 12).join(", ") + (overflowing.length > 12 ? `, … (+${overflowing.length - 12} more)` : "");
163
+ console.warn(
164
+ `OpenPress ${label}: ${overflowing.length} page(s) exceed the page body bounds (pages ${preview}). ` +
165
+ `Output will still be generated but those pages may clip; run \`openpress inspect\` to locate the overflowing elements.`,
166
+ );
167
+ }
168
+
155
169
  export async function captureUrlPagesToPng({
156
170
  root,
157
171
  url,
@@ -162,6 +176,8 @@ export async function captureUrlPagesToPng({
162
176
  debuggingPortBase = 9700,
163
177
  debuggingPortRange = 300,
164
178
  profilePrefix = "chrome-image",
179
+ pageSelector = null,
180
+ resolveSelector = null,
165
181
  }) {
166
182
  chrome ??= resolveChromePath();
167
183
  await fs.mkdir(outDir, { recursive: true });
@@ -189,14 +205,25 @@ export async function captureUrlPagesToPng({
189
205
  try {
190
206
  await preparePdfPage(client, { viewport });
191
207
  await client.send("Page.navigate", { url });
192
- const pageCount = await waitForReady(client);
208
+ const readyResult = await waitForReady(client);
209
+ warnAboutOverflowingPages("image", readyResult);
210
+ const pageCount = readyResult?.pageCount ?? 0;
193
211
  const rects = await getPrintPageRects(client);
194
212
  if (rects.length === 0) throw new Error("No OpenPress pages found for image export.");
195
213
 
214
+ const selectedPageNumbers = pageSelector
215
+ ? (resolveSelector ?? defaultResolveSelector)(pageSelector, rects.length)
216
+ : rects.map((_, index) => index + 1);
217
+ if (selectedPageNumbers.length === 0) {
218
+ throw new Error("Page selector resolved to zero pages; nothing to export.");
219
+ }
220
+
196
221
  const padWidth = Math.max(3, String(rects.length).length);
197
222
  const files = [];
198
- for (const [index, rect] of rects.entries()) {
199
- const filename = `page-${String(index + 1).padStart(padWidth, "0")}.png`;
223
+ for (const pageNumber of selectedPageNumbers) {
224
+ const rect = rects[pageNumber - 1];
225
+ if (!rect) continue;
226
+ const filename = `page-${String(pageNumber).padStart(padWidth, "0")}.png`;
200
227
  const filePath = path.join(outDir, filename);
201
228
  const result = await client.send("Page.captureScreenshot", {
202
229
  format: "png",
@@ -214,7 +241,7 @@ export async function captureUrlPagesToPng({
214
241
  files.push(filePath);
215
242
  }
216
243
 
217
- return { pageCount, files };
244
+ return { pageCount, files, selectedPageNumbers };
218
245
  } finally {
219
246
  client.close();
220
247
  }
@@ -282,59 +309,135 @@ export async function evaluateUrlWithChrome({
282
309
  }
283
310
  }
284
311
 
285
- export async function waitForPrintReady(client) {
286
- const deadline = Date.now() + 30000;
287
- while (Date.now() < deadline) {
312
+ export const PRINT_READY_DEFAULTS = Object.freeze({
313
+ totalTimeoutMs: 300_000,
314
+ idleTimeoutMs: 30_000,
315
+ pollIntervalMs: 100,
316
+ stableMs: 300,
317
+ });
318
+
319
+ export function resolvePrintReadyTiming(env = process.env) {
320
+ const total = Number(env.OPENPRESS_PRINT_READY_TIMEOUT_MS);
321
+ const idle = Number(env.OPENPRESS_PRINT_READY_IDLE_MS);
322
+ const stable = Number(env.OPENPRESS_PRINT_READY_STABLE_MS);
323
+ return {
324
+ totalTimeoutMs: Number.isFinite(total) && total > 0 ? total : PRINT_READY_DEFAULTS.totalTimeoutMs,
325
+ idleTimeoutMs: Number.isFinite(idle) && idle > 0 ? idle : PRINT_READY_DEFAULTS.idleTimeoutMs,
326
+ stableMs: Number.isFinite(stable) && stable >= 0 ? stable : PRINT_READY_DEFAULTS.stableMs,
327
+ pollIntervalMs: PRINT_READY_DEFAULTS.pollIntervalMs,
328
+ };
329
+ }
330
+
331
+ export function printReadinessExpression() {
332
+ return `Promise.resolve().then(async () => {
333
+ const root = document.querySelector('[data-openpress-print-document="true"]');
334
+ if (!root) return { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
335
+ const candidates = root.querySelectorAll('.openpress-html-page');
336
+ if (candidates.length === 0) return { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
337
+
338
+ await document.fonts?.ready;
339
+ await Promise.all(Array.from(document.images).map(async (img) => {
340
+ if (!img.complete) {
341
+ await new Promise((resolve) => {
342
+ const settle = () => {
343
+ img.removeEventListener('load', settle);
344
+ img.removeEventListener('error', settle);
345
+ resolve();
346
+ };
347
+ img.addEventListener('load', settle, { once: true });
348
+ img.addEventListener('error', settle, { once: true });
349
+ });
350
+ }
351
+ await img.decode?.().catch(() => undefined);
352
+ }));
353
+
354
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
355
+
356
+ const pages = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
357
+ const contentFitsPageBody = (body) => {
358
+ const bodyBottom = body.getBoundingClientRect().bottom;
359
+ const contentBottom = Array.from(body.children).reduce((bottom, child) => {
360
+ if (getComputedStyle(child).display === 'none') return bottom;
361
+ const marginBottom = Number.parseFloat(getComputedStyle(child).marginBottom) || 0;
362
+ return Math.max(bottom, child.getBoundingClientRect().bottom + marginBottom);
363
+ }, body.getBoundingClientRect().top);
364
+ return contentBottom <= bodyBottom + 1;
365
+ };
366
+ const overflowingPageNumbers = pages.reduce((nums, page, index) => {
367
+ const body = page.querySelector('.page-body');
368
+ if (body && !contentFitsPageBody(body)) nums.push(index + 1);
369
+ return nums;
370
+ }, []);
371
+
372
+ return {
373
+ pageCount: pages.length,
374
+ overflowingPages: overflowingPageNumbers.length,
375
+ overflowingPageNumbers,
376
+ };
377
+ })`;
378
+ }
379
+
380
+ function formatPrintReadyTimeoutMessage(reason, snapshot, timing, elapsedMs) {
381
+ const seconds = Math.round(elapsedMs / 1000);
382
+ const observed = `(observed ${snapshot.pageCount} page(s), ${snapshot.overflowingPages} overflowing)`;
383
+ if (reason === "idle") {
384
+ return (
385
+ `Timed out waiting for OpenPress pagination before PDF export. ` +
386
+ `No progress for ${seconds}s ${observed}. ` +
387
+ `Raise OPENPRESS_PRINT_READY_IDLE_MS (currently ${timing.idleTimeoutMs}ms) to extend the idle window.`
388
+ );
389
+ }
390
+ return (
391
+ `Timed out waiting for OpenPress pagination before PDF export. ` +
392
+ `Total ${seconds}s exceeded ${observed}. ` +
393
+ `Raise OPENPRESS_PRINT_READY_TIMEOUT_MS (currently ${timing.totalTimeoutMs}ms) to extend the hard cap.`
394
+ );
395
+ }
396
+
397
+ export async function waitForPrintReady(client, timing = resolvePrintReadyTiming()) {
398
+ const startedAt = Date.now();
399
+ let lastSignature = "";
400
+ let lastProgressAt = startedAt;
401
+ let stableSince = startedAt;
402
+ let lastSnapshot = { pageCount: 0, overflowingPages: 0, overflowingPageNumbers: [] };
403
+
404
+ while (true) {
405
+ const totalElapsed = Date.now() - startedAt;
406
+ if (totalElapsed > timing.totalTimeoutMs) {
407
+ throw new Error(formatPrintReadyTimeoutMessage("total", lastSnapshot, timing, totalElapsed));
408
+ }
409
+
288
410
  const result = await client.send("Runtime.evaluate", {
289
411
  returnByValue: true,
290
412
  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
- })`,
413
+ expression: printReadinessExpression(),
332
414
  });
333
- const count = Number(result.result?.value ?? 0);
334
- if (count > 0) return count;
335
- await delay(100);
415
+ const value = result.result?.value ?? {};
416
+ const pageCount = Number.isFinite(Number(value.pageCount)) ? Number(value.pageCount) : 0;
417
+ const overflowingPages = Number.isFinite(Number(value.overflowingPages)) ? Number(value.overflowingPages) : 0;
418
+ const overflowingPageNumbers = Array.isArray(value.overflowingPageNumbers)
419
+ ? value.overflowingPageNumbers.map(Number).filter(Number.isFinite)
420
+ : [];
421
+ lastSnapshot = { pageCount, overflowingPages, overflowingPageNumbers };
422
+
423
+ const signature = `${pageCount}:${overflowingPages}`;
424
+ if (signature !== lastSignature) {
425
+ lastSignature = signature;
426
+ stableSince = Date.now();
427
+ lastProgressAt = Date.now();
428
+ }
429
+
430
+ if (pageCount > 0 && Date.now() - stableSince >= timing.stableMs) {
431
+ return { pageCount, overflowingPageNumbers };
432
+ }
433
+
434
+ const idleElapsed = Date.now() - lastProgressAt;
435
+ if (idleElapsed > timing.idleTimeoutMs) {
436
+ throw new Error(formatPrintReadyTimeoutMessage("idle", lastSnapshot, timing, idleElapsed));
437
+ }
438
+
439
+ await delay(timing.pollIntervalMs);
336
440
  }
337
- throw new Error("Timed out waiting for OpenPress pagination before PDF export.");
338
441
  }
339
442
 
340
443
  async function getPrintPageRects(client) {
@@ -139,9 +139,51 @@ export async function inspectRenderedOverflow({ root, config, host = "127.0.0.1"
139
139
  }
140
140
  }
141
141
 
142
- export async function waitForInspectionReady(client) {
143
- const deadline = Date.now() + 30000;
144
- while (Date.now() < deadline) {
142
+ export const INSPECTION_READY_DEFAULTS = Object.freeze({
143
+ totalTimeoutMs: 300_000,
144
+ idleTimeoutMs: 30_000,
145
+ pollIntervalMs: 100,
146
+ });
147
+
148
+ export function resolveInspectionReadyTiming(env = process.env) {
149
+ const total = Number(env.OPENPRESS_INSPECTION_TIMEOUT_MS);
150
+ const idle = Number(env.OPENPRESS_INSPECTION_IDLE_MS);
151
+ return {
152
+ totalTimeoutMs: Number.isFinite(total) && total > 0 ? total : INSPECTION_READY_DEFAULTS.totalTimeoutMs,
153
+ idleTimeoutMs: Number.isFinite(idle) && idle > 0 ? idle : INSPECTION_READY_DEFAULTS.idleTimeoutMs,
154
+ pollIntervalMs: INSPECTION_READY_DEFAULTS.pollIntervalMs,
155
+ };
156
+ }
157
+
158
+ function formatInspectionTimeoutMessage(reason, snapshot, timing, elapsedMs) {
159
+ const seconds = Math.round(elapsedMs / 1000);
160
+ const observed = `(observed ${snapshot.pageCount} page(s))`;
161
+ if (reason === "idle") {
162
+ return (
163
+ `Timed out waiting for OpenPress pagination before inspection. ` +
164
+ `No progress for ${seconds}s ${observed}. ` +
165
+ `Raise OPENPRESS_INSPECTION_IDLE_MS (currently ${timing.idleTimeoutMs}ms) to extend the idle window.`
166
+ );
167
+ }
168
+ return (
169
+ `Timed out waiting for OpenPress pagination before inspection. ` +
170
+ `Total ${seconds}s exceeded ${observed}. ` +
171
+ `Raise OPENPRESS_INSPECTION_TIMEOUT_MS (currently ${timing.totalTimeoutMs}ms) to extend the hard cap.`
172
+ );
173
+ }
174
+
175
+ export async function waitForInspectionReady(client, timing = resolveInspectionReadyTiming()) {
176
+ const startedAt = Date.now();
177
+ let lastSignature = "";
178
+ let lastProgressAt = startedAt;
179
+ let lastSnapshot = { pageCount: 0 };
180
+
181
+ while (true) {
182
+ const totalElapsed = Date.now() - startedAt;
183
+ if (totalElapsed > timing.totalTimeoutMs) {
184
+ throw new Error(formatInspectionTimeoutMessage("total", lastSnapshot, timing, totalElapsed));
185
+ }
186
+
145
187
  const result = await client.send("Runtime.evaluate", {
146
188
  returnByValue: true,
147
189
  awaitPromise: true,
@@ -149,9 +191,23 @@ export async function waitForInspectionReady(client) {
149
191
  });
150
192
  const value = result.result?.value;
151
193
  if (Array.isArray(value)) return value;
152
- await delay(100);
194
+
195
+ const pageCount = Number.isFinite(Number(value?.pageCount)) ? Number(value.pageCount) : lastSnapshot.pageCount;
196
+ lastSnapshot = { pageCount };
197
+
198
+ const signature = String(pageCount);
199
+ if (signature !== lastSignature) {
200
+ lastSignature = signature;
201
+ lastProgressAt = Date.now();
202
+ }
203
+
204
+ const idleElapsed = Date.now() - lastProgressAt;
205
+ if (idleElapsed > timing.idleTimeoutMs) {
206
+ throw new Error(formatInspectionTimeoutMessage("idle", lastSnapshot, timing, idleElapsed));
207
+ }
208
+
209
+ await delay(timing.pollIntervalMs);
153
210
  }
154
- throw new Error("Timed out waiting for OpenPress pagination before inspection.");
155
211
  }
156
212
 
157
213
  export function overflowIssuesFromMeasurements(measurements) {
@@ -241,7 +297,9 @@ function humanOverflowTarget(code) {
241
297
  function inspectionExpression() {
242
298
  return `Promise.resolve().then(async () => {
243
299
  const root = document.querySelector('[data-openpress-print-document="true"]');
244
- if (!root || root.querySelectorAll('.openpress-html-page').length === 0) return null;
300
+ if (!root) return { pending: true, pageCount: 0 };
301
+ const candidates = root.querySelectorAll('.openpress-html-page');
302
+ if (candidates.length === 0) return { pending: true, pageCount: 0 };
245
303
 
246
304
  await document.fonts?.ready;
247
305
  await Promise.all(Array.from(document.images).map(async (img) => {
@@ -290,7 +348,7 @@ function inspectionExpression() {
290
348
  };
291
349
 
292
350
  const wrappers = Array.from(document.querySelectorAll('.openpress-public-page > .openpress-html-page'));
293
- if (wrappers.length === 0) return null;
351
+ if (wrappers.length === 0) return { pending: true, pageCount: candidates.length };
294
352
  return wrappers.map((wrapper, index) => {
295
353
  const page = wrapper.querySelector('.reader-page') || wrapper;
296
354
  const frame = page.querySelector('.page-frame') || page;
@@ -0,0 +1,87 @@
1
+ const TOKEN_PATTERN = /^\s*(-?\d*)\s*(?:-\s*(\d*))?\s*$/;
2
+
3
+ export function parsePageSelector(input) {
4
+ if (typeof input !== "string") {
5
+ throw new TypeError("Page selector must be a string");
6
+ }
7
+ const trimmed = input.trim();
8
+ if (trimmed === "") {
9
+ throw new Error("Page selector is empty; expected something like '3', '3,5-7', or '12-'.");
10
+ }
11
+
12
+ const segments = trimmed.split(",").map((part) => part.trim()).filter(Boolean);
13
+ if (segments.length === 0) {
14
+ throw new Error(`Page selector "${input}" has no usable segments.`);
15
+ }
16
+
17
+ return segments.map((segment) => parseSegment(segment, input));
18
+ }
19
+
20
+ function parseSegment(segment, original) {
21
+ if (!segment.includes("-")) {
22
+ const value = toPositiveInteger(segment, original);
23
+ return { kind: "single", value };
24
+ }
25
+
26
+ if (segment === "-") {
27
+ throw new Error(`Page selector "${original}" contains a bare "-"; ranges need at least one bound.`);
28
+ }
29
+
30
+ const dashIndex = segment.indexOf("-");
31
+ if (segment.indexOf("-", dashIndex + 1) !== -1) {
32
+ throw new Error(`Page selector segment "${segment}" has too many dashes.`);
33
+ }
34
+
35
+ const leftRaw = segment.slice(0, dashIndex);
36
+ const rightRaw = segment.slice(dashIndex + 1);
37
+ const from = leftRaw.trim() === "" ? null : toPositiveInteger(leftRaw, original);
38
+ const to = rightRaw.trim() === "" ? null : toPositiveInteger(rightRaw, original);
39
+
40
+ if (from != null && to != null && from > to) {
41
+ throw new Error(`Page selector range "${segment}" goes backwards (${from} > ${to}).`);
42
+ }
43
+
44
+ return { kind: "range", from, to };
45
+ }
46
+
47
+ function toPositiveInteger(raw, original) {
48
+ const trimmed = raw.trim();
49
+ if (!/^\d+$/.test(trimmed)) {
50
+ throw new Error(`Page selector "${original}" contains non-integer token "${raw}".`);
51
+ }
52
+ const value = Number(trimmed);
53
+ if (!Number.isInteger(value) || value < 1) {
54
+ throw new Error(`Page selector "${original}" contains out-of-range page number "${raw}"; pages start at 1.`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ export function resolvePageSelector(spec, totalPages) {
60
+ if (!Array.isArray(spec)) {
61
+ throw new TypeError("resolvePageSelector expects a parsed selector array");
62
+ }
63
+ if (!Number.isInteger(totalPages) || totalPages < 0) {
64
+ throw new TypeError("resolvePageSelector expects a non-negative integer totalPages");
65
+ }
66
+ if (totalPages === 0) return [];
67
+
68
+ const selected = new Set();
69
+ for (const segment of spec) {
70
+ if (segment.kind === "single") {
71
+ if (segment.value > totalPages) {
72
+ throw new Error(`Page ${segment.value} is out of range; document has ${totalPages} page(s).`);
73
+ }
74
+ selected.add(segment.value);
75
+ continue;
76
+ }
77
+ const from = segment.from ?? 1;
78
+ const to = segment.to ?? totalPages;
79
+ if (from > totalPages) {
80
+ throw new Error(`Range start ${from} is out of range; document has ${totalPages} page(s).`);
81
+ }
82
+ const upper = Math.min(to, totalPages);
83
+ for (let i = from; i <= upper; i += 1) selected.add(i);
84
+ }
85
+
86
+ return Array.from(selected).sort((a, b) => a - b);
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-press/core",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "description": "open-press core — runtime primitives, CLI, and render pipeline for AI-first fixed-layout documents.",
6
6
  "license": "MIT",