asideClient.ts
114 lines · make-pdf/src/asideClient.ts at 3f79d81
1/**
2 * make-pdf's render client: final HTML → PDF through lib/aside-render's
3 * `render()` — the Aside browser (macOS 15+, aside.com) when it is running,
4 * otherwise gstack's own headless browser (the browse daemon; GSTACK_BROWSE_BIN
5 * / BROWSE_BIN override where it lives). The HTML is staged into a private
6 * dir, served over loopback for the duration of the render, printed, and the
7 * PDF copied to `output`. No browser at all is exit 4 (BrowserUnavailableError).
8 */
10import * as fs from "node:fs";
11import * as path from "node:path";
13import {
14 NO_BROWSER,
15 PAGE_NUMBER_FOOTER,
16 lengthToInches,
17 paperInches,
18 render,
19 renderTmpDir,
20 type PdfStepOptions,
21 type RenderEngine,
22} from "../../lib/aside-render";
23import { BrowserUnavailableError } from "./types";
25export interface PdfOptions {
26 output: string;
27 format?: string;
28 width?: string;
29 height?: string;
30 marginTop?: string;
31 marginRight?: string;
32 marginBottom?: string;
33 marginLeft?: string;
34 headerTemplate?: string;
35 footerTemplate?: string;
36 pageNumbers?: boolean;
37 tagged?: boolean;
38 outline?: boolean;
39 printBackground?: boolean;
40 preferCSSPageSize?: boolean;
41}
43/**
44 * make-pdf's option shape → CDP Page.printToPDF options (inches). Same
45 * mapping the browse `pdf` command applies: Letter when no size is
46 * given, empty `<div></div>` for whichever header/footer slot is unset so
47 * Chromium never prints its default URL/date, margins default to none.
48 */
49export function pdfStepOptions(opts: PdfOptions): PdfStepOptions {
50 const o: PdfStepOptions = {};
52 if (opts.format) {
53 const paper = paperInches(opts.format);
54 if (!paper) throw new Error(`unknown page size: ${opts.format}`);
55 [o.paperWidth, o.paperHeight] = paper;
56 } else if (opts.width && opts.height) {
57 o.paperWidth = lengthToInches(opts.width);
58 o.paperHeight = lengthToInches(opts.height);
59 } else {
60 [o.paperWidth, o.paperHeight] = paperInches("letter")!;
61 }
63 o.marginTop = lengthToInches(opts.marginTop) ?? 0;
64 o.marginRight = lengthToInches(opts.marginRight) ?? 0;
65 o.marginBottom = lengthToInches(opts.marginBottom) ?? 0;
66 o.marginLeft = lengthToInches(opts.marginLeft) ?? 0;
68 if (opts.headerTemplate !== undefined || opts.footerTemplate !== undefined || opts.pageNumbers === true) {
69 o.displayHeaderFooter = true;
70 o.headerTemplate = opts.headerTemplate ?? "<div></div>";
71 o.footerTemplate = opts.pageNumbers ? PAGE_NUMBER_FOOTER : (opts.footerTemplate ?? "<div></div>");
72 }
74 if (opts.tagged === true) o.generateTaggedPDF = true;
75 if (opts.outline === true) o.generateDocumentOutline = true;
76 if (opts.printBackground === true) o.printBackground = true;
77 if (opts.preferCSSPageSize === true) o.preferCSSPageSize = true;
78 return o;
79}
81/**
82 * Render a self-contained HTML document to `opts.output`. Everything the page
83 * needs must be inline (the orchestrator inlines images as data URIs): only
84 * the staging dir is served.
85 */
86export async function renderPdf(
87 html: string,
88 opts: PdfOptions,
89 renderFn: typeof render = render,
90): Promise<RenderEngine | undefined> {
91 const dir = fs.mkdtempSync(path.join(renderTmpDir(), "make-pdf-"));
92 try {
93 const file = path.join(dir, "document.html");
94 fs.writeFileSync(file, html, "utf8");
95 const result = await renderFn({
96 file,
97 steps: [{ kind: "pdf", out: path.resolve(opts.output), options: pdfStepOptions(opts) }],
98 });
99 if (!result.ok) throw renderFailure(result.error ?? "unknown error");
100 return result.engine;
101 } finally {
102 fs.rmSync(dir, { recursive: true, force: true });
103 }
104}
106/**
107 * Classify a failed render: a BrowserUnavailableError (exit 4) when neither
108 * browser could run at all, otherwise a plain render error (exit 2).
109 */
110export function renderFailure(detail: string): Error {
111 return detail.startsWith(NO_BROWSER)
112 ? new BrowserUnavailableError(detail)
113 : new Error(`PDF render failed: ${detail}`);
114}