orchestrator.ts
341 lines · make-pdf/src/orchestrator.ts at 3f79d81
1/**
2 * Orchestrator — ties render, the diagram pre-pass, asideClient, and the
3 * filesystem together.
4 *
5 * generate(opts): markdown → PDF on disk. Returns output path.
6 * preview(opts): markdown → HTML, opens it in a browser.
7 *
8 * Progress indication (per DX spec):
9 * - stdout: ONLY the output path, printed by cli.ts after this returns.
10 * - stderr: spinner + per-stage status lines, unless opts.quiet.
11 * - --verbose: stage timings.
12 *
13 * Every browser step is its own lib/aside-render `render()` call (an Aside
14 * script when Aside is running, otherwise a tab in gstack's own headless
15 * browser; nothing persists between them): one batch for diagram fences, one
16 * for oversized-image downscales, one for DOCX rasters, one print. Parallel
17 * $P generate calls never share state.
18 */
20import * as fs from "node:fs";
21import * as os from "node:os";
22import * as path from "node:path";
23import { spawn } from "node:child_process";
25import { render } from "./render";
26import { screenCss } from "./print-css";
27import type { GenerateOptions, PreviewOptions } from "./types";
28import { ExitCode } from "./types";
29import { pickEngine } from "../../lib/aside-render";
30import { renderPdf } from "./asideClient";
31import { printWithTocPages } from "./toc-pages";
32import {
33 bundleRunner,
34 contentWidthInches,
35 convertDiagnosticsForDocx,
36 extractDiagramFences,
37 inlineLocalImages,
38 landscapeContentBox,
39 rasterizeDiagramFigures,
40 renderFenceSlots,
41 substituteSlots,
42} from "./diagram-prepass";
43import { applyImagePolicy } from "./image-policy";
44import { guardDocxImages } from "./docx-images";
46/** Default output location (`$P generate letter.md` → /tmp/letter.pdf). */
47export const OUTPUT_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
49class ProgressReporter {
50 private readonly quiet: boolean;
51 private readonly verbose: boolean;
52 private readonly stageStart = new Map<string, number>();
53 private readonly totalStart: number;
54 constructor(opts: { quiet?: boolean; verbose?: boolean }) {
55 this.quiet = opts.quiet === true;
56 this.verbose = opts.verbose === true;
57 this.totalStart = Date.now();
58 }
59 begin(stage: string): void {
60 this.stageStart.set(stage, Date.now());
61 if (this.quiet) return;
62 process.stderr.write(`\r\x1b[K${stage}...`);
63 }
64 end(stage: string, extra?: string): void {
65 const start = this.stageStart.get(stage) ?? Date.now();
66 const ms = Date.now() - start;
67 if (this.quiet) return;
68 if (this.verbose) {
69 process.stderr.write(`\r\x1b[K${stage} (${ms}ms)${extra ? ` — ${extra}` : ""}\n`);
70 }
71 }
72 done(extra: string): void {
73 if (this.quiet) return;
74 const total = ((Date.now() - this.totalStart) / 1000).toFixed(1);
75 process.stderr.write(`\r\x1b[KDone in ${total}s. ${extra}\n`);
76 }
77 fail(stage: string, err: Error): void {
78 if (!this.quiet) process.stderr.write("\r\x1b[K");
79 // Always emit failure info, even in quiet mode — this is an error path.
80 process.stderr.write(`${stage} failed: ${err.message}\n`);
81 }
82}
84/**
85 * generate — full pipeline. Returns the output PDF path on success.
86 */
87export async function generate(opts: GenerateOptions): Promise<string> {
88 const progress = new ProgressReporter(opts);
89 const input = path.resolve(opts.input);
91 if (!fs.existsSync(input)) {
92 throw new Error(`input file not found: ${input}`);
93 }
95 const to = opts.to ?? "pdf";
96 const outputPath = path.resolve(
97 opts.output ?? path.join(OUTPUT_TMP_DIR, `${deriveSlug(input)}.${to}`),
98 );
100 // Stage 1: read markdown
101 progress.begin("Reading markdown");
102 const markdown = fs.readFileSync(input, "utf8");
103 progress.end("Reading markdown");
105 // Stage 1.5: diagram pre-pass — extract ```mermaid/```excalidraw fences and
106 // swap in placeholder tokens. Rendering happens in Stage 2.5 below.
107 const extraction = extractDiagramFences(markdown);
109 // Stage 2: render HTML
110 progress.begin("Rendering HTML");
111 const rendered = render({
112 markdown: extraction.markdown,
113 title: opts.title,
114 author: opts.author,
115 date: opts.date,
116 cover: opts.cover,
117 toc: opts.toc,
118 watermark: opts.watermark,
119 noChapterBreaks: opts.noChapterBreaks,
120 confidential: opts.confidential,
121 pageSize: opts.pageSize,
122 margins: opts.margins,
123 marginTop: opts.marginTop,
124 marginRight: opts.marginRight,
125 marginBottom: opts.marginBottom,
126 marginLeft: opts.marginLeft,
127 pageNumbers: opts.pageNumbers,
128 footerTemplate: opts.footerTemplate,
129 });
130 progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
132 // Stage 2.5: render diagram fences through the bundle, substitute slots,
133 // then inline + probe + (if oversized) downscale local images. The runner
134 // resolves the bundle lazily, so image-only documents never touch it; a
135 // missing bundle or missing browser surfaces per fence as a diagnostic block.
136 const warn = (msg: string) => {
137 if (!opts.quiet) process.stderr.write(`\r\x1b[K[make-pdf] warning: ${msg}\n`);
138 };
139 const run = bundleRunner();
140 let hasLandscape = false;
142 let finalHtml = rendered.html;
143 if (extraction.fences.length > 0) {
144 progress.begin(`Rendering ${extraction.fences.length} diagram(s)`);
145 finalHtml = substituteSlots(finalHtml, await renderFenceSlots(extraction.fences, run, warn));
146 progress.end(`Rendering ${extraction.fences.length} diagram(s)`);
147 }
149 progress.begin("Inlining images");
150 const contentWidthIn = contentWidthInches(opts);
151 finalHtml = await inlineLocalImages(finalHtml, {
152 inputDir: path.dirname(input),
153 strict: opts.strict === true,
154 allowNetwork: opts.allowNetwork === true,
155 contentWidthIn,
156 warn,
157 run,
158 });
159 progress.end("Inlining images");
161 // Width directives + conservative auto-landscape (image-policy).
162 const policy = applyImagePolicy(finalHtml, {
163 contentWidthIn,
164 landscape: landscapeContentBox(opts),
165 warn,
166 });
167 finalHtml = policy.html;
168 hasLandscape = policy.hasLandscape;
170 // DOCX needs rasters, not inline SVG (Word's SVG support is unreliable).
171 if (to === "docx") {
172 if (/<figure class="diagram"|data:image\/svg\+xml/.test(finalHtml)) {
173 progress.begin("Rasterizing diagrams for DOCX");
174 finalHtml = await rasterizeDiagramFigures(finalHtml, run, contentWidthIn, warn);
175 progress.end("Rasterizing diagrams for DOCX");
176 }
177 finalHtml = convertDiagnosticsForDocx(finalHtml);
178 finalHtml = guardDocxImages(finalHtml, warn);
179 }
181 // ─── --to html: write the self-contained document, no print round-trip ──
182 if (to === "html") {
183 const withScreenLayer = finalHtml.replace(
184 "</style>",
185 `</style>\n<style>\n${screenCss()}\n</style>`,
186 );
187 fs.writeFileSync(outputPath, withScreenLayer, "utf8");
188 const kb = Math.round(fs.statSync(outputPath).size / 1024);
189 progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
190 return outputPath;
191 }
193 // ─── --to docx: content-fidelity conversion (eng-review P8) ────────────
194 if (to === "docx") {
195 // Print-only surfaces don't survive the conversion. The watermark div
196 // would degrade to a literal body paragraph reading "DRAFT" (worse than
197 // absent) — strip it. Warn once about print-only flags that were set.
198 finalHtml = finalHtml.replace(/<div class="watermark">[\s\S]*?<\/div>/, "");
199 const printOnly: string[] = [];
200 if (opts.watermark) printOnly.push("--watermark");
201 if (opts.headerTemplate) printOnly.push("--header-template");
202 if (opts.footerTemplate) printOnly.push("--footer-template");
203 if (opts.pageSize) printOnly.push("--page-size");
204 if (opts.margins || opts.marginTop || opts.marginRight || opts.marginBottom || opts.marginLeft) printOnly.push("--margins");
205 if (printOnly.length > 0) {
206 warn(`docx is content-fidelity: ${printOnly.join(", ")} do not apply to Word output`);
207 }
208 progress.begin("Converting to DOCX");
209 const { default: HTMLtoDOCX } = await import("html-to-docx");
210 const buf = await HTMLtoDOCX(finalHtml, null, {
211 title: rendered.meta.title,
212 creator: rendered.meta.author || undefined,
213 });
214 const bytes: Uint8Array = buf instanceof Uint8Array ? buf : new Uint8Array(await (buf as Blob).arrayBuffer());
215 fs.writeFileSync(outputPath, bytes);
216 progress.end("Converting to DOCX");
217 const kb = Math.round(fs.statSync(outputPath).size / 1024);
218 progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath} (content fidelity — layout is Word's)`);
219 return outputPath;
220 }
222 // Stage 3: print — serve the staged HTML over loopback and print through
223 // whichever browser is up. With --toc, toc-pages.ts prints until the TOC's
224 // page-number cells match the pages the PDF itself puts each heading on.
225 const engine = pickEngine().engine;
226 const via = engine === "aside" ? "Aside" : engine === "browse" ? "gstack's browser" : "a browser";
227 progress.begin(`Rendering PDF through ${via}`);
228 const print = (html: string, out: string) => renderPdf(html, {
229 output: out,
230 format: opts.pageSize ?? "letter",
231 marginTop: opts.marginTop ?? opts.margins ?? "1in",
232 marginRight: opts.marginRight ?? opts.margins ?? "1in",
233 marginBottom: opts.marginBottom ?? opts.margins ?? "1in",
234 marginLeft: opts.marginLeft ?? opts.margins ?? "1in",
235 headerTemplate: opts.headerTemplate,
236 footerTemplate: opts.footerTemplate,
237 // CSS is the single source of truth for page numbers (see print-css.ts
238 // @bottom-center). Chromium's native numbering always off to avoid double
239 // footers. The CSS layer honors pageNumbers + footerTemplate via render().
240 pageNumbers: false,
241 tagged: opts.tagged !== false,
242 outline: opts.outline !== false,
243 printBackground: !!opts.watermark,
244 // Named landscape pages only take effect when Chromium honors CSS page
245 // sizes. Flip it ONLY when a promotion exists — minimal behavior change
246 // for every other document.
247 preferCSSPageSize: hasLandscape ? true : undefined,
248 });
249 const used = opts.toc
250 ? await printWithTocPages(finalHtml, outputPath, print)
251 : await print(finalHtml, outputPath);
252 progress.end(`Rendering PDF through ${via}`);
253 if (used && used !== engine) {
254 // render() fell back mid-run (Aside quit or its CLI could not start): say
255 // which browser actually produced the file, since the label above was
256 // decided before the render.
257 process.stderr.write(" Aside was unavailable mid-run; the PDF was rendered through gstack's own browser.\n");
258 }
260 const kb = Math.round(fs.statSync(outputPath).size / 1024);
261 progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
263 return outputPath;
264}
266/**
267 * preview — render HTML and open it. No PDF round trip.
268 */
269export async function preview(opts: PreviewOptions): Promise<string> {
270 const progress = new ProgressReporter(opts);
271 const input = path.resolve(opts.input);
272 if (!fs.existsSync(input)) {
273 throw new Error(`input file not found: ${input}`);
274 }
276 progress.begin("Rendering HTML");
277 const markdown = fs.readFileSync(input, "utf8");
278 // Preview deliberately skips the diagram/image pre-pass (no browser
279 // round-trip — preview is the fast loop). Be loud about the divergence so
280 // nobody signs off on a preview that lacks what the PDF will have.
281 if (!opts.quiet) {
282 const fenceCount = extractDiagramFences(markdown).fences.length;
283 const hasLocalImages = /!\[[^\]]*\]\((?!https?:|data:)[^)]+\)/.test(markdown);
284 if (fenceCount > 0 || hasLocalImages) {
285 process.stderr.write(
286 `[make-pdf] preview note: ${fenceCount > 0 ? `${fenceCount} diagram fence(s) shown as code` : ""}` +
287 `${fenceCount > 0 && hasLocalImages ? "; " : ""}` +
288 `${hasLocalImages ? "local images may not resolve from the preview location" : ""}` +
289 ` — \`generate\` renders them fully.\n`,
290 );
291 }
292 }
293 const rendered = render({
294 markdown,
295 title: opts.title,
296 author: opts.author,
297 date: opts.date,
298 cover: opts.cover,
299 toc: opts.toc,
300 watermark: opts.watermark,
301 noChapterBreaks: opts.noChapterBreaks,
302 confidential: opts.confidential,
303 pageNumbers: opts.pageNumbers,
304 });
305 progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
307 // Write to a stable path under /tmp so the user can reload in the same tab.
308 const previewPath = path.join(OUTPUT_TMP_DIR, `make-pdf-preview-${deriveSlug(input)}.html`);
309 fs.writeFileSync(previewPath, rendered.html, "utf8");
311 progress.begin("Opening preview");
312 tryOpen(previewPath);
313 progress.end("Opening preview");
315 progress.done(`Preview at ${previewPath}`);
316 return previewPath;
317}
319// ─── helpers ──────────────────────────────────────────────
321function deriveSlug(p: string): string {
322 const base = path.basename(p).replace(/\.[^.]+$/, "");
323 return base.replace(/[^a-zA-Z0-9-_]+/g, "-").slice(0, 64) || "document";
324}
326function tryOpen(pathOrUrl: string): void {
327 const platform = process.platform;
328 const cmd = platform === "darwin" ? "open" :
329 platform === "win32" ? "cmd" :
330 "xdg-open";
331 const args = platform === "win32" ? ["/c", "start", "", pathOrUrl] : [pathOrUrl];
332 try {
333 const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
334 child.unref();
335 } catch {
336 // Non-fatal; the caller already has the path and will print it.
337 }
338}
340/** Setup-only re-export so cli.ts can dynamic-import without another file. */
341export { ExitCode };