feat(v1.4.0.0): /make-pdf — markdown to publication-quality PDFs (#1086)

* feat(browse): full $B pdf flag contract + tab-scoped load-html/js/pdf

Grow $B pdf from a 2-line wrapper (hard-coded A4) into a real PDF engine
frontend so make-pdf can shell out to it without duplicating Playwright:

- pdf: --format, --width/--height, --margins, --margin-*, --header-template,
  --footer-template, --page-numbers, --tagged, --outline, --print-background,
  --prefer-css-page-size, --toc. Mutex rules enforced. --from-file <json>
  dodges Windows argv limits (8191 char CreateProcess cap).
- load-html: add --from-file <json> mode for large inline HTML. Size + magic
  byte checks still apply to the inline content, not the payload file path.
- newtab: add --json returning {"tabId":N,"url":...} for programmatic use.
- cli: extract --tab-id flag and route as body.tabId to the HTTP layer so
  parallel callers can target specific tabs without racing on the active
  tab (makes make-pdf's per-render tab isolation possible).
- --toc: non-fatal 3s wait for window.__pagedjsAfterFired. Paged.js ships
  later; v1 renders TOC statically via the markdown renderer.

Codex round 2 flagged these P0 issues during plan review. All resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(resolvers): add MAKE_PDF_SETUP + makePdfDir host paths

Skill templates can now embed {{MAKE_PDF_SETUP}} to resolve $P to the
make-pdf binary via the same discovery order as $B / $D: env override
(MAKE_PDF_BIN), local skill root, global install, or PATH.

Mirrors the pattern established by generateBrowseSetup() and
generateDesignSetup() in scripts/resolvers/design.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(make-pdf): new /make-pdf skill + orchestrator binary

Turn markdown into publication-quality PDFs. $P generate input.md out.pdf
produces a PDF with 1in margins, intelligent page breaks, page numbers,
running header, CONFIDENTIAL footer, and curly quotes/em dashes — all on
Helvetica so copy-paste extraction works ("S ai li ng" bug avoided).

Architecture (per Codex round 2):
  markdown → render.ts (marked + sanitize + smartypants) → orchestrator
    → $B newtab --json → $B load-html --tab-id → $B js (poll Paged.js)
    → $B pdf --tab-id → $B closetab

browseClient.ts shells out to the compiled browse CLI rather than
duplicating Playwright. --tab-id isolation per render means parallel
$P generate calls don't race on the active tab. try/finally tab cleanup
survives Paged.js timeouts, browser crashes, and output-path failures.

Features in v1:
  --cover              left-aligned cover page (eyebrow + title + hairline rule)
  --toc                clickable static TOC (Paged.js page numbers deferred)
  --watermark <text>   diagonal DRAFT/CONFIDENTIAL layer
  --no-chapter-breaks  opt out of H1-starts-new-page
  --page-numbers       "N of M" footer (default on)
  --tagged --outline   accessible PDF + bookmark outline (default on)
  --allow-network      opt in to external image loading (default off for privacy)
  --quiet --verbose    stderr control

Design decisions locked from the /plan-design-review pass:
  - Helvetica everywhere (Chromium emits single-word Tj operators for
    system fonts; bundled webfonts emit per-glyph and break extraction).
  - Left-aligned body, flush-left paragraphs, no text-indent, 12pt gap.
  - Cover shares 1in margins with body pages; no flexbox-center, no
    inset padding.
  - The reference HTMLs at .context/designs/*.html are the implementation
    source of truth for print-css.ts.

Tests (56 unit + 1 E2E combined-features gate):
  - smartypants: code/URL-safe, verified against 10 fixtures
  - sanitizer: strips <script>/<iframe>/on*/javascript: URLs
  - render: HTML assembly, CJK fallback, cover/TOC/chapter wrap
  - print-css: all @page rules, margin variants, watermark
  - pdftotext: normalize()+copyPasteGate() cross-OS tolerance
  - browseClient: binary resolution + typed error propagation
  - combined-features gate (P0): 2-chapter fixture with smartypants +
    hyphens + ligatures + bold/italic + inline code + lists + blockquote
    passes through PDF → pdftotext → expected.txt diff

Deferred to Phase 4 (future PR): Paged.js vendored for accurate TOC page
numbers, highlight.js for syntax highlighting, drop caps, pull quotes,
two-column, CMYK, watermark visual-diff acceptance.

Plan: .context/ceo-plans/2026-04-19-perfect-pdf-generator.md
References: .context/designs/make-pdf-*.html

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(build): wire make-pdf into build/test/setup/bin + add marked dep

- package.json: compile make-pdf/dist/pdf as part of bun run build; add
  "make-pdf" to bin entry; include make-pdf/test/ in the free test pass;
  add marked@18.0.2 as a dep (markdown parser, ~40KB).
- setup: add make-pdf/dist/pdf to the Apple Silicon codesign loop.
- .gitignore: add make-pdf/dist/ (matches browse/dist/ and design/dist/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(make-pdf): matrix copy-paste gate on Ubuntu + macOS

Runs the combined-features P0 gate on pull requests that touch make-pdf/
or browse's PDF surface. Installs poppler (macOS) / poppler-utils (Ubuntu)
per OS. Windows deferred to tolerant mode (Xpdf / Poppler-Windows
extraction variance not yet calibrated against the normalized comparator —
Codex round 2 #18).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(skills): regenerate SKILL.md for make-pdf addition + browse pdf flags

bun run gen:skill-docs picks up:
  - the new /make-pdf skill (make-pdf/SKILL.md)
  - updated browse command descriptions for 'pdf', 'load-html', 'newtab'
    reflecting the new flag contract and --from-file mode

Source of truth stays the .tmpl files + COMMAND_DESCRIPTIONS;
these are regenerated artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): repair stale test expectations + emit _EXPLAIN_LEVEL / _QUESTION_TUNING from preamble

Three pre-existing test failures on main were blocking /ship:

- test/skill-validation.test.ts "Step 3.4 test coverage audit" expected the
  literal strings "CODE PATH COVERAGE" and "USER FLOW COVERAGE" which were
  removed when the Step 7 coverage diagram was compressed. Updated assertions
  to check the stable `Code paths:` / `User flows:` labels that still ship.

- test/skill-validation.test.ts "ship step numbering" allowed-substeps list
  didn't include 15.0 (WIP squash) and 15.1 (bisectable commits) which were
  added for continuous checkpoint mode. Extended the allowlist.

- test/writing-style-resolver.test.ts and test/plan-tune.test.ts expected
  `_EXPLAIN_LEVEL` and `_QUESTION_TUNING` bash variables in the preamble but
  generate-preamble-bash.ts had been refactored and those lines were dropped.
  Without them, downstream skills can't read `explain_level` or
  `question_tuning` config at runtime — terse mode and /plan-tune features
  were silently broken.

Added the two bash echo blocks back to generatePreambleBash and refreshed
the golden-file fixtures to match. All three preamble-related golden
baselines (claude/codex/factory) are synchronized with the new output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.4.0.0)

New /make-pdf skill + $P binary.

Turn any markdown file into a publication-quality PDF. Default output is
a 1in-margin Helvetica letter with page numbers in the footer. `--cover`
adds a left-aligned cover page, `--toc` generates a clickable table of
contents, `--watermark DRAFT` overlays a diagonal watermark. Copy-paste
extraction from the PDF produces clean words, not "S a i l i n g"
spaced out letter by letter. CI gate (macOS + Ubuntu) runs a combined-
features fixture through pdftotext on every PR.

make-pdf shells out to browse rather than duplicating Playwright.
$B pdf grew into a real PDF engine with full flag contract (--format,
--margins, --header-template, --footer-template, --page-numbers,
--tagged, --outline, --toc, --tab-id, --from-file). $B load-html and
$B js gained --tab-id. $B newtab --json returns structured output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(changelog): rewrite v1.4.0.0 headline — positive voice, no VC framing

The original headline led with "a PDF you wouldn't be embarrassed to send
to a VC": double-negative voice and audience-too-narrow. /make-pdf works
for essays, letters, memos, reports, proposals, and briefs. Framing the
whole release around founders-to-investors misses the wider audience.

New headline: "Turn any markdown file into a PDF that looks finished."
New tagline: "This one reads like a real essay or a real letter."

Positive voice. Broader aperture. Same energy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-20 13:20:30 +08:00
committed by GitHub
parent 22a4451e0e
commit d0782c4c4d
74 changed files with 4456 additions and 37 deletions
+228
View File
@@ -0,0 +1,228 @@
/**
* Orchestrator — ties render, browseClient, and 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.
*
* Tab lifecycle: every generate opens a dedicated tab via $B newtab --json,
* runs load-html/js/pdf against --tab-id <N>, and closes the tab in a
* try/finally. Parallel $P generate calls never race on the active tab.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { spawn } from "node:child_process";
import { render } from "./render";
import type { GenerateOptions, PreviewOptions } from "./types";
import { ExitCode } from "./types";
import * as browseClient from "./browseClient";
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 outputPath = path.resolve(
opts.output ?? path.join(os.tmpdir(), `${deriveSlug(input)}.pdf`),
);
// Stage 1: read markdown
progress.begin("Reading markdown");
const markdown = fs.readFileSync(input, "utf8");
progress.end("Reading markdown");
// Stage 2: render HTML
progress.begin("Rendering HTML");
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,
pageSize: opts.pageSize,
margins: opts.margins,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
// Stage 3: write HTML to a tmp file browse can read
// (We don't actually write it; we pass inline via --from-file JSON.)
// But for preview mode and debugging, we still write to tmp.
const htmlTmp = tmpFile("html");
fs.writeFileSync(htmlTmp, rendered.html, "utf8");
// Stage 4: spin up a dedicated tab, load HTML, (wait for Paged.js if TOC),
// then emit PDF. Always close the tab.
progress.begin("Opening tab");
const tabId = browseClient.newtab();
progress.end("Opening tab", `tabId=${tabId}`);
try {
progress.begin("Loading HTML into Chromium");
browseClient.loadHtml({
html: rendered.html,
waitUntil: "domcontentloaded",
tabId,
});
progress.end("Loading HTML into Chromium");
if (opts.toc) {
progress.begin("Paginating with Paged.js");
// Browse's $B pdf already waits internally when --toc is passed.
// We pass toc=true to browseClient.pdf() below.
progress.end("Paginating with Paged.js", "Paged.js after");
}
progress.begin("Generating PDF");
browseClient.pdf({
output: outputPath,
tabId,
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,
pageNumbers: opts.pageNumbers !== false && !opts.footerTemplate,
tagged: opts.tagged !== false,
outline: opts.outline !== false,
printBackground: !!opts.watermark,
toc: opts.toc,
});
progress.end("Generating PDF");
const stat = fs.statSync(outputPath);
const kb = Math.round(stat.size / 1024);
progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`);
} finally {
// Always clean up the tab — even on crash, timeout, or Chromium hang.
try {
browseClient.closetab(tabId);
} catch {
// best-effort; we already exited the main path
}
// Cleanup tmp HTML
try { fs.unlinkSync(htmlTmp); } catch { /* best-effort */ }
}
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");
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,
});
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(os.tmpdir(), `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 tmpFile(ext: string): string {
const hash = crypto.randomBytes(6).toString("hex");
return path.join(os.tmpdir(), `make-pdf-${process.pid}-${hash}.${ext}`);
}
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 };