Files
gstack/make-pdf/src/pdftotext.ts
T
Garry Tan d0782c4c4d 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>
2026-04-20 13:20:30 +08:00

255 lines
8.6 KiB
TypeScript

/**
* pdftotext wrapper — the tool behind the copy-paste CI gate.
*
* Codex round 2 surfaced two real problems we address here:
*
* #18: pdftotext (Poppler) vs pdftotext (Xpdf) vs pdftotext-next vary on
* whitespace, line wrap, Unicode normalization, form feeds, and
* extraction order. Cross-platform exact diffing is a non-starter.
* We normalize aggressively and diff the normalized form.
*
* #19: the regex /(?:\b\w\s){4,}/ only catches one failure shape (letters
* spaced out). It misses word-order corruption, missing whitespace
* between paragraphs, and homoglyph substitution. We add a word-token
* diff and a paragraph-boundary assertion on top.
*
* Resolution order for the pdftotext binary:
* 1. $PDFTOTEXT_BIN env override
* 2. `which pdftotext` on PATH
* 3. standard Homebrew paths on macOS
* 4. throws a friendly "install poppler" error
*
* The wrapper is *optional at runtime*: production renders don't need it.
* Only the CI gate and unit tests invoke pdftotext.
*/
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export class PdftotextUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "PdftotextUnavailableError";
}
}
export interface PdftotextInfo {
bin: string;
version: string; // "pdftotext version 24.02.0" or similar
flavor: "poppler" | "xpdf" | "unknown";
}
/**
* Locate pdftotext. Throws PdftotextUnavailableError if none is found.
*/
export function resolvePdftotext(): PdftotextInfo {
const envOverride = process.env.PDFTOTEXT_BIN;
if (envOverride && isExecutable(envOverride)) {
return describeBinary(envOverride);
}
// Try PATH
try {
const which = execFileSync("which", ["pdftotext"], { encoding: "utf8" }).trim();
if (which && isExecutable(which)) return describeBinary(which);
} catch {
// fall through
}
// Common macOS Homebrew locations
const macCandidates = [
"/opt/homebrew/bin/pdftotext", // Apple Silicon
"/usr/local/bin/pdftotext", // Intel Mac or Linuxbrew
"/usr/bin/pdftotext", // distro package
];
for (const candidate of macCandidates) {
if (isExecutable(candidate)) return describeBinary(candidate);
}
throw new PdftotextUnavailableError([
"pdftotext not found.",
"",
"make-pdf needs pdftotext to run the copy-paste CI gate.",
"(Runtime rendering does NOT need it. This only affects tests.)",
"",
"To install:",
" macOS: brew install poppler",
" Ubuntu: sudo apt-get install poppler-utils",
" Fedora: sudo dnf install poppler-utils",
"",
"Or set PDFTOTEXT_BIN to an explicit path:",
" export PDFTOTEXT_BIN=/path/to/pdftotext",
].join("\n"));
}
function isExecutable(p: string): boolean {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
function describeBinary(bin: string): PdftotextInfo {
let version = "unknown";
let flavor: PdftotextInfo["flavor"] = "unknown";
try {
// pdftotext -v writes to stderr and exits 0 on poppler, 99 on some xpdf builds.
const result = execFileSync(bin, ["-v"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
version = (result || "").trim().split("\n")[0] || "unknown";
} catch (err: any) {
// Many pdftotext builds exit non-zero on -v but still write to stderr.
const stderr = err?.stderr?.toString?.() ?? "";
version = stderr.trim().split("\n")[0] || "unknown";
}
const v = version.toLowerCase();
if (v.includes("poppler")) flavor = "poppler";
else if (v.includes("xpdf")) flavor = "xpdf";
return { bin, version, flavor };
}
/**
* Run pdftotext on a PDF and return the extracted text.
*
* Uses `-layout` by default because that's what downstream normalization
* expects. Callers that need raw text can pass layout=false.
*/
export function pdftotext(pdfPath: string, opts?: { layout?: boolean }): string {
const info = resolvePdftotext();
const layout = opts?.layout ?? true;
const args: string[] = [];
if (layout) args.push("-layout");
args.push(pdfPath, "-"); // "-" = stdout
try {
return execFileSync(info.bin, args, {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
} catch (err: any) {
throw new Error(`pdftotext failed on ${pdfPath}: ${err.message}`);
}
}
/**
* Normalize extracted text for cross-platform, cross-flavor diffing.
*
* What we strip / normalize:
* - Unicode: NFC canonical composition (macOS emits NFD; Linux emits NFC;
* this dodges the fundamental encoding diff).
* - CR and CRLF → LF (Windows Xpdf emits CRLF).
* - Form feeds (\f) → double newline (Poppler emits \f at page breaks).
* - Trailing spaces on every line.
* - Runs of 3+ blank lines → 2 blank lines.
* - Leading/trailing whitespace on the whole string.
* - Non-breaking space (U+00A0) → regular space.
* - Zero-width space (U+200B) and zero-width non-joiner (U+200C) → empty.
* - Soft hyphen (U+00AD) → empty (pdftotext -layout sometimes emits these
* for hyphens: auto breaks).
*/
export function normalize(raw: string): string {
let s = raw;
s = s.normalize("NFC");
s = s.replace(/\r\n/g, "\n");
s = s.replace(/\r/g, "\n");
s = s.replace(/\f/g, "\n\n");
s = s.replace(/\u00a0/g, " ");
s = s.replace(/[\u200b\u200c\u00ad]/g, "");
s = s.replace(/[ \t]+$/gm, "");
s = s.replace(/\n{3,}/g, "\n\n");
s = s.trim();
return s;
}
/**
* The canonical copy-paste gate used in the E2E tests.
*
* Returns { ok: true } when all three assertions pass; returns
* { ok: false, reasons: [...] } with one or more failure reasons otherwise.
*/
export interface GateResult {
ok: boolean;
reasons: string[];
extracted: string;
}
export function copyPasteGate(pdfPath: string, expected: string): GateResult {
const extracted = normalize(pdftotext(pdfPath, { layout: true }));
const expectedNorm = normalize(expected);
const reasons: string[] = [];
// Assertion 1: every expected paragraph appears as a whole line or
// contiguous block in the extracted text.
const expectedParagraphs = splitParagraphs(expectedNorm);
for (const paragraph of expectedParagraphs) {
const compact = collapseWhitespace(paragraph);
const extractedCompact = collapseWhitespace(extracted);
if (!extractedCompact.includes(compact)) {
reasons.push(
`expected paragraph not found in extracted text: ${truncate(paragraph, 80)}`,
);
}
}
// Assertion 2: no "S a i l i n g"-style single-char runs.
// Count groups of 4+ consecutive letter-then-space tokens. False positive
// risk on things like "A B C D" (initials) — mitigate by requiring the
// letters spell a known-word substring of the expected text.
const fragRegex = /((?:\b\w\s){4,})/g;
let fragMatch: RegExpExecArray | null;
while ((fragMatch = fragRegex.exec(extracted)) !== null) {
const letters = fragMatch[1].replace(/\s/g, "");
// Only flag if the reassembled letters appear in the expected text.
if (expectedNorm.toLowerCase().includes(letters.toLowerCase()) && letters.length >= 4) {
reasons.push(
`per-glyph emission detected (the "S ai li ng" bug): "${fragMatch[1].trim()}" reassembles to "${letters}"`,
);
}
}
// Assertion 3: paragraph boundaries preserved. Count double-newlines
// in both; they should differ by no more than ±2 (header/footer noise).
const expectedBreaks = (expectedNorm.match(/\n\n/g) || []).length;
const extractedBreaks = (extracted.match(/\n\n/g) || []).length;
if (Math.abs(expectedBreaks - extractedBreaks) > 4) {
reasons.push(
`paragraph boundary count drift: expected ~${expectedBreaks}, got ${extractedBreaks}`,
);
}
return { ok: reasons.length === 0, reasons, extracted };
}
function splitParagraphs(s: string): string[] {
return s.split(/\n\n+/).map(p => p.trim()).filter(p => p.length > 0);
}
function collapseWhitespace(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n) + "..." : s;
}
/**
* Emit diagnostic info to stderr — useful for CI failure debugging.
* Call this once before running any gate in a CI log.
*/
export function logDiagnostics(): void {
try {
const info = resolvePdftotext();
process.stderr.write(
`[pdftotext] bin=${info.bin} flavor=${info.flavor} version="${info.version}" ` +
`os=${os.platform()}-${os.arch()} node=${process.version}\n`,
);
} catch (err: any) {
process.stderr.write(`[pdftotext] unavailable: ${err.message}\n`);
}
}