Files
gstack/make-pdf/src/orchestrator.ts
T
Garry Tanandcapy-ai[bot] 9dea818e34 v1.91.122.0 fix(deps): clear the 2026-11-30 OSV suppressions, replace #3228, exempt dependabot from Version Gate rule 1 (#3239)
* fix(deps): clear the OSV suppressions that expire on 2026-11-30

Delete the hono, @anthropic-ai/sdk (#2754) and sprintf-js ignores, which the
lockfiles no longer match. Override image-size to 2.0.4 (#2753) and chokidar
to 4.0.3 in lib/diagram-render (drops braces), and delete those ignores.
Re-date katex to 2027-01-31: its fix is outside mermaid 11's range
(mermaid-js/mermaid#8388).

html-to-docx 1.8.0 bundles its own image-size 1.x, which the override cannot
reach and whose ICNS parser loops forever. make-pdf --to docx now hands it
only PNG, JPEG, GIF, WebP or SVG bytes. A golden docx test proves the Word
output is unchanged.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* build(deps-dev): @types/bun 1.4.3, prettier 3.9.10; hold the Anthropic SDKs to patches

Replaces dependabot #3228 without its agent-sdk 0.3 and sdk 0.133 bumps,
which break typecheck:test and overlay-sdk-cancel-eof. Their 0.x breaking
releases are semver-minor to dependabot, so the ignore covers minor and major.
@types/bun 1.4.3 retires 18 test type diagnostics; the baseline locks that in.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ci): exempt dependabot dependency-manifest PRs from Version Gate rule 1

Dependabot never edits VERSION, so hard rule 1 failed every package.json PR
it opened. The workflow now passes the PR author and changed-file list; the
gate skips rule 1 only for dependabot[bot] when every file is a package.json,
bun.lock(b) or .github/dependabot.yml. A failed file lookup keeps the rule.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(make-pdf): guard every html-to-docx sizing path, keep non-ICNS formats

Review found three spellings that got past the regex guard and still hung
--to docx: a data-src decoy, a percent-encoded separator, and an unquoted
encoded colon. html-to-docx sizes images on three paths, and buildImage
URI-decodes the whole src first. The guard now mirrors all three, drops ICNS
(the only input its bundled image-size routes to the looping parser) instead
of allowlisting formats, so BMP and TIFF embed as before, and re-parses the
result with html-to-docx's own html-to-vdom to fail closed on anything the
regex misreads. The golden test tolerates CRLF checkouts on Windows.

Version Gate treats a failed file lookup as an empty list, so a partial
paginated result cannot decide the exemption.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(release): stamp v1.91.121.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore(release): restamp v1.91.122.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-17 16:47:50 +00:00

342 lines
13 KiB
TypeScript

/**
* Orchestrator — ties render, the diagram pre-pass, asideClient, and the
* filesystem together.
*
* generate(opts): markdown → PDF on disk. Returns output path.
* preview(opts): markdown → HTML, opens it in a browser.
*
* Progress indication (per DX spec):
* - stdout: ONLY the output path, printed by cli.ts after this returns.
* - stderr: spinner + per-stage status lines, unless opts.quiet.
* - --verbose: stage timings.
*
* Every browser step is its own lib/aside-render `render()` call (an Aside
* script when Aside is running, otherwise a tab in gstack's own headless
* browser; nothing persists between them): one batch for diagram fences, one
* for oversized-image downscales, one for DOCX rasters, one print. Parallel
* $P generate calls never share state.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { spawn } from "node:child_process";
import { render } from "./render";
import { screenCss } from "./print-css";
import type { GenerateOptions, PreviewOptions } from "./types";
import { ExitCode } from "./types";
import { pickEngine } from "../../lib/aside-render";
import { renderPdf } from "./asideClient";
import { printWithTocPages } from "./toc-pages";
import {
bundleRunner,
contentWidthInches,
convertDiagnosticsForDocx,
extractDiagramFences,
inlineLocalImages,
landscapeContentBox,
rasterizeDiagramFigures,
renderFenceSlots,
substituteSlots,
} from "./diagram-prepass";
import { applyImagePolicy } from "./image-policy";
import { guardDocxImages } from "./docx-images";
/** Default output location (`$P generate letter.md` → /tmp/letter.pdf). */
export const OUTPUT_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
class ProgressReporter {
private readonly quiet: boolean;
private readonly verbose: boolean;
private readonly stageStart = new Map<string, number>();
private readonly totalStart: number;
constructor(opts: { quiet?: boolean; verbose?: boolean }) {
this.quiet = opts.quiet === true;
this.verbose = opts.verbose === true;
this.totalStart = Date.now();
}
begin(stage: string): void {
this.stageStart.set(stage, Date.now());
if (this.quiet) return;
process.stderr.write(`\r\x1b[K${stage}...`);
}
end(stage: string, extra?: string): void {
const start = this.stageStart.get(stage) ?? Date.now();
const ms = Date.now() - start;
if (this.quiet) return;
if (this.verbose) {
process.stderr.write(`\r\x1b[K${stage} (${ms}ms)${extra ? ` — ${extra}` : ""}\n`);
}
}
done(extra: string): void {
if (this.quiet) return;
const total = ((Date.now() - this.totalStart) / 1000).toFixed(1);
process.stderr.write(`\r\x1b[KDone in ${total}s. ${extra}\n`);
}
fail(stage: string, err: Error): void {
if (!this.quiet) process.stderr.write("\r\x1b[K");
// Always emit failure info, even in quiet mode — this is an error path.
process.stderr.write(`${stage} failed: ${err.message}\n`);
}
}
/**
* generate — full pipeline. Returns the output PDF path on success.
*/
export async function generate(opts: GenerateOptions): Promise<string> {
const progress = new ProgressReporter(opts);
const input = path.resolve(opts.input);
if (!fs.existsSync(input)) {
throw new Error(`input file not found: ${input}`);
}
const to = opts.to ?? "pdf";
const outputPath = path.resolve(
opts.output ?? path.join(OUTPUT_TMP_DIR, `${deriveSlug(input)}.${to}`),
);
// Stage 1: read markdown
progress.begin("Reading markdown");
const markdown = fs.readFileSync(input, "utf8");
progress.end("Reading markdown");
// Stage 1.5: diagram pre-pass — extract ```mermaid/```excalidraw fences and
// swap in placeholder tokens. Rendering happens in Stage 2.5 below.
const extraction = extractDiagramFences(markdown);
// Stage 2: render HTML
progress.begin("Rendering HTML");
const rendered = render({
markdown: extraction.markdown,
title: opts.title,
author: opts.author,
date: opts.date,
cover: opts.cover,
toc: opts.toc,
watermark: opts.watermark,
noChapterBreaks: opts.noChapterBreaks,
confidential: opts.confidential,
pageSize: opts.pageSize,
margins: opts.margins,
marginTop: opts.marginTop,
marginRight: opts.marginRight,
marginBottom: opts.marginBottom,
marginLeft: opts.marginLeft,
pageNumbers: opts.pageNumbers,
footerTemplate: opts.footerTemplate,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
// Stage 2.5: render diagram fences through the bundle, substitute slots,
// then inline + probe + (if oversized) downscale local images. The runner
// resolves the bundle lazily, so image-only documents never touch it; a
// missing bundle or missing browser surfaces per fence as a diagnostic block.
const warn = (msg: string) => {
if (!opts.quiet) process.stderr.write(`\r\x1b[K[make-pdf] warning: ${msg}\n`);
};
const run = bundleRunner();
let hasLandscape = false;
let finalHtml = rendered.html;
if (extraction.fences.length > 0) {
progress.begin(`Rendering ${extraction.fences.length} diagram(s)`);
finalHtml = substituteSlots(finalHtml, await renderFenceSlots(extraction.fences, run, warn));
progress.end(`Rendering ${extraction.fences.length} diagram(s)`);
}
progress.begin("Inlining images");
const contentWidthIn = contentWidthInches(opts);
finalHtml = await inlineLocalImages(finalHtml, {
inputDir: path.dirname(input),
strict: opts.strict === true,
allowNetwork: opts.allowNetwork === true,
contentWidthIn,
warn,
run,
});
progress.end("Inlining images");
// Width directives + conservative auto-landscape (image-policy).
const policy = applyImagePolicy(finalHtml, {
contentWidthIn,
landscape: landscapeContentBox(opts),
warn,
});
finalHtml = policy.html;
hasLandscape = policy.hasLandscape;
// DOCX needs rasters, not inline SVG (Word's SVG support is unreliable).
if (to === "docx") {
if (/<figure class="diagram"|data:image\/svg\+xml/.test(finalHtml)) {
progress.begin("Rasterizing diagrams for DOCX");
finalHtml = await rasterizeDiagramFigures(finalHtml, run, contentWidthIn, warn);
progress.end("Rasterizing diagrams for DOCX");
}
finalHtml = convertDiagnosticsForDocx(finalHtml);
finalHtml = guardDocxImages(finalHtml, warn);
}
// ─── --to html: write the self-contained document, no print round-trip ──
if (to === "html") {
const withScreenLayer = finalHtml.replace(
"</style>",
`</style>\n<style>\n${screenCss()}\n</style>`,
);
fs.writeFileSync(outputPath, withScreenLayer, "utf8");
const kb = Math.round(fs.statSync(outputPath).size / 1024);
progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
return outputPath;
}
// ─── --to docx: content-fidelity conversion (eng-review P8) ────────────
if (to === "docx") {
// Print-only surfaces don't survive the conversion. The watermark div
// would degrade to a literal body paragraph reading "DRAFT" (worse than
// absent) — strip it. Warn once about print-only flags that were set.
finalHtml = finalHtml.replace(/<div class="watermark">[\s\S]*?<\/div>/, "");
const printOnly: string[] = [];
if (opts.watermark) printOnly.push("--watermark");
if (opts.headerTemplate) printOnly.push("--header-template");
if (opts.footerTemplate) printOnly.push("--footer-template");
if (opts.pageSize) printOnly.push("--page-size");
if (opts.margins || opts.marginTop || opts.marginRight || opts.marginBottom || opts.marginLeft) printOnly.push("--margins");
if (printOnly.length > 0) {
warn(`docx is content-fidelity: ${printOnly.join(", ")} do not apply to Word output`);
}
progress.begin("Converting to DOCX");
const { default: HTMLtoDOCX } = await import("html-to-docx");
const buf = await HTMLtoDOCX(finalHtml, null, {
title: rendered.meta.title,
creator: rendered.meta.author || undefined,
});
const bytes: Uint8Array = buf instanceof Uint8Array ? buf : new Uint8Array(await (buf as Blob).arrayBuffer());
fs.writeFileSync(outputPath, bytes);
progress.end("Converting to DOCX");
const kb = Math.round(fs.statSync(outputPath).size / 1024);
progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath} (content fidelity — layout is Word's)`);
return outputPath;
}
// Stage 3: print — serve the staged HTML over loopback and print through
// whichever browser is up. With --toc, toc-pages.ts prints until the TOC's
// page-number cells match the pages the PDF itself puts each heading on.
const engine = pickEngine().engine;
const via = engine === "aside" ? "Aside" : engine === "browse" ? "gstack's browser" : "a browser";
progress.begin(`Rendering PDF through ${via}`);
const print = (html: string, out: string) => renderPdf(html, {
output: out,
format: opts.pageSize ?? "letter",
marginTop: opts.marginTop ?? opts.margins ?? "1in",
marginRight: opts.marginRight ?? opts.margins ?? "1in",
marginBottom: opts.marginBottom ?? opts.margins ?? "1in",
marginLeft: opts.marginLeft ?? opts.margins ?? "1in",
headerTemplate: opts.headerTemplate,
footerTemplate: opts.footerTemplate,
// CSS is the single source of truth for page numbers (see print-css.ts
// @bottom-center). Chromium's native numbering always off to avoid double
// footers. The CSS layer honors pageNumbers + footerTemplate via render().
pageNumbers: false,
tagged: opts.tagged !== false,
outline: opts.outline !== false,
printBackground: !!opts.watermark,
// Named landscape pages only take effect when Chromium honors CSS page
// sizes. Flip it ONLY when a promotion exists — minimal behavior change
// for every other document.
preferCSSPageSize: hasLandscape ? true : undefined,
});
const used = opts.toc
? await printWithTocPages(finalHtml, outputPath, print)
: await print(finalHtml, outputPath);
progress.end(`Rendering PDF through ${via}`);
if (used && used !== engine) {
// render() fell back mid-run (Aside quit or its CLI could not start): say
// which browser actually produced the file, since the label above was
// decided before the render.
process.stderr.write(" Aside was unavailable mid-run; the PDF was rendered through gstack's own browser.\n");
}
const kb = Math.round(fs.statSync(outputPath).size / 1024);
progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
return outputPath;
}
/**
* preview — render HTML and open it. No PDF round trip.
*/
export async function preview(opts: PreviewOptions): Promise<string> {
const progress = new ProgressReporter(opts);
const input = path.resolve(opts.input);
if (!fs.existsSync(input)) {
throw new Error(`input file not found: ${input}`);
}
progress.begin("Rendering HTML");
const markdown = fs.readFileSync(input, "utf8");
// Preview deliberately skips the diagram/image pre-pass (no browser
// round-trip — preview is the fast loop). Be loud about the divergence so
// nobody signs off on a preview that lacks what the PDF will have.
if (!opts.quiet) {
const fenceCount = extractDiagramFences(markdown).fences.length;
const hasLocalImages = /!\[[^\]]*\]\((?!https?:|data:)[^)]+\)/.test(markdown);
if (fenceCount > 0 || hasLocalImages) {
process.stderr.write(
`[make-pdf] preview note: ${fenceCount > 0 ? `${fenceCount} diagram fence(s) shown as code` : ""}` +
`${fenceCount > 0 && hasLocalImages ? "; " : ""}` +
`${hasLocalImages ? "local images may not resolve from the preview location" : ""}` +
` — \`generate\` renders them fully.\n`,
);
}
}
const rendered = render({
markdown,
title: opts.title,
author: opts.author,
date: opts.date,
cover: opts.cover,
toc: opts.toc,
watermark: opts.watermark,
noChapterBreaks: opts.noChapterBreaks,
confidential: opts.confidential,
pageNumbers: opts.pageNumbers,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
// Write to a stable path under /tmp so the user can reload in the same tab.
const previewPath = path.join(OUTPUT_TMP_DIR, `make-pdf-preview-${deriveSlug(input)}.html`);
fs.writeFileSync(previewPath, rendered.html, "utf8");
progress.begin("Opening preview");
tryOpen(previewPath);
progress.end("Opening preview");
progress.done(`Preview at ${previewPath}`);
return previewPath;
}
// ─── helpers ──────────────────────────────────────────────
function deriveSlug(p: string): string {
const base = path.basename(p).replace(/\.[^.]+$/, "");
return base.replace(/[^a-zA-Z0-9-_]+/g, "-").slice(0, 64) || "document";
}
function tryOpen(pathOrUrl: string): void {
const platform = process.platform;
const cmd = platform === "darwin" ? "open" :
platform === "win32" ? "cmd" :
"xdg-open";
const args = platform === "win32" ? ["/c", "start", "", pathOrUrl] : [pathOrUrl];
try {
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
child.unref();
} catch {
// Non-fatal; the caller already has the path and will print it.
}
}
/** Setup-only re-export so cli.ts can dynamic-import without another file. */
export { ExitCode };