diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci
index 60986d66..beb4bb0d 100644
--- a/.github/docker/Dockerfile.ci
+++ b/.github/docker/Dockerfile.ci
@@ -72,6 +72,18 @@ RUN npm i -g @anthropic-ai/claude-code
# Playwright system deps (Chromium) — needed for browse E2E tests
RUN npx playwright install-deps chromium
+# Linux has neither Helvetica nor Arial. make-pdf's print CSS stacks fall back
+# to Liberation Sans (metric-compatible Arial clone, SIL OFL 1.1) so PDFs don't
+# render in DejaVu Sans. playwright install-deps happens to pull this in today,
+# but the dep is implicit and could change — install explicitly so upgrades
+# can't silently regress rendering.
+RUN for i in 1 2 3; do \
+ apt-get update && apt-get install -y --no-install-recommends fonts-liberation fontconfig && break || \
+ (echo "fonts-liberation install retry $i/3"; sleep 10); \
+ done \
+ && fc-cache -f \
+ && rm -rf /var/lib/apt/lists/*
+
# Pre-install dependencies (cached layer — only rebuilds when package.json changes)
COPY package.json /workspace/
WORKDIR /workspace
@@ -84,7 +96,9 @@ RUN npx playwright install chromium \
# Verify everything works
RUN bun --version && node --version && claude --version && jq --version && gh --version \
- && npx playwright --version
+ && npx playwright --version \
+ && fc-match "Liberation Sans" | grep -qi "Liberation" \
+ || (echo "ERROR: fonts-liberation not installed — make-pdf PDFs will render in DejaVu Sans" && exit 1)
# At runtime: checkout overwrites /workspace, but node_modules persists
# if we move it out of the way and symlink back
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a87ff35c..dd50bcd2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## [1.5.1.0] - 2026-04-21
+## [1.5.2.0] - 2026-04-21
## **Sidebar prompt-injection defense got half as noisy, half as trusting of any single classifier.**
@@ -14,7 +14,7 @@ Open your sidebar on Stack Overflow posts about prompt injection, read a Wikiped
Measured on BrowseSafe-Bench smoke, 500 cases (260 yes-labeled / 240 no-labeled), `bun test browse/test/security-bench-ensemble.test.ts`:
-| Metric | v1.4.0.0 | v1.5.1.0 | Δ |
+| Metric | v1.4.0.0 | v1.5.2.0 | Δ |
|---|---|---|---|
| Detection (BLOCK verdict on injection cases) | 67.3% | **56.2%** (95% CI 50.1–62.1) | −11pp |
| False-positive rate (BLOCK on benign cases) | 44.1% | **22.9%** (95% CI 18.1–28.6) | **−21pp** |
@@ -51,6 +51,61 @@ Detection dropped by 11pp but nearly all of the lost TPs are cases where Haiku c
* WARN banner policy review. Even with FP at 22.9%, WARN verdicts still surface in the banner — separate design doc for whether WARN should be passive-log instead of banner. (P1)
* Held-out validation harness. Formalize the held-out check (cases 500–1000 from BrowseSafe-Bench) as a separate regression-only test. (P2)
+## [1.5.1.0] - 2026-04-20
+
+## **Three visible bugs in v1.4.0.0 /make-pdf, all fixed.**
+
+Page footers showed "6 of 8" twice on every page because Chromium's native footer and our print CSS were both rendering numbers. A markdown title containing `&` rendered as `Faber & Faber` in `
` and TOC entries, because the extractors stripped tags but forgot to decode entities. On Linux (Docker, CI, servers), body text fell through to DejaVu Sans because neither Helvetica nor Arial is installed by default, and nothing in the font stack caught that. This release fixes all three and extends the fix beyond the obvious symptom each time.
+
+### The numbers that matter
+
+All three bugs were caught and expanded in review before any code was written. The plan went through `/plan-eng-review` (Claude), then `/codex` (outside voice), then implementation. Source: `.github/docker/Dockerfile.ci` (Linux fonts), `make-pdf/test/render.test.ts` (17 new tests), `git log main..HEAD` (this branch).
+
+| Surface | Before (v1.4.0.0) | After (v1.5.1.0) |
+|---------|-------------------|-----------------|
+| Page footer | "6 of 8" stacked twice | "6 of 8" once |
+| `# Faber & Faber` in `` | `Faber & Faber` | `Faber & Faber` |
+| TOC entry with `&` | Double-escaped | Single-escaped |
+| `©` (copyright) in H1 | Broken | Decodes to `©` |
+| `--no-page-numbers` CLI flag | Silently did nothing | Actually suppresses page numbers |
+| `--footer-template` | Layered CSS page numbers on top | Custom footer wins cleanly |
+| Linux PDF body font | DejaVu Sans (wrong) | Liberation Sans (metric-compatible Helvetica clone) |
+
+| Review layer | Findings | Outcome |
+|--------------|----------|---------|
+| `/plan-eng-review` (Claude) | 1 architectural gap | expanded Bug 1 scope to include CSS-side conditional |
+| `/codex` (outside voice) | 11 findings | 11 incorporated (data flow, TOC site, decoder collision, footer semantic, test contract, scope boundaries, font dependency) |
+| Cross-model agreement rate | ~30% | Codex found 7 issues Claude's eng review missed by staying too high-altitude |
+
+The agreement rate is the tell. One reviewer was not enough on this diff. Codex caught that my original "one-line fix" for Bug 1 would have left the `--no-page-numbers` CLI flag silently dead, because `RenderOptions` didn't carry `pageNumbers` and the orchestrator's `render()` call didn't pass it. Without the second opinion, the CLI flag ships broken again.
+
+### What this means for anyone generating PDFs
+
+Page numbers are now controlled by one flag from CLI to CSS, with the custom-footer semantic restored. Titles, cover pages, and TOC entries render HTML entities correctly, including numeric entities like `©`. Linux environments no longer need to know about fonts-liberation — the Dockerfile installs it explicitly and a build-time `fc-match` check fails the image if the font disappears. Run `bun run dev make-pdf --cover --toc` on Mac, and now also inside Docker, and the output looks the same.
+
+### Itemized changes
+
+#### Fixed
+
+- **Page numbers no longer render twice on every page.** Chromium's native footer used to layer on top of our `@page @bottom-center` CSS. Now CSS is the single source of truth; Chromium native numbering is off unconditionally.
+- **`--no-page-numbers` works end-to-end.** The CLI flag now reaches the CSS layer via `RenderOptions.pageNumbers`. Previously it died at the orchestrator and the CSS kept rendering numbers regardless.
+- **`--footer-template` cleanly replaces the stock footer.** Passing a custom footer now also suppresses the CSS page numbers, preserving the original "custom footer wins" semantic that existed before Bug 1 collided with it.
+- **HTML entities in titles, cover pages, and TOC entries render correctly.** A markdown heading like `# Faber & Faber` renders as `Faber & Faber` in `` (single-escaped) instead of `Faber & Faber` (double-escaped). Covers both extractor call sites: `extractFirstHeading` (title + cover) and `extractHeadings` (TOC).
+- **Numeric HTML entities decode too.** `©` in an H1 now renders as `©` in the PDF title. Decimal and hex numeric entities both supported.
+- **Linux PDFs render in Liberation Sans instead of DejaVu Sans.** Font stacks in all four print-CSS slots (body, running header, page number, CONFIDENTIAL label) now include `"Liberation Sans"` between Helvetica and Arial. Metric-compatible, SIL OFL 1.1, installs via `fonts-liberation`.
+
+#### Changed
+
+- `.github/docker/Dockerfile.ci` installs `fonts-liberation` + `fontconfig` explicitly with retries, runs `fc-cache -f`, and verifies `fc-match "Liberation Sans"` in the final build step. Previously relied on Playwright's `install-deps` pulling it in transitively, which could silently regress on upgrade.
+- `SKILL.md.tmpl` documents the Linux font dependency for users who install outside CI/Docker.
+
+#### For contributors
+
+- New helper `decodeTextEntities` in `render.ts` (distinct from existing `decodeTypographicEntities`, which intentionally preserves `&` in pipeline HTML where `&` can be legitimate). Use the new one when extracting plain text destined for ``, cover, or TOC.
+- `PrintCssOptions.pageNumbers` wraps the `@bottom-center` rule in a conditional matching the existing `showConfidential` pattern. Thread `pageNumbers` through `RenderOptions` and forward from `orchestrator.ts` into both `render()` call sites (generate + preview).
+- 17 new tests in `make-pdf/test/render.test.ts`: `printCss` pageNumbers isolation (3), `render()` data flow with footerTemplate (4), parameterized entity contracts across `&`, `<`, `>`, `©`, `—` (5), `` exact single-escape assertion, TOC single-escape, numeric entity decode, smartypants-interacts contract, Liberation Sans body + @page box coverage (2).
+- Known test gaps (small, future PR): hex numeric entity path, amp-last ordering with double-encoded input, SKILL.md Linux note content assertion. Orchestrator → `browseClient.pdf({pageNumbers: false})` and orchestrator → `render()` forwarding are covered transitively via the CSS end-to-end tests, not asserted directly.
+
## [1.5.0.0] - 2026-04-20
## **Your sidebar agent now defends itself against prompt injection.**
diff --git a/TODOS.md b/TODOS.md
index f57b7b10..7981e20b 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -241,11 +241,11 @@ defend the compiled-side ingress.
### ML Prompt Injection Classifier — v2 Follow-ups
-#### ~~Cut Haiku false-positive rate from 44% toward ~15% (P0)~~ — SHIPPED in v1.5.1.0
+#### ~~Cut Haiku false-positive rate from 44% toward ~15% (P0)~~ — SHIPPED in v1.5.2.0
Measured result (500-case BrowseSafe-Bench smoke): detection 67.3% → **56.2%**, FP 44.1% → **22.9%**. Gate passes (detection ≥ 55%, FP ≤ 25%). Knobs that landed: label-first ensemble voting (verdict label trumps numeric confidence for transcript layer), hallucination guard (`verdict=block` at conf < 0.40 → warn-vote), new `THRESHOLDS.SOLO_CONTENT_BLOCK = 0.92` for label-less content classifiers, label-first extension to toolOutput path, tighter Haiku prompt + 8 few-shot exemplars, pinned Haiku model, `claude -p` spawn from `os.tmpdir()` so CLAUDE.md can't poison the classifier, timeout bumped 15s → 45s. CI gate: `browse/test/security-bench-ensemble.test.ts` replays fixture, fail-closed on missing fixture + security-layer diff. The original plan's stop-loss revert order didn't move the FP needle (FPs came from single-layer-BLOCK paths, not ensemble); the real levers turned out to be architectural (label-first) plus a new decoupled threshold.
-See CHANGELOG.md [1.5.1.0] for the full shipped summary.
+See CHANGELOG.md [1.5.2.0] for the full shipped summary.
#### Original spec (pre-ship, retained for archive)
diff --git a/VERSION b/VERSION
index 50b4d263..400ba62b 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.5.1.0
+1.5.2.0
diff --git a/browse/src/security-classifier.ts b/browse/src/security-classifier.ts
index 232e2471..b96f8aae 100644
--- a/browse/src/security-classifier.ts
+++ b/browse/src/security-classifier.ts
@@ -490,7 +490,7 @@ export async function checkTranscript(params: {
// repo with a prompt-injection-defense CLAUDE.md (like gstack itself),
// Haiku reads "we have a strict security classifier" and responds with
// meta-commentary instead of classifying the input — we measured 100%
- // timeout rate in the v1.5.1.0 ensemble bench because of this, plus
+ // timeout rate in the v1.5.2.0 ensemble bench because of this, plus
// ~44k cache_creation tokens per call (massive cost inflation).
// Using os.tmpdir() gives Haiku a clean context for pure classification.
const p = spawn('claude', [
@@ -539,7 +539,7 @@ export async function checkTranscript(params: {
p.on('error', () => {
finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'spawn_error' } });
});
- // Hard timeout. Measured in v1.5.1.0 bench: `claude -p --model
+ // Hard timeout. Measured in v1.5.2.0 bench: `claude -p --model
// claude-haiku-4-5-20251001` takes 17-33s end-to-end even for trivial
// prompts (CLI session startup + Haiku API). The v1 15s timeout caused
// 100% timeout rate when re-measured in v2 — v1's ensemble was
diff --git a/browse/src/security.ts b/browse/src/security.ts
index 008ffbfe..22009e0c 100644
--- a/browse/src/security.ts
+++ b/browse/src/security.ts
@@ -88,7 +88,7 @@ export interface StatusDetail {
/**
* Combine per-layer signals into a single verdict. Post-v2 ensemble rule
- * (v1.5.1.0+) is label-first for the transcript layer: Haiku's verdict
+ * (v1.5.2.0+) is label-first for the transcript layer: Haiku's verdict
* label is the primary signal, not its self-reported confidence. Other ML
* layers (testsavant_content, deberta_content) remain confidence-based
* because they emit only a scalar.
@@ -205,7 +205,7 @@ export function combineVerdict(signals: LayerSignal[], opts: CombineVerdictOpts
// Single-layer BLOCK. For tool-output, BLOCK directly; for user-input,
// degrade to WARN (SO-FP mitigation).
//
- // Asymmetric thresholds (v1.5.1.0+):
+ // Asymmetric thresholds (v1.5.2.0+):
// - Content classifiers (testsavant, deberta): require confidence
// >= THRESHOLDS.SOLO_CONTENT_BLOCK (0.92). These are label-less so the
// bar is higher — pattern-matching on "suspicious text" alone isn't
diff --git a/browse/test/security-adversarial.test.ts b/browse/test/security-adversarial.test.ts
index bda0afc1..1306a37d 100644
--- a/browse/test/security-adversarial.test.ts
+++ b/browse/test/security-adversarial.test.ts
@@ -267,11 +267,11 @@ describe('combineVerdict — realistic attack/defense scenarios', () => {
});
});
-// ─── Label-first voting (v1.5.1.0+) ──────────────────────────
+// ─── Label-first voting (v1.5.2.0+) ──────────────────────────
describe('combineVerdict — label-first voting for transcript_classifier', () => {
test('Haiku verdict=warn at high confidence is a soft signal only, not a block-vote', () => {
- // Under v1.5.1.0 label-first: Haiku's 'warn' label means "suspicious but
+ // Under v1.5.2.0 label-first: Haiku's 'warn' label means "suspicious but
// not hijack-level" regardless of its confidence. It should NOT single-
// handedly upgrade the ensemble to BLOCK even when pointed at 0.80.
const r = combineVerdict([
@@ -329,7 +329,7 @@ describe('combineVerdict — label-first voting for transcript_classifier', () =
});
test('backward-compat: transcript signal with no meta.verdict never block-votes', () => {
- // Pre-v1.5.1.0 signals (or adversarial tests) may arrive without
+ // Pre-v1.5.2.0 signals (or adversarial tests) may arrive without
// meta.verdict. Under the new rule, missing meta is warn-vote-only
// when confidence >= WARN, never a block-vote. Even at 0.95 (high
// confidence), transcript alone doesn't upgrade the ensemble.
diff --git a/browse/test/security-bench-ensemble-live.test.ts b/browse/test/security-bench-ensemble-live.test.ts
index 5254f524..1429334e 100644
--- a/browse/test/security-bench-ensemble-live.test.ts
+++ b/browse/test/security-bench-ensemble-live.test.ts
@@ -1,5 +1,5 @@
/**
- * BrowseSafe-Bench ensemble LIVE bench (v1.5.1.0+).
+ * BrowseSafe-Bench ensemble LIVE bench (v1.5.2.0+).
*
* Runs the 200-case smoke through the full ensemble with real Haiku calls.
* Measures detection + FP rates at the ENSEMBLE level (not just L4 like
diff --git a/browse/test/security-bench-ensemble.test.ts b/browse/test/security-bench-ensemble.test.ts
index 5ff699d6..ec1024f2 100644
--- a/browse/test/security-bench-ensemble.test.ts
+++ b/browse/test/security-bench-ensemble.test.ts
@@ -1,5 +1,5 @@
/**
- * BrowseSafe-Bench ensemble fixture-replay gate (v1.5.1.0+).
+ * BrowseSafe-Bench ensemble fixture-replay gate (v1.5.2.0+).
*
* Runs the 200-case smoke through combineVerdict using recorded Haiku
* responses from a committed fixture. Deterministic, free, gate-tier.
diff --git a/browse/test/security-bench.test.ts b/browse/test/security-bench.test.ts
index f35b405b..69ebec6c 100644
--- a/browse/test/security-bench.test.ts
+++ b/browse/test/security-bench.test.ts
@@ -118,7 +118,7 @@ describe('BrowseSafe-Bench smoke (200 cases)', () => {
let tp = 0, fp = 0, tn = 0, fn = 0;
// intentionally 0.6 — L4-only bench pinned to v1 WARN for historical
// continuity. The ensemble bench (security-bench-ensemble.test.ts) uses
- // THRESHOLDS.WARN from security.ts (0.75 in v1.5.1.0+).
+ // THRESHOLDS.WARN from security.ts (0.75 in v1.5.2.0+).
const WARN = 0.6;
for (const row of rows) {
const signal = await scanPageContent(row.content);
diff --git a/make-pdf/SKILL.md b/make-pdf/SKILL.md
index a22cc89e..0c9353fa 100644
--- a/make-pdf/SKILL.md
+++ b/make-pdf/SKILL.md
@@ -5,11 +5,9 @@ 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. Output you'd
- send to a VC partner, a book agent, a judge, or Rick Rubin's team. 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)
+ 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 (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".
triggers:
- markdown to pdf
@@ -470,6 +468,10 @@ 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.
+
## MAKE-PDF SETUP (run this check BEFORE any make-pdf command)
```bash
diff --git a/make-pdf/SKILL.md.tmpl b/make-pdf/SKILL.md.tmpl
index 38668290..0827492a 100644
--- a/make-pdf/SKILL.md.tmpl
+++ b/make-pdf/SKILL.md.tmpl
@@ -5,11 +5,9 @@ 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. Output you'd
- send to a VC partner, a book agent, a judge, or Rick Rubin's team. 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)
+ 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"
@@ -39,6 +37,10 @@ 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.
+
{{MAKE_PDF_SETUP}}
## Core patterns
diff --git a/make-pdf/src/orchestrator.ts b/make-pdf/src/orchestrator.ts
index 31710ecf..cf8dffae 100644
--- a/make-pdf/src/orchestrator.ts
+++ b/make-pdf/src/orchestrator.ts
@@ -94,6 +94,8 @@ export async function generate(opts: GenerateOptions): Promise {
confidential: opts.confidential,
pageSize: opts.pageSize,
margins: opts.margins,
+ pageNumbers: opts.pageNumbers,
+ footerTemplate: opts.footerTemplate,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
@@ -136,7 +138,10 @@ export async function generate(opts: GenerateOptions): Promise {
marginLeft: opts.marginLeft ?? opts.margins ?? "1in",
headerTemplate: opts.headerTemplate,
footerTemplate: opts.footerTemplate,
- pageNumbers: opts.pageNumbers !== false && !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,
@@ -183,6 +188,7 @@ export async function preview(opts: PreviewOptions): Promise {
watermark: opts.watermark,
noChapterBreaks: opts.noChapterBreaks,
confidential: opts.confidential,
+ pageNumbers: opts.pageNumbers,
});
progress.end("Rendering HTML", `${rendered.meta.wordCount} words`);
diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts
index a4b71dae..14d78bd5 100644
--- a/make-pdf/src/print-css.ts
+++ b/make-pdf/src/print-css.ts
@@ -5,8 +5,11 @@
* Mirror those CSS rules here. The HTML references were approved via
* /plan-design-review with explicit design decisions locked in the plan:
*
- * - Helvetica only (system font, no bundled webfonts — dodges the
- * per-glyph Tj bug that breaks copy-paste extraction).
+ * - 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 has the same 1in margins as every other page. No flexbox
@@ -15,8 +18,8 @@
* - `@page :first` suppresses running header/footer but does NOT override
* the 1in margin.
* - No , no external CSS/fonts — everything inlined.
- * - CJK fallback: Helvetica, Arial, Hiragino Kaku Gothic ProN, Noto Sans
- * CJK JP, Microsoft YaHei, sans-serif.
+ * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic
+ * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif.
*/
export interface PrintCssOptions {
@@ -37,6 +40,11 @@ export interface PrintCssOptions {
// 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;
}
/**
@@ -69,17 +77,20 @@ export function printCss(opts: PrintCssOptions = {}): string {
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: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }`
+ ? ` @top-center { content: "${runningHeader}"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
+ : ``,
+ showPageNumbers
+ ? ` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 9pt; color: #666; }`
: ``,
- ` @bottom-center { content: counter(page) " of " counter(pages); font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #666; }`,
showConfidential
- ? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
+ ? ` @bottom-right { content: "CONFIDENTIAL"; font-family: Helvetica, "Liberation Sans", Arial, sans-serif; font-size: 8pt; color: #aaa; letter-spacing: 0.05em; }`
: ``,
`}`,
``,
@@ -96,7 +107,7 @@ function rootTypography(): string {
return [
`html { lang: en; }`,
`body {`,
- ` font-family: Helvetica, Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`,
+ ` font-family: Helvetica, "Liberation Sans", Arial, "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei", sans-serif;`,
` font-size: 11pt;`,
` line-height: 1.5;`,
` color: #111;`,
diff --git a/make-pdf/src/render.ts b/make-pdf/src/render.ts
index 03bf43cd..ae5228f4 100644
--- a/make-pdf/src/render.ts
+++ b/make-pdf/src/render.ts
@@ -34,6 +34,11 @@ export interface RenderOptions {
// Page layout
pageSize?: "letter" | "a4" | "legal" | "tabloid";
margins?: 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 {
@@ -74,6 +79,10 @@ export function render(opts: RenderOptions): RenderResult {
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,
@@ -83,6 +92,7 @@ export function render(opts: RenderOptions): RenderResult {
runningHeader: derivedTitle,
pageSize: opts.pageSize,
margins: opts.margins,
+ pageNumbers: showPageNumbers,
};
const css = printCss(cssOptions);
@@ -278,7 +288,7 @@ function extractHeadings(html: string): Array<{ level: number; text: string }> {
let match;
while ((match = re.exec(html)) !== null) {
const level = parseInt(match[1].slice(1), 10);
- const text = stripTags(match[2]).trim();
+ const text = decodeTextEntities(stripTags(match[2]).trim());
if (text) headings.push({ level, text });
}
return headings;
@@ -314,7 +324,32 @@ function wrapChaptersByH1(html: string): string {
function extractFirstHeading(html: string): string | null {
const m = html.match(/]*>([\s\S]*?)<\/h1>/i);
- return m ? stripTags(m[1]).trim() : null;
+ 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 & can be legitimate there). This runs on text destined
+ * for , cover, and TOC entries where & MUST become & or escapeHtml
+ * produces &.
+ *
+ * Amp-last ordering: input "©" 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 "<".
+ */
+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(/([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
+ .replace(/&/g, "&");
}
function stripTags(html: string): string {
diff --git a/make-pdf/src/types.ts b/make-pdf/src/types.ts
index 4d170975..6d4e6710 100644
--- a/make-pdf/src/types.ts
+++ b/make-pdf/src/types.ts
@@ -63,6 +63,7 @@ export interface PreviewOptions {
watermark?: string;
noChapterBreaks?: boolean;
confidential?: boolean;
+ pageNumbers?: boolean;
allowNetwork?: boolean;
title?: string;
author?: string;
diff --git a/make-pdf/test/render.test.ts b/make-pdf/test/render.test.ts
index 5ddb5da4..a61dea50 100644
--- a/make-pdf/test/render.test.ts
+++ b/make-pdf/test/render.test.ts
@@ -311,4 +311,139 @@ describe("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\)/);
+ });
+
+ 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);
+ });
+});
+
+// ─── 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: `custom
`,
+ });
+ 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 or cover`, () => {
+ const result = render({
+ markdown: `# ${inTitle}\n\nBody.`,
+ cover: true,
+ author: "A",
+ });
+ // Meta: decoded plain text.
+ expect(result.meta.title).toBe(expectedTitleMeta);
+ // HTML: ... never contains double-escape patterns.
+ expect(result.html).not.toMatch(/[^<]*&/);
+ expect(result.html).not.toMatch(/[^<]*</);
+ expect(result.html).not.toMatch(/[^<]*>/);
+ expect(result.html).not.toMatch(/[^<]*&#\d+;/);
+ expect(result.html).not.toMatch(/[^<]*&#x[0-9a-fA-F]+;/);
+ // Cover block also single-escape.
+ expect(result.html).not.toMatch(/class="cover-title"[^>]*>[^<]*&/);
+ });
+ }
+
+ test('ampersand in renders as exactly one "&"', () => {
+ const result = render({ markdown: `# Faber & Faber\n\nBody.` });
+ expect(result.html).toContain("Faber & Faber ");
+ expect(result.html).not.toContain("&");
+ });
+
+ 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("&");
+ });
+
+ test('numeric entity in H1 (e.g. "©") decodes cleanly to ', () => {
+ // 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("A © B ");
+ });
+
+ 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(""");
+ expect(result.html).not.toContain("'");
+ // And contains exactly one level of escaping.
+ const titleMatch = result.html.match(/([^<]*)<\/title>/);
+ expect(titleMatch).toBeTruthy();
+ if (titleMatch) {
+ // Never contains a double-encoded entity.
+ expect(titleMatch[1]).not.toMatch(/&(amp|lt|gt|quot|#\d+);/);
+ }
+ });
});
diff --git a/package.json b/package.json
index 2575a45f..4103bb7a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gstack",
- "version": "1.5.0.0",
+ "version": "1.5.1.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",