chore: import upstream snapshot with attribution
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:59:46 +08:00
commit dfb0b33892
1170 changed files with 352893 additions and 0 deletions
+787
View File
@@ -0,0 +1,787 @@
---
name: make-pdf
preamble-tier: 1
version: 1.0.0
description: Turn any markdown file into a publication-quality PDF. (gstack)
triggers:
- markdown to pdf
- generate pdf
- make pdf
- export pdf
allowed-tools:
- Bash
- Read
- AskUserQuestion
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## When to invoke this skill
Proper 1in margins,
intelligent page breaks, page numbers, cover pages, running headers, curly
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Not a draft
artifact — a finished artifact. Use when asked to "make a PDF", "export to
PDF", "turn this markdown into a PDF", or "generate a document".
Voice triggers (speech-to-text aliases): "make this a pdf", "make it a pdf", "export to pdf", "turn this into a pdf", "turn this markdown into a pdf", "generate a pdf", "make a pdf from", "pdf this markdown".
## Preamble (run first)
```bash
_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true)
[ -n "$_UPD" ] && echo "$_UPD" || true
mkdir -p ~/.gstack/sessions
touch ~/.gstack/sessions/"$PPID"
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true
_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true")
_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no")
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
echo "BRANCH: $_BRANCH"
_SKILL_PREFIX=$(~/.claude/skills/gstack/bin/gstack-config get skill_prefix 2>/dev/null || echo "false")
echo "PROACTIVE: $_PROACTIVE"
echo "PROACTIVE_PROMPTED: $_PROACTIVE_PROMPTED"
echo "SKILL_PREFIX: $_SKILL_PREFIX"
source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true
REPO_MODE=${REPO_MODE:-unknown}
echo "REPO_MODE: $REPO_MODE"
_SESSION_KIND=$(~/.claude/skills/gstack/bin/gstack-session-kind 2>/dev/null || echo "interactive")
case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac
echo "SESSION_KIND: $_SESSION_KIND"
# Conductor host: AskUserQuestion is unreliable here (native disabled, MCP
# variant flaky), so skills render decisions as prose instead of calling the
# tool. Gated on !headless so an eval/CI run INSIDE Conductor (GSTACK_HEADLESS)
# still BLOCKs rather than rendering prose to nobody.
if [ "$_SESSION_KIND" != "headless" ] && { [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ]; }; then
echo "CONDUCTOR_SESSION: true"
fi
_ACTIVATED=$([ -f ~/.gstack/.activated ] && echo "yes" || echo "no")
_FIRST_LOOP_SHOWN=$([ -f ~/.gstack/.first-loop-tip-shown ] && echo "yes" || echo "no")
echo "ACTIVATED: $_ACTIVATED"
echo "FIRST_LOOP_SHOWN: $_FIRST_LOOP_SHOWN"
# First-run project detection: run the detector ONLY on the first-ever skill run
# (ACTIVATED=no, interactive) so it stays off the hot path for every run after.
_FIRST_TASK=""
if [ "$_ACTIVATED" = "no" ] && [ "$_SESSION_KIND" != "headless" ]; then
_FIRST_TASK=$(~/.claude/skills/gstack/bin/gstack-first-task-detect 2>/dev/null || true)
fi
echo "FIRST_TASK: $_FIRST_TASK"
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
echo "LAKE_INTRO: $_LAKE_SEEN"
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true)
_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no")
_TEL_START=$(date +%s)
_SESSION_ID="$$-$(date +%s)"
echo "TELEMETRY: ${_TEL:-off}"
echo "TEL_PROMPTED: $_TEL_PROMPTED"
_EXPLAIN_LEVEL=$(~/.claude/skills/gstack/bin/gstack-config get explain_level 2>/dev/null || echo "default")
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
_QUESTION_TUNING=$(~/.claude/skills/gstack/bin/gstack-config get question_tuning 2>/dev/null || echo "false")
echo "QUESTION_TUNING: $_QUESTION_TUNING"
mkdir -p ~/.gstack/analytics
if [ "$_TEL" != "off" ]; then
echo '{"skill":"make-pdf","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
fi
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
if [ -f "$_PF" ]; then
if [ "$_TEL" != "off" ] && [ -x "~/.claude/skills/gstack/bin/gstack-telemetry-log" ]; then
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true
fi
rm -f "$_PF" 2>/dev/null || true
fi
break
done
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
_LEARN_FILE="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/learnings.jsonl"
if [ -f "$_LEARN_FILE" ]; then
_LEARN_COUNT=$(wc -l < "$_LEARN_FILE" 2>/dev/null | tr -d ' ')
echo "LEARNINGS: $_LEARN_COUNT entries loaded"
if [ "$_LEARN_COUNT" -gt 5 ] 2>/dev/null; then
~/.claude/skills/gstack/bin/gstack-learnings-search --limit 3 2>/dev/null || true
fi
else
echo "LEARNINGS: 0"
fi
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"make-pdf","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
_HAS_ROUTING="no"
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
_HAS_ROUTING="yes"
fi
_ROUTING_DECLINED=$(~/.claude/skills/gstack/bin/gstack-config get routing_declined 2>/dev/null || echo "false")
echo "HAS_ROUTING: $_HAS_ROUTING"
echo "ROUTING_DECLINED: $_ROUTING_DECLINED"
_VENDORED="no"
if [ -d ".claude/skills/gstack" ] && [ ! -L ".claude/skills/gstack" ]; then
if [ -f ".claude/skills/gstack/VERSION" ] || [ -d ".claude/skills/gstack/.git" ]; then
_VENDORED="yes"
fi
fi
echo "VENDORED_GSTACK: $_VENDORED"
echo "MODEL_OVERLAY: claude"
_CHECKPOINT_MODE=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit")
_CHECKPOINT_PUSH=$(~/.claude/skills/gstack/bin/gstack-config get checkpoint_push 2>/dev/null || echo "false")
echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE"
echo "CHECKPOINT_PUSH: $_CHECKPOINT_PUSH"
# Plan-mode hint for skills like /spec that branch behavior on plan-mode state.
# Claude Code exposes plan mode via system reminders; we detect best-effort
# from CLAUDE_PLAN_FILE (set by the harness when plan mode is active) and
# fall back to "inactive". Codex hosts and Claude execution mode both end up
# inactive, which is the safe default (defaults to file+execute pipeline).
if [ -n "${CLAUDE_PLAN_FILE:-}${GSTACK_PLAN_MODE_FORCE:-}" ]; then
export GSTACK_PLAN_MODE="active"
elif [ "${GSTACK_PLAN_MODE:-}" = "active" ]; then
export GSTACK_PLAN_MODE="active"
else
export GSTACK_PLAN_MODE="inactive"
fi
echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true
```
## MAKE-PDF SETUP (run this check BEFORE any make-pdf command)
```bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
P=""
[ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN"
[ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/make-pdf/dist/pdf" ] && P="$_ROOT/.claude/skills/gstack/make-pdf/dist/pdf"
[ -z "$P" ] && P="$HOME/.claude/skills/gstack/make-pdf/dist/pdf"
if [ -x "$P" ]; then
echo "MAKE_PDF_READY: $P"
alias _p_="$P" # shellcheck alias helper (not exported)
export P # available as $P in subsequent blocks within the same skill invocation
else
echo "MAKE_PDF_NOT_AVAILABLE (run './setup' in the gstack repo to build it)"
fi
```
If `MAKE_PDF_NOT_AVAILABLE` is printed: tell the user the binary is not
built. Have them run `./setup` from the gstack repo, then retry.
If `MAKE_PDF_READY` is printed: `$P` is the binary path for the rest of
the skill. Use `$P` (not an explicit path) so the skill body stays portable.
Core commands:
- `$P generate <input.md> [output.pdf]` — render markdown to PDF (80% use case)
- `$P generate --cover --toc essay.md out.pdf` — full publication layout
- `$P generate --watermark DRAFT memo.md draft.pdf` — diagonal DRAFT watermark
- `$P preview <input.md>` — render HTML and open in browser (fast iteration)
- `$P setup` — verify browse + Chromium + pdftotext and run a smoke test
- `$P --help` — full flag reference
Output contract:
- `stdout`: ONLY the output path on success. One line.
- `stderr`: progress (`Rendering HTML... Generating PDF...`) unless `--quiet`.
- Exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable.
## Plan Mode Safe Operations
In plan mode, allowed because they inform the plan: `$B`, `$D`, `codex exec`/`codex review`, writes to `~/.gstack/`, writes to the plan file, and `open` for generated artifacts.
## Skill Invocation During Plan Mode
If the user invokes a skill in plan mode, the skill takes precedence over generic plan mode behavior. **Treat the skill file as executable instructions, not reference.** Follow it step by step starting from Step 0; the first AskUserQuestion is the workflow entering plan mode, not a violation of it. AskUserQuestion (any variant — `mcp__*__AskUserQuestion` or native; see "AskUserQuestion Format → Tool resolution") satisfies plan mode's end-of-turn requirement. If AskUserQuestion is unavailable or a call fails, follow the AskUserQuestion Format failure fallback: `headless` → BLOCKED; `interactive` → the prose fallback (also satisfies end-of-turn). At a STOP point, stop immediately. Do not continue the workflow or call ExitPlanMode there. Commands marked "PLAN MODE EXCEPTION — ALWAYS RUN" execute. Call ExitPlanMode only after the skill workflow completes, or if the user tells you to cancel the skill or leave plan mode.
If `PROACTIVE` is `"false"`, do not auto-invoke or proactively suggest skills. If a skill seems useful, ask: "I think /skillname might help here — want me to run it?"
If `SKILL_PREFIX` is `"true"`, suggest/invoke `/gstack-*` names. Disk paths stay `~/.claude/skills/gstack/[skill-name]/SKILL.md`.
If output shows `UPGRADE_AVAILABLE <old> <new>`: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined).
If output shows `JUST_UPGRADED <from> <to>`: print "Running gstack v{to} (just updated!)". If `SPAWNED_SESSION` is true, skip feature discovery.
Feature discovery, max one prompt per session:
- Missing `~/.claude/skills/gstack/.feature-prompted-continuous-checkpoint`: AskUserQuestion for Continuous checkpoint auto-commits. If accepted, run `~/.claude/skills/gstack/bin/gstack-config set checkpoint_mode continuous`. Always touch marker.
- Missing `~/.claude/skills/gstack/.feature-prompted-model-overlay`: inform "Model overlays are active. MODEL_OVERLAY shows the patch." Always touch marker.
After upgrade prompts, continue workflow.
If `WRITING_STYLE_PENDING` is `yes`: ask once about writing style:
> v1 prompts are simpler: first-use jargon glosses, outcome-framed questions, shorter prose. Keep default or restore terse?
Options:
- A) Keep the new default (recommended — good writing helps everyone)
- B) Restore V0 prose — set `explain_level: terse`
If A: leave `explain_level` unset (defaults to `default`).
If B: run `~/.claude/skills/gstack/bin/gstack-config set explain_level terse`.
Always run (regardless of choice):
```bash
rm -f ~/.gstack/.writing-style-prompt-pending
touch ~/.gstack/.writing-style-prompted
```
Skip if `WRITING_STYLE_PENDING` is `no`.
If `LAKE_INTRO` is `no`: say "gstack follows the **Boil the Ocean** principle — do the complete thing when AI makes marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" Offer to open:
```bash
open https://garryslist.org/posts/boil-the-ocean
touch ~/.gstack/.completeness-intro-seen
```
Only run `open` if yes. Always run `touch`.
If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: ask telemetry once via AskUserQuestion:
> Help gstack get better. Share usage data only: skill, duration, crashes, stable device ID. No code or file paths. Your repo name is recorded locally only and stripped before any upload.
Options:
- A) Help gstack get better! (recommended)
- B) No thanks
If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community`
If B: ask follow-up:
> Anonymous mode sends only aggregate usage, no unique ID.
Options:
- A) Sure, anonymous is fine
- B) No thanks, fully off
If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous`
If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off`
Always run:
```bash
touch ~/.gstack/.telemetry-prompted
```
Skip if `TEL_PROMPTED` is `yes`.
If `PROACTIVE_PROMPTED` is `no` AND `TEL_PROMPTED` is `yes`: ask once:
> Let gstack proactively suggest skills, like /qa for "does this work?" or /investigate for bugs?
Options:
- A) Keep it on (recommended)
- B) Turn it off — I'll type /commands myself
If A: run `~/.claude/skills/gstack/bin/gstack-config set proactive true`
If B: run `~/.claude/skills/gstack/bin/gstack-config set proactive false`
Always run:
```bash
touch ~/.gstack/.proactive-prompted
```
Skip if `PROACTIVE_PROMPTED` is `yes`.
## First-run guidance (one-time)
If `ACTIVATED` is `no` (first skill run on this machine) AND the preamble printed a non-empty `FIRST_TASK:` value that is NOT `nongit`: show ONE short, project-specific line mapped from the token, as a heads-up, then CONTINUE with whatever the user actually asked — do NOT halt their task. Map the token: `greenfield` → "Fresh repo — shape it first with `/spec` or `/office-hours`." `code_node`/`code_python`/`code_rust`/`code_go`/`code_ruby`/`code_ios` → "There's code here — `/qa` to see it work, or `/investigate` if something's off." `branch_ahead` → "Unshipped work on this branch — `/review` then `/ship`." `dirty_default` → "Uncommitted changes — `/review` before committing." `clean_default` → "Pick one: `/spec`, `/investigate`, or `/qa`." Then substitute the token you saw for TASK_TOKEN and run (best-effort), and mark activated:
```bash
~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type first_task_scaffold_shown --skill "TASK_TOKEN" --outcome shown 2>/dev/null || true
touch ~/.gstack/.activated 2>/dev/null || true
```
If `ACTIVATED` is `no` but `FIRST_TASK:` is empty or `nongit` (headless, non-git, or nothing actionable): show nothing, just run `touch ~/.gstack/.activated 2>/dev/null || true`.
Else if `ACTIVATED` is `yes` AND `FIRST_LOOP_SHOWN` is `no`: say once as a heads-up (then continue):
> Tip: gstack pays off when you complete one loop — **plan → review → ship**. A common first loop: `/office-hours` or `/spec` to shape it, `/plan-eng-review` to lock it, then `/ship`.
Then run `touch ~/.gstack/.first-loop-tip-shown 2>/dev/null || true`.
Skip this section if `ACTIVATED` and `FIRST_LOOP_SHOWN` are both `yes`.
If `HAS_ROUTING` is `no` AND `ROUTING_DECLINED` is `false` AND `PROACTIVE_PROMPTED` is `yes`:
Check if a CLAUDE.md file exists in the project root. If it does not exist, create it.
Use AskUserQuestion:
> gstack works best when your project's CLAUDE.md includes skill routing rules.
Options:
- A) Add routing rules to CLAUDE.md (recommended)
- B) No thanks, I'll invoke skills manually
If A: Append this section to the end of CLAUDE.md:
```markdown
## Skill routing
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
Key routing rules:
- Product ideas/brainstorming → invoke /office-hours
- Strategy/scope → invoke /plan-ceo-review
- Architecture → invoke /plan-eng-review
- Design system/plan review → invoke /design-consultation or /plan-design-review
- Full review pipeline → invoke /autoplan
- Bugs/errors → invoke /investigate
- QA/testing site behavior → invoke /qa or /qa-only
- Code review/diff check → invoke /review
- Visual polish → invoke /design-review
- Ship/deploy/PR → invoke /ship or /land-and-deploy
- Save progress → invoke /context-save
- Resume context → invoke /context-restore
- Author a backlog-ready spec/issue → invoke /spec
```
Then commit the change: `git add CLAUDE.md && git commit -m "chore: add gstack skill routing rules to CLAUDE.md"`
If B: run `~/.claude/skills/gstack/bin/gstack-config set routing_declined true` and say they can re-enable with `gstack-config set routing_declined false`.
This only happens once per project. Skip if `HAS_ROUTING` is `yes` or `ROUTING_DECLINED` is `true`.
If `VENDORED_GSTACK` is `yes`, warn once via AskUserQuestion unless `~/.gstack/.vendoring-warned-$SLUG` exists:
> This project has gstack vendored in `.claude/skills/gstack/`. Vendoring is deprecated.
> Migrate to team mode?
Options:
- A) Yes, migrate to team mode now
- B) No, I'll handle it myself
If A:
1. Run `git rm -r .claude/skills/gstack/`
2. Run `echo '.claude/skills/gstack/' >> .gitignore`
3. Run `~/.claude/skills/gstack/bin/gstack-team-init required` (or `optional`)
4. Run `git add .claude/ .gitignore CLAUDE.md && git commit -m "chore: migrate gstack from vendored to team mode"`
5. Tell the user: "Done. Each developer now runs: `cd ~/.claude/skills/gstack && ./setup --team`"
If B: say "OK, you're on your own to keep the vendored copy up to date."
Always run (regardless of choice):
```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true
touch ~/.gstack/.vendoring-warned-${SLUG:-unknown}
```
If marker exists, skip.
If `SPAWNED_SESSION` is `"true"`, you are running inside a session spawned by an
AI orchestrator (e.g., OpenClaw). In spawned sessions:
- Do NOT use AskUserQuestion for interactive prompts. Auto-choose the recommended option.
- Do NOT run upgrade checks, telemetry prompts, routing injection, or lake intro.
- Focus on completing the task and reporting results via prose output.
- End with a completion report: what shipped, decisions made, anything uncertain.
## Artifacts Sync (skill start)
```bash
_GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
# Prefer the v1.27.0.0 artifacts file; fall back to brain file for users
# upgrading mid-stream before the migration script runs.
if [ -f "$HOME/.gstack-artifacts-remote.txt" ]; then
_BRAIN_REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt"
else
_BRAIN_REMOTE_FILE="$HOME/.gstack-brain-remote.txt"
fi
_BRAIN_SYNC_BIN="~/.claude/skills/gstack/bin/gstack-brain-sync"
_BRAIN_CONFIG_BIN="~/.claude/skills/gstack/bin/gstack-config"
# /sync-gbrain context-load: teach the agent to use gbrain when it's available.
# Per-worktree pin: post-spike redesign uses kubectl-style `.gbrain-source` in the
# git toplevel to scope queries. Look for the pin in the worktree (not a global
# state file) so that opening worktree B without a pin doesn't claim "indexed"
# just because worktree A was synced. Empty string when gbrain is not
# configured (zero context cost for non-gbrain users).
_GBRAIN_CONFIG="$HOME/.gbrain/config.json"
if [ -f "$_GBRAIN_CONFIG" ] && command -v gbrain >/dev/null 2>&1; then
_GBRAIN_VERSION_OK=$(gbrain --version 2>/dev/null | grep -c '^gbrain ' || echo 0)
if [ "$_GBRAIN_VERSION_OK" -gt 0 ] 2>/dev/null; then
_GBRAIN_PIN_PATH=""
_REPO_TOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
if [ -n "$_REPO_TOP" ] && [ -f "$_REPO_TOP/.gbrain-source" ]; then
_GBRAIN_PIN_PATH="$_REPO_TOP/.gbrain-source"
fi
if [ -n "$_GBRAIN_PIN_PATH" ]; then
echo "GBrain configured. Prefer \`gbrain search\`/\`gbrain query\` over Grep for"
echo "semantic questions; use \`gbrain code-def\`/\`code-refs\`/\`code-callers\` for"
echo "symbol-aware code lookup. See \"## GBrain Search Guidance\" in CLAUDE.md."
echo "Run /sync-gbrain to refresh."
else
echo "GBrain configured but this worktree isn't pinned yet. Run \`/sync-gbrain --full\`"
echo "before relying on \`gbrain search\` for code questions in this worktree."
echo "Falls back to Grep until pinned."
fi
fi
fi
_BRAIN_SYNC_MODE=$("$_BRAIN_CONFIG_BIN" get artifacts_sync_mode 2>/dev/null || echo off)
# Detect remote-MCP mode (Path 4 of /setup-gbrain). Local artifacts sync is
# a no-op in remote mode; the brain server pulls from GitHub/GitLab on its
# own cadence. Read claude.json directly to keep this preamble fast (no
# subprocess to claude CLI on every skill start).
_GBRAIN_MCP_MODE="none"
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then
_GBRAIN_MCP_TYPE=$(jq -r '.mcpServers.gbrain.type // .mcpServers.gbrain.transport // empty' "$HOME/.claude.json" 2>/dev/null)
case "$_GBRAIN_MCP_TYPE" in
url|http|sse) _GBRAIN_MCP_MODE="remote-http" ;;
stdio) _GBRAIN_MCP_MODE="local-stdio" ;;
esac
fi
if [ -f "$_BRAIN_REMOTE_FILE" ] && [ ! -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" = "off" ]; then
_BRAIN_NEW_URL=$(head -1 "$_BRAIN_REMOTE_FILE" 2>/dev/null | tr -d '[:space:]')
if [ -n "$_BRAIN_NEW_URL" ]; then
echo "ARTIFACTS_SYNC: artifacts repo detected: $_BRAIN_NEW_URL"
echo "ARTIFACTS_SYNC: run 'gstack-brain-restore' to pull your cross-machine artifacts (or 'gstack-config set artifacts_sync_mode off' to dismiss forever)"
fi
fi
if [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_LAST_PULL_FILE="$_GSTACK_HOME/.brain-last-pull"
_BRAIN_NOW=$(date +%s)
_BRAIN_DO_PULL=1
if [ -f "$_BRAIN_LAST_PULL_FILE" ]; then
_BRAIN_LAST=$(cat "$_BRAIN_LAST_PULL_FILE" 2>/dev/null || echo 0)
_BRAIN_AGE=$(( _BRAIN_NOW - _BRAIN_LAST ))
[ "$_BRAIN_AGE" -lt 86400 ] && _BRAIN_DO_PULL=0
fi
if [ "$_BRAIN_DO_PULL" = "1" ]; then
( cd "$_GSTACK_HOME" && git fetch origin >/dev/null 2>&1 && git merge --ff-only "origin/$(git rev-parse --abbrev-ref HEAD)" >/dev/null 2>&1 ) || true
echo "$_BRAIN_NOW" > "$_BRAIN_LAST_PULL_FILE"
fi
"$_BRAIN_SYNC_BIN" --once 2>/dev/null || true
fi
if [ "$_GBRAIN_MCP_MODE" = "remote-http" ]; then
# Remote-MCP mode: local artifacts sync is a no-op (brain admin's server
# pulls from GitHub/GitLab). Show the user this is by design, not broken.
_GBRAIN_HOST=$(jq -r '.mcpServers.gbrain.url // empty' "$HOME/.claude.json" 2>/dev/null | sed -E 's|^https?://([^/:]+).*|\1|')
echo "ARTIFACTS_SYNC: remote-mode (managed by brain server ${_GBRAIN_HOST:-remote})"
elif [ -d "$_GSTACK_HOME/.git" ] && [ "$_BRAIN_SYNC_MODE" != "off" ]; then
_BRAIN_QUEUE_DEPTH=0
[ -f "$_GSTACK_HOME/.brain-queue.jsonl" ] && _BRAIN_QUEUE_DEPTH=$(wc -l < "$_GSTACK_HOME/.brain-queue.jsonl" | tr -d ' ')
_BRAIN_LAST_PUSH="never"
[ -f "$_GSTACK_HOME/.brain-last-push" ] && _BRAIN_LAST_PUSH=$(cat "$_GSTACK_HOME/.brain-last-push" 2>/dev/null || echo never)
echo "ARTIFACTS_SYNC: mode=$_BRAIN_SYNC_MODE | last_push=$_BRAIN_LAST_PUSH | queue=$_BRAIN_QUEUE_DEPTH"
else
echo "ARTIFACTS_SYNC: off"
fi
```
Privacy stop-gate: if output shows `ARTIFACTS_SYNC: off`, `artifacts_sync_mode_prompted` is `false`, and gbrain is on PATH or `gbrain doctor --fast --json` works, ask once:
> gstack can publish your artifacts (CEO plans, designs, reports) to a private GitHub repo that GBrain indexes across machines. How much should sync?
Options:
- A) Everything allowlisted (recommended)
- B) Only artifacts
- C) Decline, keep everything local
After answer:
```bash
# Chosen mode: full | artifacts-only | off
"$_BRAIN_CONFIG_BIN" set artifacts_sync_mode <choice>
"$_BRAIN_CONFIG_BIN" set artifacts_sync_mode_prompted true
```
If A/B and `~/.gstack/.git` is missing, ask whether to run `gstack-artifacts-init`. Do not block the skill.
At skill END before telemetry:
```bash
"~/.claude/skills/gstack/bin/gstack-brain-sync" --discover-new 2>/dev/null || true
"~/.claude/skills/gstack/bin/gstack-brain-sync" --once 2>/dev/null || true
```
## Model-Specific Behavioral Patch (claude)
The following nudges are tuned for the claude model family. They are
**subordinate** to skill workflow, STOP points, AskUserQuestion gates, plan-mode
safety, and /ship review gates. If a nudge below conflicts with skill instructions,
the skill wins. Treat these as preferences, not rules.
**Todo-list discipline.** When working through a multi-step plan, mark each task
complete individually as you finish it. Do not batch-complete at the end. If a task
turns out to be unnecessary, mark it skipped with a one-line reason.
**Think before heavy actions.** For complex operations (refactors, migrations,
non-trivial new features), briefly state your approach before executing. This lets
the user course-correct cheaply instead of mid-flight.
**Dedicated tools over Bash.** Prefer Read, Edit, Write, Glob, Grep over shell
equivalents (cat, sed, find, grep). The dedicated tools are cheaper and clearer.
## Voice
Direct, concrete, builder-to-builder. Name the file, function, command, and user-visible impact. No filler.
No em dashes. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted. Never corporate or academic. Short paragraphs. End with what to do.
The user has context you do not. Cross-model agreement is a recommendation, not a decision. The user decides.
## Completion Status Protocol
When completing a skill workflow, report status using one of:
- **DONE** — completed with evidence.
- **DONE_WITH_CONCERNS** — completed, but list concerns.
- **BLOCKED** — cannot proceed; state blocker and what was tried.
- **NEEDS_CONTEXT** — missing info; state exactly what is needed.
Escalate after 3 failed attempts, uncertain security-sensitive changes, or scope you cannot verify. Format: `STATUS`, `REASON`, `ATTEMPTED`, `RECOMMENDATION`.
## Operational Self-Improvement
Before completing, if you discovered a durable project quirk or command fix that would save 5+ minutes next time, log it:
```bash
~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"SKILL_NAME","type":"operational","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"observed"}'
```
Do not log obvious facts or one-time transient errors.
## Telemetry (run last)
After workflow completion, log telemetry. Use skill `name:` from frontmatter. OUTCOME is success/error/abort/unknown.
**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to
`~/.gstack/analytics/`, matching preamble analytics writes.
Run this bash:
```bash
_TEL_END=$(date +%s)
_TEL_DUR=$(( _TEL_END - _TEL_START ))
rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
# Session timeline: record skill completion (local-only, never sent anywhere)
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"SKILL_NAME","event":"completed","branch":"'$(git branch --show-current 2>/dev/null || echo unknown)'","outcome":"OUTCOME","duration_s":"'"$_TEL_DUR"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null || true
# Local analytics (gated on telemetry setting)
if [ "$_TEL" != "off" ]; then
echo '{"skill":"SKILL_NAME","duration_s":"'"$_TEL_DUR"'","outcome":"OUTCOME","browse":"USED_BROWSE","session":"'"$_SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
fi
# Remote telemetry (opt-in, requires binary)
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
~/.claude/skills/gstack/bin/gstack-telemetry-log \
--skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
fi
```
Replace `SKILL_NAME`, `OUTCOME`, and `USED_BROWSE` before running.
## Plan Status Footer
Skills that run plan reviews (`/plan-*-review`, `/codex review`) include the EXIT PLAN MODE GATE blocking checklist at the end of the skill, which verifies the plan file ends with `## GSTACK REVIEW REPORT` before ExitPlanMode is called. Skills that don't run plan reviews (operational skills like `/ship`, `/qa`, `/review`) typically don't operate in plan mode and have no review report to verify; this footer is a no-op for them. Writing the plan file is the one edit allowed in plan mode.
# make-pdf: publication-quality PDFs from markdown
Turn `.md` files into PDFs that look like Faber & Faber essays: 1in margins,
left-aligned body, Helvetica throughout, curly quotes and em dashes, optional
cover page and clickable TOC, diagonal DRAFT watermark when you need it.
Copy-paste from the PDF produces clean words, never "S a i l i n g".
On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial
aren't present by default, and Liberation Sans is the standard metric-compatible
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI
Emoji) ship one; most Linux distros and containers ship none, so emoji render as
empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux
(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple /
Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI
without sudo, managed or offline machines).
## Core patterns
### 80% case — memo/letter
One command, no flags. Gets a clean PDF with running header + page numbers
+ CONFIDENTIAL footer by default.
```bash
$P generate letter.md # writes /tmp/letter.pdf
$P generate letter.md letter.pdf # explicit output path
```
### Publication mode — cover + TOC + chapter breaks
```bash
$P generate --cover --toc --author "Garry Tan" --title "On Horizons" \
essay.md essay.pdf
```
Each top-level H1 in the markdown starts a new page. Disable with
`--no-chapter-breaks` for memos that happen to have multiple H1s.
### Draft-stage watermark
```bash
$P generate --watermark DRAFT memo.md draft.pdf
```
Diagonal 10% opacity DRAFT across every page. When the draft is final, drop
the flag and regenerate.
### Fast iteration via preview
```bash
$P preview essay.md
```
Renders HTML with the same print CSS and opens it in your browser. Refresh
as you edit the markdown. Skip the PDF round trip until you're ready.
### Brand-free (no CONFIDENTIAL footer)
```bash
$P generate --no-confidential memo.md memo.pdf
```
### Diagrams — mermaid and excalidraw fences render as pictures
A column-0 ` ```mermaid ` or ` ```excalidraw ` fence in the markdown renders
as a crisp vector diagram, fully offline (vendored bundle, no CDN). Indented
fences (inside lists) stay plain code blocks by design. A broken fence
produces a visible red diagnostic block with the parse error — never silent
raw code.
Fence info-string options:
```
```mermaid title="Auth flow" ← caption + aria-label
```mermaid render=false ← keep it as a code block (today's behavior)
```mermaid page=landscape ← force this diagram onto a landscape page
```mermaid page=portrait ← veto auto-landscape for this diagram
```
A ` ```excalidraw ` fence contains a full .excalidraw scene file (what
excalidraw.com saves). Authoring NEW diagrams from English is `/diagram`'s
job — it emits an editable triplet (source, .excalidraw, SVG/PNG) and pairs
with this skill: embed the `.mmd` source in your markdown, not the PNG.
### Images — scaled right, never truncated
Local images inline automatically (relative paths resolve against the
markdown file). Every image caps at the content box — zero truncation, ever.
Oversized photos downscale to print resolution (300dpi) so payloads stay
small with no visible quality loss.
Remote (http/https) images are **blocked with a visible placeholder** by
default — offline posture; pass `--allow-network` to fetch them. An image
that resolves outside the markdown's directory (even via symlink) still
inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or
non-regular files (fifos, devices) degrade to a placeholder instead of
hanging the run.
Per-image directives, written immediately after the image:
```
![chart](data.png){width=full} ← stretch to content-box width
![chart](data.png){width=50%} ← percentage or 3in/8cm/200px
![wide](arch.png){page=landscape} ← give it its own landscape page
![wide](shot.png){page=portrait} ← veto auto-landscape
```
Wide, small-text diagram images auto-promote to their own landscape page
(conservative: aspect ≥ 1.8, width over ~2.5x the content box, AND a
diagram-ish alt word — diagram/architecture/flowchart/chart/graph). The
promoted page is vertically centered. When the heuristic guesses wrong,
`{page=portrait}` vetoes it; false negatives just need `{page=landscape}`.
### Other formats — single-file HTML and Word
```bash
$P generate readme.md out.html --to html # ONE self-contained file: inline
# SVG diagrams, data-URI images,
# zero network refs, screen-readable
$P generate readme.md out.docx --to docx # Word: content fidelity (headings,
# tables, code, diagrams as PNG) —
# layout is Word's, not ours
```
`--to` is the output format. `--format` is something else entirely (a
`--page-size` alias) — don't confuse them.
### CI mode — fail loud on missing assets
```bash
$P generate docs.md --strict # missing, remote, out-of-tree, oversized,
# and non-regular-file images exit non-zero
# instead of warn + placeholder
```
## Common flags
```
Page layout:
--margins <dim> 1in (default) | 72pt | 2.54cm | 25mm
--page-size letter|a4|legal
Structure:
--cover Cover page (title, author, date, hairline rule)
--toc Clickable TOC with page numbers
--no-chapter-breaks Don't start a new page at every H1
Branding:
--watermark <text> Diagonal watermark ("DRAFT", "CONFIDENTIAL")
--header-template <html> Custom running header
--footer-template <html> Custom footer (mutex with --page-numbers)
--no-confidential Suppress the CONFIDENTIAL right-footer
Output:
--to pdf|html|docx Output format (default: pdf). html = single
self-contained file; docx = content fidelity.
--strict Missing, remote, out-of-tree, oversized, or
non-regular-file images fail the run (CI mode).
--page-numbers "N of M" footer (default on)
--tagged Accessible PDF (default on)
--outline PDF bookmarks from headings (default on)
--quiet Suppress progress on stderr
--verbose Per-stage timings
Network:
--allow-network Fetch external images. Off by default: remote
images render as a visible blocked placeholder
(no tracking pixels fetch at print time).
Metadata:
--title "..." Document title (defaults to first H1)
--author "..." Author for cover + PDF metadata
--date "..." Date for cover (defaults to today)
```
## When Claude should run it
Watch for markdown-to-PDF intent. Any of these patterns → run `$P generate`:
- "Can you make this markdown a PDF"
- "Export it as a PDF"
- "Turn this letter into a PDF"
- "I need a PDF of the essay"
- "Print this as a PDF for me"
If the user has a `.md` file open and says "make it look nice", propose
`$P generate --cover --toc` and ask before running.
## Debugging
- Output looks empty / blank → check browse daemon is running: `$B status`.
- Fragmented text on copy-paste → highlight.js output (Phase 4). Retry with
`--no-syntax` once that flag exists. For now, remove fenced code blocks
and regenerate.
- Paged.js timeout → probably no headings in the markdown. Drop `--toc`.
- "[remote image blocked]" placeholder in the output → add `--allow-network`
(understand you're giving the markdown file permission to fetch from its
image URLs).
- Generated PDF too tall/wide → `--page-size a4` or `--margins 0.75in`.
## Output contract
```
stdout: /tmp/letter.pdf ← just the path, one line
stderr: Rendering HTML... ← progress spinner (unless --quiet)
Generating PDF...
Done in 1.5s. 43 words · 22KB · /tmp/letter.pdf
exit code: 0 success / 1 bad args / 2 render error / 3 Paged.js timeout
/ 4 browse unavailable
```
Capture the path: `PDF=$($P generate letter.md)` — then use `$PDF`.
+247
View File
@@ -0,0 +1,247 @@
---
name: make-pdf
preamble-tier: 1
version: 1.0.0
description: |
Turn any markdown file into a publication-quality PDF. Proper 1in margins,
intelligent page breaks, page numbers, cover pages, running headers, curly
quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Not a draft
artifact — a finished artifact. Use when asked to "make a PDF", "export to
PDF", "turn this markdown into a PDF", or "generate a document". (gstack)
voice-triggers:
- "make this a pdf"
- "make it a pdf"
- "export to pdf"
- "turn this into a pdf"
- "turn this markdown into a pdf"
- "generate a pdf"
- "make a pdf from"
- "pdf this markdown"
triggers:
- markdown to pdf
- generate pdf
- make pdf
- export pdf
allowed-tools:
- Bash
- Read
- AskUserQuestion
---
{{PREAMBLE}}
# make-pdf: publication-quality PDFs from markdown
Turn `.md` files into PDFs that look like Faber & Faber essays: 1in margins,
left-aligned body, Helvetica throughout, curly quotes and em dashes, optional
cover page and clickable TOC, diagonal DRAFT watermark when you need it.
Copy-paste from the PDF produces clean words, never "S a i l i n g".
On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial
aren't present by default, and Liberation Sans is the standard metric-compatible
fallback. CI and Docker builds install it automatically via Dockerfile.ci.
Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI
Emoji) ship one; most Linux distros and containers ship none, so emoji render as
empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux
(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple /
Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI
without sudo, managed or offline machines).
## Core patterns
### 80% case — memo/letter
One command, no flags. Gets a clean PDF with running header + page numbers
+ CONFIDENTIAL footer by default.
```bash
$P generate letter.md # writes /tmp/letter.pdf
$P generate letter.md letter.pdf # explicit output path
```
### Publication mode — cover + TOC + chapter breaks
```bash
$P generate --cover --toc --author "Garry Tan" --title "On Horizons" \
essay.md essay.pdf
```
Each top-level H1 in the markdown starts a new page. Disable with
`--no-chapter-breaks` for memos that happen to have multiple H1s.
### Draft-stage watermark
```bash
$P generate --watermark DRAFT memo.md draft.pdf
```
Diagonal 10% opacity DRAFT across every page. When the draft is final, drop
the flag and regenerate.
### Fast iteration via preview
```bash
$P preview essay.md
```
Renders HTML with the same print CSS and opens it in your browser. Refresh
as you edit the markdown. Skip the PDF round trip until you're ready.
### Brand-free (no CONFIDENTIAL footer)
```bash
$P generate --no-confidential memo.md memo.pdf
```
### Diagrams — mermaid and excalidraw fences render as pictures
A column-0 ` ```mermaid ` or ` ```excalidraw ` fence in the markdown renders
as a crisp vector diagram, fully offline (vendored bundle, no CDN). Indented
fences (inside lists) stay plain code blocks by design. A broken fence
produces a visible red diagnostic block with the parse error — never silent
raw code.
Fence info-string options:
```
```mermaid title="Auth flow" ← caption + aria-label
```mermaid render=false ← keep it as a code block (today's behavior)
```mermaid page=landscape ← force this diagram onto a landscape page
```mermaid page=portrait ← veto auto-landscape for this diagram
```
A ` ```excalidraw ` fence contains a full .excalidraw scene file (what
excalidraw.com saves). Authoring NEW diagrams from English is `/diagram`'s
job — it emits an editable triplet (source, .excalidraw, SVG/PNG) and pairs
with this skill: embed the `.mmd` source in your markdown, not the PNG.
### Images — scaled right, never truncated
Local images inline automatically (relative paths resolve against the
markdown file). Every image caps at the content box — zero truncation, ever.
Oversized photos downscale to print resolution (300dpi) so payloads stay
small with no visible quality loss.
Remote (http/https) images are **blocked with a visible placeholder** by
default — offline posture; pass `--allow-network` to fetch them. An image
that resolves outside the markdown's directory (even via symlink) still
inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or
non-regular files (fifos, devices) degrade to a placeholder instead of
hanging the run.
Per-image directives, written immediately after the image:
```
![chart](data.png){width=full} ← stretch to content-box width
![chart](data.png){width=50%} ← percentage or 3in/8cm/200px
![wide](arch.png){page=landscape} ← give it its own landscape page
![wide](shot.png){page=portrait} ← veto auto-landscape
```
Wide, small-text diagram images auto-promote to their own landscape page
(conservative: aspect ≥ 1.8, width over ~2.5x the content box, AND a
diagram-ish alt word — diagram/architecture/flowchart/chart/graph). The
promoted page is vertically centered. When the heuristic guesses wrong,
`{page=portrait}` vetoes it; false negatives just need `{page=landscape}`.
### Other formats — single-file HTML and Word
```bash
$P generate readme.md out.html --to html # ONE self-contained file: inline
# SVG diagrams, data-URI images,
# zero network refs, screen-readable
$P generate readme.md out.docx --to docx # Word: content fidelity (headings,
# tables, code, diagrams as PNG) —
# layout is Word's, not ours
```
`--to` is the output format. `--format` is something else entirely (a
`--page-size` alias) — don't confuse them.
### CI mode — fail loud on missing assets
```bash
$P generate docs.md --strict # missing, remote, out-of-tree, oversized,
# and non-regular-file images exit non-zero
# instead of warn + placeholder
```
## Common flags
```
Page layout:
--margins <dim> 1in (default) | 72pt | 2.54cm | 25mm
--page-size letter|a4|legal
Structure:
--cover Cover page (title, author, date, hairline rule)
--toc Clickable TOC with page numbers
--no-chapter-breaks Don't start a new page at every H1
Branding:
--watermark <text> Diagonal watermark ("DRAFT", "CONFIDENTIAL")
--header-template <html> Custom running header
--footer-template <html> Custom footer (mutex with --page-numbers)
--no-confidential Suppress the CONFIDENTIAL right-footer
Output:
--to pdf|html|docx Output format (default: pdf). html = single
self-contained file; docx = content fidelity.
--strict Missing, remote, out-of-tree, oversized, or
non-regular-file images fail the run (CI mode).
--page-numbers "N of M" footer (default on)
--tagged Accessible PDF (default on)
--outline PDF bookmarks from headings (default on)
--quiet Suppress progress on stderr
--verbose Per-stage timings
Network:
--allow-network Fetch external images. Off by default: remote
images render as a visible blocked placeholder
(no tracking pixels fetch at print time).
Metadata:
--title "..." Document title (defaults to first H1)
--author "..." Author for cover + PDF metadata
--date "..." Date for cover (defaults to today)
```
## When Claude should run it
Watch for markdown-to-PDF intent. Any of these patterns → run `$P generate`:
- "Can you make this markdown a PDF"
- "Export it as a PDF"
- "Turn this letter into a PDF"
- "I need a PDF of the essay"
- "Print this as a PDF for me"
If the user has a `.md` file open and says "make it look nice", propose
`$P generate --cover --toc` and ask before running.
## Debugging
- Output looks empty / blank → check browse daemon is running: `$B status`.
- Fragmented text on copy-paste → highlight.js output (Phase 4). Retry with
`--no-syntax` once that flag exists. For now, remove fenced code blocks
and regenerate.
- Paged.js timeout → probably no headings in the markdown. Drop `--toc`.
- "[remote image blocked]" placeholder in the output → add `--allow-network`
(understand you're giving the markdown file permission to fetch from its
image URLs).
- Generated PDF too tall/wide → `--page-size a4` or `--margins 0.75in`.
## Output contract
```
stdout: /tmp/letter.pdf ← just the path, one line
stderr: Rendering HTML... ← progress spinner (unless --quiet)
Generating PDF...
Done in 1.5s. 43 words · 22KB · /tmp/letter.pdf
exit code: 0 success / 1 bad args / 2 render error / 3 Paged.js timeout
/ 4 browse unavailable
```
Capture the path: `PDF=$($P generate letter.md)` — then use `$PDF`.
+410
View File
@@ -0,0 +1,410 @@
/**
* Typed shell-out wrapper for the browse CLI.
*
* Every browse call goes through this file. Reasons:
* - One place to do binary resolution.
* - One place to enforce the --from-file convention for large payloads
* (Windows argv cap is 8191 chars; 200KB HTML dies without this).
* - One place that maps non-zero exit codes to typed errors.
*
* Binary resolution order (Codex round 2 #4, v1.24-aligned):
* 1. $GSTACK_BROWSE_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern)
* 2. $BROWSE_BIN env override (back-compat alias)
* 3. sibling dir: dirname(argv[0])/../browse/dist/browse[.exe]
* 4. ~/.claude/skills/gstack/browse/dist/browse[.exe]
* 5. PATH lookup via Bun.which('browse') — handles Windows PATHEXT natively
* 6. error with setup hint
*
* Windows quirks:
* - bun build --compile --outfile X emits X.exe on win32, so candidate paths
* need a .exe probe pass (fs.accessSync(X_OK) degrades to existence-checking
* on Windows per Node docs, so the bare path silently misses the .exe file).
* - `which` only exists in Git Bash; Bun.which() handles cmd.exe / PowerShell
* natively via PATHEXT semantics.
*/
import { execFileSync } from "node:child_process";
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 { BrowseClientError } from "./types";
export interface LoadHtmlOptions {
html: string; // raw HTML string
waitUntil?: "load" | "domcontentloaded" | "networkidle";
tabId: number;
}
export interface PdfOptions {
output: string;
tabId: number;
format?: string;
width?: string;
height?: string;
marginTop?: string;
marginRight?: string;
marginBottom?: string;
marginLeft?: string;
headerTemplate?: string;
footerTemplate?: string;
pageNumbers?: boolean;
tagged?: boolean;
outline?: boolean;
printBackground?: boolean;
preferCSSPageSize?: boolean;
toc?: boolean;
}
export interface JsOptions {
tabId: number;
expression: string; // JS expression to evaluate
}
/**
* Resolve an absolute or PATH-resolvable command via Bun.which-style semantics,
* with a Windows .exe/.cmd/.bat extension probe for absolute paths. Mirrors
* the v1.24 claude-bin.ts override-resolution shape.
*
* Returns null if nothing resolves; callers degrade with a typed error rather
* than throwing here.
*/
function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null {
if (!value?.trim()) return null;
const trimmed = value.trim().replace(/^"(.*)"$/, '$1');
if (path.isAbsolute(trimmed)) return findExecutable(trimmed);
const PATH = env.PATH ?? env.Path ?? '';
return Bun.which(trimmed, { PATH }) ?? null;
}
/**
* Probe a base path for executability, honoring Windows extension suffixes.
*
* On POSIX, isExecutable(base) is the only check that matters. On Windows,
* fs.accessSync(p, X_OK) degrades to an existence check — so a bare-path probe
* misses bun-compiled binaries (which land at base.exe). After the bare probe
* fails on win32, try .exe / .cmd / .bat. Linux/macOS behavior is unchanged.
*/
export function findExecutable(base: string): string | null {
if (isExecutable(base)) return base;
if (process.platform === "win32") {
for (const ext of [".exe", ".cmd", ".bat"]) {
const withExt = base + ext;
if (isExecutable(withExt)) return withExt;
}
}
return null;
}
/**
* Locate the browse binary. Throws a BrowseClientError with a
* canonical setup message if not found. See header for resolution order.
*/
export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env): string {
// 1 + 2: env overrides (GSTACK_BROWSE_BIN preferred, BROWSE_BIN back-compat).
const overrideRaw = env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN;
const override = resolveOverride(overrideRaw, env);
if (override) return override;
// 3: sibling — make-pdf and browse co-located in dist/.
const selfDir = path.dirname(process.argv[0]);
const siblingCandidates = [
path.resolve(selfDir, "../browse/dist/browse"),
path.resolve(selfDir, "../../browse/dist/browse"),
path.resolve(selfDir, "../browse"),
];
for (const candidate of siblingCandidates) {
const found = findExecutable(candidate);
if (found) return found;
}
// 4: global install.
const home = os.homedir();
const globalPath = path.join(home, ".claude/skills/gstack/browse/dist/browse");
const globalFound = findExecutable(globalPath);
if (globalFound) return globalFound;
// 5: PATH lookup via Bun.which — handles Windows PATHEXT natively (no `which`
// dependency on cmd.exe / PowerShell, no `where`-vs-`which` branch).
const PATH = env.PATH ?? env.Path ?? '';
const onPath = Bun.which('browse', { PATH });
if (onPath) return onPath;
throw new BrowseClientError(
/* exitCode */ 127,
"resolve",
[
"browse binary not found.",
"",
"make-pdf needs browse (the gstack Chromium daemon) to render PDFs.",
"Tried:",
` - $GSTACK_BROWSE_BIN (${env.GSTACK_BROWSE_BIN || "unset"})`,
` - $BROWSE_BIN (${env.BROWSE_BIN || "unset"})`,
` - sibling: ${siblingCandidates.join(", ")}`,
` - global: ${globalPath}`,
" - PATH: `browse`",
"",
"To fix: run gstack setup from the gstack repo:",
" cd ~/.claude/skills/gstack && ./setup",
"",
"Or set GSTACK_BROWSE_BIN explicitly:",
process.platform === "win32"
? ' setx GSTACK_BROWSE_BIN "C:\\path\\to\\browse.exe"'
: " export GSTACK_BROWSE_BIN=/path/to/browse",
].join("\n"),
);
}
function isExecutable(p: string): boolean {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Run a browse command. Returns stdout on success.
* Throws BrowseClientError on non-zero exit.
*/
function runBrowse(args: string[]): string {
const bin = resolveBrowseBin();
try {
return execFileSync(bin, args, {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024, // 16MB; tab content can be large
stdio: ["ignore", "pipe", "pipe"],
// A wedged daemon (or a hostile mermaid source spinning the renderer)
// must fail the run, not hang it forever.
timeout: 120_000,
});
} catch (err: any) {
const exitCode = typeof err.status === "number" ? err.status : 1;
const stderr = typeof err.stderr === "string"
? err.stderr
: (err.stderr?.toString() ?? "");
throw new BrowseClientError(exitCode, args[0] || "unknown", stderr);
}
}
/**
* Write a payload to a tmp file and return the path. Used for any payload
* >4KB to avoid Windows argv limits (Codex round 2 #3).
*
* Path must be under the browse safe-dirs allowlist (/tmp or cwd on
* non-Windows; os.tmpdir on Windows). v1.6.0.0 tightened --from-file
* validation to close a CLI/API parity gap (PR #1103), so os.tmpdir()
* on macOS (/var/folders/...) now fails validateReadPath. Use the same
* TEMP_DIR convention as browse/src/platform.ts.
*/
const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
function writePayloadFile(payload: Record<string, unknown>): string {
const hash = crypto.createHash("sha256")
.update(JSON.stringify(payload))
.digest("hex")
.slice(0, 12);
const tmpPath = path.join(PAYLOAD_TMP_DIR, `make-pdf-browse-${process.pid}-${hash}.json`);
fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
return tmpPath;
}
function cleanupPayloadFile(p: string): void {
try { fs.unlinkSync(p); } catch { /* best-effort */ }
}
// ─── Public API ─────────────────────────────────────────────────
/**
* Open a new tab. Returns the tabId.
* Requires `$B newtab --json` to be available (added in the browse flag
* extension for this feature). If --json isn't supported yet, the fallback
* parses "Opened tab N" from stdout.
*/
export function newtab(url?: string): number {
const args = ["newtab"];
if (url) args.push(url);
// Try --json first (preferred path for programmatic use)
try {
const out = runBrowse([...args, "--json"]);
const parsed = JSON.parse(out);
if (typeof parsed.tabId === "number") return parsed.tabId;
} catch {
// Fall back to stdout-string parsing. Brittle, but works on older browse builds.
}
const out = runBrowse(args);
const m = out.match(/tab\s+(\d+)/i);
if (!m) throw new BrowseClientError(1, "newtab", `could not parse tab id from: ${out}`);
return parseInt(m[1], 10);
}
/**
* Close a tab (by id or the active tab).
*/
export function closetab(tabId?: number): void {
const args = ["closetab"];
if (tabId !== undefined) args.push(String(tabId));
runBrowse(args);
}
/**
* Load raw HTML into a specific tab.
* Uses --from-file for any payload >4KB (Codex round 2 #3).
*/
export function loadHtml(opts: LoadHtmlOptions): void {
// Always use --from-file to dodge argv limits. The HTML is almost always >4KB.
const payload = {
html: opts.html,
waitUntil: opts.waitUntil ?? "domcontentloaded",
};
const payloadFile = writePayloadFile(payload);
try {
runBrowse([
"load-html",
"--from-file", payloadFile,
"--tab-id", String(opts.tabId),
]);
} finally {
cleanupPayloadFile(payloadFile);
}
}
/**
* Load an HTML file (already under browse's safe dirs, e.g. /tmp) into a tab
* by path. Cheaper than loadHtml for large pages — no JSON payload round-trip;
* browse reads the file directly (diagram-render bundle is ~9MB).
*/
export function loadHtmlFile(opts: { file: string; tabId: number; waitUntil?: "load" | "domcontentloaded" | "networkidle" }): void {
const args = ["load-html", opts.file, "--tab-id", String(opts.tabId)];
if (opts.waitUntil) args.push("--wait-until", opts.waitUntil);
runBrowse(args);
}
/**
* Evaluate a JS expression in a tab. Returns the serialized result as string.
*/
export function js(opts: JsOptions): string {
return runBrowse([
"js",
opts.expression,
"--tab-id", String(opts.tabId),
]).trim();
}
/**
* Evaluate a JS file in a tab (`browse eval <file>`): the argv-safe transport
* for expressions too large for a command-line element. The file must live
* under browse's safe dirs (/tmp or cwd).
*/
export function evalFile(opts: { file: string; tabId: number }): string {
return runBrowse([
"eval",
opts.file,
"--tab-id", String(opts.tabId),
]).trim();
}
/**
* Poll a boolean JS expression until it evaluates to true, or timeout.
* Returns true if it succeeded, false if timed out.
*/
export function waitForExpression(opts: {
expression: string;
tabId: number;
timeoutMs: number;
pollIntervalMs?: number;
}): boolean {
const poll = opts.pollIntervalMs ?? 200;
const deadline = Date.now() + opts.timeoutMs;
while (Date.now() < deadline) {
try {
const result = js({ expression: opts.expression, tabId: opts.tabId });
if (result === "true") return true;
} catch {
// Tab may still be loading; keep polling
}
const wait = Math.min(poll, Math.max(0, deadline - Date.now()));
if (wait <= 0) break;
// Real sleep, not a busy-wait: this poll now runs on every diagram-render
// bundle load (and after every fence render error), exactly while Chromium
// is parsing a 9MB page on the same machine — spinning a core competes
// with the work being awaited.
Bun.sleepSync(wait);
}
return false;
}
/**
* Generate a PDF from the given tab. Uses --from-file when header/footer
* templates are present (they can be HTML strings of arbitrary size).
*/
export function pdf(opts: PdfOptions): void {
// If any large payload is present, send via --from-file
const hasLargePayload =
(opts.headerTemplate && opts.headerTemplate.length > 1024) ||
(opts.footerTemplate && opts.footerTemplate.length > 1024);
if (hasLargePayload) {
const payloadFile = writePayloadFile({
output: opts.output,
tabId: opts.tabId,
...optionsToPdfFlags(opts),
});
try {
runBrowse(["pdf", "--from-file", payloadFile]);
} finally {
cleanupPayloadFile(payloadFile);
}
return;
}
// Small payload: pass flags via argv
const args = ["pdf", opts.output, "--tab-id", String(opts.tabId)];
pushFlagsFromOptions(args, opts);
runBrowse(args);
}
function optionsToPdfFlags(opts: PdfOptions): Record<string, unknown> {
// Shape mirrors what the browse `pdf` case expects when reading --from-file
const out: Record<string, unknown> = {};
if (opts.format) out.format = opts.format;
if (opts.width) out.width = opts.width;
if (opts.height) out.height = opts.height;
if (opts.marginTop) out.marginTop = opts.marginTop;
if (opts.marginRight) out.marginRight = opts.marginRight;
if (opts.marginBottom) out.marginBottom = opts.marginBottom;
if (opts.marginLeft) out.marginLeft = opts.marginLeft;
if (opts.headerTemplate !== undefined) out.headerTemplate = opts.headerTemplate;
if (opts.footerTemplate !== undefined) out.footerTemplate = opts.footerTemplate;
if (opts.pageNumbers !== undefined) out.pageNumbers = opts.pageNumbers;
if (opts.tagged !== undefined) out.tagged = opts.tagged;
if (opts.outline !== undefined) out.outline = opts.outline;
if (opts.printBackground !== undefined) out.printBackground = opts.printBackground;
if (opts.preferCSSPageSize !== undefined) out.preferCSSPageSize = opts.preferCSSPageSize;
if (opts.toc !== undefined) out.toc = opts.toc;
return out;
}
function pushFlagsFromOptions(args: string[], opts: PdfOptions): void {
if (opts.format) { args.push("--format", opts.format); }
if (opts.width) { args.push("--width", opts.width); }
if (opts.height) { args.push("--height", opts.height); }
if (opts.marginTop) { args.push("--margin-top", opts.marginTop); }
if (opts.marginRight) { args.push("--margin-right", opts.marginRight); }
if (opts.marginBottom) { args.push("--margin-bottom", opts.marginBottom); }
if (opts.marginLeft) { args.push("--margin-left", opts.marginLeft); }
if (opts.headerTemplate !== undefined) {
args.push("--header-template", opts.headerTemplate);
}
if (opts.footerTemplate !== undefined) {
args.push("--footer-template", opts.footerTemplate);
}
if (opts.pageNumbers === true) args.push("--page-numbers");
if (opts.tagged === true) args.push("--tagged");
if (opts.outline === true) args.push("--outline");
if (opts.printBackground === true) args.push("--print-background");
if (opts.preferCSSPageSize === true) args.push("--prefer-css-page-size");
if (opts.toc === true) args.push("--toc");
}
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env bun
/**
* make-pdf CLI — argv parse, dispatch, exit.
*
* Output contract (per CEO plan DX spec):
* stdout: ONLY the output path on success. One line. Nothing else.
* stderr: progress spinner per stage, final "Done in Xs. N pages."
* --quiet: suppress progress. Errors still print.
* --verbose: per-stage timings.
* exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable.
*/
import { COMMANDS } from "./commands";
import { ExitCode, BrowseClientError } from "./types";
import type { GenerateOptions, PreviewOptions } from "./types";
interface ParsedArgs {
command: string;
positional: string[];
flags: Record<string, string | boolean>;
}
function parseArgs(argv: string[]): ParsedArgs {
const args = argv.slice(2);
if (args.length === 0) {
printUsage();
process.exit(ExitCode.Success);
}
// First non-flag arg is the command.
let command = "";
const positional: string[] = [];
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const next = args[i + 1];
if (next !== undefined && !next.startsWith("--")) {
flags[key] = next;
i++;
} else {
flags[key] = true;
}
} else if (!command) {
command = a;
} else {
positional.push(a);
}
}
return { command, positional, flags };
}
function printUsage(): void {
const lines = [
"make-pdf — turn markdown into publication-quality PDFs",
"",
"Usage:",
];
for (const [name, info] of COMMANDS) {
lines.push(` $P ${info.usage}`);
lines.push(` ${info.description}`);
}
lines.push("");
lines.push("Output format:");
lines.push(" --to pdf|html|docx What to produce (default: pdf).");
lines.push(" html = single self-contained file, no network refs.");
lines.push(" docx = content fidelity, diagrams as PNG.");
lines.push("");
lines.push("Page layout:");
lines.push(" --margins <dim> All four margins (default: 1in). in, pt, cm, mm.");
lines.push(" --page-size letter|a4|legal (aliases: --format — page SIZE, not output format)");
lines.push("");
lines.push("Document structure:");
lines.push(" --cover Add a cover page.");
lines.push(" --toc Generate clickable table of contents.");
lines.push(" --no-chapter-breaks Don't start a new page at every H1.");
lines.push("");
lines.push("Branding:");
lines.push(" --watermark <text> Diagonal watermark on every page.");
lines.push(" --header-template <html>");
lines.push(" --footer-template <html> Mutex with --page-numbers.");
lines.push(" --no-confidential Suppress the CONFIDENTIAL footer.");
lines.push("");
lines.push("Output control:");
lines.push(" --page-numbers / --no-page-numbers (default: on)");
lines.push(" --tagged / --no-tagged (default: on, accessible PDF)");
lines.push(" --outline / --no-outline (default: on, PDF bookmarks)");
lines.push(" --quiet Suppress progress on stderr.");
lines.push(" --verbose Per-stage timings on stderr.");
lines.push("");
lines.push("Diagrams & images:");
lines.push(" ```mermaid / ```excalidraw fences render as vector diagrams.");
lines.push(" Add render=false to a fence info string to keep it as a code block.");
lines.push(" Local images are inlined; oversized rasters downscale to print resolution.");
lines.push(" --strict Missing/remote images fail the run (CI mode).");
lines.push("");
lines.push("Network:");
lines.push(" --allow-network Load external images (off by default).");
lines.push("");
lines.push("Examples:");
lines.push(" $P generate letter.md");
lines.push(" $P generate --cover --toc essay.md essay.pdf");
lines.push(" $P generate --watermark DRAFT memo.md draft.pdf");
lines.push(" $P preview letter.md");
lines.push("");
lines.push("Run `$P setup` to verify browse + Chromium + pdftotext install.");
console.error(lines.join("\n"));
}
function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
const p = parsed.positional;
if (p.length === 0) {
console.error("$P generate: missing <input.md>");
console.error("Usage: $P generate <input.md> [output.pdf] [options]");
process.exit(ExitCode.BadArgs);
}
const f = parsed.flags;
const booleanFlag = (key: string, def: boolean): boolean => {
if (f[key] === true) return true;
if (f[`no-${key}`] === true) return false;
return def;
};
const to = typeof f.to === "string" ? f.to.toLowerCase() : "pdf";
if (to !== "pdf" && to !== "html" && to !== "docx") {
console.error(`$P generate: invalid --to '${f.to}'. Expected pdf, html, or docx.`);
console.error("(--format is a --page-size alias, not the output format.)");
process.exit(ExitCode.BadArgs);
}
return {
input: p[0],
output: p[1],
to: to as GenerateOptions["to"],
margins: f.margins as string | undefined,
marginTop: f["margin-top"] as string | undefined,
marginRight: f["margin-right"] as string | undefined,
marginBottom: f["margin-bottom"] as string | undefined,
marginLeft: f["margin-left"] as string | undefined,
pageSize: ((f["page-size"] ?? f.format) as any),
cover: f.cover === true,
toc: f.toc === true,
noChapterBreaks: f["no-chapter-breaks"] === true,
watermark: typeof f.watermark === "string" ? f.watermark : undefined,
headerTemplate: typeof f["header-template"] === "string"
? f["header-template"] : undefined,
footerTemplate: typeof f["footer-template"] === "string"
? f["footer-template"] : undefined,
confidential: booleanFlag("confidential", true),
pageNumbers: booleanFlag("page-numbers", true),
tagged: booleanFlag("tagged", true),
outline: booleanFlag("outline", true),
quiet: f.quiet === true,
verbose: f.verbose === true,
allowNetwork: f["allow-network"] === true,
strict: f.strict === true,
title: typeof f.title === "string" ? f.title : undefined,
author: typeof f.author === "string" ? f.author : undefined,
date: typeof f.date === "string" ? f.date : undefined,
};
}
function previewOptionsFromFlags(parsed: ParsedArgs): PreviewOptions {
const p = parsed.positional;
if (p.length === 0) {
console.error("$P preview: missing <input.md>");
console.error("Usage: $P preview <input.md> [options]");
process.exit(ExitCode.BadArgs);
}
const f = parsed.flags;
const booleanFlag = (key: string, def: boolean): boolean => {
if (f[key] === true) return true;
if (f[`no-${key}`] === true) return false;
return def;
};
return {
input: p[0],
cover: f.cover === true,
toc: f.toc === true,
watermark: typeof f.watermark === "string" ? f.watermark : undefined,
noChapterBreaks: f["no-chapter-breaks"] === true,
confidential: booleanFlag("confidential", true),
allowNetwork: f["allow-network"] === true,
title: typeof f.title === "string" ? f.title : undefined,
author: typeof f.author === "string" ? f.author : undefined,
date: typeof f.date === "string" ? f.date : undefined,
quiet: f.quiet === true,
verbose: f.verbose === true,
};
}
async function main(): Promise<void> {
const parsed = parseArgs(process.argv);
if (!parsed.command) {
printUsage();
process.exit(ExitCode.BadArgs);
}
if (!COMMANDS.has(parsed.command)) {
console.error(`$P: unknown command: ${parsed.command}`);
console.error("");
printUsage();
process.exit(ExitCode.BadArgs);
}
try {
switch (parsed.command) {
case "version": {
// Read from VERSION file or fall back to a hard-coded default.
try {
const fs = await import("node:fs");
const path = await import("node:path");
const versionFile = path.resolve(
path.dirname(process.argv[1] || ""),
"../../VERSION",
);
const version = fs.readFileSync(versionFile, "utf8").trim();
console.log(version);
} catch {
console.log("make-pdf (version unknown)");
}
process.exit(ExitCode.Success);
}
case "setup": {
const { runSetup } = await import("./setup");
await runSetup();
process.exit(ExitCode.Success);
}
case "generate": {
const opts = generateOptionsFromFlags(parsed);
const { generate } = await import("./orchestrator");
const outputPath = await generate(opts);
// Contract: stdout = output path only
console.log(outputPath);
process.exit(ExitCode.Success);
}
case "preview": {
const opts = previewOptionsFromFlags(parsed);
const { preview } = await import("./orchestrator");
const htmlPath = await preview(opts);
console.log(htmlPath);
process.exit(ExitCode.Success);
}
default:
// Unreachable: COMMANDS.has guarded above
process.exit(ExitCode.BadArgs);
}
} catch (err: any) {
if (err instanceof BrowseClientError) {
console.error(`$P: ${err.message}`);
process.exit(ExitCode.BrowseUnavailable);
}
if (err?.code === "ENOENT") {
console.error(`$P: file not found: ${err.path ?? err.message}`);
process.exit(ExitCode.BadArgs);
}
if (err?.name === "PagedJsTimeout") {
console.error(`$P: ${err.message}`);
process.exit(ExitCode.PagedJsTimeout);
}
console.error(`$P: ${err?.message ?? String(err)}`);
if (parsed.flags.verbose && err?.stack) {
console.error(err.stack);
}
process.exit(ExitCode.RenderError);
}
}
main();
+62
View File
@@ -0,0 +1,62 @@
/**
* Command registry for make-pdf — single source of truth.
*
* Dependency graph:
* commands.ts ──▶ cli.ts (runtime dispatch)
* ──▶ gen-skill-docs.ts (generates usage table in SKILL.md)
* ──▶ tests (validation)
*
* Zero side effects. Safe to import from build scripts.
*/
export const COMMANDS = new Map<string, {
description: string;
usage: string;
flags?: string[];
category: "Primary" | "Setup";
}>([
["generate", {
description: "Render a markdown file to a publication-quality PDF",
usage: "generate <input.md> [output.pdf] [options]",
category: "Primary",
flags: [
// Page layout
"--margins", "--margin-top", "--margin-right", "--margin-bottom", "--margin-left",
"--page-size", "--format",
// Structure
"--cover", "--toc", "--no-chapter-breaks",
// Branding
"--watermark", "--header-template", "--footer-template", "--no-confidential",
// Output
"--page-numbers", "--no-page-numbers", "--tagged", "--no-tagged",
"--outline", "--no-outline", "--quiet", "--verbose",
// Network
"--allow-network",
// Metadata
"--title", "--author", "--date",
],
}],
["preview", {
description: "Render markdown to HTML and open it in the browser (fast iteration)",
usage: "preview <input.md> [options]",
category: "Primary",
flags: [
"--cover", "--toc", "--no-chapter-breaks", "--watermark",
"--no-confidential", "--allow-network",
"--title", "--author", "--date",
"--quiet", "--verbose",
],
}],
["setup", {
description: "Verify browse + Chromium + pdftotext, then run a smoke test",
usage: "setup",
category: "Setup",
flags: [],
}],
["version", {
description: "Print make-pdf version",
usage: "version",
category: "Setup",
flags: [],
}],
]);
+846
View File
@@ -0,0 +1,846 @@
/**
* Diagram + image pre-pass. Runs between "read markdown" and render() in the
* orchestrator, and owns everything that needs the diagram-render bundle.
*
* markdown ─▶ extractDiagramFences() ──▶ render() (marked+sanitize+smarty)
* │ fences → placeholder tokens │
* │ ▼
* └─▶ renderFenceSlots() ───────────▶ substituteSlots(html, slots)
* one browse render tab/run │
* error ⇒ diagnostic block + page reload ▼
* inlineLocalImages(html)
* data URIs, probe dims from bytes,
* downscale >2x content box @300dpi,
* remote warn / missing placeholder /
* --strict hard-fail
*
* Placeholders survive marked, the sanitizer, and smartypants because they are
* plain hyphenated lowercase tokens with no quotes or HTML. Slot HTML is run
* through the same sanitizer as user content before substitution (the bundle
* renders with securityLevel strict — the sanitizer is the second layer).
*
* Reset contract (eng-review D6.2): each fence renders with a fresh
* mermaid.render id; after ANY render error the bundle page is reloaded before
* the next fence so a poisoned global can't corrupt diagram N+1.
*/
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 { fileURLToPath } from "node:url";
import * as browseClient from "./browseClient";
import { escapeHtml, sanitizeUntrustedHtml } from "./render";
import { imageDims } from "./image-size";
// ─── Types ────────────────────────────────────────────────────────────
export interface DiagramFence {
/** "mermaid" | "excalidraw" */
lang: string;
/** Fence body (the diagram source). */
source: string;
/** Optional title="..." from the fence info string (a11y label, D6.4). */
title?: string;
/** Optional page=landscape|portrait fence directive (image-policy override). */
page?: "landscape" | "portrait";
/** render=false → leave as a plain code block (escape hatch, D6.3). */
render: boolean;
/** Placeholder token substituted into the markdown. */
token: string;
/** 1-based ordinal among rendered fences (unique ids, aria fallback). */
ordinal: number;
}
export interface FenceExtraction {
markdown: string;
fences: DiagramFence[];
}
export interface PrepassWarnings {
warn: (msg: string) => void;
}
export interface PrepassImageOptions {
/** Directory of the source markdown — relative image paths resolve here. */
inputDir: string;
/** Hard-fail on missing/remote images instead of warn (D6.1). */
strict: boolean;
/** Remote images are left untouched when network is explicitly allowed. */
allowNetwork: boolean;
/** Physical content-box width in inches (page width minus margins). */
contentWidthIn: number;
warn: (msg: string) => void;
/** Lazily provides a ready bundle tab (only opened when needed). */
getTab: () => RenderTab | null;
}
/** Print-resolution policy (eng-review D4): downscale rasters wider than
* 2 × contentWidth × 300dpi down to contentWidth × 300dpi. */
const PRINT_DPI = 300;
const DOWNSCALE_FACTOR = 2;
/** Per-image read ceiling — bounds memory before any policy runs. */
const MAX_IMAGE_BYTES = 64 * 1024 * 1024;
export class StrictModeError extends Error {
constructor(msg: string) {
super(msg);
this.name = "StrictModeError";
}
}
// ─── Fence extraction (pure) ──────────────────────────────────────────
const DIAGRAM_LANGS = new Set(["mermaid", "excalidraw"]);
/**
* Extract column-0 ```mermaid / ```excalidraw fences, replacing each with a
* unique placeholder token paragraph. Backtick and tilde fences, any length
* >= 3; closers must be at least as long as the opener (CommonMark). Fences
* with `render=false` are left untouched.
*
* Two deliberate conservatisms (red-team finding — the original version
* reconstructed fences at column 0 and restructured lists):
* - Non-diagram fences replay as their ORIGINAL raw lines, byte-for-byte
* (only a render=false flag is removed, in place, preserving indent).
* - INDENTED diagram fences (inside lists/quotes) are NOT extracted — a
* column-0 placeholder would split the list. They replay verbatim as code.
*/
export function extractDiagramFences(markdown: string): FenceExtraction {
const lines = markdown.split("\n");
const out: string[] = [];
const fences: DiagramFence[] = [];
const runId = crypto.randomBytes(4).toString("hex");
let i = 0;
let openFence: {
char: string; len: number; indent: number; info: string;
rawOpener: string; body: string[];
} | null = null;
let ordinal = 0;
while (i < lines.length) {
const line = lines[i];
if (openFence) {
const close = matchFenceLine(line);
if (close && close.char === openFence.char && close.len >= openFence.len && close.info === "") {
const info = parseInfoString(openFence.info);
if (DIAGRAM_LANGS.has(info.lang) && info.render && openFence.indent === 0) {
ordinal++;
const token = `gstack-diagram-slot-${runId}-${ordinal}`;
fences.push({
lang: info.lang,
source: openFence.body.join("\n"),
title: info.title,
page: info.page,
render: true,
token,
ordinal,
});
out.push("", token, "");
} else {
// Not extracted (other language, render=false, or indented): replay
// the ORIGINAL lines verbatim; only strip a render=false flag.
out.push(stripRenderFalse(openFence.rawOpener));
out.push(...openFence.body);
out.push(line);
}
openFence = null;
i++;
continue;
}
openFence.body.push(line);
i++;
continue;
}
const open = matchFenceLine(line);
if (open && open.info !== "") {
openFence = { ...open, rawOpener: line, body: [] };
i++;
continue;
}
if (open) {
// Anonymous fence (plain code block) — copy through to its closer so a
// ```mermaid example INSIDE a plain fence is never extracted.
out.push(line);
i++;
while (i < lines.length) {
const l = lines[i];
const close = matchFenceLine(l);
out.push(l);
i++;
if (close && close.char === open.char && close.len >= open.len && close.info === "") break;
}
continue;
}
out.push(line);
i++;
}
// Unclosed fence at EOF: replay verbatim (CommonMark treats it as code to EOF).
if (openFence) {
out.push(openFence.rawOpener);
out.push(...openFence.body);
}
return { markdown: out.join("\n"), fences };
}
function matchFenceLine(line: string): { char: string; len: number; indent: number; info: string } | null {
const m = line.match(/^( {0,3})(`{3,}|~{3,})\s*(.*)$/);
if (!m) return null;
return { indent: m[1].length, char: m[2][0], len: m[2].length, info: m[3].trim() };
}
/** Remove a render=false flag from a raw opener line, preserving everything else. */
function stripRenderFalse(rawOpener: string): string {
return rawOpener.replace(/\s*\brender\s*=\s*false\b/i, "");
}
/** Parse a fence info string: `mermaid`, `mermaid render=false`,
* `mermaid title="Auth flow"`, `mermaid page=landscape`. */
export function parseInfoString(info: string): {
lang: string; render: boolean; title?: string; page?: "landscape" | "portrait";
} {
const lang = (info.match(/^\S+/)?.[0] ?? "").toLowerCase();
const render = !/\brender\s*=\s*false\b/i.test(info);
const title = info.match(/\btitle\s*=\s*"([^"]*)"/i)?.[1]
?? info.match(/\btitle\s*=\s*'([^']*)'/i)?.[1];
const pageRaw = info.match(/\bpage\s*=\s*(landscape|portrait)\b/i)?.[1]?.toLowerCase();
const page = pageRaw === "landscape" || pageRaw === "portrait" ? pageRaw : undefined;
return { lang, render, title, page };
}
// ─── Slot substitution (pure) ─────────────────────────────────────────
/**
* Replace placeholder tokens in rendered HTML with their final slot HTML.
* marked wraps the bare token line in <p>…</p>; replace the wrapper too so
* the figure isn't nested inside a paragraph.
*/
export function substituteSlots(html: string, slots: Map<string, string>): string {
let s = html;
for (const [token, slotHtml] of slots) {
// Function replacement is load-bearing: slot HTML carries user/LLM-authored
// diagram label text, and string-form replace() expands $&, $', $` patterns
// inside it — a label containing "$'" would duplicate the document tail.
const wrapped = new RegExp(`<p>\\s*${token}\\s*</p>`, "g");
const replaced = s.replace(wrapped, () => slotHtml);
s = replaced !== s ? replaced : s.split(token).join(slotHtml);
}
return s;
}
/**
* Visible diagnostic block for a failed fence render — never silent raw code
* (eng-review: explicit error blocks). Sanitizer-safe: all dynamic content is
* HTML-escaped.
*/
export function buildDiagnosticBlock(fence: DiagramFence, errorMessage: string): string {
const excerpt = fence.source.split("\n").slice(0, 8).join("\n");
const truncated = fence.source.split("\n").length > 8 ? "\n…" : "";
return [
`<figure class="diagram diagram-error" role="img" aria-label="${escapeHtml(diagramLabel(fence))} (failed to render)">`,
`<figcaption class="diagram-error-title">Diagram failed to render (${escapeHtml(fence.lang)})</figcaption>`,
`<pre class="diagram-error-detail">${escapeHtml(errorMessage.trim())}\n\n${escapeHtml(excerpt + truncated)}</pre>`,
`</figure>`,
].join("\n");
}
/**
* Wrap a rendered SVG in an accessible figure (D6.4). The raw fence source is
* preserved base64-encoded in a data attribute — an HTML comment would need
* `--` escaping, which corrupts every mermaid arrow (`-->`) and breaks
* round-trip recovery.
*/
export function buildDiagramFigure(fence: DiagramFence, svg: string): string {
const label = diagramLabel(fence);
const cleanSvg = sanitizeUntrustedHtml(svg);
const captioned = fence.title
? `\n<figcaption class="diagram-caption">${escapeHtml(fence.title)}</figcaption>`
: "";
const pageAttr = fence.page ? ` data-gstack-page="${fence.page}"` : "";
const sourceB64 = Buffer.from(fence.source, "utf8").toString("base64");
return [
`<figure class="diagram" role="img" aria-label="${escapeHtml(label)}"${pageAttr}` +
` data-gstack-lang="${escapeHtml(fence.lang)}" data-gstack-source="${sourceB64}">`,
cleanSvg,
captioned,
`</figure>`,
].join("\n");
}
/** Recover the original fence source from a rendered figure (round-trip). */
export function decodeFigureSource(figureHtml: string): string | null {
const m = figureHtml.match(/\bdata-gstack-source="([A-Za-z0-9+/=]*)"/);
if (!m) return null;
try {
return Buffer.from(m[1], "base64").toString("utf8");
} catch {
return null;
}
}
function diagramLabel(fence: DiagramFence): string {
return fence.title ?? `diagram ${fence.ordinal}`;
}
// ─── Render tab (bundle page lifecycle) ───────────────────────────────
const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp";
const READY_TIMEOUT_MS = 20_000;
// Expressions bigger than this ship via `browse eval <file>` instead of argv.
// 8KB is safe on every platform (Windows CreateProcess caps the WHOLE command
// line at 32,767 chars; Linux MAX_ARG_STRLEN is ~128KiB) and the tmp-file
// round-trip costs microseconds — one spawn regardless of payload size.
const MAX_ARGV_EXPR_BYTES = 8_000;
export class RenderTab {
private constructor(
public readonly tabId: number,
private readonly stagedBundlePath: string,
) {}
/**
* Open a tab and load the diagram-render bundle. The bundle HTML is staged
* under /tmp (content-addressed, reused across runs — load-html only reads
* inside its safe dirs) and loaded by PATH, not --from-file: a 9MB JSON
* round-trip per run would be pure waste.
*/
static open(): RenderTab {
const bundleSrc = resolveBundlePath();
const html = fs.readFileSync(bundleSrc);
const sha = crypto.createHash("sha256").update(html).digest("hex").slice(0, 16);
const staged = path.join(PAYLOAD_TMP_DIR, `gstack-diagram-render-${sha}.html`);
// Never trust an existing file at the predictable shared-/tmp name: verify
// its content hash and re-stage on mismatch (a pre-planted file would
// otherwise be loaded into the render tab as the bundle).
let needsWrite = true;
if (fs.existsSync(staged)) {
try {
const existing = crypto.createHash("sha256").update(fs.readFileSync(staged)).digest("hex").slice(0, 16);
needsWrite = existing !== sha;
} catch {
needsWrite = true;
}
}
if (needsWrite) {
// Concurrent-safe: write to a unique temp name, then atomic rename.
const tmp = `${staged}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
fs.writeFileSync(tmp, html);
try {
fs.renameSync(tmp, staged);
} catch (renameErr) {
try { fs.unlinkSync(tmp); } catch { /* best-effort tmp cleanup */ }
// Only swallow the rename failure when the surviving file HASHES to
// the expected bundle (a concurrent writer won an OS-level race).
// Sticky-bit /tmp makes rename-over-foreign-file fail EPERM — if the
// survivor were trusted on existence alone, a pre-planted file would
// ride through the exact check added to stop it.
let survivorOk = false;
try {
const survivor = crypto.createHash("sha256").update(fs.readFileSync(staged)).digest("hex").slice(0, 16);
survivorOk = survivor === sha;
} catch { /* unreadable survivor = not ok */ }
if (!survivorOk) throw renameErr;
}
}
const tabId = browseClient.newtab();
const tab = new RenderTab(tabId, staged);
tab.loadBundle();
return tab;
}
/** (Re)load the bundle page — also the reset path after a render error. */
loadBundle(): void {
browseClient.loadHtmlFile({ file: this.stagedBundlePath, tabId: this.tabId });
const ready = browseClient.waitForExpression({
expression: "document.getElementById('status') !== null && document.getElementById('status').textContent === 'ready'",
tabId: this.tabId,
timeoutMs: READY_TIMEOUT_MS,
});
if (!ready) {
throw new Error(
"diagram-render bundle did not become ready in the browse tab " +
`(${READY_TIMEOUT_MS}ms). Check \`browse js "window.__errors"\` on tab ${this.tabId}.`,
);
}
}
/**
* Call one of the bundle's async window functions with JSON-safe string
* args. Errors come back as a recognizable ERR: prefix so a render failure
* is data, not a thrown browse exit.
*/
call(fn: string, ...args: Array<string | number>): string {
const argList = args.map((a) => JSON.stringify(a)).join(",");
const expression =
`window.${fn}(${argList})` +
`.then(r => "OK:" + r)` +
`.catch(e => "ERR:" + String((e && e.message) || e))`;
const result = this.js(expression);
if (result.startsWith("OK:")) return result.slice(3);
if (result.startsWith("ERR:")) throw new RenderCallError(result.slice(4));
throw new RenderCallError(`unexpected bundle result: ${result.slice(0, 200)}`);
}
private js(expression: string): string {
// Large payloads (scene JSON, SVG text, data URIs) blow past argv limits —
// browseClient.js shells out with the expression as an argv element. The
// limit is BYTES, not chars (CJK content is 3x its char count in UTF-8),
// and Windows caps the whole command line at 32,767 chars — so anything
// big ships via `browse eval <file>` instead: one spawn, any size.
if (Buffer.byteLength(expression, "utf8") <= MAX_ARGV_EXPR_BYTES) {
return browseClient.js({ expression, tabId: this.tabId });
}
return this.jsViaFile(expression);
}
/** argv-safe path for big expressions: stage to a tmp file under browse's
* safe dirs and run `browse eval <file>` (one spawn regardless of size). */
private jsViaFile(expression: string): string {
const file = path.join(
PAYLOAD_TMP_DIR,
`gstack-diagram-expr-${process.pid}-${crypto.randomBytes(4).toString("hex")}.js`,
);
fs.writeFileSync(file, expression, "utf8");
try {
return browseClient.evalFile({ file, tabId: this.tabId });
} finally {
try { fs.unlinkSync(file); } catch { /* best-effort tmp cleanup */ }
}
}
close(): void {
try {
browseClient.closetab(this.tabId);
} catch {
// best-effort: orchestrator finally path
}
}
}
export class RenderCallError extends Error {
constructor(msg: string) {
super(msg);
this.name = "RenderCallError";
}
}
/** Resolve dist/diagram-render.html: env override → repo-relative (dev) → global install. */
export function resolveBundlePath(env: NodeJS.ProcessEnv = process.env): string {
const candidates = [
env.GSTACK_DIAGRAM_BUNDLE,
// dev: make-pdf/src/* → repo root lib/. (In a compiled binary this is the
// virtual /$bunfs/root and simply never exists — harmless.)
path.resolve(import.meta.dir, "../../lib/diagram-render/dist/diagram-render.html"),
// compiled binary at <root>/make-pdf/dist/pdf → <root>/lib/… — same shape
// in the repo and in the ~/.claude/skills/gstack global install. argv[0]
// is the literal string "bun" in compiled binaries; execPath is real.
path.resolve(path.dirname(process.execPath), "../../lib/diagram-render/dist/diagram-render.html"),
path.join(os.homedir(), ".claude/skills/gstack/lib/diagram-render/dist/diagram-render.html"),
].filter((p): p is string => !!p);
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
"diagram-render bundle not found. Tried:\n" +
candidates.map((c) => ` - ${c}`).join("\n") +
"\nRun `bun run build:diagram-render` (repo) or re-run ./setup (install).",
);
}
// ─── Fence rendering ──────────────────────────────────────────────────
/**
* Render every extracted fence to its slot HTML. One bundle tab serves all
* fences; a failed fence yields a diagnostic block and a bundle reload
* (reset contract) before the next fence renders.
*/
export function renderFenceSlots(
fences: DiagramFence[],
tab: RenderTab,
warn: (msg: string) => void,
): Map<string, string> {
const slots = new Map<string, string>();
for (const fence of fences) {
try {
let svg: string;
if (fence.lang === "mermaid") {
svg = tab.call("__renderMermaid", `mermaid-fence-${fence.ordinal}`, fence.source);
} else {
JSON.parse(fence.source); // fail fast with a JSON diagnostic, not a bundle stack
svg = tab.call("__excalidrawToSvg", fence.source);
}
slots.set(fence.token, buildDiagramFigure(fence, svg));
} catch (err: any) {
const msg = err?.message ?? String(err);
warn(`diagram ${fence.ordinal} (${fence.lang}) failed to render: ${firstLine(msg)}`);
slots.set(fence.token, buildDiagnosticBlock(fence, msg));
// Reset contract: a poisoned page must not corrupt the next fence.
try {
tab.loadBundle();
} catch (reloadErr: any) {
warn(`bundle reload after render error failed: ${firstLine(reloadErr?.message ?? String(reloadErr))}`);
}
}
}
return slots;
}
// ─── DOCX rasterization (eng-review D6.5, P8) ─────────────────────────
/**
* Replace inline diagram SVGs (and svg data-URI images) with PNG <img> tags
* for the DOCX export — Word's SVG support is unreliable, so the content-
* fidelity contract embeds rasters at 300dpi of the placed width (the
* content box). Diagnostic blocks keep their text form.
*/
export function rasterizeDiagramFigures(
html: string,
tab: RenderTab,
contentWidthIn: number,
warn: (msg: string) => void,
): string {
const targetPx = Math.round(contentWidthIn * PRINT_DPI);
// 1. Rendered diagram figures → <img> with the figure's aria-label as alt.
let out = html.replace(
/<figure class="diagram"[^>]*>[\s\S]*?<\/figure>/gi,
(figure) => {
const svgMatch = figure.match(/<svg\b[\s\S]*<\/svg>/i);
if (!svgMatch) return figure;
const label = figure.match(/\baria-label\s*=\s*"([^"]*)"/i)?.[1] ?? "diagram";
try {
const png = tab.call("__rasterize", svgMatch[0], targetPx);
return `<p><img src="${png}" alt="${label}"></p>`;
} catch (err: any) {
const reason = firstLine(err?.message ?? String(err));
warn(`docx: diagram rasterization failed (${reason}); embedding source text instead`);
// The converter drops <figure>/<svg> entirely, so returning the figure
// would make the diagram vanish without a trace — the exact invisible
// failure the diagnostic contract forbids. Surface the source.
const source = decodeFigureSource(figure) ?? "(source unavailable)";
return [
`<p><strong>Diagram could not be rasterized for DOCX (${escapeHtml(reason)}) — source:</strong></p>`,
`<pre>${escapeHtml(source)}</pre>`,
].join("\n");
}
},
);
// 2. SVG data-URI images (inlined .svg files) → PNG.
out = out.replace(/<img\b[^>]*>/gi, (tag) => {
const m = tag.match(SRC_RE);
const src = m?.[2] ?? m?.[3] ?? "";
if (!src.startsWith("data:image/svg+xml")) return tag;
try {
const b64 = src.slice(src.indexOf(",") + 1);
const svgText = Buffer.from(b64, "base64").toString("utf8");
const png = tab.call("__rasterize", svgText, targetPx);
// Function replacement: data URIs can contain $-patterns.
return tag.replace(SRC_RE, () => `src="${png}"`);
} catch (err: any) {
warn(`docx: svg image rasterization failed (${firstLine(err?.message ?? String(err))})`);
return tag;
}
});
return out;
}
/**
* Diagnostic figures → plain <p>/<pre> for the DOCX converter, which drops
* <figure> elements it can't map. An invisible error is the one thing the
* diagnostic contract forbids. Pure — no render tab needed.
*/
export function convertDiagnosticsForDocx(html: string): string {
return html.replace(
/<figure class="diagram diagram-error"[^>]*>([\s\S]*?)<\/figure>/gi,
(_full, body: string) => {
const title = body.match(/<figcaption[^>]*>([\s\S]*?)<\/figcaption>/i)?.[1] ?? "Diagram failed to render";
const detail = body.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i)?.[1] ?? "";
return `<p><strong>${title}</strong></p>\n<pre>${detail}</pre>`;
},
);
}
// ─── Image inlining (eng-review D1 + D4 + D6.1) ───────────────────────
const IMG_TAG_RE = /<img\b[^>]*>/gi;
const SRC_RE = /\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i;
/**
* Inline every local <img> as a data URI, probe intrinsic dimensions from the
* bytes, and annotate the tag with data-gstack-px-width/-height for the width
* policy. Oversized rasters are downscaled to print resolution via the bundle
* tab. Missing files become visible placeholders (or throw under --strict);
* remote URLs warn (offline posture) unless --allow-network.
*/
export function inlineLocalImages(html: string, opts: PrepassImageOptions): string {
const maxPx = Math.round(opts.contentWidthIn * PRINT_DPI * DOWNSCALE_FACTOR);
const targetPx = Math.round(opts.contentWidthIn * PRINT_DPI);
// An image referenced N times is read/probed/downscaled once; the same data
// URI string is reused (also dedupes memory until the final join).
const memo = new Map<string, { dataUri: string; attrs: string }>();
return html.replace(IMG_TAG_RE, (tag) => {
const srcMatch = tag.match(SRC_RE);
if (!srcMatch) return tag;
const src = srcMatch[2] ?? srcMatch[3] ?? "";
if (src.startsWith("data:")) return annotateFromDataUri(tag, src);
// Windows drive-letter paths (C:/x.png, C:\x.png) look like single-letter
// URL schemes — they are local paths, not URLs.
const isDrivePath = /^[a-zA-Z]:[\\/]/.test(src);
if (!isDrivePath && /^[a-z][a-z0-9+.-]*:/i.test(src)) {
// Absolute URL with a scheme (http, https, file, …)
if (opts.allowNetwork && /^https?:/i.test(src)) return tag;
if (/^https?:/i.test(src)) {
const msg = `remote image blocked (offline posture): ${src}`;
if (opts.strict) throw new StrictModeError(msg + " — re-run without --strict or pass --allow-network");
opts.warn(msg);
// Leaving the tag would make Chromium fetch it at print time anyway —
// the warn would be a lie. Replace with a visible placeholder.
return buildBlockedRemotePlaceholder(src);
}
// file:// and friends fall through to the local path branch
if (!src.startsWith("file:")) return tag;
}
// decodeURIComponent throws on malformed escapes (foo%zz.png) — a broken
// URL must degrade to the missing-image path, not crash the run.
let decodedSrc = src;
try {
decodedSrc = decodeURIComponent(src);
} catch { /* keep raw src */ }
const filePath = src.startsWith("file:")
? fileURLToPath(src)
: isDrivePath
? path.resolve(src)
: path.resolve(opts.inputDir, decodedSrc);
const cached = memo.get(filePath);
if (cached !== undefined) return rewriteImgTag(tag, cached);
if (!fs.existsSync(filePath)) {
const msg = `image not found: ${src} (resolved to ${filePath})`;
if (opts.strict) throw new StrictModeError(msg);
opts.warn(msg);
return buildMissingImagePlaceholder(src);
}
// Out-of-tree reads are legal (local CLI semantics — like pandoc) but
// never silent: an agent PDF-ing untrusted markdown should not quietly
// embed ~/.ssh/config into a shareable document. --strict makes it fatal.
// Compare REAL paths — a symlink inside the input dir pointing outside
// would otherwise pass a string-prefix check (Codex adversarial finding).
// Runs after the existence check: realpath of a missing file can't
// resolve, and on macOS /var vs /private/var would false-positive.
const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep;
const realFilePath = safeRealpath(filePath);
if (!realFilePath.startsWith(inputRoot)) {
const msg = `image resolves OUTSIDE the input directory: ${src}${realFilePath}`;
if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory or drop --strict");
opts.warn(msg);
}
// Bound the read BEFORE reading: a markdown image pointing at a special
// file (fifo, device) would hang readFileSync, and a multi-GB file would
// exhaust memory before any policy ran.
let stat: fs.Stats;
try {
stat = fs.statSync(filePath);
} catch {
opts.warn(`image unreadable: ${src}`);
return buildMissingImagePlaceholder(src);
}
if (!stat.isFile()) {
const msg = `image is not a regular file: ${src}`;
if (opts.strict) throw new StrictModeError(msg);
opts.warn(msg);
return buildMissingImagePlaceholder(src);
}
if (stat.size > MAX_IMAGE_BYTES) {
const msg = `image exceeds ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB cap: ${src} (${Math.round(stat.size / 1024 / 1024)}MB)`;
if (opts.strict) throw new StrictModeError(msg);
opts.warn(msg);
return buildMissingImagePlaceholder(src);
}
let buf = fs.readFileSync(filePath);
let dims = imageDims(buf);
let mime = dims?.mime ?? mimeFromExtension(filePath);
// Print-resolution normalization (D4): rasters only — SVG scales free.
if (dims && mime !== "image/svg+xml" && dims.width > maxPx) {
const tab = opts.getTab();
if (tab) {
try {
const dataUri = `data:${mime};base64,${buf.toString("base64")}`;
const scaled = tab.call("__downscaleRaster", dataUri, targetPx, mime);
const scaledB64 = scaled.replace(/^data:[^,]*,/, "");
opts.warn(
`downscaled ${path.basename(filePath)} ${dims.width}px → ${targetPx}px ` +
`(print is ${PRINT_DPI}dpi; original exceeds ${maxPx}px content-box ceiling)`,
);
buf = Buffer.from(scaledB64, "base64");
mime = scaled.slice(5, scaled.indexOf(";"));
dims = { ...dims, height: Math.round((dims.height * targetPx) / dims.width), width: targetPx };
} catch (err: any) {
opts.warn(`downscale failed for ${src}, inlining at full size: ${firstLine(err?.message ?? String(err))}`);
}
}
}
const dataUri = `data:${mime};base64,${buf.toString("base64")}`;
const attrs = dims
? ` data-gstack-px-width="${Math.round(dims.width)}" data-gstack-px-height="${Math.round(dims.height)}"`
: "";
memo.set(filePath, { dataUri, attrs });
return rewriteImgTag(tag, memo.get(filePath)!);
});
}
/** Apply a memoized inline result to an img tag. */
function rewriteImgTag(tag: string, entry: { dataUri: string; attrs: string }): string {
// Function replacement: data URIs are user-content-derived; string-form
// replace() would expand $-patterns inside them.
let out = tag.replace(SRC_RE, () => `src="${entry.dataUri}"`);
if (entry.attrs) out = out.replace(/^<img\b/i, () => `<img${entry.attrs}`);
return out;
}
function annotateFromDataUri(tag: string, src: string): string {
try {
const b64 = src.slice(src.indexOf(",") + 1);
const head = Buffer.from(b64.slice(0, 8192), "base64");
const dims = imageDims(head);
if (!dims) return tag;
return tag.replace(
/^<img\b/i,
`<img data-gstack-px-width="${Math.round(dims.width)}" data-gstack-px-height="${Math.round(dims.height)}"`,
);
} catch {
return tag;
}
}
function buildMissingImagePlaceholder(src: string): string {
return (
`<span class="image-missing" role="img" aria-label="missing image">` +
`[missing image: ${escapeHtml(src)}]</span>`
);
}
function buildBlockedRemotePlaceholder(src: string): string {
return (
`<span class="image-missing" role="img" aria-label="remote image blocked">` +
`[remote image blocked (use --allow-network): ${escapeHtml(src)}]</span>`
);
}
/** realpath that degrades to the input path when resolution fails. */
function safeRealpath(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
function mimeFromExtension(p: string): string {
switch (path.extname(p).toLowerCase()) {
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".gif": return "image/gif";
case ".webp": return "image/webp";
case ".svg": return "image/svg+xml";
default: return "application/octet-stream";
}
}
// ─── Content-box math ─────────────────────────────────────────────────
const PAGE_WIDTHS_IN: Record<string, number> = {
letter: 8.5,
a4: 8.27,
legal: 8.5,
tabloid: 11,
};
/** Parse a CSS dimension ("1in" | "72pt" | "25mm" | "2.54cm") to inches. */
export function dimToInches(dim: string | undefined, fallbackIn: number): number {
if (!dim) return fallbackIn;
const m = dim.trim().match(/^([0-9.]+)\s*(in|pt|cm|mm|px)?$/i);
if (!m) return fallbackIn;
const v = parseFloat(m[1]);
switch ((m[2] ?? "in").toLowerCase()) {
case "in": return v;
case "pt": return v / 72;
case "cm": return v / 2.54;
case "mm": return v / 25.4;
case "px": return v / 96;
default: return fallbackIn;
}
}
export function contentWidthInches(opts: {
pageSize?: string;
margins?: string;
marginLeft?: string;
marginRight?: string;
}): number {
const pageW = PAGE_WIDTHS_IN[opts.pageSize ?? "letter"] ?? 8.5;
const left = dimToInches(opts.marginLeft ?? opts.margins, 1);
const right = dimToInches(opts.marginRight ?? opts.margins, 1);
return Math.max(1, pageW - left - right);
}
const PAGE_HEIGHTS_IN: Record<string, number> = {
letter: 11,
a4: 11.69,
legal: 14,
tabloid: 17,
};
/**
* Content box of the rotated (landscape) named page: portrait page HEIGHT
* becomes the landscape width; portrait WIDTH becomes the landscape height.
* Used by image-policy to vertically center promoted blocks.
*/
export function landscapeContentBox(opts: {
pageSize?: string;
margins?: string;
marginLeft?: string;
marginRight?: string;
marginTop?: string;
marginBottom?: string;
}): { contentWIn: number; contentHIn: number } {
const size = opts.pageSize ?? "letter";
const pageH = PAGE_HEIGHTS_IN[size] ?? 11;
const pageW = PAGE_WIDTHS_IN[size] ?? 8.5;
const left = dimToInches(opts.marginLeft ?? opts.margins, 1);
const right = dimToInches(opts.marginRight ?? opts.margins, 1);
const top = dimToInches(opts.marginTop ?? opts.margins, 1);
const bottom = dimToInches(opts.marginBottom ?? opts.margins, 1);
return {
contentWIn: Math.max(1, pageH - left - right),
contentHIn: Math.max(1, pageW - top - bottom),
};
}
// ─── tiny helpers ─────────────────────────────────────────────────────
// escapeHtml is imported from ./render — single definition, no drift.
function firstLine(s: string): string {
return s.split("\n")[0].slice(0, 200);
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Image width policy + conservative auto-landscape (eng-review P4, D4 spec).
*
* Two pure passes over rendered HTML:
*
* 1. applyImageDirectives — runs inside render() right after marked, before
* the sanitizer. Translates the markdown-adjacent directive suffix
* `![alt](x.png){width=50%}` / `{page=landscape}` into data-gstack-*
* attributes (the sanitizer keeps data- attributes; the brace text is
* consumed so it never reaches smartypants or the page).
*
* 2. applyImagePolicy — runs in the orchestrator after image inlining (which
* annotates data-gstack-px-width/-height from real bytes). Applies the
* width rule and decides landscape promotion:
*
* WIDTH RULE: render at intrinsic CSS-px width, capped at the content box,
* never upscaled — that is exactly `figure img { max-width: 100% }` doing
* its job, so the default needs no inline style. Directives opt into more:
* width=full stretches to the content box; <pct>/<dim> set explicit width.
*
* LANDSCAPE (conservative, false negatives are cheap):
* promote only when ALL hold —
* aspect ratio ≥ 1.8
* AND intrinsic CSS-px width > SHRINK_LIMIT × content box
* (content shrunk below ~40% of natural size = unreadable)
* AND diagram provenance (rendered fence) or an alt-text token from
* ALT_HINT_TOKENS (plain images)
* `{page=landscape}` forces, `{page=portrait}` vetoes — both skip the
* heuristics entirely.
*
* Promotion wraps the block in <div class="page-wide"> whose CSS named
* page (`@page wide { size: <size> landscape }`, print-css.ts) rotates
* just that page. Chromium only honors CSS page sizes when the print call
* passes preferCSSPageSize — the orchestrator sets it when hasLandscape.
*/
import { svgTagDims } from "./image-size";
export interface ImagePolicyOptions {
/** Physical content-box width in inches (page width minus margins). */
contentWidthIn: number;
/**
* Landscape named-page content box (inches). Used to vertically center a
* promoted block via a computed inline margin-top — CSS flex/min-height
* centering fragments into phantom landscape pages in Chromium, so the
* margin is computed here from the block's known aspect ratio instead.
*/
landscape: { contentWIn: number; contentHIn: number };
warn: (msg: string) => void;
}
export interface ImagePolicyResult {
html: string;
/** True when at least one block was promoted to the landscape named page. */
hasLandscape: boolean;
}
/** Aspect ratio floor for auto-promotion. */
const MIN_ASPECT = 1.8;
/**
* Auto-promote only when the intrinsic CSS-px width exceeds this multiple of
* the content box (in CSS px @96dpi). 2.5 ≈ the plan's ~1600px threshold on a
* 6.5in letter box; calibrated against fixtures (design doc Open Question 4).
*/
const SHRINK_LIMIT = 2.5;
/** Alt-text tokens that mark a plain image as diagram-like (case-insensitive). */
const ALT_HINT_TOKENS = ["diagram", "architecture", "flowchart", "chart", "graph"];
// ─── Pass 1: directive suffixes ───────────────────────────────────────
const IMG_WITH_SUFFIX_RE = /(<img\b[^>]*>)\s*\{([^{}<>\n]{1,120})\}/gi;
/**
* Consume `{...}` directive suffixes adjacent to <img> tags. Unrecognized
* brace groups are left untouched (someone's literal prose).
*/
export function applyImageDirectives(html: string): string {
return html.replace(IMG_WITH_SUFFIX_RE, (full, imgTag: string, body: string) => {
const parsed = parseDirectives(body);
if (!parsed) return full;
let tag = imgTag;
if (parsed.width) tag = addAttr(tag, "data-gstack-width", parsed.width);
if (parsed.page) tag = addAttr(tag, "data-gstack-page", parsed.page);
return tag;
});
}
export function parseDirectives(body: string): { width?: string; page?: string } | null {
let width: string | undefined;
let page: string | undefined;
let recognized = false;
for (const part of body.trim().split(/\s+/)) {
const m = part.match(/^(width|page)=(.+)$/i);
if (!m) return null; // any unknown token ⇒ not a directive group
const key = m[1].toLowerCase();
const value = m[2].toLowerCase();
if (key === "width" && /^(full|\d{1,3}%|[0-9.]+(in|cm|mm|pt|px))$/.test(value)) {
width = value;
recognized = true;
} else if (key === "page" && /^(landscape|portrait)$/.test(value)) {
page = value;
recognized = true;
} else {
return null; // recognized key, malformed value ⇒ leave visible, not silent
}
}
return recognized ? { width, page } : null;
}
function addAttr(imgTag: string, name: string, value: string): string {
return imgTag.replace(/^<img\b/i, `<img ${name}="${value}"`);
}
// ─── Pass 2: width styles + landscape promotion ───────────────────────
export function applyImagePolicy(html: string, opts: ImagePolicyOptions): ImagePolicyResult {
let hasLandscape = false;
const boxCssPx = opts.contentWidthIn * 96;
const widthThresholdPx = boxCssPx * SHRINK_LIMIT;
// 2a. width directives → inline styles on the img.
let out = html.replace(/<img\b[^>]*>/gi, (tag) => {
const width = attrValue(tag, "data-gstack-width");
if (!width) return tag;
const css = width === "full" ? "100%" : width;
return mergeStyle(tag, `width: ${css}; height: auto;`);
});
// 2b. landscape promotion — standalone images (markdown images render as
// <p><img …></p>; promote by swapping the paragraph for the wide wrapper).
out = out.replace(/<p>\s*(<img\b[^>]*>)\s*<\/p>/gi, (full, tag: string) => {
const decision = decideImagePromotion(tag, widthThresholdPx);
if (!decision.promote) return full;
hasLandscape = true;
opts.warn(`promoting image to a landscape page (${decision.reason})`);
const w = num(attrValue(tag, "data-gstack-px-width"));
const h = num(attrValue(tag, "data-gstack-px-height"));
return wrapPageWide(tag, w && h ? h / w : null, opts.landscape);
});
// 2c. landscape promotion — rendered diagram figures (provenance is
// automatic; dims come from the SVG's width/height or viewBox).
out = out.replace(
/<figure class="diagram[^"]*"[^>]*>[\s\S]*?<\/figure>/gi,
(figure) => {
if (figure.includes("diagram-error")) return figure;
const decision = decideDiagramPromotion(figure, widthThresholdPx);
if (!decision.promote) return figure;
hasLandscape = true;
opts.warn(`promoting diagram to a landscape page (${decision.reason})`);
const dims = svgCssDims(figure);
return wrapPageWide(figure, dims ? dims.height / dims.width : null, opts.landscape);
},
);
return { html: out, hasLandscape };
}
/**
* Wrap a promoted block in the wide-page div, vertically centered via a
* computed margin-top: placed height = landscape content width × aspect,
* centered in the landscape content height. Unknown aspect → no margin
* (top placement beats a wrong guess).
*/
function wrapPageWide(
inner: string,
aspectHoverW: number | null,
landscape: { contentWIn: number; contentHIn: number },
): string {
if (!aspectHoverW) return `<div class="page-wide">${inner}</div>`;
const placedHIn = landscape.contentWIn * aspectHoverW;
const marginIn = Math.max(0, (landscape.contentHIn - placedHIn) / 2);
if (marginIn < 0.1) return `<div class="page-wide">${inner}</div>`;
return `<div class="page-wide" style="margin-top: ${marginIn.toFixed(2)}in">${inner}</div>`;
}
interface PromotionDecision {
promote: boolean;
reason: string;
}
function decideImagePromotion(tag: string, widthThresholdPx: number): PromotionDecision {
const page = attrValue(tag, "data-gstack-page");
if (page === "portrait") return { promote: false, reason: "page=portrait veto" };
if (page === "landscape") return { promote: true, reason: "page=landscape directive" };
const w = num(attrValue(tag, "data-gstack-px-width"));
const h = num(attrValue(tag, "data-gstack-px-height"));
if (!w || !h) return { promote: false, reason: "no intrinsic dimensions" };
if (w / h < MIN_ASPECT) return { promote: false, reason: "aspect below floor" };
if (w <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" };
const alt = (attrValue(tag, "alt") ?? "").toLowerCase();
const hinted = ALT_HINT_TOKENS.some((t) => new RegExp(`\\b${t}\\b`).test(alt));
if (!hinted) return { promote: false, reason: "no diagram hint in alt text" };
return { promote: true, reason: `wide diagram-like image (${Math.round(w)}px, alt hint)` };
}
function decideDiagramPromotion(figure: string, widthThresholdPx: number): PromotionDecision {
const page = attrValue(figure, "data-gstack-page");
if (page === "portrait") return { promote: false, reason: "page=portrait veto" };
if (page === "landscape") return { promote: true, reason: "page=landscape fence directive" };
const dims = svgCssDims(figure);
if (!dims) return { promote: false, reason: "no measurable SVG dimensions" };
if (dims.width / dims.height < MIN_ASPECT) return { promote: false, reason: "aspect below floor" };
if (dims.width <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" };
return { promote: true, reason: `wide diagram (${Math.round(dims.width)}px)` };
}
/** SVG dimension probing is shared with the byte prober — see image-size.ts. */
const svgCssDims = svgTagDims;
function attrValue(tag: string, name: string): string | null {
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i"))
?? tag.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`, "i"));
return m ? m[1] : null;
}
function num(s: string | null): number | null {
if (s === null) return null;
const n = parseFloat(s);
return Number.isFinite(n) && n > 0 ? n : null;
}
function mergeStyle(tag: string, css: string): string {
const existing = attrValue(tag, "style");
if (existing !== null) {
// Function replacement (no $-pattern expansion from user-controlled style
// values) and the existing declarations are preserved verbatim — attrValue
// already returned the unquoted inner value.
return tag.replace(/\bstyle\s*=\s*(".*?"|'.*?')/i, () => `style="${existing}; ${css}"`);
}
return tag.replace(/^<img\b/i, () => `<img style="${css}"`);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Intrinsic image dimensions from raw bytes. Pure, no DOM, no deps.
*
* The diagram pre-pass probes every local image it inlines (eng-review D1:
* "dimensions are probed from the bytes") so the width policy and landscape
* detector never need a browser round-trip. Formats: PNG, JPEG, GIF, WebP
* (VP8/VP8L/VP8X), and SVG (attribute/viewBox best-effort).
*
* Returns null when the format is unrecognized or the header is truncated —
* callers treat unknown dimensions as "no policy applied", never an error.
*/
export interface ImageDims {
width: number;
height: number;
mime: string;
}
export function imageDims(buf: Buffer): ImageDims | null {
if (buf.length < 12) return null;
return pngDims(buf) ?? jpegDims(buf) ?? gifDims(buf) ?? webpDims(buf) ?? svgDims(buf);
}
function pngDims(b: Buffer): ImageDims | null {
// 8-byte signature, then IHDR chunk: length(4) "IHDR"(4) width(4) height(4)
if (b.length < 24) return null;
if (b.readUInt32BE(0) !== 0x89504e47 || b.readUInt32BE(4) !== 0x0d0a1a0a) return null;
if (b.toString("ascii", 12, 16) !== "IHDR") return null;
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20), mime: "image/png" };
}
function jpegDims(b: Buffer): ImageDims | null {
if (b[0] !== 0xff || b[1] !== 0xd8) return null;
let i = 2;
while (i + 9 < b.length) {
if (b[i] !== 0xff) { i++; continue; }
const marker = b[i + 1];
// Standalone markers without length payload
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { i += 2; continue; }
const len = b.readUInt16BE(i + 2);
if (len < 2) return null;
// SOF0-SOF15 except DHT(C4)/JPGA(C8)/DAC(CC) carry dimensions
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
if (i + 9 >= b.length) return null;
return { height: b.readUInt16BE(i + 5), width: b.readUInt16BE(i + 7), mime: "image/jpeg" };
}
i += 2 + len;
}
return null;
}
function gifDims(b: Buffer): ImageDims | null {
const sig = b.toString("ascii", 0, 6);
if (sig !== "GIF87a" && sig !== "GIF89a") return null;
return { width: b.readUInt16LE(6), height: b.readUInt16LE(8), mime: "image/gif" };
}
function webpDims(b: Buffer): ImageDims | null {
if (b.toString("ascii", 0, 4) !== "RIFF" || b.toString("ascii", 8, 12) !== "WEBP") return null;
const fmt = b.toString("ascii", 12, 16);
if (fmt === "VP8X" && b.length >= 30) {
// 24-bit little-endian width-1 / height-1 at offsets 24 / 27
const w = 1 + (b[24] | (b[25] << 8) | (b[26] << 16));
const h = 1 + (b[27] | (b[28] << 8) | (b[29] << 16));
return { width: w, height: h, mime: "image/webp" };
}
if (fmt === "VP8 " && b.length >= 30) {
// Lossy: dimensions at offset 26, 14 bits each, little-endian
return {
width: b.readUInt16LE(26) & 0x3fff,
height: b.readUInt16LE(28) & 0x3fff,
mime: "image/webp",
};
}
if (fmt === "VP8L" && b.length >= 25) {
if (b[20] !== 0x2f) return null;
const bits = b.readUInt32LE(21);
return {
width: (bits & 0x3fff) + 1,
height: ((bits >> 14) & 0x3fff) + 1,
mime: "image/webp",
};
}
return null;
}
/**
* SVG: parse width/height attributes (px or unitless) off the root element,
* falling back to viewBox. CSS-unit widths (em, %, pt) are ignored — the
* width policy treats them as "no intrinsic size".
*/
function svgDims(b: Buffer): ImageDims | null {
const head = b.toString("utf8", 0, Math.min(b.length, 4096));
const dims = svgTagDims(head);
return dims ? { ...dims, mime: "image/svg+xml" } : null;
}
/**
* CSS-px dimensions of the first <svg> element in a markup string: explicit
* width/height attributes (px or unitless) first, else viewBox. Shared by the
* byte prober above and image-policy's diagram-figure measurements — one
* regex, no drift.
*/
export function svgTagDims(markup: string): { width: number; height: number } | null {
const tag = markup.match(/<svg\b[^>]*>/i)?.[0];
if (!tag) return null;
const attr = (name: string): number | null => {
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']\\s*([0-9.]+)(px)?\\s*["']`, "i"));
return m ? parseFloat(m[1]) : null;
};
const w = attr("width");
const h = attr("height");
if (w && h) return { width: w, height: h };
const vb = tag.match(/\bviewBox\s*=\s*["']\s*[-0-9.]+[\s,]+[-0-9.]+[\s,]+([0-9.]+)[\s,]+([0-9.]+)\s*["']/i);
if (vb) return { width: parseFloat(vb[1]), height: parseFloat(vb[2]) };
return null;
}
+399
View File
@@ -0,0 +1,399 @@
/**
* 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 { screenCss } from "./print-css";
import type { GenerateOptions, PreviewOptions } from "./types";
import { ExitCode } from "./types";
import * as browseClient from "./browseClient";
import {
RenderTab,
contentWidthInches,
convertDiagnosticsForDocx,
extractDiagramFences,
inlineLocalImages,
landscapeContentBox,
rasterizeDiagramFigures,
renderFenceSlots,
substituteSlots,
} from "./diagram-prepass";
import { applyImagePolicy } from "./image-policy";
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(os.tmpdir(), `${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 after the tab opens 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 in a dedicated bundle tab, substitute
// slots, then inline + probe + (if oversized) downscale local images.
// The bundle tab is lazy: image-only documents open it only when a raster
// actually needs print-resolution downscaling (eng-review D4).
const warn = (msg: string) => {
if (!opts.quiet) process.stderr.write(`\r\x1b[K[make-pdf] warning: ${msg}\n`);
};
let renderTab: RenderTab | null = null;
let hasLandscape = false;
const getRenderTab = (): RenderTab | null => {
if (renderTab) return renderTab;
try {
renderTab = RenderTab.open();
} catch (err: any) {
warn(`diagram-render tab unavailable: ${String(err?.message ?? err).split("\n")[0]}`);
return null;
}
return renderTab;
};
let finalHtml = rendered.html;
try {
if (extraction.fences.length > 0) {
progress.begin(`Rendering ${extraction.fences.length} diagram(s)`);
const tab = getRenderTab();
if (tab) {
const slots = renderFenceSlots(extraction.fences, tab, warn);
finalHtml = substituteSlots(finalHtml, slots);
} else {
// No bundle/tab: visible diagnostic beats silent raw tokens.
const slots = new Map(
extraction.fences.map((f) => [
f.token,
`<figure class="diagram diagram-error" role="img" aria-label="diagram ${f.ordinal} (not rendered)">` +
`<figcaption class="diagram-error-title">Diagram not rendered (${f.lang}) — diagram-render bundle unavailable</figcaption></figure>`,
]),
);
finalHtml = substituteSlots(finalHtml, slots);
}
progress.end(`Rendering ${extraction.fences.length} diagram(s)`);
}
progress.begin("Inlining images");
const contentWidthIn = contentWidthInches(opts);
finalHtml = inlineLocalImages(finalHtml, {
inputDir: path.dirname(input),
strict: opts.strict === true,
allowNetwork: opts.allowNetwork === true,
contentWidthIn,
warn,
getTab: getRenderTab,
});
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) —
// do it while the render tab is still open.
if (to === "docx") {
const needsRaster = /<figure class="diagram"|data:image\/svg\+xml/.test(finalHtml);
if (needsRaster) {
progress.begin("Rasterizing diagrams for DOCX");
const tab = getRenderTab();
if (tab) {
finalHtml = rasterizeDiagramFigures(finalHtml, tab, contentWidthIn, warn);
} else {
warn("docx: no render tab — diagrams keep their source text form");
}
progress.end("Rasterizing diagrams for DOCX");
}
finalHtml = convertDiagnosticsForDocx(finalHtml);
}
} finally {
renderTab?.close();
}
// ─── --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: 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, finalHtml, "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: finalHtml,
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,
// 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,
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");
// Preview deliberately skips the diagram/image pre-pass (no browse daemon
// 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(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 };
+312
View File
@@ -0,0 +1,312 @@
/**
* 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 (v1.24-aligned):
* 1. $GSTACK_PDFTOTEXT_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern)
* 2. $PDFTOTEXT_BIN env override (back-compat alias)
* 3. PATH lookup via Bun.which('pdftotext') — handles Windows PATHEXT natively
* 4. standard POSIX paths (Homebrew + distro) — no Windows candidates because
* Poppler scatters across Scoop / Chocolatey / oschwartz10612-poppler-windows
* and guessing causes false positives. Set GSTACK_PDFTOTEXT_BIN explicitly.
* 5. 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";
}
/**
* Probe a base path for executability, honoring Windows extension suffixes.
* Matches browseClient.ts:findExecutable — duplicated rather than shared
* because the two modules already duplicate isExecutable for compile-isolation.
*/
export function findExecutable(base: string): string | null {
if (isExecutable(base)) return base;
if (process.platform === "win32") {
for (const ext of [".exe", ".cmd", ".bat"]) {
const withExt = base + ext;
if (isExecutable(withExt)) return withExt;
}
}
return null;
}
function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null {
if (!value?.trim()) return null;
const trimmed = value.trim().replace(/^"(.*)"$/, '$1');
if (path.isAbsolute(trimmed)) return findExecutable(trimmed);
const PATH = env.PATH ?? env.Path ?? '';
return Bun.which(trimmed, { PATH }) ?? null;
}
/**
* Locate pdftotext. Throws PdftotextUnavailableError if none is found.
*/
export function resolvePdftotext(env: NodeJS.ProcessEnv = process.env): PdftotextInfo {
// 1 + 2: env overrides (GSTACK_PDFTOTEXT_BIN preferred, PDFTOTEXT_BIN back-compat).
const overrideRaw = env.GSTACK_PDFTOTEXT_BIN ?? env.PDFTOTEXT_BIN;
const override = resolveOverride(overrideRaw, env);
if (override) return describeBinary(override);
// 3: PATH lookup via Bun.which — handles Windows PATHEXT natively.
const PATH = env.PATH ?? env.Path ?? '';
const onPath = Bun.which('pdftotext', { PATH });
if (onPath) return describeBinary(onPath);
// 4: POSIX-only standard locations. No Windows candidates — Poppler installs
// scatter across Scoop/Chocolatey/portable zips and guessing causes false
// positives. Windows users set GSTACK_PDFTOTEXT_BIN explicitly.
const posixCandidates = [
"/opt/homebrew/bin/pdftotext", // Apple Silicon Homebrew
"/usr/local/bin/pdftotext", // Intel Mac or Linuxbrew
"/usr/bin/pdftotext", // distro package
];
for (const candidate of posixCandidates) {
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",
" Windows: scoop install poppler (or download from",
" https://github.com/oschwartz10612/poppler-windows)",
"",
"Or set GSTACK_PDFTOTEXT_BIN to an explicit path:",
process.platform === "win32"
? ' setx GSTACK_PDFTOTEXT_BIN "C:\\path\\to\\pdftotext.exe"'
: " export GSTACK_PDFTOTEXT_BIN=/path/to/pdftotext",
].join("\n"));
}
/**
* Locate a poppler companion tool (pdffonts, pdfimages, pdftoppm) used by the
* emoji render gate. Mirrors resolvePdftotext's resolution order:
* 1. $GSTACK_<TOOL>_BIN env override (e.g. GSTACK_PDFFONTS_BIN)
* 2. PATH via Bun.which
* 3. standard POSIX locations (Homebrew + distro)
*
* Returns null (does NOT throw) when the tool is missing — the emoji gate skips
* cleanly rather than failing on a box without full poppler-utils.
*/
export function resolvePopplerTool(
tool: "pdffonts" | "pdfimages" | "pdftoppm",
env: NodeJS.ProcessEnv = process.env,
): string | null {
const override = resolveOverride(env[`GSTACK_${tool.toUpperCase()}_BIN`], env);
if (override) return override;
const PATH = env.PATH ?? env.Path ?? "";
const onPath = Bun.which(tool, { PATH });
if (onPath) return onPath;
for (const dir of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) {
const candidate = findExecutable(path.join(dir, tool));
if (candidate) return candidate;
}
return null;
}
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`);
}
}
+452
View File
@@ -0,0 +1,452 @@
/**
* Print stylesheet generator.
*
* Source of truth: .context/designs/make-pdf-print-reference.html and siblings.
* Mirror those CSS rules here. The HTML references were approved via
* /plan-design-review with explicit design decisions locked in the plan:
*
* - Helvetica first, with Liberation Sans as a metric-compatible Linux
* fallback (Helvetica and Arial aren't installed on most Linux distros;
* Liberation Sans ships via the fonts-liberation package and Playwright's
* install-deps). No bundled webfonts — dodges the per-glyph Tj bug that
* breaks copy-paste extraction.
* - All paragraphs flush-left. No first-line indent, no justify, no
* p+p indent. text-align: left everywhere. 12pt margin-bottom.
* - Cover page (v1.58.0.0 poster revision, user-directed): 56pt title,
* 13pt meta, padding-top 1.4in for poster placement. Still no flexbox
* and no vertical centering; the inset is a deliberate top-third drop.
* (Supersedes the original "no inset padding" lock from the first
* /plan-design-review — the 32pt cover read as too small in print.)
* - `@page :first` suppresses running header/footer but does NOT override
* the 1in margin.
* - No <link>, no external CSS/fonts — everything inlined.
* - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
* ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
* - Emoji fallback: the body and @top-center running-header stacks end in an
* emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color
* Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph
* source for emoji code points instead of emitting .notdef tofu (▯). The
* @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL"
* string, so they get no emoji families. On Linux this requires an
* installed color-emoji font — `setup` installs fonts-noto-color-emoji.
*
* Font stacks are composed from the constants below so each family list has a
* single source of truth (DRY) and every stack stays in sync.
*/
// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux,
// ships via fonts-liberation), Arial (Windows). Shared by every text surface.
const SANS_STACK = `Helvetica, "Liberation Sans", Arial`;
// CJK fallback families, appended to the body stack only.
const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`;
// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux).
const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`;
export interface PrintCssOptions {
// Document structure
cover?: boolean;
toc?: boolean;
noChapterBreaks?: boolean;
// Branding
watermark?: string;
confidential?: boolean;
// Header (running title, top of page)
runningHeader?: string;
// Page size (in CSS `@page size:` terms)
pageSize?: "letter" | "a4" | "legal" | "tabloid";
// Margins (default 1in)
margins?: string;
// Whether to render "N of M" page numbers in the @page @bottom-center rule.
// Default true. Set false to suppress CSS numbering (used when the caller
// supplies a custom Chromium footerTemplate, or when --no-page-numbers).
pageNumbers?: boolean;
}
/**
* Produce a CSS block (no <style> wrapper) for inline injection.
*/
export function printCss(opts: PrintCssOptions = {}): string {
const size = opts.pageSize ?? "letter";
const margin = opts.margins ?? "1in";
const hasWatermark = typeof opts.watermark === "string" && opts.watermark.length > 0;
return [
pageRules(size, margin, opts),
rootTypography(),
coverRules(opts.cover === true),
tocRules(opts.toc === true),
chapterRules(opts.noChapterBreaks === true),
blockRules(),
inlineRules(),
codeRules(),
quoteRules(),
figureRules(),
tableRules(),
listRules(),
footnoteRules(),
hasWatermark ? watermarkRules() : "",
breakAvoidRules(),
].filter(Boolean).join("\n\n");
}
function pageRules(size: string, margin: string, opts: PrintCssOptions): string {
const runningHeader = escapeCssString(opts.runningHeader ?? "");
const showConfidential = opts.confidential !== false;
const showPageNumbers = opts.pageNumbers !== false;
return [
`@page {`,
` size: ${size};`,
` margin: ${margin};`,
runningHeader
? ` @top-center { content: "${runningHeader}"; font-family: ${SANS_STACK}, ${EMOJI_FAMILIES}, sans-serif; font-size: 9pt; color: #666; }`
: ``,
showPageNumbers
? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: ${SANS_STACK}, sans-serif; font-size: 9pt; color: #666; }`
: ``,
showConfidential
? ` @bottom-right { content: "CONFIDENTIAL"; font-family: ${SANS_STACK}, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
: ``,
`}`,
``,
// Cover page: suppress running header/footer but keep margins.
`@page :first {`,
` @top-center { content: none; }`,
` @bottom-center { content: none; }`,
` @bottom-right { content: none; }`,
`}`,
``,
// Landscape named page for promoted wide diagrams/images (image-policy).
// Chromium-only — exactly the engine this pipeline always prints with.
// Honored only when the print call passes preferCSSPageSize (orchestrator
// sets it when a promotion exists). Vertical centering is NOT done here —
// image-policy emits a computed inline margin-top instead (see the
// .page-wide comment below for why).
`@page wide {`,
` size: ${size} landscape;`,
` margin: ${margin};`,
`}`,
// No explicit break-before/after (the page-name CHANGE already forces a
// break on both sides) and NO height/flex centering: a flex .page-wide
// with min-height fragments into a phantom empty landscape page in
// Chromium (landscape-gate counted 5 pages for 3 promotions; bisected to
// min-height at any value). Vertical centering is done by image-policy
// instead — it knows each promoted block's aspect ratio and emits an
// inline margin-top, which fragmentation handles fine.
`.page-wide {`,
` page: wide;`,
` text-align: center;`,
`}`,
// width: 100% stretch is intentional for promoted content: auto-promoted
// rasters are >=~1600px (≈190dpi at the 9in landscape box — prints fine),
// and a directive-forced small image is the user's explicit call.
`.page-wide img, .page-wide svg { width: 100%; height: auto; max-width: none; }`,
`.page-wide figure.diagram > svg { max-width: none; }`,
].filter(line => line !== "").join("\n");
}
/**
* Screen layer appended for `--to html` exports. The print CSS stays the
* source of truth; this only makes the same document readable in a browser
* (centered measure, padding, no print-only chapter breaks forcing scroll
* gaps). Print output is unaffected — media-scoped.
*/
export function screenCss(): string {
return [
`@media screen {`,
// ~42em at 12pt ≈ 70-75 characters per line — the readable ceiling.
` body { max-width: 42em; margin: 0 auto; padding: 2.5em 1.5em; }`,
` .chapter { break-before: auto; }`,
` .watermark { display: none; }`,
` figure.diagram { overflow-x: auto; }`,
// Page numbers only exist in print; hide the empty spans + dot leaders.
` .toc li .toc-page, .toc li .toc-dots { display: none; }`,
`}`,
].join("\n");
}
function rootTypography(): string {
return [
`html { lang: en; }`,
// Zero image truncation, ever: every image caps at the content box,
// whatever element it lives in. Markdown images render as <p><img> (no
// figure), so a figure-scoped cap alone lets a 1900px screenshot run off
// the page edge. .page-wide deliberately overrides to fill its landscape
// box — still bounded, never clipped.
`img { max-width: 100%; height: auto; }`,
`body {`,
` font-family: ${SANS_STACK}, ${CJK_STACK}, ${EMOJI_FAMILIES}, sans-serif;`,
` font-size: 12pt;`,
` line-height: 1.5;`,
` color: #111;`,
` background: white;`,
// No auto-hyphenation: it puts real "dif-\nferent" breaks into the PDF
// text layer, and clean copy-paste is the product contract (the
// combined-gate caught this the moment 12pt body made lines wrap).
// Left-aligned rag doesn't need hyphenation.
` hyphens: manual;`,
` font-variant-ligatures: common-ligatures;`,
` font-kerning: normal;`,
` text-rendering: geometricPrecision;`,
` margin: 0;`,
` padding: 0;`,
`}`,
].join("\n");
}
function coverRules(enabled: boolean): string {
if (!enabled) return "";
return [
// Poster scale: the cover is the one page where type should feel huge.
`.cover {`,
` page: first;`,
` page-break-after: always;`,
` break-after: page;`,
` text-align: left;`,
` padding-top: 1.4in;`,
`}`,
`.cover .eyebrow {`,
` font-size: 11pt;`,
` letter-spacing: 0.2em;`,
` text-transform: uppercase;`,
` color: #666;`,
` margin: 0 0 36pt;`,
`}`,
`.cover h1.cover-title {`,
` font-size: 56pt;`,
` line-height: 1.08;`,
` font-weight: 700;`,
` letter-spacing: -0.02em;`,
` margin: 0 0 24pt;`,
` max-width: 6in;`,
` text-align: left;`,
`}`,
`.cover .cover-subtitle {`,
` font-size: 18pt;`,
` line-height: 1.35;`,
` font-weight: 400;`,
` color: #333;`,
` margin: 0 0 36pt;`,
` max-width: 5.5in;`,
` text-align: left;`,
`}`,
`.cover hr.rule {`,
` width: 2.5in;`,
` height: 0;`,
` border: 0;`,
` border-top: 1.5px solid #111;`,
` margin: 0 0 24pt 0;`,
`}`,
`.cover .cover-meta { font-size: 13pt; line-height: 1.6; color: #333; text-align: left; }`,
`.cover .cover-meta strong { font-weight: 700; }`,
].join("\n");
}
function tocRules(enabled: boolean): string {
if (!enabled) return "";
return [
`.toc { page-break-after: always; break-after: page; }`,
`.toc h2 {`,
` font-size: 16pt;`,
` text-transform: uppercase;`,
` letter-spacing: 0.15em;`,
` color: #444;`,
` font-weight: 700;`,
` margin: 0 0 0.4in;`,
`}`,
`.toc ol {`,
` list-style: none;`,
` padding: 0;`,
` margin: 0;`,
`}`,
`.toc li {`,
` display: flex;`,
` align-items: baseline;`,
` gap: 0.25in;`,
` font-size: 12pt;`,
` line-height: 1.7;`,
` padding: 3pt 0;`,
`}`,
`.toc li .toc-title { flex: 0 0 auto; }`,
`.toc li .toc-dots { flex: 1 1 auto; border-bottom: 1px dotted #aaa; margin: 0 6pt; transform: translateY(-4pt); }`,
`.toc li .toc-page { flex: 0 0 auto; color: #666; font-variant-numeric: tabular-nums; }`,
`.toc li.level-2 { padding-left: 0.35in; font-size: 11pt; }`,
`.toc li a { color: inherit; text-decoration: none; }`,
].join("\n");
}
function chapterRules(noChapterBreaks: boolean): string {
const breakRule = noChapterBreaks
? `/* chapter breaks disabled */`
: [
`.chapter { break-before: page; page-break-before: always; }`,
`.chapter:first-of-type { break-before: auto; page-break-before: auto; }`,
].join("\n");
return [
breakRule,
`h1 {`,
` font-size: 26pt;`,
` line-height: 1.2;`,
` font-weight: 700;`,
` letter-spacing: -0.01em;`,
` margin: 0 0 0.25in;`,
` break-after: avoid;`,
` page-break-after: avoid;`,
`}`,
`h2 { font-size: 18pt; line-height: 1.3; font-weight: 700; margin: 26pt 0 8pt; break-after: avoid; page-break-after: avoid; }`,
`h3 { font-size: 13.5pt; line-height: 1.4; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #333; margin: 20pt 0 5pt; break-after: avoid; page-break-after: avoid; }`,
`h4 { font-size: 12pt; font-weight: 700; margin: 14pt 0 5pt; break-after: avoid; page-break-after: avoid; }`,
].join("\n");
}
function blockRules(): string {
// Flush-left paragraphs, no indent, 12pt gap. No justify.
// Rule from the plan's "Body paragraph rule (post-review fix)".
return [
`p {`,
` margin: 0 0 12pt;`,
` text-align: left;`,
` widows: 3;`,
` orphans: 3;`,
`}`,
`p:first-child { margin-top: 0; }`,
`p.lead { font-size: 14pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
].join("\n");
}
function inlineRules(): string {
return [
`a {`,
` color: #0055cc;`,
` text-decoration: underline;`,
` text-decoration-thickness: 0.5pt;`,
` text-underline-offset: 1.5pt;`,
`}`,
`strong { font-weight: 700; }`,
`em { font-style: italic; }`,
].join("\n");
}
function codeRules(): string {
return [
`code {`,
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
` font-size: 10.5pt;`,
` background: #f4f4f4;`,
` padding: 1pt 3pt;`,
` border-radius: 2pt;`,
` border: 0.5pt solid #e4e4e4;`,
`}`,
`pre {`,
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
` font-size: 10pt;`,
` line-height: 1.4;`,
` background: #f7f7f5;`,
` padding: 10pt 12pt;`,
` border: 0.5pt solid #e0e0e0;`,
` border-radius: 3pt;`,
` margin: 12pt 0;`,
` overflow: hidden;`,
` white-space: pre-wrap;`,
`}`,
`pre code { background: none; border: 0; padding: 0; font-size: inherit; }`,
// highlight.js minimal palette (kept neutral, prints well)
`.hljs-keyword { color: #8b0000; font-weight: 500; }`,
`.hljs-string { color: #0d6608; }`,
`.hljs-comment { color: #888; font-style: italic; }`,
`.hljs-function, .hljs-title { color: #0044aa; }`,
`.hljs-number { color: #a64d00; }`,
].join("\n");
}
function quoteRules(): string {
return [
`blockquote {`,
` margin: 12pt 0;`,
` padding: 0 0 0 18pt;`,
` border-left: 2pt solid #111;`,
` color: #333;`,
` font-size: 12pt;`,
` line-height: 1.5;`,
`}`,
`blockquote p { margin-bottom: 6pt; text-align: left; }`,
`blockquote cite { display: block; margin-top: 6pt; font-style: normal; font-size: 10pt; color: #666; letter-spacing: 0.02em; }`,
`blockquote cite::before { content: "— "; }`,
].join("\n");
}
function figureRules(): string {
return [
`figure { margin: 12pt 0; }`,
`figure img { display: block; max-width: 100%; height: auto; }`,
`figcaption { font-size: 10pt; color: #666; margin-top: 6pt; font-style: italic; }`,
// Diagram figures (diagram-prepass): rendered mermaid/excalidraw SVG.
// SVGs scale to the content box and never split across pages.
`figure.diagram { break-inside: avoid; text-align: center; }`,
`figure.diagram > svg { max-width: 100%; height: auto; }`,
`figure.diagram .diagram-caption { text-align: center; }`,
// Diagnostic block for a fence that failed to render — loud, boxed,
// unmistakably an error (never silent raw code).
`figure.diagram-error { border: 1.5pt solid #b00020; padding: 8pt 10pt; text-align: left; }`,
`figure.diagram-error .diagram-error-title { font-weight: 700; color: #b00020; font-style: normal; margin: 0 0 6pt; }`,
`figure.diagram-error .diagram-error-detail { font-size: 8.5pt; white-space: pre-wrap; margin: 0; }`,
// Missing local image placeholder (non-strict mode).
`.image-missing { display: inline-block; border: 1pt dashed #b00020; color: #b00020; padding: 4pt 8pt; font-size: 9pt; }`,
].join("\n");
}
function tableRules(): string {
return [
`table { width: 100%; border-collapse: collapse; margin: 12pt 0; font-size: 11pt; }`,
`th, td { border-bottom: 0.5pt solid #ccc; padding: 5pt 8pt; text-align: left; vertical-align: top; }`,
`th { font-weight: 700; border-bottom: 1pt solid #111; background: transparent; }`,
].join("\n");
}
function listRules(): string {
return [
`ul, ol { margin: 0 0 12pt 0; padding-left: 20pt; }`,
`li { margin-bottom: 3pt; line-height: 1.45; }`,
`li > ul, li > ol { margin-top: 3pt; margin-bottom: 0; }`,
].join("\n");
}
function footnoteRules(): string {
return [
`.footnote-ref { font-size: 0.75em; vertical-align: super; line-height: 0; text-decoration: none; color: #0055cc; }`,
`.footnotes { margin-top: 24pt; padding-top: 12pt; border-top: 0.5pt solid #ccc; font-size: 10pt; line-height: 1.4; }`,
`.footnotes ol { padding-left: 18pt; }`,
].join("\n");
}
function watermarkRules(): string {
return [
`.watermark {`,
` position: fixed;`,
` top: 50%;`,
` left: 50%;`,
` transform: translate(-50%, -50%) rotate(-30deg);`,
` font-size: 140pt;`,
` font-weight: 700;`,
` color: rgba(200, 0, 0, 0.06);`,
` letter-spacing: 0.08em;`,
` pointer-events: none;`,
` z-index: 9999;`,
` user-select: none;`,
` white-space: nowrap;`,
`}`,
].join("\n");
}
function breakAvoidRules(): string {
return `blockquote, pre, code, table, figure, li, .keep-together { break-inside: avoid; page-break-inside: avoid; }`;
}
function escapeCssString(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
}
+438
View File
@@ -0,0 +1,438 @@
/**
* Markdown → HTML renderer. Pure function, no I/O, no Playwright.
*
* Pipeline:
* 1. marked parses markdown → HTML
* 2. Sanitize: strip <script>, <iframe>, <object>, <embed>, <link>,
* <meta>, <base>, <form>, and all on* event handlers + javascript:
* URLs. (Codex round 2 #9: untrusted markdown can embed raw HTML.)
* 3. Smartypants transform (code/URL-safe).
* 4. Assemble full HTML document with print CSS inlined and
* semantic structure (cover, TOC placeholder, body).
*/
import { marked } from "marked";
import { smartypants } from "./smartypants";
import { printCss, type PrintCssOptions } from "./print-css";
import { applyImageDirectives } from "./image-policy";
export interface RenderOptions {
markdown: string;
// Document-level metadata (used for cover, PDF metadata, running header).
title?: string;
author?: string;
date?: string; // ISO or human string
subtitle?: string;
// Features
cover?: boolean;
toc?: boolean;
watermark?: string;
noChapterBreaks?: boolean;
confidential?: boolean; // default: true
// Page layout
pageSize?: "letter" | "a4" | "legal" | "tabloid";
margins?: string;
// Per-side margins (override `margins`). Must reach the CSS @page rule:
// when a landscape promotion flips preferCSSPageSize on, the CSS margins
// are the ones Chromium honors — dropping per-side flags there would
// silently change the whole document's layout (Codex P2).
marginTop?: string;
marginRight?: string;
marginBottom?: string;
marginLeft?: string;
// Footer behavior. pageNumbers defaults to true. When footerTemplate is set,
// CSS page numbers are suppressed so the custom Chromium footer wins cleanly.
pageNumbers?: boolean;
footerTemplate?: string;
}
export interface RenderResult {
html: string; // full HTML document, ready for $B load-html
printCss: string; // for debugging / preview
bodyHtml: string; // just the rendered body (tests, snapshots)
meta: {
title: string;
author: string;
date: string;
wordCount: number;
};
}
/**
* Pure renderer. No side effects.
*/
export function render(opts: RenderOptions): RenderResult {
// 1. Markdown → HTML
const rawHtml = marked.parse(opts.markdown, { async: false }) as string;
// 1.5. Image directive suffixes: `![a](x.png){width=50%}` → data-gstack-*
// attributes. Before the sanitizer (which keeps data- attrs) so the brace
// text never reaches smartypants or the final page.
const directedHtml = applyImageDirectives(rawHtml);
// 2. Sanitize
const cleanHtml = sanitizeUntrustedHtml(directedHtml);
// 3. Decode common entities so smartypants can match raw " and '.
// marked HTML-encodes quotes in text ("hello" → &quot;hello&quot;);
// without decoding, smartypants' regex never fires. These get re-encoded
// implicitly by the browser's HTML parser downstream, and for the ones
// that should stay as curly-quote Unicode, that IS the final form.
const decoded = decodeTypographicEntities(cleanHtml);
// 4. Smartypants (code-safe)
const typographicHtml = smartypants(decoded);
// 4. Derive metadata (title from first H1 if not provided)
const derivedTitle = opts.title ?? extractFirstHeading(typographicHtml) ?? "Document";
const derivedAuthor = opts.author ?? "";
const derivedDate = opts.date ?? formatToday();
// 5. Build CSS
// CSS is the single source of truth for page numbers (Chromium native
// numbering is always off in orchestrator). If the caller supplied a custom
// footerTemplate, suppress CSS page numbers too so their footer wins.
const showPageNumbers = opts.pageNumbers !== false && !opts.footerTemplate;
const cssOptions: PrintCssOptions = {
cover: opts.cover,
toc: opts.toc,
noChapterBreaks: opts.noChapterBreaks,
watermark: opts.watermark,
confidential: opts.confidential !== false,
runningHeader: derivedTitle,
pageSize: opts.pageSize,
// Compose per-side margins into the CSS shorthand so @page stays the
// single source of truth even under preferCSSPageSize.
margins: composeMargins(opts),
pageNumbers: showPageNumbers,
};
const css = printCss(cssOptions);
// 6. Assemble document
const coverBlock = opts.cover
? buildCoverBlock({
title: derivedTitle,
subtitle: opts.subtitle,
author: derivedAuthor,
date: derivedDate,
})
: "";
// TOC anchors must resolve: assign id="toc-N" to each H1-H3 in the same
// order buildTocBlock scans them, or every TOC link is a dead href (masked
// in PDFs by Chromium outline bookmarks, glaring in --to html). Headings
// that already carry an id keep it — the ids array records the ACTUAL id
// per heading so TOC entries always link to something real.
const anchored = opts.toc ? addHeadingIds(typographicHtml) : { html: typographicHtml, ids: [] };
const anchoredHtml = anchored.html;
const tocBlock = opts.toc
? buildTocBlock(anchoredHtml, anchored.ids)
: "";
// Wrap body in .chapter sections at H1 boundaries if chapter breaks are on.
const chapterHtml = opts.noChapterBreaks
? `<section class="chapter">${anchoredHtml}</section>`
: wrapChaptersByH1(anchoredHtml);
const watermarkBlock = opts.watermark
? `<div class="watermark">${escapeHtml(opts.watermark)}</div>`
: "";
const fullHtml = [
`<!doctype html>`,
`<html lang="en">`,
`<head>`,
`<meta charset="utf-8">`,
`<title>${escapeHtml(derivedTitle)}</title>`,
derivedAuthor ? `<meta name="author" content="${escapeHtml(derivedAuthor)}">` : ``,
`<style>`,
css,
`</style>`,
`</head>`,
`<body>`,
watermarkBlock,
coverBlock,
tocBlock,
chapterHtml,
`</body>`,
`</html>`,
].filter(Boolean).join("\n");
return {
html: fullHtml,
printCss: css,
bodyHtml: typographicHtml,
meta: {
title: derivedTitle,
author: derivedAuthor,
date: derivedDate,
wordCount: countWords(stripTags(typographicHtml)),
},
};
}
/**
* Decode the HTML entities that marked emits for text-node quotes/apostrophes.
* Only the four that matter for smartypants — leaves &amp; alone because it
* can be legitimately doubled (&amp;amp;) and we don't want to double-decode.
*/
function decodeTypographicEntities(html: string): string {
return html
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#x27;/g, "'");
}
// ─── Sanitizer ────────────────────────────────────────────────────────
/**
* Strip dangerous HTML from markdown-produced output.
*
* We can't use DOMPurify (server-side; adds a jsdom dep). A conservative
* regex sanitizer is fine for this use case because:
* 1. marked produces structured HTML (never malformed)
* 2. we only need to strip a fixed blacklist of elements + attrs
* 3. the output goes through Chromium's parser again, which normalizes
*
* What's stripped:
* - <script>, <iframe>, <object>, <embed>, <link>, <meta>, <base>, <form>
* (and their content).
* - on* event handler attributes (onclick, ONCLICK, etc.).
* - href/src with javascript: scheme.
* - <svg> tags with <script> inside them.
*/
export function sanitizeUntrustedHtml(html: string): string {
let s = html;
// Elements to remove entirely (including content).
const DANGER_TAGS = [
"script", "iframe", "object", "embed", "link", "meta", "base", "form",
"applet", "frame", "frameset",
];
for (const tag of DANGER_TAGS) {
const re = new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}>`, "gi");
s = s.replace(re, "");
// Self-closing / unclosed variants
const selfRe = new RegExp(`<${tag}\\b[^>]*/?>`, "gi");
s = s.replace(selfRe, "");
}
// SVG <script>
s = s.replace(/<svg([^>]*)>([\s\S]*?)<\/svg>/gi, (_, attrs, body) => {
return `<svg${attrs}>${body.replace(/<script\b[\s\S]*?<\/script>/gi, "")}</svg>`;
});
// Event handler attributes (on* in any case).
s = s.replace(/\s+on[a-zA-Z]+\s*=\s*"[^"]*"/gi, "");
s = s.replace(/\s+on[a-zA-Z]+\s*=\s*'[^']*'/gi, "");
s = s.replace(/\s+on[a-zA-Z]+\s*=\s*[^\s>]+/gi, "");
// javascript: URLs in href/src/action/formaction
s = s.replace(
/(\s(?:href|src|action|formaction|xlink:href)\s*=\s*)(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/gi,
'$1"#"',
);
// srcdoc attribute (iframe escape hatch — already stripped via iframe above,
// but defense-in-depth).
s = s.replace(/\s+srcdoc\s*=\s*"[^"]*"/gi, "");
s = s.replace(/\s+srcdoc\s*=\s*'[^']*'/gi, "");
// style="url(javascript:..)" — strip javascript: inside style attrs.
s = s.replace(/url\(\s*javascript:[^)]*\)/gi, "url(#)");
return s;
}
// ─── Cover / TOC / Chapter helpers ────────────────────────────────────
function buildCoverBlock(opts: {
title: string;
subtitle?: string;
author?: string;
date: string;
}): string {
const title = escapeHtml(opts.title);
const subtitle = opts.subtitle ? escapeHtml(opts.subtitle) : "";
const author = opts.author ? escapeHtml(opts.author) : "";
const date = escapeHtml(opts.date);
return [
`<section class="cover">`,
` <h1 class="cover-title">${title}</h1>`,
subtitle ? ` <p class="cover-subtitle">${subtitle}</p>` : ``,
` <hr class="rule">`,
` <div class="cover-meta">`,
author ? ` <div><strong>${author}</strong></div>` : ``,
` <div>${date}</div>`,
` </div>`,
`</section>`,
].filter(Boolean).join("\n");
}
/**
* Scan HTML for H1/H2/H3 headings and emit a TOC placeholder.
* Page numbers are filled in by Paged.js (when --toc is passed and Paged.js
* polyfill is injected).
*/
function buildTocBlock(html: string, ids: string[] = []): string {
const headings = extractHeadings(html);
if (headings.length === 0) return "";
const items = headings.map((h, i) => {
const level = h.level >= 2 ? "level-2" : "level-1";
const id = ids[i] ?? `toc-${i}`;
return [
` <li class="${level}">`,
` <span class="toc-title"><a href="#${id}">${escapeHtml(h.text)}</a></span>`,
` <span class="toc-dots"></span>`,
` <span class="toc-page" data-toc-target="${id}"></span>`,
` </li>`,
].join("\n");
}).join("\n");
return [
`<section class="toc">`,
` <h2>Contents</h2>`,
` <ol>`,
items,
` </ol>`,
`</section>`,
].join("\n");
}
/**
* Assign id="toc-N" to every H1-H3 in document order — the same order
* extractHeadings/buildTocBlock use, so anchors and entries line up by index.
* A heading that already carries an id keeps it, and the returned ids array
* records the actual id for that slot so the TOC links to the real anchor
* instead of a nonexistent toc-N.
*/
function addHeadingIds(html: string): { html: string; ids: string[] } {
const ids: string[] = [];
const out = html.replace(/<(h[1-3])([^>]*)>/gi, (full, tag: string, attrs: string) => {
const existing = attrs.match(/\bid\s*=\s*["']([^"']*)["']/i)?.[1];
if (existing) {
ids.push(existing);
return full;
}
const id = `toc-${ids.length}`;
ids.push(id);
return `<${tag}${attrs} id="${id}">`;
});
return { html: out, ids };
}
function extractHeadings(html: string): Array<{ level: number; text: string }> {
const re = /<(h[1-3])[^>]*>([\s\S]*?)<\/\1>/gi;
const headings: Array<{ level: number; text: string }> = [];
let match;
while ((match = re.exec(html)) !== null) {
const level = parseInt(match[1].slice(1), 10);
const text = decodeTextEntities(stripTags(match[2]).trim());
if (text) headings.push({ level, text });
}
return headings;
}
/**
* Wrap H1-rooted sections in <section class="chapter">. When chapter breaks
* are on (default), CSS `.chapter { break-before: page }` fires between them.
*/
function wrapChaptersByH1(html: string): string {
// Split on H1 openings. Everything before the first H1 is a preamble.
const h1Re = /<h1\b[^>]*>/gi;
const matches: number[] = [];
let m;
while ((m = h1Re.exec(html)) !== null) {
matches.push(m.index);
}
if (matches.length === 0) {
return `<section class="chapter">${html}</section>`;
}
const chunks: string[] = [];
const preamble = html.slice(0, matches[0]);
if (preamble.trim().length > 0) {
chunks.push(`<section class="chapter">${preamble}</section>`);
}
for (let i = 0; i < matches.length; i++) {
const start = matches[i];
const end = i + 1 < matches.length ? matches[i + 1] : html.length;
chunks.push(`<section class="chapter">${html.slice(start, end)}</section>`);
}
return chunks.join("\n");
}
function extractFirstHeading(html: string): string | null {
const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;
}
/**
* Decode HTML entities in plain text extracted from rendered HTML. Distinct
* from decodeTypographicEntities (which runs on in-pipeline HTML and preserves
* &amp; because &amp;amp; can be legitimate there). This runs on text destined
* for <title>, cover, and TOC entries where &amp; MUST become & or escapeHtml
* produces &amp;amp;.
*
* Amp-last ordering: input "&amp;#169;" decodes to "&#169;" in the named pass,
* then the numeric pass decodes "&#169;" to "©". Decoding &amp; first would
* produce "&#169;" and the numeric pass would consume it — different end state
* but risks double-decode on inputs like "&amp;lt;".
*/
function decodeTextEntities(s: string): string {
return s
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&amp;/g, "&");
}
/** Compose `margin: top right bottom left` from per-side overrides + base. */
function composeMargins(opts: {
margins?: string; marginTop?: string; marginRight?: string;
marginBottom?: string; marginLeft?: string;
}): string | undefined {
const base = opts.margins ?? "1in";
if (!opts.marginTop && !opts.marginRight && !opts.marginBottom && !opts.marginLeft) {
return opts.margins;
}
return [
opts.marginTop ?? base,
opts.marginRight ?? base,
opts.marginBottom ?? base,
opts.marginLeft ?? base,
].join(" ");
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, "");
}
export function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function countWords(text: string): number {
return text.split(/\s+/).filter(w => w.length > 0).length;
}
function formatToday(): string {
const now = new Date();
return now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
+110
View File
@@ -0,0 +1,110 @@
/**
* `$P setup` — guided smoke test.
*
* Flow (per the CEO plan CLI UX spec):
* 1. Verify browse binary exists and responds
* 2. Verify Chromium launches via $B goto about:blank
* 3. Verify pdftotext is installed (warn, don't fail)
* 4. Generate a smoke-test PDF from an inline 2-paragraph fixture
* 5. Open it
* 6. Print a 3-command cheatsheet
*/
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
import * as browseClient from "./browseClient";
import { resolvePdftotext, PdftotextUnavailableError } from "./pdftotext";
import { generate } from "./orchestrator";
export async function runSetup(): Promise<void> {
process.stderr.write("make-pdf setup — verifying install\n\n");
// 1. Resolve browse binary
process.stderr.write(" [1/5] Checking browse binary...");
try {
const bin = browseClient.resolveBrowseBin();
process.stderr.write(` OK (${bin})\n`);
} catch (err: any) {
process.stderr.write(" FAIL\n");
process.stderr.write(`\n${err.message}\n`);
process.exit(4);
}
// 2. Chromium smoke (navigate a dedicated tab to about:blank)
process.stderr.write(" [2/5] Launching Chromium...");
let chromiumTab: number | null = null;
try {
chromiumTab = browseClient.newtab("about:blank");
process.stderr.write(` OK (tab ${chromiumTab})\n`);
} catch (err: any) {
process.stderr.write(" FAIL\n");
process.stderr.write(`\nChromium failed to launch: ${err.message}\n`);
process.stderr.write("\nTo fix: run gstack setup from the gstack repo:\n");
process.stderr.write(" cd ~/.claude/skills/gstack && ./setup\n");
process.exit(4);
} finally {
if (chromiumTab !== null) {
try { browseClient.closetab(chromiumTab); } catch { /* ignore */ }
}
}
// 3. pdftotext (optional — CI gate only)
process.stderr.write(" [3/5] Checking pdftotext (optional)...");
try {
const info = resolvePdftotext();
process.stderr.write(` OK (${info.flavor}, ${info.version.split(" ").slice(-1)[0] || "version unknown"})\n`);
} catch (err) {
process.stderr.write(" SKIP\n");
if (err instanceof PdftotextUnavailableError) {
process.stderr.write(
" pdftotext not installed. This is optional — only the CI\n" +
" copy-paste gate needs it. To enable:\n" +
" macOS: brew install poppler\n" +
" Ubuntu: sudo apt-get install poppler-utils\n",
);
}
}
// 4. Render smoke-test PDF
process.stderr.write(" [4/5] Generating smoke-test PDF...\n");
const fixture = [
"# Hello from make-pdf",
"",
"This is a two-paragraph smoke test. If you can read this sentence in the PDF that just opened, the pipeline works end-to-end.",
"",
"The second paragraph contains curly quotes (\"hello\"), an em dash -- like this, and an ellipsis... all of which should render correctly.",
"",
].join("\n");
const fixturePath = path.join(os.tmpdir(), `make-pdf-smoke-${process.pid}.md`);
const outPath = path.join(os.tmpdir(), `make-pdf-smoke-${process.pid}.pdf`);
fs.writeFileSync(fixturePath, fixture, "utf8");
try {
await generate({
input: fixturePath,
output: outPath,
quiet: true,
pageNumbers: true,
});
process.stderr.write(` PASSED. Smoke test saved to ${outPath}\n`);
} catch (err: any) {
process.stderr.write(` FAILED: ${err.message}\n`);
process.exit(2);
} finally {
try { fs.unlinkSync(fixturePath); } catch { /* ignore */ }
}
// 5. Cheatsheet
process.stderr.write(" [5/5] All checks passed.\n\n");
process.stderr.write([
"make-pdf is ready. Try:",
" $P generate letter.md # default memo mode",
" $P generate --cover --toc essay.md # full publication",
" $P generate --watermark DRAFT memo.md # diagonal watermark",
"",
`Smoke-test PDF: ${outPath}`,
"",
].join("\n"));
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Inline typographic transform (smartypants).
*
* Converts ASCII typography to real Unicode:
* "quoted" → "quoted" (U+201C/U+201D)
* 'quoted' → 'quoted' (U+2018/U+2019)
* don't → don't (apostrophe: U+2019)
* -- → — (em dash U+2014)
* ... → … (ellipsis U+2026)
*
* Critical: must NOT touch code, URLs, or HTML attributes. The Codex round
* 2 review flagged this specifically — smartypants run over a fenced code
* block corrupts the code and tokens inside tag attributes can break
* parsing.
*
* This operates on HTML (marked already produced it) and walks text nodes
* only via a lightweight regex that recognizes code/pre/URL zones and
* skips them entirely.
*/
const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
const TAG_RE = /<[^>]+>/g;
const URL_RE = /\bhttps?:\/\/\S+/g;
/**
* Apply smartypants to an HTML string. Zones that should not be touched:
* - <pre>, <code>, <script>, <style> blocks (content unchanged)
* - HTML tags themselves (attributes unchanged)
* - URLs (http:// and https:// spans unchanged)
*/
export function smartypants(html: string): string {
// Step 1: split into preserved + transformed zones.
// Preserved zones: code/pre/script/style, tags, URLs.
// We carve them out with placeholder tokens, transform the rest, and
// splice them back.
const preserved: string[] = [];
const PLACEHOLDER = (i: number) => `\u0000SMARTPANTS_PRESERVED_${i}\u0000`;
const carve = (source: string, pattern: RegExp): string => {
return source.replace(pattern, (match) => {
const idx = preserved.length;
preserved.push(match);
return PLACEHOLDER(idx);
});
};
let s = html;
s = carve(s, CODE_ZONE_RE);
s = carve(s, TAG_RE);
s = carve(s, URL_RE);
s = transformText(s);
// Step 2: restore preserved zones.
// Use a function to avoid $-substitution gotchas.
s = s.replace(/\u0000SMARTPANTS_PRESERVED_(\d+)\u0000/g, (_, idx) => {
return preserved[parseInt(idx, 10)] ?? "";
});
return s;
}
/**
* Transform plain text (no HTML, no code, no URLs).
*
* Order matters:
* 1. Triple dots first (so they don't collide with later apostrophes)
* 2. Em dashes (two hyphens → em dash)
* 3. Apostrophes (contractions + possessives)
* 4. Double quotes (open/close pairing)
* 5. Single quotes (open/close pairing — after apostrophes)
*/
function transformText(text: string): string {
let s = text;
// Ellipsis: three literal dots (with optional spaces) → …
s = s.replace(/\.\s?\.\s?\./g, "\u2026");
// Em dash: -- → —. Require space or word-char boundary on both sides so
// we don't mangle ARGV-style flags in prose like `--verbose`.
s = s.replace(/(\w|\s)--(\w|\s)/g, "$1\u2014$2");
// Standalone -- at start/end
s = s.replace(/^--\s/gm, "\u2014 ");
s = s.replace(/\s--$/gm, " \u2014");
// Apostrophes in contractions and possessives.
// "don't", "it's", "they're", "Garry's"
s = s.replace(/(\w)'(\w)/g, "$1\u2019$2");
// Double quotes: open if preceded by whitespace/bol, close if preceded
// by word char or punctuation.
s = s.replace(/(^|[\s\(\[\{\-])"/g, "$1\u201c"); // opening "
s = s.replace(/"/g, "\u201d"); // remaining " are closing
// Single quotes (after apostrophe pass):
s = s.replace(/(^|[\s\(\[\{\-])'/g, "$1\u2018"); // opening '
s = s.replace(/'/g, "\u2019"); // remaining ' are closing
return s;
}
+136
View File
@@ -0,0 +1,136 @@
/**
* make-pdf — shared types.
*
* No runtime code. Imports are safe from any module.
*/
export type PageSize = "letter" | "a4" | "legal" | "tabloid";
export type FontMode = "sans"; // v1: Helvetica only. Future: "serif" | "custom".
/**
* Options for `$P generate` — the public CLI contract.
* Matches the flag set documented in the CEO plan.
*/
export type OutputFormat = "pdf" | "html" | "docx";
export interface GenerateOptions {
input: string; // markdown input path
output?: string; // output path (default: /tmp/<slug>.<ext>)
// Output format (NOT --format, which is a --page-size alias):
// pdf — print-quality PDF via Chromium (default)
// html — single self-contained file, zero network references
// docx — content-fidelity Word document (diagrams embedded as PNG)
to?: OutputFormat;
// Page layout
margins?: string; // "1in" | "72pt" | "25mm" | "2.54cm"
marginTop?: string;
marginRight?: string;
marginBottom?: string;
marginLeft?: string;
pageSize?: PageSize; // default "letter"
// Document structure
cover?: boolean;
toc?: boolean;
noChapterBreaks?: boolean; // default: chapter breaks ON
// Branding
watermark?: string; // e.g. "DRAFT"
headerTemplate?: string; // raw HTML
footerTemplate?: string; // raw HTML, mutex with pageNumbers
confidential?: boolean; // default: true
// Output control
pageNumbers?: boolean; // default: true
tagged?: boolean; // default: true (accessible PDF)
outline?: boolean; // default: true (PDF bookmarks)
quiet?: boolean; // suppress progress on stderr
verbose?: boolean; // per-stage timings on stderr
// Network
allowNetwork?: boolean; // default: false
// Strict mode (eng-review D6.1): missing/remote images hard-fail instead of
// warn + placeholder. For CI docs pipelines that need determinism.
strict?: boolean; // default: false
// Metadata
title?: string;
author?: string;
date?: string; // ISO-ish; default: today
}
/**
* Options for `$P preview`.
*/
export interface PreviewOptions {
input: string;
quiet?: boolean;
verbose?: boolean;
// Same render flags as generate so preview matches output
cover?: boolean;
toc?: boolean;
watermark?: string;
noChapterBreaks?: boolean;
confidential?: boolean;
pageNumbers?: boolean;
allowNetwork?: boolean;
title?: string;
author?: string;
date?: string;
}
/**
* Parsed page.pdf() options passed to browse.
*/
export interface BrowsePdfOptions {
output: string;
tabId: number;
format?: PageSize;
width?: string;
height?: string;
margins?: {
top: string;
right: string;
bottom: string;
left: string;
};
headerTemplate?: string;
footerTemplate?: string;
pageNumbers?: boolean;
displayHeaderFooter?: boolean;
tagged?: boolean;
outline?: boolean;
printBackground?: boolean;
preferCSSPageSize?: boolean;
toc?: boolean; // signals browse to wait for Paged.js
}
/**
* Exit codes for $P generate.
* Mirror these in orchestrator error paths.
*/
export const ExitCode = {
Success: 0,
BadArgs: 1,
RenderError: 2,
PagedJsTimeout: 3,
BrowseUnavailable: 4,
} as const;
export type ExitCode = typeof ExitCode[keyof typeof ExitCode];
/**
* Structured error for browse CLI shell-out failures.
*/
export class BrowseClientError extends Error {
constructor(
public readonly exitCode: number,
public readonly command: string,
public readonly stderr: string,
) {
super(`browse ${command} exited ${exitCode}: ${stderr.trim()}`);
this.name = "BrowseClientError";
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* browseClient unit tests — binary resolution and error mapping.
*
* These are pure unit tests; they do NOT require a running browse daemon.
* Cross-platform: assertions that pin POSIX behavior early-return on win32
* and vice versa, so both lanes only exercise their own branch.
*/
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { BrowseClientError } from "../src/types";
import { resolveBrowseBin, findExecutable } from "../src/browseClient";
// A real, always-present executable for the test platform — `cmd.exe` on
// Windows (System32 is on every install) and `/bin/sh` on POSIX. Lets the
// "honors override when it points at a real executable" test work in both
// lanes without writing a temp script.
const REAL_EXE: string =
process.platform === "win32"
? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
: "/bin/sh";
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
const saved: Record<string, string | undefined> = {};
for (const k of Object.keys(overrides)) saved[k] = process.env[k];
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
try {
return fn();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}
describe("findExecutable", () => {
test("returns the bare path on POSIX when it's executable", () => {
if (process.platform === "win32") return;
const found = findExecutable("/bin/sh");
expect(found).toBe("/bin/sh");
});
test("on win32, probes .exe / .cmd / .bat after the bare-path miss", () => {
if (process.platform !== "win32") return;
// cmd.exe lives at System32\cmd.exe — probe with the bare base.
const base = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd");
const found = findExecutable(base);
expect(found).toBe(base + ".exe");
});
test("returns null when no extension matches", () => {
const found = findExecutable("/nonexistent/path/to/nothing");
expect(found).toBeNull();
});
});
describe("resolveBrowseBin", () => {
test("throws BrowseClientError with setup hint when nothing is found", () => {
// Point overrides at non-existent paths and clear PATH so Bun.which finds
// nothing. Sibling/global probes go through findExecutable on real paths,
// but the test asserts on the error shape rather than depending on whether
// a real browse install exists on the box.
let thrown: unknown = null;
try {
withEnv(
{
GSTACK_BROWSE_BIN: "/nonexistent/gstack-browse-bin",
BROWSE_BIN: "/nonexistent/browse-bin",
PATH: "",
Path: "",
},
() => resolveBrowseBin(),
);
} catch (err) {
thrown = err;
}
if (thrown) {
expect(thrown).toBeInstanceOf(BrowseClientError);
expect((thrown as BrowseClientError).message).toContain("browse binary not found");
expect((thrown as BrowseClientError).message).toContain("./setup");
expect((thrown as BrowseClientError).message).toContain("GSTACK_BROWSE_BIN");
// Back-compat alias still surfaces in the diagnostic.
expect((thrown as BrowseClientError).message).toContain("BROWSE_BIN");
}
// If the test box has a real browse install on disk, sibling/global may
// resolve and the helper won't throw — that's fine; the assertion is
// gated on whether it threw at all.
});
test("honors GSTACK_BROWSE_BIN when it points at a real executable", () => {
const resolved = withEnv({ GSTACK_BROWSE_BIN: REAL_EXE }, () => resolveBrowseBin());
expect(resolved).toBe(REAL_EXE);
});
test("honors BROWSE_BIN as a back-compat alias", () => {
const resolved = withEnv(
{ GSTACK_BROWSE_BIN: undefined, BROWSE_BIN: REAL_EXE },
() => resolveBrowseBin(),
);
expect(resolved).toBe(REAL_EXE);
});
test("GSTACK_BROWSE_BIN takes precedence over BROWSE_BIN", () => {
const resolved = withEnv(
{ GSTACK_BROWSE_BIN: REAL_EXE, BROWSE_BIN: "/nonexistent/legacy" },
() => resolveBrowseBin(),
);
expect(resolved).toBe(REAL_EXE);
});
test("strips wrapping double quotes from override values", () => {
const resolved = withEnv({ GSTACK_BROWSE_BIN: `"${REAL_EXE}"` }, () => resolveBrowseBin());
expect(resolved).toBe(REAL_EXE);
});
});
describe("BrowseClientError", () => {
test("captures exit code, command, and stderr", () => {
const err = new BrowseClientError(127, "pdf", "Chromium not found");
expect(err.exitCode).toBe(127);
expect(err.command).toBe("pdf");
expect(err.stderr).toBe("Chromium not found");
expect(err.message).toContain("browse pdf exited 127");
expect(err.message).toContain("Chromium not found");
expect(err.name).toBe("BrowseClientError");
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* Coverage-gap fills from the v1.58.0.0 ship audit — the branches the main
* suites couldn't reach without a live browse tab (mock-tab here), plus the
* pure-function stragglers (WebP probing, landscape geometry, bundle path
* resolution, screen CSS).
*/
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
RenderCallError,
type RenderTab,
landscapeContentBox,
rasterizeDiagramFigures,
renderFenceSlots,
resolveBundlePath,
substituteSlots,
} from "../src/diagram-prepass";
import { imageDims } from "../src/image-size";
import { screenCss } from "../src/print-css";
/** Duck-typed RenderTab: scripted call results + a loadBundle counter. */
function mockTab(script: (fn: string, ...args: Array<string | number>) => string) {
const calls: string[] = [];
let reloads = 0;
const tab = {
call: (fn: string, ...args: Array<string | number>) => {
calls.push(fn);
return script(fn, ...args);
},
loadBundle: () => { reloads++; },
close: () => {},
} as unknown as RenderTab;
return { tab, calls, reloadCount: () => reloads };
}
const fence = (over: Partial<{ lang: string; source: string; ordinal: number }>) => ({
lang: "mermaid",
source: "graph LR\n A --> B",
render: true as const,
token: `tok-${over.ordinal ?? 1}`,
ordinal: over.ordinal ?? 1,
title: undefined,
page: undefined,
...over,
});
// ─── renderFenceSlots: reset contract + excalidraw branches ───────────
describe("renderFenceSlots (mock tab)", () => {
test("reset contract: a failure reloads the bundle and the NEXT fence still renders", () => {
const { tab, reloadCount } = mockTab((fn, ...args) => {
if (String(args[1] ?? "").includes("BROKEN")) throw new RenderCallError("Parse error on line 1");
return "<svg><g/></svg>";
});
const warnings: string[] = [];
const slots = renderFenceSlots(
[
fence({ ordinal: 1 }),
fence({ ordinal: 2, source: "BROKEN" }),
fence({ ordinal: 3 }),
],
tab,
(m) => warnings.push(m),
);
expect(slots.get("tok-1")).toContain("<svg>");
expect(slots.get("tok-2")).toContain("diagram-error");
expect(slots.get("tok-3")).toContain("<svg>"); // post-failure fence rendered
expect(reloadCount()).toBe(1); // exactly one reset reload
expect(warnings[0]).toContain("failed to render");
});
test("excalidraw fence renders via __excalidrawToSvg", () => {
const { tab, calls } = mockTab(() => "<svg data-x><g/></svg>");
const slots = renderFenceSlots(
[fence({ lang: "excalidraw", source: '{"type":"excalidraw","elements":[]}' })],
tab,
() => {},
);
expect(calls).toEqual(["__excalidrawToSvg"]);
expect(slots.get("tok-1")).toContain("<svg");
});
test("invalid excalidraw JSON fails fast into a diagnostic WITHOUT calling the tab", () => {
const { tab, calls, reloadCount } = mockTab(() => "<svg/>");
const warnings: string[] = [];
const slots = renderFenceSlots(
[fence({ lang: "excalidraw", source: "{not json" })],
tab,
(m) => warnings.push(m),
);
expect(calls).toEqual([]); // JSON.parse threw before any bundle call
expect(slots.get("tok-1")).toContain("diagram-error");
expect(reloadCount()).toBe(1);
expect(warnings).toHaveLength(1);
});
});
// ─── rasterizeDiagramFigures: svg-data-URI + error fallbacks ──────────
describe("rasterizeDiagramFigures (mock tab)", () => {
const figure = `<figure class="diagram" role="img" aria-label="flow"><svg viewBox="0 0 10 10"><g/></svg></figure>`;
test("svg data-URI images rasterize to PNG", () => {
const svgUri = `data:image/svg+xml;base64,${Buffer.from("<svg/>").toString("base64")}`;
const { tab } = mockTab(() => "data:image/png;base64,AAAA");
const out = rasterizeDiagramFigures(`<img src="${svgUri}" alt="v">`, tab, 6.5, () => {});
expect(out).toContain('src="data:image/png;base64,AAAA"');
});
test("figure rasterization failure surfaces the SOURCE as text (never silent loss)", () => {
// Returning the figure unchanged would make the diagram vanish in DOCX
// (the converter drops <figure>/<svg>) — the failure must be visible.
const { tab } = mockTab(() => { throw new RenderCallError("tainted"); });
const warnings: string[] = [];
const srcFigure = figure.replace(
'<figure class="diagram"',
`<figure class="diagram" data-gstack-source="${Buffer.from("graph LR\n A --> B").toString("base64")}"`,
);
const out = rasterizeDiagramFigures(srcFigure, tab, 6.5, (m) => warnings.push(m));
expect(out).toContain("could not be rasterized");
expect(out).toContain("A --&gt; B"); // source visible (escaped), not dropped
expect(out).not.toContain("<figure");
expect(warnings[0]).toContain("rasterization failed");
});
test("svg data-URI rasterization failure keeps the original tag", () => {
const svgUri = `data:image/svg+xml;base64,${Buffer.from("<svg/>").toString("base64")}`;
const { tab } = mockTab(() => { throw new RenderCallError("decode failed"); });
const tagIn = `<img src="${svgUri}">`;
const out = rasterizeDiagramFigures(tagIn, tab, 6.5, () => {});
expect(out).toBe(tagIn);
});
});
// ─── image-size: WebP variants ────────────────────────────────────────
describe("imageDims WebP", () => {
function riff(fmt: string, body: Buffer): Buffer {
const b = Buffer.alloc(12 + 4 + body.length);
b.write("RIFF", 0, "ascii");
b.writeUInt32LE(4 + body.length + 4, 4);
b.write("WEBP", 8, "ascii");
b.write(fmt, 12, "ascii");
body.copy(b, 16);
return b;
}
test("VP8 (lossy)", () => {
const body = Buffer.alloc(16);
body.writeUInt16LE(800 & 0x3fff, 10); // width at chunk offset 26 = body offset 10
body.writeUInt16LE(600 & 0x3fff, 12);
expect(imageDims(riff("VP8 ", body))).toEqual({ width: 800, height: 600, mime: "image/webp" });
});
test("VP8L (lossless)", () => {
const body = Buffer.alloc(10);
body[4] = 0x2f; // signature at chunk offset 20 = body offset 4
const w = 1023, h = 511;
const bits = (w - 1) | ((h - 1) << 14);
body.writeUInt32LE(bits >>> 0, 5);
expect(imageDims(riff("VP8L", body))).toEqual({ width: 1023, height: 511, mime: "image/webp" });
});
test("VP8X (extended)", () => {
const body = Buffer.alloc(14);
const w = 4000 - 1, h = 250 - 1; // 24-bit minus-one at offsets 24/27 = body 8/11
body[8] = w & 0xff; body[9] = (w >> 8) & 0xff; body[10] = (w >> 16) & 0xff;
body[11] = h & 0xff; body[12] = (h >> 8) & 0xff; body[13] = (h >> 16) & 0xff;
expect(imageDims(riff("VP8X", body))).toEqual({ width: 4000, height: 250, mime: "image/webp" });
});
test("unknown RIFF subtype → null", () => {
expect(imageDims(riff("XXXX", Buffer.alloc(14)))).toBeNull();
});
});
// ─── landscape geometry + slot fallback + bundle path + screen css ────
describe("pure-function stragglers", () => {
test("landscapeContentBox letter defaults: 9in × 6.5in", () => {
expect(landscapeContentBox({})).toEqual({ contentWIn: 9, contentHIn: 6.5 });
});
test("landscapeContentBox a4 + asymmetric margins", () => {
const box = landscapeContentBox({ pageSize: "a4", marginLeft: "0.5in", marginRight: "0.5in", marginTop: "25mm", marginBottom: "1in" });
expect(box.contentWIn).toBeCloseTo(11.69 - 1, 2);
expect(box.contentHIn).toBeCloseTo(8.27 - 25 / 25.4 - 1, 2);
});
test("substituteSlots bare-token fallback (token not <p>-wrapped)", () => {
const slots = new Map([["gstack-diagram-slot-x-1", "<figure>D</figure>"]]);
const out = substituteSlots("<li>gstack-diagram-slot-x-1</li>", slots);
expect(out).toBe("<li><figure>D</figure></li>");
});
test("resolveBundlePath honors the env override", () => {
const tmp = path.join(os.tmpdir(), `bundle-override-${process.pid}.html`);
fs.writeFileSync(tmp, "<!doctype html>");
try {
expect(resolveBundlePath({ GSTACK_DIAGRAM_BUNDLE: tmp } as NodeJS.ProcessEnv)).toBe(tmp);
} finally {
fs.unlinkSync(tmp);
}
});
// NOTE: resolveBundlePath's not-found error shape is untestable from inside
// this checkout (the repo-relative candidate always exists), and a vacuous
// if-guarded assertion was worse than none. The env-override test above is
// the honest coverage; the error path is exercised manually via
// GSTACK_DIAGRAM_BUNDLE pointing at a missing file outside a repo.
test("screenCss is media-scoped and readable-width", () => {
const css = screenCss();
expect(css).toContain("@media screen");
// 42em at 12pt ≈ 70-75 chars/line — the readable ceiling (design review).
expect(css).toContain("max-width: 42em");
expect(css).toContain(".watermark { display: none; }");
});
});
+403
View File
@@ -0,0 +1,403 @@
/**
* Unit tests for the diagram pre-pass: fence extraction, info-string parsing,
* slot substitution, diagnostic blocks, image inlining policy, and the
* byte-level image dimension prober. No browse daemon required — the tab
* factory returns null so downscale paths are exercised as no-ops.
*/
import { afterAll, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import zlib from "node:zlib";
import {
StrictModeError,
buildDiagnosticBlock,
buildDiagramFigure,
contentWidthInches,
dimToInches,
extractDiagramFences,
inlineLocalImages,
parseInfoString,
substituteSlots,
decodeFigureSource,
} from "../src/diagram-prepass";
import { imageDims } from "../src/image-size";
// ─── fence extraction ─────────────────────────────────────────────────
describe("extractDiagramFences", () => {
test("extracts a mermaid fence and replaces it with a token paragraph", () => {
const md = "# T\n\n```mermaid\ngraph LR\n A --> B\n```\n\ntail";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(1);
expect(fences[0].lang).toBe("mermaid");
expect(fences[0].source).toBe("graph LR\n A --> B");
expect(markdown).toContain(fences[0].token);
expect(markdown).not.toContain("```mermaid");
});
test("extracts excalidraw fences", () => {
const md = '```excalidraw\n{"type":"excalidraw","elements":[]}\n```';
const { fences } = extractDiagramFences(md);
expect(fences).toHaveLength(1);
expect(fences[0].lang).toBe("excalidraw");
});
test("render=false keeps the fence as code and strips the flag", () => {
const md = "```mermaid render=false\ngraph LR\n X --> Y\n```";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(0);
expect(markdown).toContain("```mermaid\ngraph LR");
expect(markdown).not.toContain("render=false");
});
test("title is captured from the info string", () => {
const md = '```mermaid title="Auth flow"\ngraph LR\n A --> B\n```';
const { fences } = extractDiagramFences(md);
expect(fences[0].title).toBe("Auth flow");
});
test("non-diagram fences pass through untouched", () => {
const md = "```js\nconst a = 1;\n```";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(0);
expect(markdown).toBe(md);
});
test("a mermaid example inside a plain fence is never extracted", () => {
const md = "````\n```mermaid\ngraph LR\n```\n````";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(0);
expect(markdown).toBe(md);
});
test("tilde fences work", () => {
const md = "~~~mermaid\ngraph TD\n A --> B\n~~~";
const { fences } = extractDiagramFences(md);
expect(fences).toHaveLength(1);
});
test("unclosed fence at EOF replays verbatim", () => {
const md = "```mermaid\ngraph LR\n A --> B";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(0);
expect(markdown).toBe(md);
});
test("multiple fences get distinct ordinals and tokens", () => {
const md = "```mermaid\nA\n```\n\nmiddle\n\n```mermaid\nB\n```";
const { fences } = extractDiagramFences(md);
expect(fences).toHaveLength(2);
expect(fences[0].ordinal).toBe(1);
expect(fences[1].ordinal).toBe(2);
expect(fences[0].token).not.toBe(fences[1].token);
});
});
describe("parseInfoString", () => {
test("plain language", () => {
expect(parseInfoString("mermaid")).toEqual({ lang: "mermaid", render: true, title: undefined });
});
test("render=false", () => {
expect(parseInfoString("mermaid render=false").render).toBe(false);
});
test("single-quoted title", () => {
expect(parseInfoString("mermaid title='Hi there'").title).toBe("Hi there");
});
});
// ─── slots ────────────────────────────────────────────────────────────
describe("substituteSlots", () => {
test("replaces the <p>-wrapped token with slot HTML", () => {
const slots = new Map([["gstack-diagram-slot-ab-1", "<figure>X</figure>"]]);
const html = "<h1>T</h1>\n<p>gstack-diagram-slot-ab-1</p>\n<p>tail</p>";
const out = substituteSlots(html, slots);
expect(out).toContain("<figure>X</figure>");
expect(out).not.toContain("gstack-diagram-slot");
expect(out).not.toContain("<p><figure>");
});
});
describe("diagnostic + figure blocks", () => {
const fence = {
lang: "mermaid", source: "graph LR\n A --> B", render: true,
token: "t", ordinal: 3, title: undefined,
};
test("diagnostic block escapes error content and names the lang", () => {
const block = buildDiagnosticBlock(fence, 'Parse <error> "quoted"');
expect(block).toContain("diagram-error");
expect(block).toContain("Diagram failed to render (mermaid)");
expect(block).toContain("Parse &lt;error&gt;");
expect(block).not.toContain("<error>");
});
test("figure carries role=img and ordinal-based aria-label fallback", () => {
const fig = buildDiagramFigure(fence, "<svg></svg>");
expect(fig).toContain('role="img"');
expect(fig).toContain('aria-label="diagram 3"');
expect(fig).toContain("<svg></svg>");
});
test("figure strips scripts from SVG (sanitizer second layer)", () => {
const fig = buildDiagramFigure(fence, "<svg><script>alert(1)</script><g/></svg>");
expect(fig).not.toContain("<script>");
});
test("title becomes aria-label and caption", () => {
const fig = buildDiagramFigure({ ...fence, title: "Auth flow" }, "<svg></svg>");
expect(fig).toContain('aria-label="Auth flow"');
expect(fig).toContain("diagram-caption");
});
test("embedded source round-trips mermaid arrows exactly", () => {
const source = "graph LR\n A --> B\n B -->|label with $& and `ticks`| C";
const fig = buildDiagramFigure({ ...fence, source }, "<svg></svg>");
expect(decodeFigureSource(fig)).toBe(source);
});
test("slot substitution is immune to $-replacement patterns in labels", () => {
const slotHtml = `<figure>label says $' and $& here</figure>`;
const out = substituteSlots("<p>tok-x</p><p>tail</p>", new Map([["tok-x", slotHtml]]));
expect(out).toContain("label says $' and $& here");
expect(out).toContain("<p>tail</p>");
expect(out).not.toContain("tailtail"); // $' expansion would duplicate the tail
});
});
// ─── image dimension probing ──────────────────────────────────────────
function tinyPng(w: number, h: number): Buffer {
const chunk = (t: string, d: Buffer) => {
const body = Buffer.concat([Buffer.from(t, "ascii"), d]);
const len = Buffer.alloc(4);
len.writeUInt32BE(d.length);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(zlib.crc32 ? zlib.crc32(body) : 0);
return Buffer.concat([len, body, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.concat(
Array.from({ length: h }, () => Buffer.concat([Buffer.from([0]), Buffer.alloc(w * 3, 0x80)])),
);
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", zlib.deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
describe("imageDims", () => {
test("PNG", () => {
expect(imageDims(tinyPng(640, 480))).toEqual({ width: 640, height: 480, mime: "image/png" });
});
test("GIF", () => {
const b = Buffer.alloc(13);
b.write("GIF89a", 0, "ascii");
b.writeUInt16LE(320, 6);
b.writeUInt16LE(200, 8);
expect(imageDims(b)).toEqual({ width: 320, height: 200, mime: "image/gif" });
});
test("JPEG (SOF0)", () => {
const b = Buffer.from([
0xff, 0xd8, // SOI
0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, // APP0 len 4
0xff, 0xc0, 0x00, 0x0b, 0x08, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, // SOF0 h=256 w=512
]);
expect(imageDims(b)).toEqual({ width: 512, height: 256, mime: "image/jpeg" });
});
test("SVG via width/height attrs", () => {
const b = Buffer.from('<svg xmlns="x" width="800" height="400"></svg>');
expect(imageDims(b)).toEqual({ width: 800, height: 400, mime: "image/svg+xml" });
});
test("SVG via viewBox", () => {
const b = Buffer.from('<svg viewBox="0 0 1200 600"></svg>');
expect(imageDims(b)).toEqual({ width: 1200, height: 600, mime: "image/svg+xml" });
});
test("unknown bytes → null", () => {
expect(imageDims(Buffer.from("definitely not an image, sorry"))).toBeNull();
});
});
// ─── content-box math ─────────────────────────────────────────────────
describe("content width", () => {
test("letter with 1in margins = 6.5in", () => {
expect(contentWidthInches({})).toBeCloseTo(6.5);
});
test("a4 with 25mm margins", () => {
expect(contentWidthInches({ pageSize: "a4", margins: "25mm" })).toBeCloseTo(8.27 - 50 / 25.4, 2);
});
test("dimToInches parses pt/cm/mm/px", () => {
expect(dimToInches("72pt", 1)).toBeCloseTo(1);
expect(dimToInches("2.54cm", 1)).toBeCloseTo(1);
expect(dimToInches("25.4mm", 1)).toBeCloseTo(1);
expect(dimToInches("96px", 1)).toBeCloseTo(1);
expect(dimToInches("garbage", 1.5)).toBe(1.5);
});
});
// ─── image inlining ───────────────────────────────────────────────────
describe("inlineLocalImages", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-img-"));
fs.writeFileSync(path.join(dir, "ok.png"), tinyPng(40, 20));
afterAll(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
});
const base = {
inputDir: dir,
strict: false,
allowNetwork: false,
contentWidthIn: 6.5,
getTab: () => null,
};
test("local image becomes a data URI with probed dimensions", () => {
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="ok.png" alt="x">`, { ...base, warn: (m) => warnings.push(m) });
expect(out).toContain("data:image/png;base64,");
expect(out).toContain('data-gstack-px-width="40"');
expect(out).toContain('data-gstack-px-height="20"');
expect(warnings).toHaveLength(0);
});
test("missing image → visible placeholder + warning", () => {
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="nope.png">`, { ...base, warn: (m) => warnings.push(m) });
expect(out).toContain("image-missing");
expect(out).toContain("nope.png");
expect(warnings.length).toBe(1);
});
test("missing image + --strict → StrictModeError", () => {
expect(() =>
inlineLocalImages(`<img src="nope.png">`, { ...base, strict: true, warn: () => {} }),
).toThrow(StrictModeError);
});
test("remote image is BLOCKED with a visible placeholder (offline posture)", () => {
// Leaving the tag would make Chromium fetch it at print time anyway —
// the offline posture must remove the src, not just warn about it.
const warnings: string[] = [];
const tag = `<img src="https://example.com/x.png">`;
const out = inlineLocalImages(tag, { ...base, warn: (m) => warnings.push(m) });
expect(out).not.toContain("https://example.com/x.png\"");
expect(out).toContain("remote image blocked");
expect(warnings[0]).toContain("offline");
});
test("symlink escaping the input dir is caught by the realpath check", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-symlink-"));
fs.writeFileSync(path.join(outside, "secret.png"), tinyPng(5, 5));
const link = path.join(dir, "innocent.png");
try {
fs.symlinkSync(path.join(outside, "secret.png"), link);
const warnings: string[] = [];
inlineLocalImages(`<img src="innocent.png">`, { ...base, warn: (m) => warnings.push(m) });
expect(warnings.some((w) => w.includes("OUTSIDE the input directory"))).toBe(true);
} finally {
try { fs.unlinkSync(link); } catch { /* ignore */ }
fs.rmSync(outside, { recursive: true, force: true });
}
});
test("special files and oversized images degrade to placeholders, never hang", () => {
// Directory masquerading as an image — not a regular file.
fs.mkdirSync(path.join(dir, "dir.png"), { recursive: true });
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="dir.png">`, { ...base, warn: (m) => warnings.push(m) });
expect(out).toContain("image-missing");
expect(warnings.some((w) => w.includes("not a regular file"))).toBe(true);
});
test("malformed percent-encoding degrades to missing-image, never throws", () => {
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="foo%zz.png">`, { ...base, warn: (m) => warnings.push(m) });
expect(out).toContain("image-missing");
});
test("remote image + --allow-network passes silently", () => {
const warnings: string[] = [];
const tag = `<img src="https://example.com/x.png">`;
const out = inlineLocalImages(tag, { ...base, allowNetwork: true, warn: (m) => warnings.push(m) });
expect(out).toBe(tag);
expect(warnings).toHaveLength(0);
});
test("remote image + --strict → StrictModeError", () => {
expect(() =>
inlineLocalImages(`<img src="https://example.com/x.png">`, { ...base, strict: true, warn: () => {} }),
).toThrow(StrictModeError);
});
test("existing data URI gets dimension annotations only", () => {
const uri = `data:image/png;base64,${tinyPng(33, 44).toString("base64")}`;
const out = inlineLocalImages(`<img src="${uri}">`, { ...base, warn: () => {} });
expect(out).toContain('data-gstack-px-width="33"');
expect(out).toContain('data-gstack-px-height="44"');
});
test("out-of-tree image reads warn (never silent) and still inline", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-outside-"));
fs.writeFileSync(path.join(outside, "ext.png"), tinyPng(10, 10));
try {
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
...base, warn: (m) => warnings.push(m),
});
expect(out).toContain("data:image/png;base64,");
expect(warnings.some((w) => w.includes("OUTSIDE the input directory"))).toBe(true);
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
test("out-of-tree image + --strict → StrictModeError", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-outside-"));
fs.writeFileSync(path.join(outside, "ext.png"), tinyPng(10, 10));
try {
expect(() =>
inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
...base, strict: true, warn: () => {},
}),
).toThrow(StrictModeError);
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
test("Windows drive-letter src is treated as a local path, not a URL scheme", () => {
// C:/x.png matches the single-letter-scheme regex — it must reach the
// local-path branch (and the missing-file placeholder), never silently
// pass through as an unknown URL.
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="C:/missing/x.png">`, { ...base, warn: (m) => warnings.push(m) });
expect(out).toContain("image-missing");
// Two warnings: it's out-of-tree (resolved outside inputDir) AND missing.
expect(warnings.some((w) => w.includes("image not found"))).toBe(true);
});
test("indented fences inside lists replay byte-for-byte (no list splitting)", () => {
const md = "- item\n\n ```js\n code();\n ```\n\n- next";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(0);
expect(markdown).toBe(md);
});
test("indented mermaid fences are NOT extracted (column-0 placeholder would split the list)", () => {
const md = "- item\n\n ```mermaid\n graph LR\n ```\n";
const { markdown, fences } = extractDiagramFences(md);
expect(fences).toHaveLength(0);
expect(markdown).toBe(md);
});
test("oversized raster without a tab inlines at full size with no downscale", () => {
// 6000px-wide PNG header (body irrelevant for probing; file must exist)
fs.writeFileSync(path.join(dir, "wide.png"), tinyPng(6000, 100));
const warnings: string[] = [];
const out = inlineLocalImages(`<img src="wide.png">`, { ...base, warn: (m) => warnings.push(m) });
expect(out).toContain('data-gstack-px-width="6000"');
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Combined-features copy-paste gate — the P0 CI gate.
*
* This test runs the compiled `make-pdf/dist/pdf` binary against a fixture
* that has every v1 typography feature on (smartypants, hyphens, chapter
* breaks, bold/italic, inline code, blockquote, lists, headings). It then
* pipes the output through pdftotext and asserts the extracted text
* matches the handwritten expected.txt.
*
* Codex round 2 told us this (not per-feature gates) is the real gate a
* user actually cares about — features interact, and the combined
* extraction is what predicts production quality.
*
* Gating: only runs when the compiled binary + browse + pdftotext are all
* available. Skipped cleanly otherwise (local dev without full install).
*/
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { copyPasteGate, resolvePdftotext } from "../../src/pdftotext";
const FIXTURE = path.resolve(__dirname, "../fixtures/combined-gate.md");
const EXPECTED = path.resolve(__dirname, "../fixtures/combined-gate.expected.txt");
const ROOT = path.resolve(__dirname, "../../..");
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
if (!fs.existsSync(EXPECTED)) return { ok: false, reason: `expected.txt missing (${EXPECTED}).` };
try { resolvePdftotext(); } catch (err: any) { return { ok: false, reason: err.message }; }
return { ok: true };
}
describe("combined-features copy-paste gate", () => {
const avail = prerequisitesAvailable();
test.skipIf(!avail.ok)("fixture PDF extracts cleanly through pdftotext", () => {
if (!avail.ok) return; // satisfies the type checker
// Use /tmp directly (browse's validateOutputPath allows /private/tmp,
// which macOS resolves /tmp to). os.tmpdir() returns /var/folders/...
// which is outside the safe-dirs allowlist.
const outputPdf = `/tmp/make-pdf-combined-gate-${process.pid}.pdf`;
try {
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
encoding: "utf8",
env: { ...process.env, BROWSE_BIN },
stdio: ["ignore", "pipe", "pipe"],
});
expect(fs.existsSync(outputPdf)).toBe(true);
const expected = fs.readFileSync(EXPECTED, "utf8");
const result = copyPasteGate(outputPdf, expected);
if (!result.ok) {
// Attach the extracted text so CI logs make the failure diagnosable
process.stderr.write(`\n--- EXTRACTED ---\n${result.extracted}\n--- END ---\n\n`);
process.stderr.write(`--- REASONS ---\n${result.reasons.join("\n")}\n--- END ---\n`);
}
expect(result.ok).toBe(true);
} finally {
try { fs.unlinkSync(outputPdf); } catch { /* ignore */ }
}
}, 30000);
if (!avail.ok) {
test("prerequisites check", () => {
console.warn(`[skip] ${avail.reason}`);
});
}
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Diagram render gate — proves the diagram pre-pass works end-to-end through
* the compiled binary: mermaid fences render as vector SVG (not raw code),
* multiple fences coexist (id-collision check), render=false keeps source,
* a broken fence yields a visible diagnostic block, and a relative local
* image actually renders (CRITICAL regression — pre-pass D1 fixed the
* setContent/about:blank path where relative images silently 404'd).
*
* Oracles (per the emoji-gate lessons — text extraction alone lies):
* 1. pdftotext: node labels from BOTH diagrams present (vector text made it
* into the PDF), diagnostic title present, raw mermaid only where
* render=false kept it.
* 2. pdftoppm + saturated-pixel count: the red fixture image rasterizes to
* colored pixels — text extraction can't fake that.
*
* Free-tier deterministic gate: runs under plain `bun test` when the compiled
* binaries + poppler are available; hard-fails in CI when missing.
*/
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { resolvePopplerTool } from "../../src/pdftotext";
const FIXTURE = path.resolve(__dirname, "../fixtures/diagram-gate.md");
const ROOT = path.resolve(__dirname, "../../..");
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html");
const CHILD_TIMEOUT_MS = 60_000;
// The 80x40 red fixture image at 100dpi occupies ~80x40 px of strong red.
// Floor sits well below that but far above AA noise.
const SATURATED_PIXEL_FLOOR = 500;
const SATURATION_DELTA = 60;
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}). Run bun run build:diagram-render.` };
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." };
if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." };
return { ok: true };
}
function countSaturatedPixels(ppmPath: string, delta: number): number {
const b = fs.readFileSync(ppmPath);
let i = 0;
const token = (): string => {
while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++;
if (b[i] === 0x23) { while (i < b.length && b[i] !== 0x0a) i++; return token(); }
const s = i;
while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++;
return b.slice(s, i).toString("ascii");
};
if (token() !== "P6") throw new Error("expected P6 PPM");
const w = Number(token());
const h = Number(token());
if (Number(token()) !== 255) throw new Error("expected 8-bit PPM");
i++;
let sat = 0;
for (let p = 0; p < w * h; p++) {
const o = i + p * 3;
if (Math.max(b[o], b[o + 1], b[o + 2]) - Math.min(b[o], b[o + 1], b[o + 2]) > delta) sat++;
}
return sat;
}
describe("diagram render gate", () => {
const avail = prerequisitesAvailable();
test.skipIf(!avail.ok)("mermaid fences render as vector diagrams; images and diagnostics behave", () => {
if (!avail.ok) return;
const workDir = fs.mkdtempSync("/tmp/make-pdf-diagram-gate-");
const outputPdf = path.join(workDir, "out.pdf");
const ppmPrefix = path.join(workDir, "page");
try {
// No --quiet: stderr carries the downscale warning asserted below.
const run = Bun.spawnSync([PDF_BIN, "generate", FIXTURE, outputPdf], {
env: { ...process.env, BROWSE_BIN },
stdout: "pipe",
stderr: "pipe",
});
const stderr = new TextDecoder().decode(run.stderr);
if (run.exitCode !== 0) {
throw new Error(`generate failed (exit ${run.exitCode}):\n${stderr}`);
}
expect(fs.existsSync(outputPdf)).toBe(true);
// 0. Print-resolution downscale fired on the 4200px noise photo — this
// is the only live coverage of __downscaleRaster AND the chunked
// jsViaBuffer transport (the data URI exceeds the 100KB argv path).
expect(stderr).toMatch(/downscaled huge-noise\.png 4200px → \d+px/);
const pdftotext = resolvePopplerTool("pdftotext")!;
const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
// 1. Vector text from BOTH diagrams (multi-fence + id-collision check).
// The broken fence sits BETWEEN them in the fixture, so the second
// diagram rendering at all proves the reset contract (D6.2): the
// bundle page reloaded after the failure and kept working.
for (const label of ["gatealphanode", "gatebetanode", "gategammanode", "gatedeltanode", "gateepsilonnode"]) {
expect(text).toContain(label);
}
// 1b. The excalidraw fence rendered through exportToSvg (vector text
// from the scene file, plus its caption).
expect(text).toContain("excalialphanode");
expect(text).toContain("excalibetanode");
expect(text).toContain("Converted flowchart");
// 2. Rendered fences must NOT ship raw mermaid/scene JSON; render=false must.
expect(text).not.toContain("GATEALPHA[");
expect(text).not.toContain('"type":"excalidraw"');
expect(text).toContain("RAWKEPT");
expect(text).toContain("ASCODE");
// 3. The broken fence produced a visible diagnostic, not silence.
expect(text).toContain("Diagram failed to render (mermaid)");
// 4. CRITICAL regression: the relative image rasterizes to color.
const pdftoppm = resolvePopplerTool("pdftoppm")!;
execFileSync(pdftoppm, ["-r", "100", "-f", "1", "-l", "1", "-singlefile", outputPdf, ppmPrefix], {
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
const saturated = countSaturatedPixels(`${ppmPrefix}.ppm`, SATURATION_DELTA);
if (saturated < SATURATED_PIXEL_FLOOR) {
process.stderr.write(`\n[diagram-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`);
}
expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR);
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120000);
test.skipIf(!avail.ok)("--strict fails on a missing image with a non-zero exit", () => {
if (!avail.ok) return;
const workDir = fs.mkdtempSync("/tmp/make-pdf-diagram-strict-");
const md = path.join(workDir, "doc.md");
fs.writeFileSync(md, "# T\n\n![gone](./does-not-exist.png)\n");
try {
let failed = false;
try {
execFileSync(PDF_BIN, ["generate", md, path.join(workDir, "out.pdf"), "--quiet", "--strict"], {
encoding: "utf8",
env: { ...process.env, BROWSE_BIN },
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
} catch (err: any) {
failed = true;
const stderr = err.stderr?.toString() ?? "";
expect(stderr).toContain("image not found");
}
expect(failed).toBe(true);
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120000);
if (!avail.ok) {
test("diagram gate prerequisites are present (hard-required in CI)", () => {
if (process.env.CI) {
throw new Error(`diagram gate prerequisites missing in CI: ${avail.reason}`);
}
console.warn(`[skip] ${avail.reason}`);
});
}
});
+197
View File
@@ -0,0 +1,197 @@
/**
* Emoji render gate — proves emoji code points render as real color glyphs in
* the output PDF instead of .notdef tofu boxes (▯). This is the regression gate
* for fix/make-pdf-emoji-tofu.
*
* Why not just check pdftotext? Because text extraction is a FALSE oracle for
* emoji: Skia preserves the Unicode in the text cluster even when the displayed
* glyph is .notdef, so pdftotext can report the emoji survived on a render that
* actually drew tofu. Verified empirically on macOS — pdftotext extracts 😀
* regardless of whether a color font was available.
*
* Two assertions that DO distinguish a real render from tofu:
* 1. pdffonts shows an emoji family embedded in the PDF (the cascade selected
* a real emoji font — AppleColorEmoji as Type 3 on macOS, NotoColorEmoji
* on Linux). Missing-fallback => no emoji font embedded.
* 2. pdftoppm rasterizes the page and we count saturated (colored) pixels.
* A color-emoji render has hundreds (measured: ~1650 at 100dpi); a tofu
* render is a monochrome black outline on white (~0 saturated). Tolerant
* threshold, not an exact-pixel fixture diff, to dodge cross-platform AA
* and font-version variance.
*
* Note: pdfimages -list is intentionally NOT used — macOS embeds color emoji as
* Type 3 fonts, so pdfimages lists nothing even on a correct render.
*
* Gating: runs only when the compiled binary + browse + pdffonts + pdftoppm are
* available AND a color-emoji font is installed for Chromium to fall back to.
* In CI (process.env.CI set) missing prerequisites are a HARD FAILURE, not a
* skip — CI is expected to install poppler-utils + fonts-noto-color-emoji, so a
* silent skip there would let the tofu regression ship behind a green build.
* Local dev without those tools skips cleanly.
*/
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { resolvePopplerTool } from "../../src/pdftotext";
const FIXTURE = path.resolve(__dirname, "../fixtures/emoji-gate.md");
const ROOT = path.resolve(__dirname, "../../..");
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
// Saturated-pixel floor. Measured ~1650 at 100dpi for the fixture's color
// emoji; a tofu render yields ~0. 200 sits well clear of both.
const SATURATED_PIXEL_FLOOR = 200;
// A pixel is "colored" when its max-min channel spread exceeds this. Black text,
// gray rules, and white background all stay near 0; color emoji spike high.
const SATURATION_DELTA = 40;
// Per-child wall-clock bound. Bun's test timeout doesn't reliably interrupt a
// synchronous execFileSync, so each child gets its own ceiling — a wedged
// browser/poppler binary (or a hostile GSTACK_*_BIN override) fails instead of
// hanging the whole job.
const CHILD_TIMEOUT_MS = 25_000;
/** Is a color-emoji font available for Chromium to fall back to? */
function emojiFontAvailable(): boolean {
if (process.platform === "darwin") {
return fs.existsSync("/System/Library/Fonts/Apple Color Emoji.ttc");
}
if (process.platform === "linux") {
const fcMatch = Bun.which("fc-match");
if (!fcMatch) return false;
try {
const out = execFileSync(
fcMatch,
["-f", "%{color}\n", ":lang=und-zsye:charset=1F600"],
{ encoding: "utf8", timeout: CHILD_TIMEOUT_MS },
);
return /true/i.test(out);
} catch {
return false;
}
}
return false;
}
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
if (!resolvePopplerTool("pdffonts")) return { ok: false, reason: "pdffonts not found (install poppler-utils)." };
if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." };
if (!emojiFontAvailable()) return { ok: false, reason: "no color-emoji font installed; run ./setup (Linux) or install one." };
return { ok: true };
}
/**
* Count pixels in a P6 (binary) PPM whose RGB channel spread exceeds delta.
* Validates the header and buffer length so malformed/variant output is a hard
* diagnostic (thrown), never a silently-wrong count.
*/
function countSaturatedPixels(ppmPath: string, delta: number): number {
const b = fs.readFileSync(ppmPath);
let i = 0;
const skipWhitespaceAndComments = () => {
for (;;) {
while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++;
if (b[i] === 0x23) { // '#': comment runs to end of line
while (i < b.length && b[i] !== 0x0a) i++;
continue;
}
break;
}
};
const token = (): string => {
skipWhitespaceAndComments();
const s = i;
while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++;
return b.slice(s, i).toString("ascii");
};
const magic = token();
if (magic !== "P6") throw new Error(`expected P6 PPM, got "${magic}"`);
const w = Number(token());
const h = Number(token());
const maxval = Number(token());
if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) {
throw new Error(`invalid PPM dimensions: ${w}x${h}`);
}
if (maxval !== 255) {
// pdftoppm emits 8-bit P6 (maxval 255). 16-bit would be 2 bytes/channel and
// would break the byte math below — fail loudly rather than miscount.
throw new Error(`unexpected PPM maxval ${maxval} (expected 255)`);
}
i++; // single whitespace byte after maxval precedes the pixel block
const total = w * h;
if (b.length - i < total * 3) {
throw new Error(`PPM pixel buffer too short: have ${b.length - i}, need ${total * 3}`);
}
let sat = 0;
for (let p = 0; p < total; p++) {
const o = i + p * 3;
const r = b[o], g = b[o + 1], bl = b[o + 2];
if (Math.max(r, g, bl) - Math.min(r, g, bl) > delta) sat++;
}
return sat;
}
describe("emoji render gate", () => {
const avail = prerequisitesAvailable();
test.skipIf(!avail.ok)("emoji render as color glyphs, not tofu", () => {
if (!avail.ok) return; // type narrowing
// Private temp dir under /tmp: browse's validateOutputPath only allows
// /tmp and /private/tmp (not os.tmpdir()'s /var/folders), and mkdtemp
// dodges the predictable-path symlink/collision risk.
const workDir = fs.mkdtempSync("/tmp/make-pdf-emoji-gate-");
const outputPdf = path.join(workDir, "out.pdf");
const ppmPrefix = path.join(workDir, "page");
const ppmPath = `${ppmPrefix}.ppm`;
try {
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
encoding: "utf8",
env: { ...process.env, BROWSE_BIN },
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
expect(fs.existsSync(outputPdf)).toBe(true);
// 1. An emoji family must be embedded — the cascade found a real emoji
// font instead of falling through to .notdef.
const pdffonts = resolvePopplerTool("pdffonts")!;
const fontList = execFileSync(pdffonts, [outputPdf], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
if (!/emoji/i.test(fontList)) {
process.stderr.write(`\n--- pdffonts ---\n${fontList}\n--- END ---\n`);
}
expect(/emoji/i.test(fontList)).toBe(true);
// 2. The page must actually rasterize to color, not a monochrome tofu box.
const pdftoppm = resolvePopplerTool("pdftoppm")!;
execFileSync(pdftoppm, ["-r", "100", "-singlefile", outputPdf, ppmPrefix], {
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
expect(fs.existsSync(ppmPath)).toBe(true);
const saturated = countSaturatedPixels(ppmPath, SATURATION_DELTA);
if (saturated < SATURATED_PIXEL_FLOOR) {
process.stderr.write(`\n[emoji-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`);
}
expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR);
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 60000);
if (!avail.ok) {
// In CI, missing prerequisites are a hard failure — a silent skip would let
// the Linux tofu regression ship behind a green build. Locally, just warn.
test("emoji gate prerequisites are present (hard-required in CI)", () => {
if (process.env.CI) {
throw new Error(`emoji gate prerequisites missing in CI: ${avail.reason}`);
}
console.warn(`[skip] ${avail.reason}`);
});
}
});
+131
View File
@@ -0,0 +1,131 @@
/**
* Output-format gate for `--to html` and `--to docx` (eng-review P7/P8),
* driven through the compiled binary against the diagram-gate fixture
* (diagrams + relative image + broken fence + render=false fence).
*
* HTML contract: ONE self-contained file — zero network references, no
* scripts, diagrams as inline SVG, images as data URIs, screen media layer.
*
* DOCX contract: content fidelity, not layout fidelity — valid OOXML zip,
* document.xml carries headings/code/diagnostics, diagrams embedded as PNG
* media. (A .docx is a zip: unzip -p is the oracle.)
*/
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
const FIXTURE = path.resolve(__dirname, "../fixtures/diagram-gate.md");
const ROOT = path.resolve(__dirname, "../../..");
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html");
const CHILD_TIMEOUT_MS = 60_000;
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}).` };
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
if (!Bun.which("unzip")) return { ok: false, reason: "unzip not found (needed for docx zip checks)." };
return { ok: true };
}
function generate(to: string, outputPath: string): void {
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPath, "--quiet", "--to", to], {
encoding: "utf8",
env: { ...process.env, BROWSE_BIN },
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
}
describe("output format gate", () => {
const avail = prerequisitesAvailable();
test.skipIf(!avail.ok)("--to html: single self-contained file, zero network refs", () => {
if (!avail.ok) return;
const workDir = fs.mkdtempSync("/tmp/make-pdf-format-html-");
const out = path.join(workDir, "out.html");
try {
generate("html", out);
const html = fs.readFileSync(out, "utf8");
// Zero network references and zero scripts. (The only http(s) tokens
// allowed are XML namespace identifiers inside inline SVG, which are
// never fetched.)
const refs = html.match(/\b(?:src|href)\s*=\s*"https?:[^"]*"/gi) ?? [];
expect(refs).toEqual([]);
expect(html).not.toMatch(/<script\b/i);
expect(html).not.toMatch(/<link\b/i);
// Diagrams inline as vector SVG; images inline as data URIs.
expect(html).toContain('<figure class="diagram"');
expect(html).toMatch(/<svg/i);
expect(html).toContain("data:image/png;base64,");
// Screen layer present; diagnostic block survived.
expect(html).toContain("@media screen");
expect(html).toContain("Diagram failed to render (mermaid)");
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120000);
test.skipIf(!avail.ok)("--to docx: valid OOXML with content + PNG diagram media", () => {
if (!avail.ok) return;
const workDir = fs.mkdtempSync("/tmp/make-pdf-format-docx-");
const out = path.join(workDir, "out.docx");
try {
generate("docx", out);
const listing = execFileSync("unzip", ["-l", out], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
expect(listing).toContain("word/document.xml");
expect(listing).toContain("[Content_Types].xml");
// Diagram PNGs + fixture image land in media/.
expect((listing.match(/word\/media\/image[^\s]*\.png/g) ?? []).length).toBeGreaterThanOrEqual(2);
const xml = execFileSync("unzip", ["-p", out, "word/document.xml"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
const text = xml
.replace(/<[^>]+>/g, " ")
.replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/g, "&");
// Headings, render=false code, and the diagnostic all survive.
expect(text).toContain("Diagram Gate");
expect(text).toContain("RAWKEPT");
expect(text).toContain("Diagram failed to render");
// Rendered fences ship as images, not leaked source.
expect(text).not.toContain("GATEALPHA[");
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120000);
test.skipIf(!avail.ok)("--to rejects unknown formats with a --format disambiguation hint", () => {
if (!avail.ok) return;
let stderr = "";
try {
execFileSync(PDF_BIN, ["generate", FIXTURE, "--to", "epub"], {
encoding: "utf8",
env: { ...process.env, BROWSE_BIN },
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
} catch (err: any) {
stderr = err.stderr?.toString() ?? "";
}
expect(stderr).toContain("invalid --to");
expect(stderr).toContain("--page-size alias");
}, 60000);
if (!avail.ok) {
test("format gate prerequisites are present (hard-required in CI)", () => {
if (process.env.CI) {
throw new Error(`format gate prerequisites missing in CI: ${avail.reason}`);
}
console.warn(`[skip] ${avail.reason}`);
});
}
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Landscape promotion gate — proves the conservative auto-landscape policy
* end-to-end through the compiled binary, asserted on pdfinfo per-page boxes
* (the only oracle that can't lie about orientation).
*
* The fixture encodes one of each decision:
* - wide screenshot, no alt hint → MUST stay portrait (false-positive guard)
* - wide image, alt "architecture diagram" → promotes
* - small image with {page=landscape} → promotes (directive force)
* - wide mermaid sequence diagram → promotes (provenance automatic)
* - wide mermaid with page=portrait fence → MUST stay portrait (veto)
*
* Also runs the --toc combo: Paged.js isn't shipped in v1 (TOC renders
* without page numbers, browse falls through after 3s), so named-page
* landscape must survive a --toc run unchanged. If Paged.js ever lands and
* re-paginates, this is the test that catches the interaction.
*/
import { describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { resolvePopplerTool } from "../../src/pdftotext";
const FIXTURE = path.resolve(__dirname, "../fixtures/landscape-gate.md");
const ROOT = path.resolve(__dirname, "../../..");
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html");
const CHILD_TIMEOUT_MS = 60_000;
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}).` };
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
if (!resolvePopplerTool("pdfinfo")) return { ok: false, reason: "pdfinfo not found (install poppler-utils)." };
if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." };
return { ok: true };
}
interface PageBox {
page: number;
width: number;
height: number;
}
function pageBoxes(pdfPath: string): PageBox[] {
const pdfinfo = resolvePopplerTool("pdfinfo")!;
const out = execFileSync(pdfinfo, ["-f", "1", "-l", "99", pdfPath], {
encoding: "utf8",
timeout: CHILD_TIMEOUT_MS,
});
const boxes: PageBox[] = [];
for (const m of out.matchAll(/Page\s+(\d+)\s+size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts/g)) {
boxes.push({ page: Number(m[1]), width: parseFloat(m[2]), height: parseFloat(m[3]) });
}
if (boxes.length === 0) throw new Error(`pdfinfo reported no page sizes:\n${out}`);
return boxes;
}
const isLandscape = (b: PageBox) => b.width > b.height;
function generate(args: string[], outputPdf: string): void {
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet", ...args], {
encoding: "utf8",
env: { ...process.env, BROWSE_BIN },
stdio: ["ignore", "pipe", "pipe"],
timeout: CHILD_TIMEOUT_MS,
});
}
describe("landscape promotion gate", () => {
const avail = prerequisitesAvailable();
test.skipIf(!avail.ok)("exactly the promoted blocks get landscape pages", () => {
if (!avail.ok) return;
const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-gate-");
const outputPdf = path.join(workDir, "out.pdf");
try {
generate([], outputPdf);
const boxes = pageBoxes(outputPdf);
const landscape = boxes.filter(isLandscape);
const portrait = boxes.filter((b) => !isLandscape(b));
// Three promotions: alt-hinted image, directive-forced image, wide diagram.
expect(landscape.length).toBe(3);
// First page (intro + screenshot) and the veto'd diagram stay portrait.
expect(portrait.length).toBeGreaterThanOrEqual(2);
expect(isLandscape(boxes[0])).toBe(false);
// The veto'd diagram rendered on SOME portrait page and NO landscape
// page — the actual invariant. (Asserting a specific page index breaks
// spuriously when font metrics shift pagination.)
const pdftotext = resolvePopplerTool("pdftotext")!;
const pageText = (page: number) =>
execFileSync(pdftotext, ["-f", String(page), "-l", String(page), outputPdf, "-"], {
encoding: "utf8",
timeout: CHILD_TIMEOUT_MS,
});
expect(portrait.some((b) => pageText(b.page).includes("vetoalpha"))).toBe(true);
expect(landscape.some((b) => pageText(b.page).includes("vetoalpha"))).toBe(false);
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120000);
test.skipIf(!avail.ok)("--toc combo: TOC renders and landscape promotion survives", () => {
if (!avail.ok) return;
const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-toc-");
const outputPdf = path.join(workDir, "out.pdf");
try {
generate(["--toc"], outputPdf);
const boxes = pageBoxes(outputPdf);
expect(boxes.filter(isLandscape).length).toBe(3);
const pdftotext = resolvePopplerTool("pdftotext")!;
const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
// TOC heading extracts uppercase (small-caps styling).
expect(text.toUpperCase()).toContain("CONTENTS");
} finally {
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}, 120000);
if (!avail.ok) {
test("landscape gate prerequisites are present (hard-required in CI)", () => {
if (process.env.CI) {
throw new Error(`landscape gate prerequisites missing in CI: ${avail.reason}`);
}
console.warn(`[skip] ${avail.reason}`);
});
}
});
+20
View File
@@ -0,0 +1,20 @@
The Horizon
This is the combined-features fixture. Every feature turned on simultaneously. The gate asserts that all of these paragraphs extract cleanly from the PDF with pdftotext.
A paragraph with bold, italic, and inline code tokens — each of which gets a different HTML treatment. None should fragment text on copy-paste.
A paragraph with “curly quotes”, single quotes, an em dash — like this, and an ellipsis… All three get smartypants transforms.
A subsection heading
First list item with some words that keep it on one line.
Second list item with more words.
Third list item.
A blockquote from Van Dyke. Her diminished size is in me, not in her.
A second chapter
This content begins on a fresh page because the default chapter-breaks rule fires. Extract must still find these paragraphs.
A final paragraph with enough words to trigger hyphenation across the line wrap boundary. Extraordinary words sometimes hyphenate. Interdisciplinary ones certainly do.
+30
View File
@@ -0,0 +1,30 @@
# The Horizon
This is the combined-features fixture. Every feature turned on simultaneously.
The gate asserts that all of these paragraphs extract cleanly from the PDF
with pdftotext.
A paragraph with **bold**, *italic*, and `inline code` tokens — each of which
gets a different HTML treatment. None should fragment text on copy-paste.
A paragraph with "curly quotes", 'single quotes', an em dash -- like this,
and an ellipsis... All three get smartypants transforms.
## A subsection heading
Lists must not break mid-item:
- First list item with some words that keep it on one line.
- Second list item with more words.
- Third list item.
> A blockquote from Van Dyke. Her diminished size is in me, not in her.
# A second chapter
This content begins on a fresh page because the default chapter-breaks rule
fires. Extract must still find these paragraphs.
A final paragraph with enough words to trigger hyphenation across the line
wrap boundary. Extraordinary words sometimes hyphenate. Interdisciplinary
ones certainly do.
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

+48
View File
@@ -0,0 +1,48 @@
# Diagram Gate
A relative local image (CRITICAL regression: must render, not 404):
![a red box](./diagram-assets/red-box.png)
## First diagram
```mermaid title="Gate pipeline"
graph LR
GATEALPHA[gatealphanode] --> GATEBETA{gatebetanode}
GATEBETA -->|yes| GATEGAMMA[gategammanode]
```
## Deliberately broken
```mermaid
graph LR
A -->
(((
```
## Second diagram (id-collision check)
```mermaid
graph TD
GATEDELTA[gatedeltanode] --> GATEEPSILON[gateepsilonnode]
```
## Kept as source
```mermaid render=false
graph LR
RAWKEPT --> ASCODE
```
## Excalidraw scene
```excalidraw title="Converted flowchart"
{"type":"excalidraw","version":2,"source":"gstack-diagram-render","elements":[{"id":"VL7JRGkMTpqCVBye2mq3X","type":"rectangle","x":0,"y":0,"width":197.046875,"height":44,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,"seed":172328728,"version":3,"versionNonce":1118377320,"isDeleted":false,"boundElements":[{"type":"text","id":"mQsqVweT6BUmQpwbW6sOU"},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow"}],"updated":1781273248718,"link":null,"locked":false},{"id":"YX9Ff_UgFhhRa7lGo6xS9","type":"rectangle","x":247.046875,"y":0,"width":186.4375,"height":44,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":null,"seed":1275860584,"version":3,"versionNonce":45230184,"isDeleted":false,"boundElements":[{"type":"text","id":"9oes2DZoL-mRrT3RGakLq"},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow"}],"updated":1781273248718,"link":null,"locked":false},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow","x":197.047,"y":22,"width":44.70000000000002,"height":0,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":2},"seed":1530192920,"version":4,"versionNonce":1747670296,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"points":[[0.5,0],[44.20000000000002,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"VL7JRGkMTpqCVBye2mq3X","focus":0,"gap":1},"endBinding":{"elementId":"YX9Ff_UgFhhRa7lGo6xS9","focus":0,"gap":5.299874999999986},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"mQsqVweT6BUmQpwbW6sOU","type":"text","x":33.5576171875,"y":9.5,"width":129.931640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a3","roundness":null,"seed":1219280408,"version":3,"versionNonce":1462825496,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"text":"excalialphanode","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"VL7JRGkMTpqCVBye2mq3X","originalText":"excalialphanode","autoResize":true,"lineHeight":1.25},{"id":"9oes2DZoL-mRrT3RGakLq","type":"text","x":280.2998046875,"y":9.5,"width":119.931640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a4","roundness":null,"seed":1436367640,"version":3,"versionNonce":639687528,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"text":"excalibetanode","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"YX9Ff_UgFhhRa7lGo6xS9","originalText":"excalibetanode","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}
```
## Huge photo (downscale trigger, no diagram hint)
![a big noisy photo](./diagram-assets/huge-noise.png)
Done.
+12
View File
@@ -0,0 +1,12 @@
# Emoji rendering gate 😀
This fixture exists to prove that emoji code points render as real color
glyphs in the output PDF, not as `.notdef` tofu boxes (▯).
Color emoji on one line: 😀 ❤️ 🚀 ✅ 💡
A variation-selector sequence (FE0F) renders color: ❤️ — the bare code point
❤ is text-style. Both must come from a font in the cascade, never tofu.
Non-emoji Unicode (unchanged, regression guard): em dash —, times ×, arrow →,
bullet •, ellipsis …
+52
View File
@@ -0,0 +1,52 @@
# Landscape Gate
Intro text under the first heading.
## Negative: screenshot stays portrait
![just a screenshot of the app](./diagram-assets/wide-screenshot.png)
## Positive: alt-hinted wide image promotes
![architecture diagram of the system](./diagram-assets/wide-arch.png)
## Positive: directive forces a small image
![small forced](./diagram-assets/red-box.png){page=landscape}
## Positive: wide diagram auto-promotes
```mermaid title="Wide sequence"
sequenceDiagram
participant A as seqalpha
participant B as seqbeta
participant C as seqgamma
participant D as seqdelta
participant E as seqepsilon
participant F as seqzeta
participant G as seqeta
participant H as seqtheta
participant I as seqiota
participant J as seqkappa
A->>J: long hop
B->>I: cross
```
## Negative: directive vetoes a wide diagram
```mermaid page=portrait
sequenceDiagram
participant A as vetoalpha
participant B as vetobeta
participant C as vetogamma
participant D as vetodelta
participant E as vetoepsilon
participant F as vetozeta
participant G as vetoeta
participant H as vetotheta
participant I as vetoiota
participant J as vetokappa
A->>J: long hop
```
Closing text.
+215
View File
@@ -0,0 +1,215 @@
/**
* Unit tests for the image width policy + conservative auto-landscape
* (image-policy.ts). Pure HTML-in/HTML-out — no browse daemon.
*
* The promotion heuristic is deliberately conservative (eng-review P4):
* false negatives are cheap (add {page=landscape}), false positives feel
* broken. The negative cases here are the load-bearing ones.
*/
import { describe, expect, test } from "bun:test";
import {
applyImageDirectives,
applyImagePolicy,
parseDirectives,
} from "../src/image-policy";
const silent = { warn: () => {} };
// 6.5in content box → threshold = 6.5 × 96 × 2.5 = 1560 CSS px.
// Letter landscape content box: 9in wide × 6.5in tall.
const LANDSCAPE = { contentWIn: 9, contentHIn: 6.5 };
const OPTS = { contentWidthIn: 6.5, landscape: LANDSCAPE, ...silent };
function img(attrs: string): string {
return `<p><img ${attrs}></p>`;
}
// ─── directive parsing ────────────────────────────────────────────────
describe("parseDirectives", () => {
test("width grammar", () => {
expect(parseDirectives("width=full")).toEqual({ width: "full", page: undefined });
expect(parseDirectives("width=50%")).toEqual({ width: "50%", page: undefined });
expect(parseDirectives("width=3in")).toEqual({ width: "3in", page: undefined });
expect(parseDirectives("width=2.5cm")).toEqual({ width: "2.5cm", page: undefined });
});
test("page grammar + combination", () => {
expect(parseDirectives("page=landscape")).toEqual({ width: undefined, page: "landscape" });
expect(parseDirectives("width=full page=portrait")).toEqual({ width: "full", page: "portrait" });
});
test("unknown tokens reject the whole group (stays visible text)", () => {
expect(parseDirectives("widht=full")).toBeNull();
expect(parseDirectives("width=full caption=x")).toBeNull();
});
test("malformed values reject", () => {
expect(parseDirectives("width=banana")).toBeNull();
expect(parseDirectives("page=sideways")).toBeNull();
});
});
describe("applyImageDirectives", () => {
test("brace suffix becomes data attrs and is consumed", () => {
const out = applyImageDirectives(`<p><img src="x.png" alt="a">{width=50%}</p>`);
expect(out).toContain('data-gstack-width="50%"');
expect(out).not.toContain("{width=50%}");
});
test("unrecognized brace group is left as literal text", () => {
const html = `<p><img src="x.png">{not a directive}</p>`;
expect(applyImageDirectives(html)).toBe(html);
});
test("non-adjacent braces untouched", () => {
const html = `<p>set {width=full} in config</p>`;
expect(applyImageDirectives(html)).toBe(html);
});
});
// ─── width policy ─────────────────────────────────────────────────────
describe("width styles", () => {
test("width=full → inline 100% style", () => {
const { html } = applyImagePolicy(img(`src="x" data-gstack-width="full"`), OPTS);
expect(html).toContain("width: 100%");
});
test("explicit dimension passes through", () => {
const { html } = applyImagePolicy(img(`src="x" data-gstack-width="3in"`), OPTS);
expect(html).toContain("width: 3in");
});
test("width directive merges with an existing style attribute, preserving it", () => {
const { html } = applyImagePolicy(
img(`src="x" style="border: 1px solid" data-gstack-width="50%"`),
OPTS,
);
expect(html).toContain("border: 1px solid");
expect(html).toContain("width: 50%");
});
test("no directive → no inline style (CSS max-width owns the default)", () => {
const { html } = applyImagePolicy(img(`src="x" data-gstack-px-width="40" data-gstack-px-height="20"`), OPTS);
expect(html).not.toContain("style=");
});
});
// ─── landscape promotion ──────────────────────────────────────────────
describe("auto-landscape: negative cases (the load-bearing ones)", () => {
test("wide screenshot with no alt hint stays portrait", () => {
const r = applyImagePolicy(
img(`src="x" alt="screenshot of the app" data-gstack-px-width="3000" data-gstack-px-height="900"`),
OPTS,
);
expect(r.hasLandscape).toBe(false);
expect(r.html).not.toContain("page-wide");
});
test("wide banner with hint but below width threshold stays portrait", () => {
const r = applyImagePolicy(
img(`src="x" alt="chart" data-gstack-px-width="1200" data-gstack-px-height="400"`),
OPTS,
);
expect(r.hasLandscape).toBe(false);
});
test("tall diagram (aspect below 1.8) stays portrait", () => {
const r = applyImagePolicy(
img(`src="x" alt="architecture diagram" data-gstack-px-width="2000" data-gstack-px-height="1500"`),
OPTS,
);
expect(r.hasLandscape).toBe(false);
});
test("no intrinsic dimensions stays portrait", () => {
const r = applyImagePolicy(img(`src="x" alt="diagram"`), OPTS);
expect(r.hasLandscape).toBe(false);
});
test("page=portrait vetoes everything", () => {
const r = applyImagePolicy(
img(`src="x" alt="diagram" data-gstack-page="portrait" data-gstack-px-width="4000" data-gstack-px-height="1000"`),
OPTS,
);
expect(r.hasLandscape).toBe(false);
});
test("threshold boundary is deterministic: exactly at threshold stays portrait", () => {
// threshold = 6.5 × 96 × 2.5 = 1560
const r = applyImagePolicy(
img(`src="x" alt="diagram" data-gstack-px-width="1560" data-gstack-px-height="600"`),
OPTS,
);
expect(r.hasLandscape).toBe(false);
const r2 = applyImagePolicy(
img(`src="x" alt="diagram" data-gstack-px-width="1561" data-gstack-px-height="600"`),
OPTS,
);
expect(r2.hasLandscape).toBe(true);
});
});
describe("auto-landscape: positive cases", () => {
test("wide + alt hint + over threshold promotes, wraps, and vertically centers", () => {
const warnings: string[] = [];
const r = applyImagePolicy(
img(`src="x" alt="architecture diagram" data-gstack-px-width="2400" data-gstack-px-height="1000"`),
{ contentWidthIn: 6.5, landscape: LANDSCAPE, warn: (m) => warnings.push(m) },
);
expect(r.hasLandscape).toBe(true);
// placed height = 9in × (1000/2400) = 3.75in → margin-top = (6.53.75)/2 ≈ 1.38in
expect(r.html).toContain('<div class="page-wide" style="margin-top: 1.38in"><img');
expect(r.html).not.toContain("<p><img");
expect(warnings[0]).toContain("landscape");
});
test("directive-forced tall block that fills the page gets no centering margin", () => {
// aspect 0.9 → placed height 9×0.9 = 8.1in > 6.5in box → margin clamps to 0
const r = applyImagePolicy(
img(`src="x" data-gstack-page="landscape" data-gstack-px-width="1000" data-gstack-px-height="900"`),
OPTS,
);
expect(r.hasLandscape).toBe(true);
expect(r.html).toContain('<div class="page-wide"><img');
expect(r.html).not.toContain("margin-top");
});
test("page=landscape forces promotion regardless of size", () => {
const r = applyImagePolicy(img(`src="x" data-gstack-page="landscape"`), OPTS);
expect(r.hasLandscape).toBe(true);
// no intrinsic dims → no centering guess, top placement
expect(r.html).toContain('<div class="page-wide"><img');
});
test("alt hint matches whole words only", () => {
const r = applyImagePolicy(
img(`src="x" alt="photographic" data-gstack-px-width="2400" data-gstack-px-height="1000"`),
OPTS,
);
expect(r.hasLandscape).toBe(false); // "graph" inside "photographic" must not match
});
});
describe("auto-landscape: diagram figures", () => {
const fig = (svgAttrs: string, figAttrs = "") =>
`<figure class="diagram" role="img" aria-label="d"${figAttrs}>\n<svg ${svgAttrs}><g/></svg>\n</figure>`;
test("wide diagram via viewBox promotes and centers (provenance automatic, no alt needed)", () => {
const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 2050 600"`), OPTS);
expect(r.hasLandscape).toBe(true);
// placed height = 9 × 600/2050 ≈ 2.63in → margin-top = (6.52.63)/2 ≈ 1.93in
expect(r.html).toContain('<div class="page-wide" style="margin-top: 1.93in"><figure');
});
test("normal flowchart stays portrait", () => {
const r = applyImagePolicy(fig(`width="100%" viewBox="0 0 800 400"`), OPTS);
expect(r.hasLandscape).toBe(false);
});
test("fence page=portrait vetoes a wide diagram", () => {
const r = applyImagePolicy(
fig(`width="100%" viewBox="0 0 3000 600"`, ` data-gstack-page="portrait"`),
OPTS,
);
expect(r.hasLandscape).toBe(false);
});
test("fence page=landscape forces a small diagram", () => {
const r = applyImagePolicy(
fig(`width="100%" viewBox="0 0 400 300"`, ` data-gstack-page="landscape"`),
OPTS,
);
expect(r.hasLandscape).toBe(true);
});
test("diagnostic blocks are never promoted", () => {
const html = `<figure class="diagram diagram-error" role="img" aria-label="x"><svg viewBox="0 0 4000 600"></svg></figure>`;
const r = applyImagePolicy(html, OPTS);
expect(r.hasLandscape).toBe(false);
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* pdftotext unit tests — normalize() and copyPasteGate() assertions.
*
* These tests are pure unit tests of the normalization + assertion logic.
* They do NOT require pdftotext to be installed (the actual binary is
* mocked by manipulating strings directly).
*/
import { describe, expect, test } from "bun:test";
import * as path from "node:path";
import { normalize, copyPasteGate, findExecutable, resolvePdftotext, PdftotextUnavailableError } from "../src/pdftotext";
describe("normalize", () => {
test("strips trailing spaces", () => {
expect(normalize("hello \nworld")).toBe("hello\nworld");
});
test("collapses runs of 3+ blank lines to 2", () => {
expect(normalize("a\n\n\n\nb")).toBe("a\n\nb");
});
test("converts form feeds to double newlines (page break boundary)", () => {
expect(normalize("page1\fpage2")).toBe("page1\n\npage2");
});
test("normalizes CRLF and CR to LF (Windows Xpdf)", () => {
expect(normalize("a\r\nb\rc")).toBe("a\nb\nc");
});
test("removes soft hyphens (hyphens: auto artifact)", () => {
expect(normalize("extra\u00adordinary")).toBe("extraordinary");
});
test("replaces non-breaking space with regular space", () => {
expect(normalize("hello\u00a0world")).toBe("hello world");
});
test("strips zero-width characters", () => {
expect(normalize("a\u200bb\u200cc")).toBe("abc");
});
test("NFC-normalizes composed glyphs (macOS NFD → Linux NFC)", () => {
// "é" composed vs decomposed
const decomposed = "e\u0301";
const composed = "\u00e9";
expect(normalize(decomposed)).toBe(composed);
});
test("trims leading/trailing whitespace on whole string", () => {
expect(normalize("\n\n hello \n\n")).toBe("hello");
});
});
describe("copyPasteGate — assertion logic", () => {
// These tests exercise the gate's internal assertions by mocking the
// pdftotext step. We can't easily run the real binary in every test
// env, so we verify the assertion logic directly via fake inputs.
//
// The gate takes a PDF path — but assertion #1 (paragraph presence) and
// #2 (per-glyph emission) are string operations we can validate here.
test("flags 'S ai li ng' per-glyph emission when reassembled letters appear in source", () => {
// Build expected/extracted strings that would trip the gate.
const expected = "Sailing on the open sea.";
const extracted = "S a i l i n g on the open sea.";
// Simulate by running normalize + assertion manually; the regex is
// looked at in the gate.
const fragRegex = /((?:\b\w\s){4,})/g;
const match = fragRegex.exec(extracted);
expect(match).not.toBeNull();
if (match) {
const letters = match[1].replace(/\s/g, "");
expect(letters.toLowerCase()).toBe("sailing");
expect(expected.toLowerCase().includes(letters.toLowerCase())).toBe(true);
}
});
test("does NOT flag 'A B C D' as per-glyph when letters don't appear in source", () => {
const expected = "The quick brown fox.";
const extracted = "The quick A B C D brown fox.";
const fragRegex = /((?:\b\w\s){4,})/g;
const match = fragRegex.exec(extracted);
if (match) {
const letters = match[1].replace(/\s/g, "");
// "ABCD" is not a substring of expected
expect(expected.toLowerCase().includes(letters.toLowerCase())).toBe(false);
}
});
test("paragraph boundary count drift calculation", () => {
const expected = "para1\n\npara2\n\npara3";
const extractedOk = "para1\n\npara2\n\npara3";
const extractedTooFew = "para1 para2 para3";
const extractedTooMany = "para1\n\n\n\npara2\n\n\n\npara3\n\n\n\npara4\n\n\n\npara5";
const expectedBreaks = (expected.match(/\n\n/g) || []).length;
const okBreaks = (extractedOk.match(/\n\n/g) || []).length;
const tooFewBreaks = (extractedTooFew.match(/\n\n/g) || []).length;
const tooManyBreaksNormalized = (normalize(extractedTooMany).match(/\n\n/g) || []).length;
expect(Math.abs(expectedBreaks - okBreaks)).toBeLessThanOrEqual(4);
expect(Math.abs(expectedBreaks - tooFewBreaks)).toBeGreaterThan(1);
// After normalize, 3+ newlines become 2, so the count matches
expect(Math.abs(expectedBreaks - tooManyBreaksNormalized)).toBeLessThanOrEqual(4);
});
});
// ─── Binary resolution (v1.24-aligned) ──────────────────────────
const REAL_EXE: string =
process.platform === "win32"
? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
: "/bin/sh";
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
const saved: Record<string, string | undefined> = {};
for (const k of Object.keys(overrides)) saved[k] = process.env[k];
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
try {
return fn();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}
describe("findExecutable (pdftotext.ts)", () => {
test("returns the bare path on POSIX when it's executable", () => {
if (process.platform === "win32") return;
expect(findExecutable("/bin/sh")).toBe("/bin/sh");
});
test("on win32, probes .exe / .cmd / .bat after the bare-path miss", () => {
if (process.platform !== "win32") return;
const base = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd");
expect(findExecutable(base)).toBe(base + ".exe");
});
test("returns null when no extension matches", () => {
expect(findExecutable("/nonexistent/path/to/nothing")).toBeNull();
});
});
describe("resolvePdftotext (override resolution, v1.24-aligned)", () => {
test("honors GSTACK_PDFTOTEXT_BIN when it points at a real executable", () => {
// We can't fake a real pdftotext, but we can fake "any executable" to
// exercise the override-resolution path. describeBinary will mark flavor
// as "unknown" since cmd.exe / /bin/sh don't respond to -v like pdftotext;
// the test asserts on the bin-path resolution, not the version probe.
const info = withEnv({ GSTACK_PDFTOTEXT_BIN: REAL_EXE }, () => resolvePdftotext());
expect(info.bin).toBe(REAL_EXE);
});
test("honors PDFTOTEXT_BIN as a back-compat alias", () => {
const info = withEnv(
{ GSTACK_PDFTOTEXT_BIN: undefined, PDFTOTEXT_BIN: REAL_EXE },
() => resolvePdftotext(),
);
expect(info.bin).toBe(REAL_EXE);
});
test("GSTACK_PDFTOTEXT_BIN takes precedence over PDFTOTEXT_BIN", () => {
const info = withEnv(
{ GSTACK_PDFTOTEXT_BIN: REAL_EXE, PDFTOTEXT_BIN: "/nonexistent/legacy" },
() => resolvePdftotext(),
);
expect(info.bin).toBe(REAL_EXE);
});
test("strips wrapping double quotes from override values", () => {
const info = withEnv({ GSTACK_PDFTOTEXT_BIN: `"${REAL_EXE}"` }, () => resolvePdftotext());
expect(info.bin).toBe(REAL_EXE);
});
test("error message includes Windows install hint and GSTACK_PDFTOTEXT_BIN", () => {
let thrown: unknown = null;
try {
withEnv(
{
GSTACK_PDFTOTEXT_BIN: "/nonexistent/gstack-pdftotext",
PDFTOTEXT_BIN: "/nonexistent/pdftotext",
PATH: "",
Path: "",
},
() => resolvePdftotext(),
);
} catch (err) {
thrown = err;
}
// If the test box has a real pdftotext on disk, resolution succeeds
// (POSIX candidates) — that's fine; the assertion is gated on whether
// it threw. On Windows-CI without poppler, it throws.
if (thrown) {
expect(thrown).toBeInstanceOf(PdftotextUnavailableError);
expect((thrown as Error).message).toContain("pdftotext not found");
expect((thrown as Error).message).toContain("GSTACK_PDFTOTEXT_BIN");
expect((thrown as Error).message).toContain("Windows");
expect((thrown as Error).message).toContain("scoop install poppler");
}
});
});
+523
View File
@@ -0,0 +1,523 @@
/**
* Renderer unit tests — pure-function assertions for render.ts, smartypants.ts,
* and print-css.ts. No Playwright, no PDF generation.
*/
import { describe, expect, test } from "bun:test";
import { render, sanitizeUntrustedHtml } from "../src/render";
import { smartypants } from "../src/smartypants";
import { printCss } from "../src/print-css";
// ─── smartypants ──────────────────────────────────────────────
describe("smartypants", () => {
test("converts straight double quotes to curly", () => {
const out = smartypants(`<p>She said "hello" to him.</p>`);
expect(out).toContain("\u201chello\u201d");
});
test("converts em dash (--)", () => {
const out = smartypants(`<p>This is it -- the answer.</p>`);
expect(out).toContain("\u2014");
});
test("converts ellipsis (...)", () => {
const out = smartypants(`<p>Wait...</p>`);
expect(out).toContain("\u2026");
});
test("converts apostrophes in contractions", () => {
const out = smartypants(`<p>don't you know?</p>`);
expect(out).toContain("don\u2019t");
});
test("does NOT touch content inside <code> blocks", () => {
const input = `<pre><code>const x = "hello"; // it's fine</code></pre>`;
const out = smartypants(input);
expect(out).toBe(input); // unchanged
});
test("does NOT touch content inside <pre> blocks", () => {
const input = `<pre>"quoted" -- don't</pre>`;
const out = smartypants(input);
expect(out).toBe(input);
});
test("does NOT touch inline code", () => {
const out = smartypants(`<p>Use <code>it's</code> like this: "hello".</p>`);
expect(out).toContain("<code>it's</code>");
expect(out).toContain("\u201chello\u201d");
});
test("does NOT touch URLs", () => {
const out = smartypants(`<p>Visit https://example.com/it's-page for "details".</p>`);
expect(out).toContain("https://example.com/it's-page");
expect(out).toContain("\u201cdetails\u201d");
});
test("does NOT touch HTML attribute values", () => {
const out = smartypants(`<a href="it's-a-test.html">link</a>`);
expect(out).toContain(`href="it's-a-test.html"`);
});
test("does NOT convert -- in CLI flags", () => {
// Prose like "try --verbose mode" should not turn -- into em dash
const out = smartypants(`<p>Try --verbose mode.</p>`);
// Since "--" is followed by a word char but not preceded by word/space,
// it should remain intact. We're lenient here — acceptable either way.
expect(out).toMatch(/--verbose|—verbose/);
});
});
// ─── sanitizer ──────────────────────────────────────────────
describe("sanitizeUntrustedHtml", () => {
test("strips <script> tags and content", () => {
const input = `<p>hello</p><script>alert(1)</script><p>world</p>`;
const out = sanitizeUntrustedHtml(input);
expect(out).not.toContain("<script");
expect(out).not.toContain("alert");
expect(out).toContain("<p>hello</p>");
expect(out).toContain("<p>world</p>");
});
test("strips <iframe>", () => {
const input = `<p>hi</p><iframe src="evil.com"></iframe>`;
expect(sanitizeUntrustedHtml(input)).not.toContain("<iframe");
});
test("strips onclick attribute", () => {
const input = `<a href="#" onclick="alert(1)">click</a>`;
const out = sanitizeUntrustedHtml(input);
expect(out).not.toContain("onclick");
expect(out).toContain("href=\"#\"");
});
test("strips event handlers with mixed case (onClick, ONCLICK)", () => {
const input1 = `<a href="#" onClick="x()">a</a>`;
const input2 = `<a href="#" ONCLICK="x()">b</a>`;
expect(sanitizeUntrustedHtml(input1)).not.toContain("onClick");
expect(sanitizeUntrustedHtml(input2)).not.toContain("ONCLICK");
});
test("rewrites javascript: URLs in href to #", () => {
const input = `<a href="javascript:alert(1)">bad</a>`;
const out = sanitizeUntrustedHtml(input);
expect(out).not.toContain("javascript:");
expect(out).toContain('href="#"');
});
test("strips inline SVG <script>", () => {
const input = `<svg><script>alert(1)</script><circle r="5"/></svg>`;
const out = sanitizeUntrustedHtml(input);
expect(out).not.toContain("<script");
expect(out).toContain("<circle");
});
test("strips <object>, <embed>, <link>, <meta>, <base>, <form>", () => {
const input = `
<object data="x.swf"></object>
<embed src="y.mov">
<link rel="stylesheet" href="evil.css">
<meta http-equiv="refresh" content="0;url=evil">
<base href="evil.com">
<form action="evil"><input/></form>
`;
const out = sanitizeUntrustedHtml(input);
expect(out).not.toContain("<object");
expect(out).not.toContain("<embed");
expect(out).not.toContain("<link");
expect(out).not.toContain("<meta");
expect(out).not.toContain("<base");
expect(out).not.toContain("<form");
});
test("strips srcdoc attribute (iframe escape vector)", () => {
const input = `<div srcdoc="<script>bad</script>">hi</div>`;
expect(sanitizeUntrustedHtml(input)).not.toContain("srcdoc");
});
});
// ─── end-to-end render ──────────────────────────────────────────────
describe("render (end-to-end)", () => {
test("produces a full HTML document with title, body, and CSS", () => {
const result = render({
markdown: `# Hello\n\nA paragraph with "quotes" and -- dashes.\n`,
});
expect(result.html).toContain("<!doctype html>");
expect(result.html).toContain("<title>Hello</title>");
expect(result.html).toContain("<h1");
expect(result.html).toContain("Hello");
// CSS should be inlined as <style>...
expect(result.html).toMatch(/<style>[\s\S]*font-family: Helvetica/);
// Smartypants ran
expect(result.html).toContain("\u201cquotes\u201d");
expect(result.html).toContain("\u2014");
});
test("derives title from first H1 when --title is not passed", () => {
const result = render({ markdown: `# My Title\n\nBody.` });
expect(result.meta.title).toBe("My Title");
});
test("uses --title override when provided", () => {
const result = render({
markdown: `# Auto-derived\n\nBody.`,
title: "Explicit Title",
});
expect(result.meta.title).toBe("Explicit Title");
});
test("includes cover block when cover=true", () => {
const result = render({
markdown: `# Doc\n\nBody.`,
cover: true,
subtitle: "A subtitle",
author: "Garry Tan",
});
expect(result.html).toContain(`class="cover"`);
expect(result.html).toContain(`class="cover-title"`);
expect(result.html).toContain("A subtitle");
expect(result.html).toContain("Garry Tan");
});
test("omits cover block when cover=false", () => {
const result = render({ markdown: `# Memo\n\nBody.` });
expect(result.html).not.toContain(`class="cover"`);
});
test("injects watermark element when --watermark is set", () => {
const result = render({ markdown: `# Doc`, watermark: "DRAFT" });
expect(result.html).toContain(`class="watermark"`);
expect(result.html).toContain("DRAFT");
// And the CSS rule for it must be present
expect(result.html).toContain("position: fixed");
expect(result.html).toContain("rotate(-30deg)");
});
test("wraps each H1 in its own .chapter section (default)", () => {
const result = render({
markdown: `# One\n\nbody 1\n\n# Two\n\nbody 2\n`,
});
const chapterMatches = result.html.match(/class="chapter"/g);
expect(chapterMatches).toBeTruthy();
if (chapterMatches) expect(chapterMatches.length).toBe(2);
});
test("does NOT create chapter sections when noChapterBreaks=true", () => {
const result = render({
markdown: `# One\n\nbody\n\n# Two\n\nbody\n`,
noChapterBreaks: true,
});
const chapterMatches = result.html.match(/class="chapter"/g) ?? [];
expect(chapterMatches.length).toBe(1);
});
test("builds a TOC with H1/H2 entries when toc=true", () => {
const result = render({
markdown: `# One\n\n## Sub\n\nbody\n\n# Two\n\nbody\n`,
toc: true,
});
expect(result.html).toContain(`class="toc"`);
expect(result.html).toContain(`<h2>Contents</h2>`);
expect(result.html).toContain("One");
expect(result.html).toContain("Sub");
expect(result.html).toContain("Two");
});
test("strips dangerous HTML from untrusted markdown", () => {
const result = render({
markdown: `# Safe\n\n<script>alert('xss')</script>\n\nBody.`,
});
expect(result.html).not.toContain("<script");
expect(result.html).not.toContain("alert");
expect(result.html).toContain("Safe");
});
test("respects text-align: left — no justify in print CSS", () => {
const result = render({ markdown: `para1\n\npara2\n` });
// The rule from the design-review fix: no p + p indent, text-align: left.
expect(result.printCss).toContain("text-align: left");
expect(result.printCss).not.toContain("text-align: justify");
expect(result.printCss).not.toContain("text-indent");
});
test("includes CJK font fallback in body", () => {
const result = render({ markdown: `body` });
expect(result.printCss).toContain("Hiragino Kaku Gothic");
expect(result.printCss).toContain("Noto Sans CJK");
});
});
// ─── print-css ──────────────────────────────────────────────
describe("printCss", () => {
test("emits 1in margins by default", () => {
const css = printCss();
expect(css).toContain("margin: 1in");
});
test("respects custom margins flag", () => {
const css = printCss({ margins: "72pt" });
expect(css).toContain("margin: 72pt");
});
test("per-side margins reach the CSS @page rule (preferCSSPageSize parity)", () => {
// Under a landscape promotion Chromium honors the CSS margins, not the
// CDP per-side options — render() must compose them into the shorthand.
const r = render({ markdown: "# T", marginLeft: "0.5in", marginRight: "0.5in" });
expect(r.printCss).toContain("margin: 1in 0.5in 1in 0.5in");
});
test("emits letter page size by default", () => {
const css = printCss();
expect(css).toContain("size: letter");
});
test("respects custom page size", () => {
const css = printCss({ pageSize: "a4" });
expect(css).toContain("size: a4");
});
test("suppresses running header and footer on cover page", () => {
const css = printCss();
expect(css).toMatch(/@page\s*:first\s*\{[\s\S]*?content:\s*none[\s\S]*?content:\s*none/);
});
test("omits CONFIDENTIAL when confidential=false", () => {
const css = printCss({ confidential: false });
expect(css).not.toContain("CONFIDENTIAL");
});
test("emits watermark CSS only when watermark is set", () => {
const withWatermark = printCss({ watermark: "DRAFT" });
expect(withWatermark).toContain(".watermark");
expect(withWatermark).toContain("rotate(-30deg)");
const withoutWatermark = printCss();
expect(withoutWatermark).not.toContain(".watermark");
});
test("drops chapter break rule when noChapterBreaks=true", () => {
const on = printCss({ noChapterBreaks: false });
expect(on).toContain("break-before: page");
const off = printCss({ noChapterBreaks: true });
expect(off).not.toContain(".chapter { break-before: page");
});
test("always sets p { text-align: left }", () => {
const css = printCss();
expect(css).toContain("text-align: left");
});
test("never sets text-indent on p", () => {
const css = printCss();
// Confirm no p-indent slipped in
expect(css).not.toMatch(/p\s*\+\s*p\s*\{[^}]*text-indent/);
});
test("emits @bottom-center page-number rule by default", () => {
const css = printCss();
expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("suppresses @bottom-center page-number rule when pageNumbers=false", () => {
const css = printCss({ pageNumbers: false });
expect(css).not.toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("still emits @bottom-center when pageNumbers=true (explicit)", () => {
const css = printCss({ pageNumbers: true });
expect(css).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
// Zero image truncation, ever: the cap must be a GLOBAL img rule. Markdown
// images render as <p><img> (no figure), so a figure-scoped cap alone lets
// wide screenshots run off the page edge — the exact regression this pins.
test("emits a global img max-width cap (zero truncation invariant)", () => {
const css = printCss();
expect(css).toMatch(/(^|\n)img\s*\{\s*max-width:\s*100%;\s*height:\s*auto;\s*\}/);
});
test("typography floor: body 12pt, poster cover, readable TOC", () => {
const css = printCss({ cover: true, toc: true });
expect(css).toContain("font-size: 12pt"); // body
expect(css).toMatch(/\.cover h1\.cover-title\s*\{[^}]*font-size:\s*56pt/);
expect(css).toMatch(/\.cover \.cover-meta\s*\{[^}]*font-size:\s*13pt/);
expect(css).toMatch(/\.toc li\s*\{[^}]*font-size:\s*12pt/);
});
test("page-wide carries the named page and NO height/flex centering", () => {
const css = printCss();
expect(css).toMatch(/\.page-wide\s*\{[^}]*page:\s*wide/);
// Centering is computed by image-policy as an inline margin-top. CSS
// flex/min-height centering fragments into phantom empty landscape pages
// in Chromium — this pins the regression (landscape-gate: 5 pages for 3
// promotions, bisected to min-height at any value).
expect(css).not.toMatch(/\.page-wide\s*\{[^}]*min-height/);
expect(css).not.toMatch(/\.page-wide\s*\{[^}]*flex/);
});
test("font stacks include Liberation Sans adjacent to Helvetica", () => {
const css = printCss({ confidential: true });
// Body stack
expect(css).toMatch(/font-family:\s*Helvetica,\s*"Liberation Sans",\s*Arial/);
// At least one @page margin box (running header / page number / CONFIDENTIAL)
// should also have the updated stack.
const marginBoxStacks = css.match(/@(top|bottom)-(center|right)\s*\{[^}]*Liberation Sans/g) ?? [];
expect(marginBoxStacks.length).toBeGreaterThanOrEqual(1);
});
test("all four original Helvetica stacks now include Liberation Sans", () => {
const css = printCss({ runningHeader: "Running Title", confidential: true });
// Count: body (1) + running header (1) + page numbers (1) + confidential (1) = 4
const occurrences = (css.match(/"Liberation Sans"/g) ?? []).length;
expect(occurrences).toBeGreaterThanOrEqual(4);
});
// ─── emoji fallback (fix/make-pdf-emoji-tofu) ────────────────
// Body + @top-center running header get the color-emoji families so
// Chromium has a glyph source for emoji code points instead of tofu (▯).
// The @bottom-* boxes hold counters / "CONFIDENTIAL" only — no emoji.
test("body stack includes all three emoji families before sans-serif", () => {
const css = printCss();
expect(css).toContain(`"Apple Color Emoji"`);
expect(css).toContain(`"Segoe UI Emoji"`);
expect(css).toContain(`"Noto Color Emoji"`);
// Emoji families must precede the generic family so per-character fallback
// reaches them before terminating at sans-serif.
expect(css).toMatch(/"Noto Color Emoji",\s*sans-serif/);
});
test("@top-center running header includes emoji families", () => {
const css = printCss({ runningHeader: "Q3 Report 🚀" });
const topCenter = css.match(/@top-center\s*\{[^}]*\}/)?.[0] ?? "";
expect(topCenter).toContain(`"Apple Color Emoji"`);
expect(topCenter).toContain(`"Noto Color Emoji"`);
});
test("@bottom-center and @bottom-right do NOT include emoji families", () => {
const css = printCss({ confidential: true });
const bottomCenter = css.match(/@bottom-center\s*\{[^}]*\}/)?.[0] ?? "";
const bottomRight = css.match(/@bottom-right\s*\{[^}]*\}/)?.[0] ?? "";
expect(bottomCenter).not.toContain("Emoji");
expect(bottomRight).not.toContain("Emoji");
// ...but they still share the sans stack via the SANS_STACK constant.
expect(bottomCenter).toContain(`"Liberation Sans"`);
expect(bottomRight).toContain(`"Liberation Sans"`);
});
test("emoji families appear in exactly the two emoji-bearing stacks", () => {
const css = printCss({ runningHeader: "Title", confidential: true });
// body (1) + @top-center (1) = 2 occurrences of the emoji group.
const occurrences = (css.match(/"Apple Color Emoji"/g) ?? []).length;
expect(occurrences).toBe(2);
});
});
// ─── render() — pageNumbers / footerTemplate data flow ───────────────
describe("render() — pageNumbers data flow", () => {
test("CSS footer renders by default", () => {
const result = render({ markdown: `# Doc\n\nBody.` });
expect(result.printCss).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("--no-page-numbers reaches the CSS layer", () => {
const result = render({ markdown: `# Doc\n\nBody.`, pageNumbers: false });
expect(result.printCss).not.toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("footerTemplate suppresses CSS page numbers (custom footer wins)", () => {
const result = render({
markdown: `# Doc\n\nBody.`,
footerTemplate: `<div class="foo">custom</div>`,
});
expect(result.printCss).not.toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
test("pageNumbers=true + no footerTemplate keeps CSS footer", () => {
const result = render({ markdown: `# Doc`, pageNumbers: true });
expect(result.printCss).toMatch(/@bottom-center\s*\{\s*content:\s*counter\(page\)/);
});
});
// ─── render() — HTML entity handling in titles, cover, TOC ───────────
describe("render() — no double HTML entity escaping", () => {
type Case = { char: string; inTitle: string; expectedTitleMeta: string };
// Only characters that should flow through unchanged. `"` and `'` are
// omitted from this set because smartypants converts them to curly quotes
// before heading extraction — asserted separately below.
const cases: Case[] = [
{ char: "&", inTitle: "A & B", expectedTitleMeta: "A & B" },
{ char: "<", inTitle: "A < B", expectedTitleMeta: "A < B" },
{ char: ">", inTitle: "A > B", expectedTitleMeta: "A > B" },
{ char: "©", inTitle: "A © B", expectedTitleMeta: "A © B" },
{ char: "—", inTitle: "A — B", expectedTitleMeta: "A — B" },
];
for (const { char, inTitle, expectedTitleMeta } of cases) {
test(`"${char}" in H1 has no double-escape in <title> or cover`, () => {
const result = render({
markdown: `# ${inTitle}\n\nBody.`,
cover: true,
author: "A",
});
// Meta: decoded plain text.
expect(result.meta.title).toBe(expectedTitleMeta);
// HTML: <title>...</title> never contains double-escape patterns.
expect(result.html).not.toMatch(/<title>[^<]*&amp;amp;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;lt;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;gt;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;#\d+;/);
expect(result.html).not.toMatch(/<title>[^<]*&amp;#x[0-9a-fA-F]+;/);
// Cover block also single-escape.
expect(result.html).not.toMatch(/class="cover-title"[^>]*>[^<]*&amp;amp;/);
});
}
test('ampersand in <title> renders as exactly one "&amp;"', () => {
const result = render({ markdown: `# Faber & Faber\n\nBody.` });
expect(result.html).toContain("<title>Faber &amp; Faber</title>");
expect(result.html).not.toContain("&amp;amp;");
});
test("TOC entries have no double-escape when a heading contains '&'", () => {
const result = render({
markdown: `# Doc\n\n## Faber & Faber\n\nBody.\n\n## Other\n\nMore.`,
toc: true,
});
// TOC renders the heading text through escapeHtml; must be single-escaped.
expect(result.html).toContain("Faber &amp; Faber");
expect(result.html).not.toContain("&amp;amp;");
});
test('numeric entity in H1 (e.g. "&#169;") decodes cleanly to <title>', () => {
// Marked passes through numeric entities verbatim in the HTML output,
// so the decoder must handle them.
const result = render({ markdown: `# A &#169; B\n\nBody.` });
expect(result.meta.title).toBe("A © B");
expect(result.html).toContain("<title>A © B</title>");
});
test("smartypants converts raw quotes in title BEFORE extraction (contract)", () => {
// We do NOT assert raw `"` survives — smartypants is expected to convert it.
// The contract is: no double-escape of the encoded form.
const result = render({ markdown: `# Say "hi"\n\nBody.` });
expect(result.html).not.toContain("&amp;quot;");
expect(result.html).not.toContain("&amp;#39;");
// And <title> contains exactly one level of escaping.
const titleMatch = result.html.match(/<title>([^<]*)<\/title>/);
expect(titleMatch).toBeTruthy();
if (titleMatch) {
// Never contains a double-encoded entity.
expect(titleMatch[1]).not.toMatch(/&amp;(amp|lt|gt|quot|#\d+);/);
}
});
});