Workflow-App/Workflow/lib/pdf-render.ts

100 lines
3.4 KiB
TypeScript
Raw Normal View History

// lib/pdf-render.ts
// SERVER-ONLY — bridges the Workflow app to the patched minimax-pdf skill.
//
// Why: minimax-pdf produces print-quality branded PDFs (Playfair Display, navy
// accent rules, alternating row tints). The skill lives in the ysterbaard
// profile at ~/.hermes/profiles/ysterbaard/skills/minimax-pdf — installed with
// auto h2 keepWithNext (no orphan headings) + an explicit `keep_together`
// block type.
//
// Fallback order:
// 1. Profile skill path (preferred, has David's edits).
// 2. Default skill path (~/.hermes/skills/minimax-pdf, symlinked in this env).
// 3. Throw with a clear "skill not installed" error.
import "server-only";
import { spawn } from "child_process";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
const HOME = os.homedir();
const SKILL_PATHS = [
path.join(HOME, ".hermes/profiles/ysterbaard/skills/minimax-pdf"),
path.join(HOME, ".hermes/skills/minimax-pdf"),
];
export interface RenderPdfOptions {
/** Title shown on the cover page. */
title: string;
/** Subtitle / company-or-director name shown on the cover. */
author: string;
/** Date string for the cover (e.g. "30 August 2026"). */
date: string;
/** Hex color, e.g. "#1C3A5E". Deep navy = legal/compliance. */
accent: string;
/** Document type — see skill (report/proposal/etc). "report" is the default. */
type?: "report" | "proposal" | "general" | "minimal";
/** minimax-pdf content.json: a flat array of blocks. */
blocks: unknown[];
/** Absolute path for the rendered PDF. */
outPath: string;
}
function pickSkill(): string {
for (const p of SKILL_PATHS) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("fs").accessSync(path.join(p, "scripts/make.sh"));
return p;
} catch { /* try next */ }
}
throw new Error(
`minimax-pdf skill not installed at any of: ${SKILL_PATHS.join(", ")}. ` +
"Copy the skill into the ysterbaard profile and re-deploy."
);
}
function run(cmd: string, args: string[], cwd: string): Promise<{ ok: boolean; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, { cwd });
let stderr = "";
child.stderr.on("data", (b) => (stderr += b.toString()));
child.on("error", (err) => resolve({ ok: false, stderr: err.message }));
child.on("close", (code) => resolve({ ok: code === 0, stderr }));
});
}
export async function renderPdf(opts: RenderPdfOptions): Promise<Buffer> {
const skillRoot = pickSkill();
const makeSh = path.join(skillRoot, "scripts/make.sh");
// Write content.json to a temp file in the skill's working dir
// (make.sh reads it relative to cwd).
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "wf-report-"));
const contentPath = path.join(tmpDir, "content.json");
await fs.writeFile(contentPath, JSON.stringify(opts.blocks, null, 2));
try {
const result = await run("bash", [
makeSh, "run",
"--title", opts.title,
"--type", opts.type || "report",
"--author", opts.author,
"--date", opts.date,
"--accent", opts.accent,
"--content", contentPath,
"--out", opts.outPath,
], tmpDir);
if (!result.ok) {
throw new Error(`minimax-pdf render failed: ${result.stderr.slice(0, 800)}`);
}
return await fs.readFile(opts.outPath);
} finally {
await fs.unlink(contentPath).catch(() => {});
await fs.rmdir(tmpDir).catch(() => {});
}
}