chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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:
|
||||
|
||||
```
|
||||
{width=full} ← stretch to content-box width
|
||||
{width=50%} ← percentage or 3in/8cm/200px
|
||||
{page=landscape} ← give it its own landscape page
|
||||
{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`.
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
{width=full} ← stretch to content-box width
|
||||
{width=50%} ← percentage or 3in/8cm/200px
|
||||
{page=landscape} ← give it its own landscape page
|
||||
{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`.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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: [],
|
||||
}],
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
* `{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}"`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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, "\\\"");
|
||||
}
|
||||
@@ -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: `{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" → "hello");
|
||||
// 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 & alone because it
|
||||
* can be legitimately doubled (&amp;) and we don't want to double-decode.
|
||||
*/
|
||||
function decodeTypographicEntities(html: string): string {
|
||||
return html
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/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
|
||||
* & because &amp; can be legitimate there). This runs on text destined
|
||||
* for <title>, cover, and TOC entries where & MUST become & or escapeHtml
|
||||
* produces &amp;.
|
||||
*
|
||||
* Amp-last ordering: input "&#169;" decodes to "©" in the named pass,
|
||||
* then the numeric pass decodes "©" to "©". Decoding & first would
|
||||
* produce "©" and the numeric pass would consume it — different end state
|
||||
* but risks double-decode on inputs like "&lt;".
|
||||
*/
|
||||
function decodeTextEntities(s: string): string {
|
||||
return s
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
||||
.replace(/&/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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 --> 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; }");
|
||||
});
|
||||
});
|
||||
@@ -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 <error>");
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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\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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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(/>/g, ">").replace(/</g, "<").replace(/&/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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -0,0 +1,48 @@
|
||||
# Diagram Gate
|
||||
|
||||
A relative local image (CRITICAL regression: must render, not 404):
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
||||

|
||||
|
||||
Done.
|
||||
Vendored
+12
@@ -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
@@ -0,0 +1,52 @@
|
||||
# Landscape Gate
|
||||
|
||||
Intro text under the first heading.
|
||||
|
||||
## Negative: screenshot stays portrait
|
||||
|
||||

|
||||
|
||||
## Positive: alt-hinted wide image promotes
|
||||
|
||||

|
||||
|
||||
## Positive: directive forces a small image
|
||||
|
||||
{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.
|
||||
@@ -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.5−3.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.5−2.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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;/);
|
||||
expect(result.html).not.toMatch(/<title>[^<]*&lt;/);
|
||||
expect(result.html).not.toMatch(/<title>[^<]*&gt;/);
|
||||
expect(result.html).not.toMatch(/<title>[^<]*&#\d+;/);
|
||||
expect(result.html).not.toMatch(/<title>[^<]*&#x[0-9a-fA-F]+;/);
|
||||
// Cover block also single-escape.
|
||||
expect(result.html).not.toMatch(/class="cover-title"[^>]*>[^<]*&amp;/);
|
||||
});
|
||||
}
|
||||
|
||||
test('ampersand in <title> renders as exactly one "&"', () => {
|
||||
const result = render({ markdown: `# Faber & Faber\n\nBody.` });
|
||||
expect(result.html).toContain("<title>Faber & Faber</title>");
|
||||
expect(result.html).not.toContain("&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 & Faber");
|
||||
expect(result.html).not.toContain("&amp;");
|
||||
});
|
||||
|
||||
test('numeric entity in H1 (e.g. "©") 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 © 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("&quot;");
|
||||
expect(result.html).not.toContain("&#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|lt|gt|quot|#\d+);/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user