mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-18 07:40:09 +02:00
Merge remote-tracking branch 'origin/main' into garrytan/conductor-skip-askuserquestion
# Conflicts: # CHANGELOG.md # VERSION # test/skill-e2e-bws.test.ts
This commit is contained in:
+83
-4
@@ -605,6 +605,79 @@ as you edit the markdown. Skip the PDF round trip until you're ready.
|
||||
$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
|
||||
|
||||
```
|
||||
@@ -624,6 +697,10 @@ Branding:
|
||||
--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)
|
||||
@@ -631,8 +708,9 @@ Output:
|
||||
--verbose Per-stage timings
|
||||
|
||||
Network:
|
||||
--allow-network Fetch external images. Off by default
|
||||
(blocks tracking pixels).
|
||||
--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)
|
||||
@@ -660,8 +738,9 @@ If the user has a `.md` file open and says "make it look nice", propose
|
||||
`--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`.
|
||||
- External image missing → add `--allow-network` (understand you're giving
|
||||
the markdown file permission to fetch from its image URLs).
|
||||
- "[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
|
||||
|
||||
+83
-4
@@ -94,6 +94,79 @@ as you edit the markdown. Skip the PDF round trip until you're ready.
|
||||
$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
|
||||
|
||||
```
|
||||
@@ -113,6 +186,10 @@ Branding:
|
||||
--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)
|
||||
@@ -120,8 +197,9 @@ Output:
|
||||
--verbose Per-stage timings
|
||||
|
||||
Network:
|
||||
--allow-network Fetch external images. Off by default
|
||||
(blocks tracking pixels).
|
||||
--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)
|
||||
@@ -149,8 +227,9 @@ If the user has a `.md` file open and says "make it look nice", propose
|
||||
`--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`.
|
||||
- External image missing → add `--allow-network` (understand you're giving
|
||||
the markdown file permission to fetch from its image URLs).
|
||||
- "[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
|
||||
|
||||
@@ -176,6 +176,9 @@ function runBrowse(args: string[]): string {
|
||||
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;
|
||||
@@ -268,6 +271,17 @@ export function loadHtml(opts: LoadHtmlOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -279,6 +293,19 @@ export function js(opts: JsOptions): string {
|
||||
]).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.
|
||||
@@ -300,9 +327,11 @@ export function waitForExpression(opts: {
|
||||
}
|
||||
const wait = Math.min(poll, Math.max(0, deadline - Date.now()));
|
||||
if (wait <= 0) break;
|
||||
// Synchronous sleep is fine — this only runs once per PDF render
|
||||
const end = Date.now() + wait;
|
||||
while (Date.now() < end) { /* busy wait */ }
|
||||
// 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;
|
||||
}
|
||||
|
||||
+20
-1
@@ -64,9 +64,14 @@ function printUsage(): void {
|
||||
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)");
|
||||
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.");
|
||||
@@ -86,6 +91,12 @@ function printUsage(): void {
|
||||
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("");
|
||||
@@ -112,9 +123,16 @@ function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
|
||||
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,
|
||||
@@ -136,6 +154,7 @@ function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,9 +21,22 @@ 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;
|
||||
@@ -71,8 +84,9 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
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)}.pdf`),
|
||||
opts.output ?? path.join(os.tmpdir(), `${deriveSlug(input)}.${to}`),
|
||||
);
|
||||
|
||||
// Stage 1: read markdown
|
||||
@@ -80,10 +94,14 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
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,
|
||||
markdown: extraction.markdown,
|
||||
title: opts.title,
|
||||
author: opts.author,
|
||||
date: opts.date,
|
||||
@@ -94,16 +112,144 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
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, rendered.html, "utf8");
|
||||
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.
|
||||
@@ -114,7 +260,7 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
try {
|
||||
progress.begin("Loading HTML into Chromium");
|
||||
browseClient.loadHtml({
|
||||
html: rendered.html,
|
||||
html: finalHtml,
|
||||
waitUntil: "domcontentloaded",
|
||||
tabId,
|
||||
});
|
||||
@@ -145,6 +291,10 @@ export async function generate(opts: GenerateOptions): Promise<string> {
|
||||
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");
|
||||
@@ -178,6 +328,21 @@ export async function preview(opts: PreviewOptions): Promise<string> {
|
||||
|
||||
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,
|
||||
|
||||
+110
-37
@@ -12,9 +12,11 @@
|
||||
* 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
|
||||
* center, no inset padding, no vertical centering. Distinction comes
|
||||
* from eyebrow + larger title + hairline rule, not from centering.
|
||||
* - 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.
|
||||
@@ -118,19 +120,76 @@ function pageRules(size: string, margin: string, opts: PrintCssOptions): string
|
||||
` @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: 11pt;`,
|
||||
` font-size: 12pt;`,
|
||||
` line-height: 1.5;`,
|
||||
` color: #111;`,
|
||||
` background: white;`,
|
||||
` hyphens: auto;`,
|
||||
// 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;`,
|
||||
@@ -143,45 +202,47 @@ function rootTypography(): string {
|
||||
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: 9pt;`,
|
||||
` font-size: 11pt;`,
|
||||
` letter-spacing: 0.2em;`,
|
||||
` text-transform: uppercase;`,
|
||||
` color: #666;`,
|
||||
` margin: 0 0 36pt;`,
|
||||
`}`,
|
||||
`.cover h1.cover-title {`,
|
||||
` font-size: 32pt;`,
|
||||
` line-height: 1.15;`,
|
||||
` font-size: 56pt;`,
|
||||
` line-height: 1.08;`,
|
||||
` font-weight: 700;`,
|
||||
` letter-spacing: -0.01em;`,
|
||||
` margin: 0 0 18pt;`,
|
||||
` max-width: 5.5in;`,
|
||||
` letter-spacing: -0.02em;`,
|
||||
` margin: 0 0 24pt;`,
|
||||
` max-width: 6in;`,
|
||||
` text-align: left;`,
|
||||
`}`,
|
||||
`.cover .cover-subtitle {`,
|
||||
` font-size: 14pt;`,
|
||||
` line-height: 1.4;`,
|
||||
` font-size: 18pt;`,
|
||||
` line-height: 1.35;`,
|
||||
` font-weight: 400;`,
|
||||
` color: #333;`,
|
||||
` margin: 0 0 36pt;`,
|
||||
` max-width: 5in;`,
|
||||
` max-width: 5.5in;`,
|
||||
` text-align: left;`,
|
||||
`}`,
|
||||
`.cover hr.rule {`,
|
||||
` width: 2.5in;`,
|
||||
` height: 0;`,
|
||||
` border: 0;`,
|
||||
` border-top: 1px solid #111;`,
|
||||
` margin: 0 0 18pt 0;`,
|
||||
` border-top: 1.5px solid #111;`,
|
||||
` margin: 0 0 24pt 0;`,
|
||||
`}`,
|
||||
`.cover .cover-meta { font-size: 10pt; line-height: 1.6; color: #333; text-align: left; }`,
|
||||
`.cover .cover-meta { font-size: 13pt; line-height: 1.6; color: #333; text-align: left; }`,
|
||||
`.cover .cover-meta strong { font-weight: 700; }`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -191,12 +252,12 @@ function tocRules(enabled: boolean): string {
|
||||
return [
|
||||
`.toc { page-break-after: always; break-after: page; }`,
|
||||
`.toc h2 {`,
|
||||
` font-size: 13pt;`,
|
||||
` font-size: 16pt;`,
|
||||
` text-transform: uppercase;`,
|
||||
` letter-spacing: 0.15em;`,
|
||||
` color: #666;`,
|
||||
` font-weight: 600;`,
|
||||
` margin: 0 0 0.5in;`,
|
||||
` color: #444;`,
|
||||
` font-weight: 700;`,
|
||||
` margin: 0 0 0.4in;`,
|
||||
`}`,
|
||||
`.toc ol {`,
|
||||
` list-style: none;`,
|
||||
@@ -207,14 +268,14 @@ function tocRules(enabled: boolean): string {
|
||||
` display: flex;`,
|
||||
` align-items: baseline;`,
|
||||
` gap: 0.25in;`,
|
||||
` font-size: 11pt;`,
|
||||
` line-height: 2;`,
|
||||
` padding: 4pt 0;`,
|
||||
` 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: 10pt; }`,
|
||||
`.toc li.level-2 { padding-left: 0.35in; font-size: 11pt; }`,
|
||||
`.toc li a { color: inherit; text-decoration: none; }`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -229,7 +290,7 @@ function chapterRules(noChapterBreaks: boolean): string {
|
||||
return [
|
||||
breakRule,
|
||||
`h1 {`,
|
||||
` font-size: 22pt;`,
|
||||
` font-size: 26pt;`,
|
||||
` line-height: 1.2;`,
|
||||
` font-weight: 700;`,
|
||||
` letter-spacing: -0.01em;`,
|
||||
@@ -237,9 +298,9 @@ function chapterRules(noChapterBreaks: boolean): string {
|
||||
` break-after: avoid;`,
|
||||
` page-break-after: avoid;`,
|
||||
`}`,
|
||||
`h2 { font-size: 15pt; line-height: 1.3; font-weight: 700; margin: 24pt 0 6pt; break-after: avoid; page-break-after: avoid; }`,
|
||||
`h3 { font-size: 12pt; line-height: 1.4; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #333; margin: 18pt 0 4pt; break-after: avoid; page-break-after: avoid; }`,
|
||||
`h4 { font-size: 11pt; font-weight: 700; margin: 12pt 0 4pt; 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");
|
||||
}
|
||||
|
||||
@@ -254,7 +315,7 @@ function blockRules(): string {
|
||||
` orphans: 3;`,
|
||||
`}`,
|
||||
`p:first-child { margin-top: 0; }`,
|
||||
`p.lead { font-size: 13pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
|
||||
`p.lead { font-size: 14pt; line-height: 1.45; color: #222; margin: 0 0 18pt; }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -275,7 +336,7 @@ function codeRules(): string {
|
||||
return [
|
||||
`code {`,
|
||||
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
||||
` font-size: 9.5pt;`,
|
||||
` font-size: 10.5pt;`,
|
||||
` background: #f4f4f4;`,
|
||||
` padding: 1pt 3pt;`,
|
||||
` border-radius: 2pt;`,
|
||||
@@ -283,7 +344,7 @@ function codeRules(): string {
|
||||
`}`,
|
||||
`pre {`,
|
||||
` font-family: "SF Mono", Menlo, Consolas, monospace;`,
|
||||
` font-size: 9pt;`,
|
||||
` font-size: 10pt;`,
|
||||
` line-height: 1.4;`,
|
||||
` background: #f7f7f5;`,
|
||||
` padding: 10pt 12pt;`,
|
||||
@@ -310,11 +371,11 @@ function quoteRules(): string {
|
||||
` padding: 0 0 0 18pt;`,
|
||||
` border-left: 2pt solid #111;`,
|
||||
` color: #333;`,
|
||||
` font-size: 11pt;`,
|
||||
` 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: 9.5pt; color: #666; letter-spacing: 0.02em; }`,
|
||||
`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");
|
||||
}
|
||||
@@ -323,13 +384,25 @@ function figureRules(): string {
|
||||
return [
|
||||
`figure { margin: 12pt 0; }`,
|
||||
`figure img { display: block; max-width: 100%; height: auto; }`,
|
||||
`figcaption { font-size: 9pt; color: #666; margin-top: 6pt; font-style: italic; }`,
|
||||
`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: 10pt; }`,
|
||||
`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");
|
||||
@@ -346,7 +419,7 @@ function listRules(): string {
|
||||
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: 9.5pt; line-height: 1.4; }`,
|
||||
`.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");
|
||||
}
|
||||
|
||||
+71
-8
@@ -14,6 +14,7 @@
|
||||
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;
|
||||
@@ -34,6 +35,14 @@ export interface RenderOptions {
|
||||
// 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.
|
||||
@@ -60,8 +69,13 @@ 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(rawHtml);
|
||||
const cleanHtml = sanitizeUntrustedHtml(directedHtml);
|
||||
|
||||
// 3. Decode common entities so smartypants can match raw " and '.
|
||||
// marked HTML-encodes quotes in text ("hello" → "hello");
|
||||
@@ -91,7 +105,9 @@ export function render(opts: RenderOptions): RenderResult {
|
||||
confidential: opts.confidential !== false,
|
||||
runningHeader: derivedTitle,
|
||||
pageSize: opts.pageSize,
|
||||
margins: opts.margins,
|
||||
// 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);
|
||||
@@ -106,14 +122,22 @@ export function render(opts: RenderOptions): RenderResult {
|
||||
})
|
||||
: "";
|
||||
|
||||
// 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(typographicHtml)
|
||||
? buildTocBlock(anchoredHtml, anchored.ids)
|
||||
: "";
|
||||
|
||||
// Wrap body in .chapter sections at H1 boundaries if chapter breaks are on.
|
||||
const chapterHtml = opts.noChapterBreaks
|
||||
? `<section class="chapter">${typographicHtml}</section>`
|
||||
: wrapChaptersByH1(typographicHtml);
|
||||
? `<section class="chapter">${anchoredHtml}</section>`
|
||||
: wrapChaptersByH1(anchoredHtml);
|
||||
|
||||
const watermarkBlock = opts.watermark
|
||||
? `<div class="watermark">${escapeHtml(opts.watermark)}</div>`
|
||||
@@ -256,13 +280,13 @@ function buildCoverBlock(opts: {
|
||||
* Page numbers are filled in by Paged.js (when --toc is passed and Paged.js
|
||||
* polyfill is injected).
|
||||
*/
|
||||
function buildTocBlock(html: string): string {
|
||||
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 = `toc-${i}`;
|
||||
const id = ids[i] ?? `toc-${i}`;
|
||||
return [
|
||||
` <li class="${level}">`,
|
||||
` <span class="toc-title"><a href="#${id}">${escapeHtml(h.text)}</a></span>`,
|
||||
@@ -282,6 +306,28 @@ function buildTocBlock(html: string): string {
|
||||
].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 }> = [];
|
||||
@@ -352,11 +398,28 @@ function decodeTextEntities(s: string): string {
|
||||
.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, "");
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
|
||||
+13
-1
@@ -11,9 +11,17 @@ 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; // PDF output path (default: /tmp/<slug>.pdf)
|
||||
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"
|
||||
@@ -44,6 +52,10 @@ export interface GenerateOptions {
|
||||
// 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;
|
||||
|
||||
@@ -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,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,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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
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.
|
||||
+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);
|
||||
});
|
||||
});
|
||||
@@ -264,6 +264,13 @@ describe("printCss", () => {
|
||||
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");
|
||||
@@ -327,6 +334,33 @@ describe("printCss", () => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user