@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 +2 -2
- package/engine/commands/_shared.mjs +91 -9
- package/engine/commands/image.mjs +20 -4
- package/engine/commands/pdf.mjs +4 -1
- package/engine/output/chrome-pdf.mjs +257 -52
- package/engine/output/static-server.mjs +48 -8
- package/engine/react/document-export.mjs +22 -0
- package/engine/runtime/inspection.mjs +65 -7
- package/engine/runtime/page-selector.mjs +87 -0
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +1 -0
- package/src/openpress/app/OpenPressRuntime.tsx +59 -5
- package/src/openpress/reader/PublicReaderPage.tsx +163 -74
- package/src/openpress/reader/SlidePresentationPage.tsx +8 -3
- package/src/openpress/reader/usePanelState.ts +14 -5
- package/src/openpress/shared/index.ts +1 -0
- package/src/openpress/shared/staticSearch.ts +174 -0
- package/src/openpress/workbench/Workbench.tsx +19 -16
- package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +14 -3
- package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
- package/vite.config.ts +50 -8
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
|
|
182
|
+
const result = await printUrlToPdf({
|
|
124
183
|
root,
|
|
125
|
-
url:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
167
|
-
|
|
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) :
|
|
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(`
|
|
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
|
-
|
|
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
|
}
|
package/engine/commands/pdf.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
199
|
-
const
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
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:
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
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) {
|