chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* browseClient unit tests — binary resolution and error mapping.
|
||||
*
|
||||
* These are pure unit tests; they do NOT require a running browse daemon.
|
||||
* Cross-platform: assertions that pin POSIX behavior early-return on win32
|
||||
* and vice versa, so both lanes only exercise their own branch.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { BrowseClientError } from "../src/types";
|
||||
import { resolveBrowseBin, findExecutable } from "../src/browseClient";
|
||||
|
||||
// A real, always-present executable for the test platform — `cmd.exe` on
|
||||
// Windows (System32 is on every install) and `/bin/sh` on POSIX. Lets the
|
||||
// "honors override when it points at a real executable" test work in both
|
||||
// lanes without writing a temp script.
|
||||
const REAL_EXE: string =
|
||||
process.platform === "win32"
|
||||
? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe")
|
||||
: "/bin/sh";
|
||||
|
||||
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of Object.keys(overrides)) saved[k] = process.env[k];
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("findExecutable", () => {
|
||||
test("returns the bare path on POSIX when it's executable", () => {
|
||||
if (process.platform === "win32") return;
|
||||
const found = findExecutable("/bin/sh");
|
||||
expect(found).toBe("/bin/sh");
|
||||
});
|
||||
|
||||
test("on win32, probes .exe / .cmd / .bat after the bare-path miss", () => {
|
||||
if (process.platform !== "win32") return;
|
||||
// cmd.exe lives at System32\cmd.exe — probe with the bare base.
|
||||
const base = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "cmd");
|
||||
const found = findExecutable(base);
|
||||
expect(found).toBe(base + ".exe");
|
||||
});
|
||||
|
||||
test("returns null when no extension matches", () => {
|
||||
const found = findExecutable("/nonexistent/path/to/nothing");
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBrowseBin", () => {
|
||||
test("throws BrowseClientError with setup hint when nothing is found", () => {
|
||||
// Point overrides at non-existent paths and clear PATH so Bun.which finds
|
||||
// nothing. Sibling/global probes go through findExecutable on real paths,
|
||||
// but the test asserts on the error shape rather than depending on whether
|
||||
// a real browse install exists on the box.
|
||||
let thrown: unknown = null;
|
||||
try {
|
||||
withEnv(
|
||||
{
|
||||
GSTACK_BROWSE_BIN: "/nonexistent/gstack-browse-bin",
|
||||
BROWSE_BIN: "/nonexistent/browse-bin",
|
||||
PATH: "",
|
||||
Path: "",
|
||||
},
|
||||
() => resolveBrowseBin(),
|
||||
);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
if (thrown) {
|
||||
expect(thrown).toBeInstanceOf(BrowseClientError);
|
||||
expect((thrown as BrowseClientError).message).toContain("browse binary not found");
|
||||
expect((thrown as BrowseClientError).message).toContain("./setup");
|
||||
expect((thrown as BrowseClientError).message).toContain("GSTACK_BROWSE_BIN");
|
||||
// Back-compat alias still surfaces in the diagnostic.
|
||||
expect((thrown as BrowseClientError).message).toContain("BROWSE_BIN");
|
||||
}
|
||||
// If the test box has a real browse install on disk, sibling/global may
|
||||
// resolve and the helper won't throw — that's fine; the assertion is
|
||||
// gated on whether it threw at all.
|
||||
});
|
||||
|
||||
test("honors GSTACK_BROWSE_BIN when it points at a real executable", () => {
|
||||
const resolved = withEnv({ GSTACK_BROWSE_BIN: REAL_EXE }, () => resolveBrowseBin());
|
||||
expect(resolved).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("honors BROWSE_BIN as a back-compat alias", () => {
|
||||
const resolved = withEnv(
|
||||
{ GSTACK_BROWSE_BIN: undefined, BROWSE_BIN: REAL_EXE },
|
||||
() => resolveBrowseBin(),
|
||||
);
|
||||
expect(resolved).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("GSTACK_BROWSE_BIN takes precedence over BROWSE_BIN", () => {
|
||||
const resolved = withEnv(
|
||||
{ GSTACK_BROWSE_BIN: REAL_EXE, BROWSE_BIN: "/nonexistent/legacy" },
|
||||
() => resolveBrowseBin(),
|
||||
);
|
||||
expect(resolved).toBe(REAL_EXE);
|
||||
});
|
||||
|
||||
test("strips wrapping double quotes from override values", () => {
|
||||
const resolved = withEnv({ GSTACK_BROWSE_BIN: `"${REAL_EXE}"` }, () => resolveBrowseBin());
|
||||
expect(resolved).toBe(REAL_EXE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowseClientError", () => {
|
||||
test("captures exit code, command, and stderr", () => {
|
||||
const err = new BrowseClientError(127, "pdf", "Chromium not found");
|
||||
expect(err.exitCode).toBe(127);
|
||||
expect(err.command).toBe("pdf");
|
||||
expect(err.stderr).toBe("Chromium not found");
|
||||
expect(err.message).toContain("browse pdf exited 127");
|
||||
expect(err.message).toContain("Chromium not found");
|
||||
expect(err.name).toBe("BrowseClientError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Coverage-gap fills from the v1.58.0.0 ship audit — the branches the main
|
||||
* suites couldn't reach without a live browse tab (mock-tab here), plus the
|
||||
* pure-function stragglers (WebP probing, landscape geometry, bundle path
|
||||
* resolution, screen CSS).
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
RenderCallError,
|
||||
type RenderTab,
|
||||
landscapeContentBox,
|
||||
rasterizeDiagramFigures,
|
||||
renderFenceSlots,
|
||||
resolveBundlePath,
|
||||
substituteSlots,
|
||||
} from "../src/diagram-prepass";
|
||||
import { imageDims } from "../src/image-size";
|
||||
import { screenCss } from "../src/print-css";
|
||||
|
||||
/** Duck-typed RenderTab: scripted call results + a loadBundle counter. */
|
||||
function mockTab(script: (fn: string, ...args: Array<string | number>) => string) {
|
||||
const calls: string[] = [];
|
||||
let reloads = 0;
|
||||
const tab = {
|
||||
call: (fn: string, ...args: Array<string | number>) => {
|
||||
calls.push(fn);
|
||||
return script(fn, ...args);
|
||||
},
|
||||
loadBundle: () => { reloads++; },
|
||||
close: () => {},
|
||||
} as unknown as RenderTab;
|
||||
return { tab, calls, reloadCount: () => reloads };
|
||||
}
|
||||
|
||||
const fence = (over: Partial<{ lang: string; source: string; ordinal: number }>) => ({
|
||||
lang: "mermaid",
|
||||
source: "graph LR\n A --> B",
|
||||
render: true as const,
|
||||
token: `tok-${over.ordinal ?? 1}`,
|
||||
ordinal: over.ordinal ?? 1,
|
||||
title: undefined,
|
||||
page: undefined,
|
||||
...over,
|
||||
});
|
||||
|
||||
// ─── renderFenceSlots: reset contract + excalidraw branches ───────────
|
||||
|
||||
describe("renderFenceSlots (mock tab)", () => {
|
||||
test("reset contract: a failure reloads the bundle and the NEXT fence still renders", () => {
|
||||
const { tab, reloadCount } = mockTab((fn, ...args) => {
|
||||
if (String(args[1] ?? "").includes("BROKEN")) throw new RenderCallError("Parse error on line 1");
|
||||
return "<svg><g/></svg>";
|
||||
});
|
||||
const warnings: string[] = [];
|
||||
const slots = renderFenceSlots(
|
||||
[
|
||||
fence({ ordinal: 1 }),
|
||||
fence({ ordinal: 2, source: "BROKEN" }),
|
||||
fence({ ordinal: 3 }),
|
||||
],
|
||||
tab,
|
||||
(m) => warnings.push(m),
|
||||
);
|
||||
expect(slots.get("tok-1")).toContain("<svg>");
|
||||
expect(slots.get("tok-2")).toContain("diagram-error");
|
||||
expect(slots.get("tok-3")).toContain("<svg>"); // post-failure fence rendered
|
||||
expect(reloadCount()).toBe(1); // exactly one reset reload
|
||||
expect(warnings[0]).toContain("failed to render");
|
||||
});
|
||||
|
||||
test("excalidraw fence renders via __excalidrawToSvg", () => {
|
||||
const { tab, calls } = mockTab(() => "<svg data-x><g/></svg>");
|
||||
const slots = renderFenceSlots(
|
||||
[fence({ lang: "excalidraw", source: '{"type":"excalidraw","elements":[]}' })],
|
||||
tab,
|
||||
() => {},
|
||||
);
|
||||
expect(calls).toEqual(["__excalidrawToSvg"]);
|
||||
expect(slots.get("tok-1")).toContain("<svg");
|
||||
});
|
||||
|
||||
test("invalid excalidraw JSON fails fast into a diagnostic WITHOUT calling the tab", () => {
|
||||
const { tab, calls, reloadCount } = mockTab(() => "<svg/>");
|
||||
const warnings: string[] = [];
|
||||
const slots = renderFenceSlots(
|
||||
[fence({ lang: "excalidraw", source: "{not json" })],
|
||||
tab,
|
||||
(m) => warnings.push(m),
|
||||
);
|
||||
expect(calls).toEqual([]); // JSON.parse threw before any bundle call
|
||||
expect(slots.get("tok-1")).toContain("diagram-error");
|
||||
expect(reloadCount()).toBe(1);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── rasterizeDiagramFigures: svg-data-URI + error fallbacks ──────────
|
||||
|
||||
describe("rasterizeDiagramFigures (mock tab)", () => {
|
||||
const figure = `<figure class="diagram" role="img" aria-label="flow"><svg viewBox="0 0 10 10"><g/></svg></figure>`;
|
||||
|
||||
test("svg data-URI images rasterize to PNG", () => {
|
||||
const svgUri = `data:image/svg+xml;base64,${Buffer.from("<svg/>").toString("base64")}`;
|
||||
const { tab } = mockTab(() => "data:image/png;base64,AAAA");
|
||||
const out = rasterizeDiagramFigures(`<img src="${svgUri}" alt="v">`, tab, 6.5, () => {});
|
||||
expect(out).toContain('src="data:image/png;base64,AAAA"');
|
||||
});
|
||||
|
||||
test("figure rasterization failure surfaces the SOURCE as text (never silent loss)", () => {
|
||||
// Returning the figure unchanged would make the diagram vanish in DOCX
|
||||
// (the converter drops <figure>/<svg>) — the failure must be visible.
|
||||
const { tab } = mockTab(() => { throw new RenderCallError("tainted"); });
|
||||
const warnings: string[] = [];
|
||||
const srcFigure = figure.replace(
|
||||
'<figure class="diagram"',
|
||||
`<figure class="diagram" data-gstack-source="${Buffer.from("graph LR\n A --> B").toString("base64")}"`,
|
||||
);
|
||||
const out = rasterizeDiagramFigures(srcFigure, tab, 6.5, (m) => warnings.push(m));
|
||||
expect(out).toContain("could not be rasterized");
|
||||
expect(out).toContain("A --> B"); // source visible (escaped), not dropped
|
||||
expect(out).not.toContain("<figure");
|
||||
expect(warnings[0]).toContain("rasterization failed");
|
||||
});
|
||||
|
||||
test("svg data-URI rasterization failure keeps the original tag", () => {
|
||||
const svgUri = `data:image/svg+xml;base64,${Buffer.from("<svg/>").toString("base64")}`;
|
||||
const { tab } = mockTab(() => { throw new RenderCallError("decode failed"); });
|
||||
const tagIn = `<img src="${svgUri}">`;
|
||||
const out = rasterizeDiagramFigures(tagIn, tab, 6.5, () => {});
|
||||
expect(out).toBe(tagIn);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── image-size: WebP variants ────────────────────────────────────────
|
||||
|
||||
describe("imageDims WebP", () => {
|
||||
function riff(fmt: string, body: Buffer): Buffer {
|
||||
const b = Buffer.alloc(12 + 4 + body.length);
|
||||
b.write("RIFF", 0, "ascii");
|
||||
b.writeUInt32LE(4 + body.length + 4, 4);
|
||||
b.write("WEBP", 8, "ascii");
|
||||
b.write(fmt, 12, "ascii");
|
||||
body.copy(b, 16);
|
||||
return b;
|
||||
}
|
||||
|
||||
test("VP8 (lossy)", () => {
|
||||
const body = Buffer.alloc(16);
|
||||
body.writeUInt16LE(800 & 0x3fff, 10); // width at chunk offset 26 = body offset 10
|
||||
body.writeUInt16LE(600 & 0x3fff, 12);
|
||||
expect(imageDims(riff("VP8 ", body))).toEqual({ width: 800, height: 600, mime: "image/webp" });
|
||||
});
|
||||
|
||||
test("VP8L (lossless)", () => {
|
||||
const body = Buffer.alloc(10);
|
||||
body[4] = 0x2f; // signature at chunk offset 20 = body offset 4
|
||||
const w = 1023, h = 511;
|
||||
const bits = (w - 1) | ((h - 1) << 14);
|
||||
body.writeUInt32LE(bits >>> 0, 5);
|
||||
expect(imageDims(riff("VP8L", body))).toEqual({ width: 1023, height: 511, mime: "image/webp" });
|
||||
});
|
||||
|
||||
test("VP8X (extended)", () => {
|
||||
const body = Buffer.alloc(14);
|
||||
const w = 4000 - 1, h = 250 - 1; // 24-bit minus-one at offsets 24/27 = body 8/11
|
||||
body[8] = w & 0xff; body[9] = (w >> 8) & 0xff; body[10] = (w >> 16) & 0xff;
|
||||
body[11] = h & 0xff; body[12] = (h >> 8) & 0xff; body[13] = (h >> 16) & 0xff;
|
||||
expect(imageDims(riff("VP8X", body))).toEqual({ width: 4000, height: 250, mime: "image/webp" });
|
||||
});
|
||||
|
||||
test("unknown RIFF subtype → null", () => {
|
||||
expect(imageDims(riff("XXXX", Buffer.alloc(14)))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── landscape geometry + slot fallback + bundle path + screen css ────
|
||||
|
||||
describe("pure-function stragglers", () => {
|
||||
test("landscapeContentBox letter defaults: 9in × 6.5in", () => {
|
||||
expect(landscapeContentBox({})).toEqual({ contentWIn: 9, contentHIn: 6.5 });
|
||||
});
|
||||
test("landscapeContentBox a4 + asymmetric margins", () => {
|
||||
const box = landscapeContentBox({ pageSize: "a4", marginLeft: "0.5in", marginRight: "0.5in", marginTop: "25mm", marginBottom: "1in" });
|
||||
expect(box.contentWIn).toBeCloseTo(11.69 - 1, 2);
|
||||
expect(box.contentHIn).toBeCloseTo(8.27 - 25 / 25.4 - 1, 2);
|
||||
});
|
||||
|
||||
test("substituteSlots bare-token fallback (token not <p>-wrapped)", () => {
|
||||
const slots = new Map([["gstack-diagram-slot-x-1", "<figure>D</figure>"]]);
|
||||
const out = substituteSlots("<li>gstack-diagram-slot-x-1</li>", slots);
|
||||
expect(out).toBe("<li><figure>D</figure></li>");
|
||||
});
|
||||
|
||||
test("resolveBundlePath honors the env override", () => {
|
||||
const tmp = path.join(os.tmpdir(), `bundle-override-${process.pid}.html`);
|
||||
fs.writeFileSync(tmp, "<!doctype html>");
|
||||
try {
|
||||
expect(resolveBundlePath({ GSTACK_DIAGRAM_BUNDLE: tmp } as NodeJS.ProcessEnv)).toBe(tmp);
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
// NOTE: resolveBundlePath's not-found error shape is untestable from inside
|
||||
// this checkout (the repo-relative candidate always exists), and a vacuous
|
||||
// if-guarded assertion was worse than none. The env-override test above is
|
||||
// the honest coverage; the error path is exercised manually via
|
||||
// GSTACK_DIAGRAM_BUNDLE pointing at a missing file outside a repo.
|
||||
|
||||
test("screenCss is media-scoped and readable-width", () => {
|
||||
const css = screenCss();
|
||||
expect(css).toContain("@media screen");
|
||||
// 42em at 12pt ≈ 70-75 chars/line — the readable ceiling (design review).
|
||||
expect(css).toContain("max-width: 42em");
|
||||
expect(css).toContain(".watermark { display: none; }");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Unit tests for the diagram pre-pass: fence extraction, info-string parsing,
|
||||
* slot substitution, diagnostic blocks, image inlining policy, and the
|
||||
* byte-level image dimension prober. No browse daemon required — the tab
|
||||
* factory returns null so downscale paths are exercised as no-ops.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
import {
|
||||
StrictModeError,
|
||||
buildDiagnosticBlock,
|
||||
buildDiagramFigure,
|
||||
contentWidthInches,
|
||||
dimToInches,
|
||||
extractDiagramFences,
|
||||
inlineLocalImages,
|
||||
parseInfoString,
|
||||
substituteSlots,
|
||||
decodeFigureSource,
|
||||
} from "../src/diagram-prepass";
|
||||
import { imageDims } from "../src/image-size";
|
||||
|
||||
// ─── fence extraction ─────────────────────────────────────────────────
|
||||
|
||||
describe("extractDiagramFences", () => {
|
||||
test("extracts a mermaid fence and replaces it with a token paragraph", () => {
|
||||
const md = "# T\n\n```mermaid\ngraph LR\n A --> B\n```\n\ntail";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(1);
|
||||
expect(fences[0].lang).toBe("mermaid");
|
||||
expect(fences[0].source).toBe("graph LR\n A --> B");
|
||||
expect(markdown).toContain(fences[0].token);
|
||||
expect(markdown).not.toContain("```mermaid");
|
||||
});
|
||||
|
||||
test("extracts excalidraw fences", () => {
|
||||
const md = '```excalidraw\n{"type":"excalidraw","elements":[]}\n```';
|
||||
const { fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(1);
|
||||
expect(fences[0].lang).toBe("excalidraw");
|
||||
});
|
||||
|
||||
test("render=false keeps the fence as code and strips the flag", () => {
|
||||
const md = "```mermaid render=false\ngraph LR\n X --> Y\n```";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(0);
|
||||
expect(markdown).toContain("```mermaid\ngraph LR");
|
||||
expect(markdown).not.toContain("render=false");
|
||||
});
|
||||
|
||||
test("title is captured from the info string", () => {
|
||||
const md = '```mermaid title="Auth flow"\ngraph LR\n A --> B\n```';
|
||||
const { fences } = extractDiagramFences(md);
|
||||
expect(fences[0].title).toBe("Auth flow");
|
||||
});
|
||||
|
||||
test("non-diagram fences pass through untouched", () => {
|
||||
const md = "```js\nconst a = 1;\n```";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(0);
|
||||
expect(markdown).toBe(md);
|
||||
});
|
||||
|
||||
test("a mermaid example inside a plain fence is never extracted", () => {
|
||||
const md = "````\n```mermaid\ngraph LR\n```\n````";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(0);
|
||||
expect(markdown).toBe(md);
|
||||
});
|
||||
|
||||
test("tilde fences work", () => {
|
||||
const md = "~~~mermaid\ngraph TD\n A --> B\n~~~";
|
||||
const { fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("unclosed fence at EOF replays verbatim", () => {
|
||||
const md = "```mermaid\ngraph LR\n A --> B";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(0);
|
||||
expect(markdown).toBe(md);
|
||||
});
|
||||
|
||||
test("multiple fences get distinct ordinals and tokens", () => {
|
||||
const md = "```mermaid\nA\n```\n\nmiddle\n\n```mermaid\nB\n```";
|
||||
const { fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(2);
|
||||
expect(fences[0].ordinal).toBe(1);
|
||||
expect(fences[1].ordinal).toBe(2);
|
||||
expect(fences[0].token).not.toBe(fences[1].token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseInfoString", () => {
|
||||
test("plain language", () => {
|
||||
expect(parseInfoString("mermaid")).toEqual({ lang: "mermaid", render: true, title: undefined });
|
||||
});
|
||||
test("render=false", () => {
|
||||
expect(parseInfoString("mermaid render=false").render).toBe(false);
|
||||
});
|
||||
test("single-quoted title", () => {
|
||||
expect(parseInfoString("mermaid title='Hi there'").title).toBe("Hi there");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── slots ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("substituteSlots", () => {
|
||||
test("replaces the <p>-wrapped token with slot HTML", () => {
|
||||
const slots = new Map([["gstack-diagram-slot-ab-1", "<figure>X</figure>"]]);
|
||||
const html = "<h1>T</h1>\n<p>gstack-diagram-slot-ab-1</p>\n<p>tail</p>";
|
||||
const out = substituteSlots(html, slots);
|
||||
expect(out).toContain("<figure>X</figure>");
|
||||
expect(out).not.toContain("gstack-diagram-slot");
|
||||
expect(out).not.toContain("<p><figure>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnostic + figure blocks", () => {
|
||||
const fence = {
|
||||
lang: "mermaid", source: "graph LR\n A --> B", render: true,
|
||||
token: "t", ordinal: 3, title: undefined,
|
||||
};
|
||||
test("diagnostic block escapes error content and names the lang", () => {
|
||||
const block = buildDiagnosticBlock(fence, 'Parse <error> "quoted"');
|
||||
expect(block).toContain("diagram-error");
|
||||
expect(block).toContain("Diagram failed to render (mermaid)");
|
||||
expect(block).toContain("Parse <error>");
|
||||
expect(block).not.toContain("<error>");
|
||||
});
|
||||
test("figure carries role=img and ordinal-based aria-label fallback", () => {
|
||||
const fig = buildDiagramFigure(fence, "<svg></svg>");
|
||||
expect(fig).toContain('role="img"');
|
||||
expect(fig).toContain('aria-label="diagram 3"');
|
||||
expect(fig).toContain("<svg></svg>");
|
||||
});
|
||||
test("figure strips scripts from SVG (sanitizer second layer)", () => {
|
||||
const fig = buildDiagramFigure(fence, "<svg><script>alert(1)</script><g/></svg>");
|
||||
expect(fig).not.toContain("<script>");
|
||||
});
|
||||
test("title becomes aria-label and caption", () => {
|
||||
const fig = buildDiagramFigure({ ...fence, title: "Auth flow" }, "<svg></svg>");
|
||||
expect(fig).toContain('aria-label="Auth flow"');
|
||||
expect(fig).toContain("diagram-caption");
|
||||
});
|
||||
test("embedded source round-trips mermaid arrows exactly", () => {
|
||||
const source = "graph LR\n A --> B\n B -->|label with $& and `ticks`| C";
|
||||
const fig = buildDiagramFigure({ ...fence, source }, "<svg></svg>");
|
||||
expect(decodeFigureSource(fig)).toBe(source);
|
||||
});
|
||||
test("slot substitution is immune to $-replacement patterns in labels", () => {
|
||||
const slotHtml = `<figure>label says $' and $& here</figure>`;
|
||||
const out = substituteSlots("<p>tok-x</p><p>tail</p>", new Map([["tok-x", slotHtml]]));
|
||||
expect(out).toContain("label says $' and $& here");
|
||||
expect(out).toContain("<p>tail</p>");
|
||||
expect(out).not.toContain("tailtail"); // $' expansion would duplicate the tail
|
||||
});
|
||||
});
|
||||
|
||||
// ─── image dimension probing ──────────────────────────────────────────
|
||||
|
||||
function tinyPng(w: number, h: number): Buffer {
|
||||
const chunk = (t: string, d: Buffer) => {
|
||||
const body = Buffer.concat([Buffer.from(t, "ascii"), d]);
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(d.length);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(zlib.crc32 ? zlib.crc32(body) : 0);
|
||||
return Buffer.concat([len, body, crc]);
|
||||
};
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0);
|
||||
ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 2;
|
||||
const raw = Buffer.concat(
|
||||
Array.from({ length: h }, () => Buffer.concat([Buffer.from([0]), Buffer.alloc(w * 3, 0x80)])),
|
||||
);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", zlib.deflateSync(raw)),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("imageDims", () => {
|
||||
test("PNG", () => {
|
||||
expect(imageDims(tinyPng(640, 480))).toEqual({ width: 640, height: 480, mime: "image/png" });
|
||||
});
|
||||
test("GIF", () => {
|
||||
const b = Buffer.alloc(13);
|
||||
b.write("GIF89a", 0, "ascii");
|
||||
b.writeUInt16LE(320, 6);
|
||||
b.writeUInt16LE(200, 8);
|
||||
expect(imageDims(b)).toEqual({ width: 320, height: 200, mime: "image/gif" });
|
||||
});
|
||||
test("JPEG (SOF0)", () => {
|
||||
const b = Buffer.from([
|
||||
0xff, 0xd8, // SOI
|
||||
0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, // APP0 len 4
|
||||
0xff, 0xc0, 0x00, 0x0b, 0x08, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, // SOF0 h=256 w=512
|
||||
]);
|
||||
expect(imageDims(b)).toEqual({ width: 512, height: 256, mime: "image/jpeg" });
|
||||
});
|
||||
test("SVG via width/height attrs", () => {
|
||||
const b = Buffer.from('<svg xmlns="x" width="800" height="400"></svg>');
|
||||
expect(imageDims(b)).toEqual({ width: 800, height: 400, mime: "image/svg+xml" });
|
||||
});
|
||||
test("SVG via viewBox", () => {
|
||||
const b = Buffer.from('<svg viewBox="0 0 1200 600"></svg>');
|
||||
expect(imageDims(b)).toEqual({ width: 1200, height: 600, mime: "image/svg+xml" });
|
||||
});
|
||||
test("unknown bytes → null", () => {
|
||||
expect(imageDims(Buffer.from("definitely not an image, sorry"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── content-box math ─────────────────────────────────────────────────
|
||||
|
||||
describe("content width", () => {
|
||||
test("letter with 1in margins = 6.5in", () => {
|
||||
expect(contentWidthInches({})).toBeCloseTo(6.5);
|
||||
});
|
||||
test("a4 with 25mm margins", () => {
|
||||
expect(contentWidthInches({ pageSize: "a4", margins: "25mm" })).toBeCloseTo(8.27 - 50 / 25.4, 2);
|
||||
});
|
||||
test("dimToInches parses pt/cm/mm/px", () => {
|
||||
expect(dimToInches("72pt", 1)).toBeCloseTo(1);
|
||||
expect(dimToInches("2.54cm", 1)).toBeCloseTo(1);
|
||||
expect(dimToInches("25.4mm", 1)).toBeCloseTo(1);
|
||||
expect(dimToInches("96px", 1)).toBeCloseTo(1);
|
||||
expect(dimToInches("garbage", 1.5)).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── image inlining ───────────────────────────────────────────────────
|
||||
|
||||
describe("inlineLocalImages", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-img-"));
|
||||
fs.writeFileSync(path.join(dir, "ok.png"), tinyPng(40, 20));
|
||||
afterAll(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
const base = {
|
||||
inputDir: dir,
|
||||
strict: false,
|
||||
allowNetwork: false,
|
||||
contentWidthIn: 6.5,
|
||||
getTab: () => null,
|
||||
};
|
||||
|
||||
test("local image becomes a data URI with probed dimensions", () => {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="ok.png" alt="x">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("data:image/png;base64,");
|
||||
expect(out).toContain('data-gstack-px-width="40"');
|
||||
expect(out).toContain('data-gstack-px-height="20"');
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("missing image → visible placeholder + warning", () => {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="nope.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
expect(out).toContain("nope.png");
|
||||
expect(warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
test("missing image + --strict → StrictModeError", () => {
|
||||
expect(() =>
|
||||
inlineLocalImages(`<img src="nope.png">`, { ...base, strict: true, warn: () => {} }),
|
||||
).toThrow(StrictModeError);
|
||||
});
|
||||
|
||||
test("remote image is BLOCKED with a visible placeholder (offline posture)", () => {
|
||||
// Leaving the tag would make Chromium fetch it at print time anyway —
|
||||
// the offline posture must remove the src, not just warn about it.
|
||||
const warnings: string[] = [];
|
||||
const tag = `<img src="https://example.com/x.png">`;
|
||||
const out = inlineLocalImages(tag, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).not.toContain("https://example.com/x.png\"");
|
||||
expect(out).toContain("remote image blocked");
|
||||
expect(warnings[0]).toContain("offline");
|
||||
});
|
||||
|
||||
test("symlink escaping the input dir is caught by the realpath check", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-symlink-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.png"), tinyPng(5, 5));
|
||||
const link = path.join(dir, "innocent.png");
|
||||
try {
|
||||
fs.symlinkSync(path.join(outside, "secret.png"), link);
|
||||
const warnings: string[] = [];
|
||||
inlineLocalImages(`<img src="innocent.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(warnings.some((w) => w.includes("OUTSIDE the input directory"))).toBe(true);
|
||||
} finally {
|
||||
try { fs.unlinkSync(link); } catch { /* ignore */ }
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("special files and oversized images degrade to placeholders, never hang", () => {
|
||||
// Directory masquerading as an image — not a regular file.
|
||||
fs.mkdirSync(path.join(dir, "dir.png"), { recursive: true });
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="dir.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
expect(warnings.some((w) => w.includes("not a regular file"))).toBe(true);
|
||||
});
|
||||
|
||||
test("malformed percent-encoding degrades to missing-image, never throws", () => {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="foo%zz.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
});
|
||||
|
||||
test("remote image + --allow-network passes silently", () => {
|
||||
const warnings: string[] = [];
|
||||
const tag = `<img src="https://example.com/x.png">`;
|
||||
const out = inlineLocalImages(tag, { ...base, allowNetwork: true, warn: (m) => warnings.push(m) });
|
||||
expect(out).toBe(tag);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("remote image + --strict → StrictModeError", () => {
|
||||
expect(() =>
|
||||
inlineLocalImages(`<img src="https://example.com/x.png">`, { ...base, strict: true, warn: () => {} }),
|
||||
).toThrow(StrictModeError);
|
||||
});
|
||||
|
||||
test("existing data URI gets dimension annotations only", () => {
|
||||
const uri = `data:image/png;base64,${tinyPng(33, 44).toString("base64")}`;
|
||||
const out = inlineLocalImages(`<img src="${uri}">`, { ...base, warn: () => {} });
|
||||
expect(out).toContain('data-gstack-px-width="33"');
|
||||
expect(out).toContain('data-gstack-px-height="44"');
|
||||
});
|
||||
|
||||
test("out-of-tree image reads warn (never silent) and still inline", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "ext.png"), tinyPng(10, 10));
|
||||
try {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
|
||||
...base, warn: (m) => warnings.push(m),
|
||||
});
|
||||
expect(out).toContain("data:image/png;base64,");
|
||||
expect(warnings.some((w) => w.includes("OUTSIDE the input directory"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("out-of-tree image + --strict → StrictModeError", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "ext.png"), tinyPng(10, 10));
|
||||
try {
|
||||
expect(() =>
|
||||
inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
|
||||
...base, strict: true, warn: () => {},
|
||||
}),
|
||||
).toThrow(StrictModeError);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Windows drive-letter src is treated as a local path, not a URL scheme", () => {
|
||||
// C:/x.png matches the single-letter-scheme regex — it must reach the
|
||||
// local-path branch (and the missing-file placeholder), never silently
|
||||
// pass through as an unknown URL.
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="C:/missing/x.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
// Two warnings: it's out-of-tree (resolved outside inputDir) AND missing.
|
||||
expect(warnings.some((w) => w.includes("image not found"))).toBe(true);
|
||||
});
|
||||
|
||||
test("indented fences inside lists replay byte-for-byte (no list splitting)", () => {
|
||||
const md = "- item\n\n ```js\n code();\n ```\n\n- next";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(0);
|
||||
expect(markdown).toBe(md);
|
||||
});
|
||||
|
||||
test("indented mermaid fences are NOT extracted (column-0 placeholder would split the list)", () => {
|
||||
const md = "- item\n\n ```mermaid\n graph LR\n ```\n";
|
||||
const { markdown, fences } = extractDiagramFences(md);
|
||||
expect(fences).toHaveLength(0);
|
||||
expect(markdown).toBe(md);
|
||||
});
|
||||
|
||||
test("oversized raster without a tab inlines at full size with no downscale", () => {
|
||||
// 6000px-wide PNG header (body irrelevant for probing; file must exist)
|
||||
fs.writeFileSync(path.join(dir, "wide.png"), tinyPng(6000, 100));
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="wide.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain('data-gstack-px-width="6000"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Combined-features copy-paste gate — the P0 CI gate.
|
||||
*
|
||||
* This test runs the compiled `make-pdf/dist/pdf` binary against a fixture
|
||||
* that has every v1 typography feature on (smartypants, hyphens, chapter
|
||||
* breaks, bold/italic, inline code, blockquote, lists, headings). It then
|
||||
* pipes the output through pdftotext and asserts the extracted text
|
||||
* matches the handwritten expected.txt.
|
||||
*
|
||||
* Codex round 2 told us this (not per-feature gates) is the real gate a
|
||||
* user actually cares about — features interact, and the combined
|
||||
* extraction is what predicts production quality.
|
||||
*
|
||||
* Gating: only runs when the compiled binary + browse + pdftotext are all
|
||||
* available. Skipped cleanly otherwise (local dev without full install).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { copyPasteGate, resolvePdftotext } from "../../src/pdftotext";
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/combined-gate.md");
|
||||
const EXPECTED = path.resolve(__dirname, "../fixtures/combined-gate.expected.txt");
|
||||
const ROOT = path.resolve(__dirname, "../../..");
|
||||
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||
|
||||
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||
if (!fs.existsSync(EXPECTED)) return { ok: false, reason: `expected.txt missing (${EXPECTED}).` };
|
||||
try { resolvePdftotext(); } catch (err: any) { return { ok: false, reason: err.message }; }
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
describe("combined-features copy-paste gate", () => {
|
||||
const avail = prerequisitesAvailable();
|
||||
|
||||
test.skipIf(!avail.ok)("fixture PDF extracts cleanly through pdftotext", () => {
|
||||
if (!avail.ok) return; // satisfies the type checker
|
||||
// Use /tmp directly (browse's validateOutputPath allows /private/tmp,
|
||||
// which macOS resolves /tmp to). os.tmpdir() returns /var/folders/...
|
||||
// which is outside the safe-dirs allowlist.
|
||||
const outputPdf = `/tmp/make-pdf-combined-gate-${process.pid}.pdf`;
|
||||
try {
|
||||
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
expect(fs.existsSync(outputPdf)).toBe(true);
|
||||
|
||||
const expected = fs.readFileSync(EXPECTED, "utf8");
|
||||
const result = copyPasteGate(outputPdf, expected);
|
||||
if (!result.ok) {
|
||||
// Attach the extracted text so CI logs make the failure diagnosable
|
||||
process.stderr.write(`\n--- EXTRACTED ---\n${result.extracted}\n--- END ---\n\n`);
|
||||
process.stderr.write(`--- REASONS ---\n${result.reasons.join("\n")}\n--- END ---\n`);
|
||||
}
|
||||
expect(result.ok).toBe(true);
|
||||
} finally {
|
||||
try { fs.unlinkSync(outputPdf); } catch { /* ignore */ }
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
if (!avail.ok) {
|
||||
test("prerequisites check", () => {
|
||||
console.warn(`[skip] ${avail.reason}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Diagram render gate — proves the diagram pre-pass works end-to-end through
|
||||
* the compiled binary: mermaid fences render as vector SVG (not raw code),
|
||||
* multiple fences coexist (id-collision check), render=false keeps source,
|
||||
* a broken fence yields a visible diagnostic block, and a relative local
|
||||
* image actually renders (CRITICAL regression — pre-pass D1 fixed the
|
||||
* setContent/about:blank path where relative images silently 404'd).
|
||||
*
|
||||
* Oracles (per the emoji-gate lessons — text extraction alone lies):
|
||||
* 1. pdftotext: node labels from BOTH diagrams present (vector text made it
|
||||
* into the PDF), diagnostic title present, raw mermaid only where
|
||||
* render=false kept it.
|
||||
* 2. pdftoppm + saturated-pixel count: the red fixture image rasterizes to
|
||||
* colored pixels — text extraction can't fake that.
|
||||
*
|
||||
* Free-tier deterministic gate: runs under plain `bun test` when the compiled
|
||||
* binaries + poppler are available; hard-fails in CI when missing.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { resolvePopplerTool } from "../../src/pdftotext";
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/diagram-gate.md");
|
||||
const ROOT = path.resolve(__dirname, "../../..");
|
||||
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||
const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html");
|
||||
|
||||
const CHILD_TIMEOUT_MS = 60_000;
|
||||
// The 80x40 red fixture image at 100dpi occupies ~80x40 px of strong red.
|
||||
// Floor sits well below that but far above AA noise.
|
||||
const SATURATED_PIXEL_FLOOR = 500;
|
||||
const SATURATION_DELTA = 60;
|
||||
|
||||
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||
if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}). Run bun run build:diagram-render.` };
|
||||
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||
if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." };
|
||||
if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function countSaturatedPixels(ppmPath: string, delta: number): number {
|
||||
const b = fs.readFileSync(ppmPath);
|
||||
let i = 0;
|
||||
const token = (): string => {
|
||||
while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++;
|
||||
if (b[i] === 0x23) { while (i < b.length && b[i] !== 0x0a) i++; return token(); }
|
||||
const s = i;
|
||||
while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++;
|
||||
return b.slice(s, i).toString("ascii");
|
||||
};
|
||||
if (token() !== "P6") throw new Error("expected P6 PPM");
|
||||
const w = Number(token());
|
||||
const h = Number(token());
|
||||
if (Number(token()) !== 255) throw new Error("expected 8-bit PPM");
|
||||
i++;
|
||||
let sat = 0;
|
||||
for (let p = 0; p < w * h; p++) {
|
||||
const o = i + p * 3;
|
||||
if (Math.max(b[o], b[o + 1], b[o + 2]) - Math.min(b[o], b[o + 1], b[o + 2]) > delta) sat++;
|
||||
}
|
||||
return sat;
|
||||
}
|
||||
|
||||
describe("diagram render gate", () => {
|
||||
const avail = prerequisitesAvailable();
|
||||
|
||||
test.skipIf(!avail.ok)("mermaid fences render as vector diagrams; images and diagnostics behave", () => {
|
||||
if (!avail.ok) return;
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-diagram-gate-");
|
||||
const outputPdf = path.join(workDir, "out.pdf");
|
||||
const ppmPrefix = path.join(workDir, "page");
|
||||
try {
|
||||
// No --quiet: stderr carries the downscale warning asserted below.
|
||||
const run = Bun.spawnSync([PDF_BIN, "generate", FIXTURE, outputPdf], {
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const stderr = new TextDecoder().decode(run.stderr);
|
||||
if (run.exitCode !== 0) {
|
||||
throw new Error(`generate failed (exit ${run.exitCode}):\n${stderr}`);
|
||||
}
|
||||
expect(fs.existsSync(outputPdf)).toBe(true);
|
||||
|
||||
// 0. Print-resolution downscale fired on the 4200px noise photo — this
|
||||
// is the only live coverage of __downscaleRaster AND the chunked
|
||||
// jsViaBuffer transport (the data URI exceeds the 100KB argv path).
|
||||
expect(stderr).toMatch(/downscaled huge-noise\.png 4200px → \d+px/);
|
||||
|
||||
const pdftotext = resolvePopplerTool("pdftotext")!;
|
||||
const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
|
||||
|
||||
// 1. Vector text from BOTH diagrams (multi-fence + id-collision check).
|
||||
// The broken fence sits BETWEEN them in the fixture, so the second
|
||||
// diagram rendering at all proves the reset contract (D6.2): the
|
||||
// bundle page reloaded after the failure and kept working.
|
||||
for (const label of ["gatealphanode", "gatebetanode", "gategammanode", "gatedeltanode", "gateepsilonnode"]) {
|
||||
expect(text).toContain(label);
|
||||
}
|
||||
|
||||
// 1b. The excalidraw fence rendered through exportToSvg (vector text
|
||||
// from the scene file, plus its caption).
|
||||
expect(text).toContain("excalialphanode");
|
||||
expect(text).toContain("excalibetanode");
|
||||
expect(text).toContain("Converted flowchart");
|
||||
|
||||
// 2. Rendered fences must NOT ship raw mermaid/scene JSON; render=false must.
|
||||
expect(text).not.toContain("GATEALPHA[");
|
||||
expect(text).not.toContain('"type":"excalidraw"');
|
||||
expect(text).toContain("RAWKEPT");
|
||||
expect(text).toContain("ASCODE");
|
||||
|
||||
// 3. The broken fence produced a visible diagnostic, not silence.
|
||||
expect(text).toContain("Diagram failed to render (mermaid)");
|
||||
|
||||
// 4. CRITICAL regression: the relative image rasterizes to color.
|
||||
const pdftoppm = resolvePopplerTool("pdftoppm")!;
|
||||
execFileSync(pdftoppm, ["-r", "100", "-f", "1", "-l", "1", "-singlefile", outputPdf, ppmPrefix], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
const saturated = countSaturatedPixels(`${ppmPrefix}.ppm`, SATURATION_DELTA);
|
||||
if (saturated < SATURATED_PIXEL_FLOOR) {
|
||||
process.stderr.write(`\n[diagram-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`);
|
||||
}
|
||||
expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR);
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test.skipIf(!avail.ok)("--strict fails on a missing image with a non-zero exit", () => {
|
||||
if (!avail.ok) return;
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-diagram-strict-");
|
||||
const md = path.join(workDir, "doc.md");
|
||||
fs.writeFileSync(md, "# T\n\n\n");
|
||||
try {
|
||||
let failed = false;
|
||||
try {
|
||||
execFileSync(PDF_BIN, ["generate", md, path.join(workDir, "out.pdf"), "--quiet", "--strict"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err: any) {
|
||||
failed = true;
|
||||
const stderr = err.stderr?.toString() ?? "";
|
||||
expect(stderr).toContain("image not found");
|
||||
}
|
||||
expect(failed).toBe(true);
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
if (!avail.ok) {
|
||||
test("diagram gate prerequisites are present (hard-required in CI)", () => {
|
||||
if (process.env.CI) {
|
||||
throw new Error(`diagram gate prerequisites missing in CI: ${avail.reason}`);
|
||||
}
|
||||
console.warn(`[skip] ${avail.reason}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Emoji render gate — proves emoji code points render as real color glyphs in
|
||||
* the output PDF instead of .notdef tofu boxes (▯). This is the regression gate
|
||||
* for fix/make-pdf-emoji-tofu.
|
||||
*
|
||||
* Why not just check pdftotext? Because text extraction is a FALSE oracle for
|
||||
* emoji: Skia preserves the Unicode in the text cluster even when the displayed
|
||||
* glyph is .notdef, so pdftotext can report the emoji survived on a render that
|
||||
* actually drew tofu. Verified empirically on macOS — pdftotext extracts 😀
|
||||
* regardless of whether a color font was available.
|
||||
*
|
||||
* Two assertions that DO distinguish a real render from tofu:
|
||||
* 1. pdffonts shows an emoji family embedded in the PDF (the cascade selected
|
||||
* a real emoji font — AppleColorEmoji as Type 3 on macOS, NotoColorEmoji
|
||||
* on Linux). Missing-fallback => no emoji font embedded.
|
||||
* 2. pdftoppm rasterizes the page and we count saturated (colored) pixels.
|
||||
* A color-emoji render has hundreds (measured: ~1650 at 100dpi); a tofu
|
||||
* render is a monochrome black outline on white (~0 saturated). Tolerant
|
||||
* threshold, not an exact-pixel fixture diff, to dodge cross-platform AA
|
||||
* and font-version variance.
|
||||
*
|
||||
* Note: pdfimages -list is intentionally NOT used — macOS embeds color emoji as
|
||||
* Type 3 fonts, so pdfimages lists nothing even on a correct render.
|
||||
*
|
||||
* Gating: runs only when the compiled binary + browse + pdffonts + pdftoppm are
|
||||
* available AND a color-emoji font is installed for Chromium to fall back to.
|
||||
* In CI (process.env.CI set) missing prerequisites are a HARD FAILURE, not a
|
||||
* skip — CI is expected to install poppler-utils + fonts-noto-color-emoji, so a
|
||||
* silent skip there would let the tofu regression ship behind a green build.
|
||||
* Local dev without those tools skips cleanly.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { resolvePopplerTool } from "../../src/pdftotext";
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/emoji-gate.md");
|
||||
const ROOT = path.resolve(__dirname, "../../..");
|
||||
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||
|
||||
// Saturated-pixel floor. Measured ~1650 at 100dpi for the fixture's color
|
||||
// emoji; a tofu render yields ~0. 200 sits well clear of both.
|
||||
const SATURATED_PIXEL_FLOOR = 200;
|
||||
// A pixel is "colored" when its max-min channel spread exceeds this. Black text,
|
||||
// gray rules, and white background all stay near 0; color emoji spike high.
|
||||
const SATURATION_DELTA = 40;
|
||||
// Per-child wall-clock bound. Bun's test timeout doesn't reliably interrupt a
|
||||
// synchronous execFileSync, so each child gets its own ceiling — a wedged
|
||||
// browser/poppler binary (or a hostile GSTACK_*_BIN override) fails instead of
|
||||
// hanging the whole job.
|
||||
const CHILD_TIMEOUT_MS = 25_000;
|
||||
|
||||
/** Is a color-emoji font available for Chromium to fall back to? */
|
||||
function emojiFontAvailable(): boolean {
|
||||
if (process.platform === "darwin") {
|
||||
return fs.existsSync("/System/Library/Fonts/Apple Color Emoji.ttc");
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
const fcMatch = Bun.which("fc-match");
|
||||
if (!fcMatch) return false;
|
||||
try {
|
||||
const out = execFileSync(
|
||||
fcMatch,
|
||||
["-f", "%{color}\n", ":lang=und-zsye:charset=1F600"],
|
||||
{ encoding: "utf8", timeout: CHILD_TIMEOUT_MS },
|
||||
);
|
||||
return /true/i.test(out);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||
if (!resolvePopplerTool("pdffonts")) return { ok: false, reason: "pdffonts not found (install poppler-utils)." };
|
||||
if (!resolvePopplerTool("pdftoppm")) return { ok: false, reason: "pdftoppm not found (install poppler-utils)." };
|
||||
if (!emojiFontAvailable()) return { ok: false, reason: "no color-emoji font installed; run ./setup (Linux) or install one." };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count pixels in a P6 (binary) PPM whose RGB channel spread exceeds delta.
|
||||
* Validates the header and buffer length so malformed/variant output is a hard
|
||||
* diagnostic (thrown), never a silently-wrong count.
|
||||
*/
|
||||
function countSaturatedPixels(ppmPath: string, delta: number): number {
|
||||
const b = fs.readFileSync(ppmPath);
|
||||
let i = 0;
|
||||
const skipWhitespaceAndComments = () => {
|
||||
for (;;) {
|
||||
while (i < b.length && (b[i] === 0x20 || b[i] === 0x0a || b[i] === 0x09 || b[i] === 0x0d)) i++;
|
||||
if (b[i] === 0x23) { // '#': comment runs to end of line
|
||||
while (i < b.length && b[i] !== 0x0a) i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
const token = (): string => {
|
||||
skipWhitespaceAndComments();
|
||||
const s = i;
|
||||
while (i < b.length && b[i] !== 0x20 && b[i] !== 0x0a && b[i] !== 0x09 && b[i] !== 0x0d) i++;
|
||||
return b.slice(s, i).toString("ascii");
|
||||
};
|
||||
const magic = token();
|
||||
if (magic !== "P6") throw new Error(`expected P6 PPM, got "${magic}"`);
|
||||
const w = Number(token());
|
||||
const h = Number(token());
|
||||
const maxval = Number(token());
|
||||
if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) {
|
||||
throw new Error(`invalid PPM dimensions: ${w}x${h}`);
|
||||
}
|
||||
if (maxval !== 255) {
|
||||
// pdftoppm emits 8-bit P6 (maxval 255). 16-bit would be 2 bytes/channel and
|
||||
// would break the byte math below — fail loudly rather than miscount.
|
||||
throw new Error(`unexpected PPM maxval ${maxval} (expected 255)`);
|
||||
}
|
||||
i++; // single whitespace byte after maxval precedes the pixel block
|
||||
const total = w * h;
|
||||
if (b.length - i < total * 3) {
|
||||
throw new Error(`PPM pixel buffer too short: have ${b.length - i}, need ${total * 3}`);
|
||||
}
|
||||
let sat = 0;
|
||||
for (let p = 0; p < total; p++) {
|
||||
const o = i + p * 3;
|
||||
const r = b[o], g = b[o + 1], bl = b[o + 2];
|
||||
if (Math.max(r, g, bl) - Math.min(r, g, bl) > delta) sat++;
|
||||
}
|
||||
return sat;
|
||||
}
|
||||
|
||||
describe("emoji render gate", () => {
|
||||
const avail = prerequisitesAvailable();
|
||||
|
||||
test.skipIf(!avail.ok)("emoji render as color glyphs, not tofu", () => {
|
||||
if (!avail.ok) return; // type narrowing
|
||||
// Private temp dir under /tmp: browse's validateOutputPath only allows
|
||||
// /tmp and /private/tmp (not os.tmpdir()'s /var/folders), and mkdtemp
|
||||
// dodges the predictable-path symlink/collision risk.
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-emoji-gate-");
|
||||
const outputPdf = path.join(workDir, "out.pdf");
|
||||
const ppmPrefix = path.join(workDir, "page");
|
||||
const ppmPath = `${ppmPrefix}.ppm`;
|
||||
try {
|
||||
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
expect(fs.existsSync(outputPdf)).toBe(true);
|
||||
|
||||
// 1. An emoji family must be embedded — the cascade found a real emoji
|
||||
// font instead of falling through to .notdef.
|
||||
const pdffonts = resolvePopplerTool("pdffonts")!;
|
||||
const fontList = execFileSync(pdffonts, [outputPdf], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
|
||||
if (!/emoji/i.test(fontList)) {
|
||||
process.stderr.write(`\n--- pdffonts ---\n${fontList}\n--- END ---\n`);
|
||||
}
|
||||
expect(/emoji/i.test(fontList)).toBe(true);
|
||||
|
||||
// 2. The page must actually rasterize to color, not a monochrome tofu box.
|
||||
const pdftoppm = resolvePopplerTool("pdftoppm")!;
|
||||
execFileSync(pdftoppm, ["-r", "100", "-singlefile", outputPdf, ppmPrefix], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
expect(fs.existsSync(ppmPath)).toBe(true);
|
||||
const saturated = countSaturatedPixels(ppmPath, SATURATION_DELTA);
|
||||
if (saturated < SATURATED_PIXEL_FLOOR) {
|
||||
process.stderr.write(`\n[emoji-gate] saturated pixels: ${saturated} (floor ${SATURATED_PIXEL_FLOOR})\n`);
|
||||
}
|
||||
expect(saturated).toBeGreaterThanOrEqual(SATURATED_PIXEL_FLOOR);
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
if (!avail.ok) {
|
||||
// In CI, missing prerequisites are a hard failure — a silent skip would let
|
||||
// the Linux tofu regression ship behind a green build. Locally, just warn.
|
||||
test("emoji gate prerequisites are present (hard-required in CI)", () => {
|
||||
if (process.env.CI) {
|
||||
throw new Error(`emoji gate prerequisites missing in CI: ${avail.reason}`);
|
||||
}
|
||||
console.warn(`[skip] ${avail.reason}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Output-format gate for `--to html` and `--to docx` (eng-review P7/P8),
|
||||
* driven through the compiled binary against the diagram-gate fixture
|
||||
* (diagrams + relative image + broken fence + render=false fence).
|
||||
*
|
||||
* HTML contract: ONE self-contained file — zero network references, no
|
||||
* scripts, diagrams as inline SVG, images as data URIs, screen media layer.
|
||||
*
|
||||
* DOCX contract: content fidelity, not layout fidelity — valid OOXML zip,
|
||||
* document.xml carries headings/code/diagnostics, diagrams embedded as PNG
|
||||
* media. (A .docx is a zip: unzip -p is the oracle.)
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/diagram-gate.md");
|
||||
const ROOT = path.resolve(__dirname, "../../..");
|
||||
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||
const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html");
|
||||
|
||||
const CHILD_TIMEOUT_MS = 60_000;
|
||||
|
||||
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||
if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}).` };
|
||||
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||
if (!Bun.which("unzip")) return { ok: false, reason: "unzip not found (needed for docx zip checks)." };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function generate(to: string, outputPath: string): void {
|
||||
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPath, "--quiet", "--to", to], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
describe("output format gate", () => {
|
||||
const avail = prerequisitesAvailable();
|
||||
|
||||
test.skipIf(!avail.ok)("--to html: single self-contained file, zero network refs", () => {
|
||||
if (!avail.ok) return;
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-format-html-");
|
||||
const out = path.join(workDir, "out.html");
|
||||
try {
|
||||
generate("html", out);
|
||||
const html = fs.readFileSync(out, "utf8");
|
||||
|
||||
// Zero network references and zero scripts. (The only http(s) tokens
|
||||
// allowed are XML namespace identifiers inside inline SVG, which are
|
||||
// never fetched.)
|
||||
const refs = html.match(/\b(?:src|href)\s*=\s*"https?:[^"]*"/gi) ?? [];
|
||||
expect(refs).toEqual([]);
|
||||
expect(html).not.toMatch(/<script\b/i);
|
||||
expect(html).not.toMatch(/<link\b/i);
|
||||
|
||||
// Diagrams inline as vector SVG; images inline as data URIs.
|
||||
expect(html).toContain('<figure class="diagram"');
|
||||
expect(html).toMatch(/<svg/i);
|
||||
expect(html).toContain("data:image/png;base64,");
|
||||
|
||||
// Screen layer present; diagnostic block survived.
|
||||
expect(html).toContain("@media screen");
|
||||
expect(html).toContain("Diagram failed to render (mermaid)");
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test.skipIf(!avail.ok)("--to docx: valid OOXML with content + PNG diagram media", () => {
|
||||
if (!avail.ok) return;
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-format-docx-");
|
||||
const out = path.join(workDir, "out.docx");
|
||||
try {
|
||||
generate("docx", out);
|
||||
|
||||
const listing = execFileSync("unzip", ["-l", out], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
|
||||
expect(listing).toContain("word/document.xml");
|
||||
expect(listing).toContain("[Content_Types].xml");
|
||||
// Diagram PNGs + fixture image land in media/.
|
||||
expect((listing.match(/word\/media\/image[^\s]*\.png/g) ?? []).length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const xml = execFileSync("unzip", ["-p", out, "word/document.xml"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
|
||||
const text = xml
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
||||
|
||||
// Headings, render=false code, and the diagnostic all survive.
|
||||
expect(text).toContain("Diagram Gate");
|
||||
expect(text).toContain("RAWKEPT");
|
||||
expect(text).toContain("Diagram failed to render");
|
||||
// Rendered fences ship as images, not leaked source.
|
||||
expect(text).not.toContain("GATEALPHA[");
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test.skipIf(!avail.ok)("--to rejects unknown formats with a --format disambiguation hint", () => {
|
||||
if (!avail.ok) return;
|
||||
let stderr = "";
|
||||
try {
|
||||
execFileSync(PDF_BIN, ["generate", FIXTURE, "--to", "epub"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
} catch (err: any) {
|
||||
stderr = err.stderr?.toString() ?? "";
|
||||
}
|
||||
expect(stderr).toContain("invalid --to");
|
||||
expect(stderr).toContain("--page-size alias");
|
||||
}, 60000);
|
||||
|
||||
if (!avail.ok) {
|
||||
test("format gate prerequisites are present (hard-required in CI)", () => {
|
||||
if (process.env.CI) {
|
||||
throw new Error(`format gate prerequisites missing in CI: ${avail.reason}`);
|
||||
}
|
||||
console.warn(`[skip] ${avail.reason}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Landscape promotion gate — proves the conservative auto-landscape policy
|
||||
* end-to-end through the compiled binary, asserted on pdfinfo per-page boxes
|
||||
* (the only oracle that can't lie about orientation).
|
||||
*
|
||||
* The fixture encodes one of each decision:
|
||||
* - wide screenshot, no alt hint → MUST stay portrait (false-positive guard)
|
||||
* - wide image, alt "architecture diagram" → promotes
|
||||
* - small image with {page=landscape} → promotes (directive force)
|
||||
* - wide mermaid sequence diagram → promotes (provenance automatic)
|
||||
* - wide mermaid with page=portrait fence → MUST stay portrait (veto)
|
||||
*
|
||||
* Also runs the --toc combo: Paged.js isn't shipped in v1 (TOC renders
|
||||
* without page numbers, browse falls through after 3s), so named-page
|
||||
* landscape must survive a --toc run unchanged. If Paged.js ever lands and
|
||||
* re-paginates, this is the test that catches the interaction.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { resolvePopplerTool } from "../../src/pdftotext";
|
||||
|
||||
const FIXTURE = path.resolve(__dirname, "../fixtures/landscape-gate.md");
|
||||
const ROOT = path.resolve(__dirname, "../../..");
|
||||
const PDF_BIN = path.join(ROOT, "make-pdf/dist/pdf");
|
||||
const BROWSE_BIN = path.join(ROOT, "browse/dist/browse");
|
||||
const BUNDLE = path.join(ROOT, "lib/diagram-render/dist/diagram-render.html");
|
||||
|
||||
const CHILD_TIMEOUT_MS = 60_000;
|
||||
|
||||
function prerequisitesAvailable(): { ok: true } | { ok: false; reason: string } {
|
||||
if (!fs.existsSync(PDF_BIN)) return { ok: false, reason: `make-pdf binary missing (${PDF_BIN}). Run bun run build.` };
|
||||
if (!fs.existsSync(BROWSE_BIN)) return { ok: false, reason: `browse binary missing (${BROWSE_BIN}).` };
|
||||
if (!fs.existsSync(BUNDLE)) return { ok: false, reason: `diagram-render bundle missing (${BUNDLE}).` };
|
||||
if (!fs.existsSync(FIXTURE)) return { ok: false, reason: `fixture missing (${FIXTURE}).` };
|
||||
if (!resolvePopplerTool("pdfinfo")) return { ok: false, reason: "pdfinfo not found (install poppler-utils)." };
|
||||
if (!resolvePopplerTool("pdftotext")) return { ok: false, reason: "pdftotext not found (install poppler-utils)." };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
interface PageBox {
|
||||
page: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function pageBoxes(pdfPath: string): PageBox[] {
|
||||
const pdfinfo = resolvePopplerTool("pdfinfo")!;
|
||||
const out = execFileSync(pdfinfo, ["-f", "1", "-l", "99", pdfPath], {
|
||||
encoding: "utf8",
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
const boxes: PageBox[] = [];
|
||||
for (const m of out.matchAll(/Page\s+(\d+)\s+size:\s+([0-9.]+)\s+x\s+([0-9.]+)\s+pts/g)) {
|
||||
boxes.push({ page: Number(m[1]), width: parseFloat(m[2]), height: parseFloat(m[3]) });
|
||||
}
|
||||
if (boxes.length === 0) throw new Error(`pdfinfo reported no page sizes:\n${out}`);
|
||||
return boxes;
|
||||
}
|
||||
|
||||
const isLandscape = (b: PageBox) => b.width > b.height;
|
||||
|
||||
function generate(args: string[], outputPdf: string): void {
|
||||
execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet", ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, BROWSE_BIN },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
describe("landscape promotion gate", () => {
|
||||
const avail = prerequisitesAvailable();
|
||||
|
||||
test.skipIf(!avail.ok)("exactly the promoted blocks get landscape pages", () => {
|
||||
if (!avail.ok) return;
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-gate-");
|
||||
const outputPdf = path.join(workDir, "out.pdf");
|
||||
try {
|
||||
generate([], outputPdf);
|
||||
const boxes = pageBoxes(outputPdf);
|
||||
const landscape = boxes.filter(isLandscape);
|
||||
const portrait = boxes.filter((b) => !isLandscape(b));
|
||||
|
||||
// Three promotions: alt-hinted image, directive-forced image, wide diagram.
|
||||
expect(landscape.length).toBe(3);
|
||||
// First page (intro + screenshot) and the veto'd diagram stay portrait.
|
||||
expect(portrait.length).toBeGreaterThanOrEqual(2);
|
||||
expect(isLandscape(boxes[0])).toBe(false);
|
||||
|
||||
// The veto'd diagram rendered on SOME portrait page and NO landscape
|
||||
// page — the actual invariant. (Asserting a specific page index breaks
|
||||
// spuriously when font metrics shift pagination.)
|
||||
const pdftotext = resolvePopplerTool("pdftotext")!;
|
||||
const pageText = (page: number) =>
|
||||
execFileSync(pdftotext, ["-f", String(page), "-l", String(page), outputPdf, "-"], {
|
||||
encoding: "utf8",
|
||||
timeout: CHILD_TIMEOUT_MS,
|
||||
});
|
||||
expect(portrait.some((b) => pageText(b.page).includes("vetoalpha"))).toBe(true);
|
||||
expect(landscape.some((b) => pageText(b.page).includes("vetoalpha"))).toBe(false);
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
test.skipIf(!avail.ok)("--toc combo: TOC renders and landscape promotion survives", () => {
|
||||
if (!avail.ok) return;
|
||||
const workDir = fs.mkdtempSync("/tmp/make-pdf-landscape-toc-");
|
||||
const outputPdf = path.join(workDir, "out.pdf");
|
||||
try {
|
||||
generate(["--toc"], outputPdf);
|
||||
const boxes = pageBoxes(outputPdf);
|
||||
expect(boxes.filter(isLandscape).length).toBe(3);
|
||||
|
||||
const pdftotext = resolvePopplerTool("pdftotext")!;
|
||||
const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS });
|
||||
// TOC heading extracts uppercase (small-caps styling).
|
||||
expect(text.toUpperCase()).toContain("CONTENTS");
|
||||
} finally {
|
||||
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
if (!avail.ok) {
|
||||
test("landscape gate prerequisites are present (hard-required in CI)", () => {
|
||||
if (process.env.CI) {
|
||||
throw new Error(`landscape gate prerequisites missing in CI: ${avail.reason}`);
|
||||
}
|
||||
console.warn(`[skip] ${avail.reason}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
The Horizon
|
||||
This is the combined-features fixture. Every feature turned on simultaneously. The gate asserts that all of these paragraphs extract cleanly from the PDF with pdftotext.
|
||||
|
||||
A paragraph with bold, italic, and inline code tokens — each of which gets a different HTML treatment. None should fragment text on copy-paste.
|
||||
|
||||
A paragraph with “curly quotes”, ‘single quotes’, an em dash — like this, and an ellipsis… All three get smartypants transforms.
|
||||
|
||||
A subsection heading
|
||||
|
||||
First list item with some words that keep it on one line.
|
||||
Second list item with more words.
|
||||
Third list item.
|
||||
|
||||
A blockquote from Van Dyke. Her diminished size is in me, not in her.
|
||||
|
||||
A second chapter
|
||||
|
||||
This content begins on a fresh page because the default chapter-breaks rule fires. Extract must still find these paragraphs.
|
||||
|
||||
A final paragraph with enough words to trigger hyphenation across the line wrap boundary. Extraordinary words sometimes hyphenate. Interdisciplinary ones certainly do.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# The Horizon
|
||||
|
||||
This is the combined-features fixture. Every feature turned on simultaneously.
|
||||
The gate asserts that all of these paragraphs extract cleanly from the PDF
|
||||
with pdftotext.
|
||||
|
||||
A paragraph with **bold**, *italic*, and `inline code` tokens — each of which
|
||||
gets a different HTML treatment. None should fragment text on copy-paste.
|
||||
|
||||
A paragraph with "curly quotes", 'single quotes', an em dash -- like this,
|
||||
and an ellipsis... All three get smartypants transforms.
|
||||
|
||||
## A subsection heading
|
||||
|
||||
Lists must not break mid-item:
|
||||
|
||||
- First list item with some words that keep it on one line.
|
||||
- Second list item with more words.
|
||||
- Third list item.
|
||||
|
||||
> A blockquote from Van Dyke. Her diminished size is in me, not in her.
|
||||
|
||||
# A second chapter
|
||||
|
||||
This content begins on a fresh page because the default chapter-breaks rule
|
||||
fires. Extract must still find these paragraphs.
|
||||
|
||||
A final paragraph with enough words to trigger hyphenation across the line
|
||||
wrap boundary. Extraordinary words sometimes hyphenate. Interdisciplinary
|
||||
ones certainly do.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
+48
@@ -0,0 +1,48 @@
|
||||
# Diagram Gate
|
||||
|
||||
A relative local image (CRITICAL regression: must render, not 404):
|
||||
|
||||

|
||||
|
||||
## First diagram
|
||||
|
||||
```mermaid title="Gate pipeline"
|
||||
graph LR
|
||||
GATEALPHA[gatealphanode] --> GATEBETA{gatebetanode}
|
||||
GATEBETA -->|yes| GATEGAMMA[gategammanode]
|
||||
```
|
||||
|
||||
## Deliberately broken
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A -->
|
||||
(((
|
||||
```
|
||||
|
||||
## Second diagram (id-collision check)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
GATEDELTA[gatedeltanode] --> GATEEPSILON[gateepsilonnode]
|
||||
```
|
||||
|
||||
## Kept as source
|
||||
|
||||
```mermaid render=false
|
||||
graph LR
|
||||
RAWKEPT --> ASCODE
|
||||
```
|
||||
|
||||
|
||||
## Excalidraw scene
|
||||
|
||||
```excalidraw title="Converted flowchart"
|
||||
{"type":"excalidraw","version":2,"source":"gstack-diagram-render","elements":[{"id":"VL7JRGkMTpqCVBye2mq3X","type":"rectangle","x":0,"y":0,"width":197.046875,"height":44,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,"seed":172328728,"version":3,"versionNonce":1118377320,"isDeleted":false,"boundElements":[{"type":"text","id":"mQsqVweT6BUmQpwbW6sOU"},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow"}],"updated":1781273248718,"link":null,"locked":false},{"id":"YX9Ff_UgFhhRa7lGo6xS9","type":"rectangle","x":247.046875,"y":0,"width":186.4375,"height":44,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":null,"seed":1275860584,"version":3,"versionNonce":45230184,"isDeleted":false,"boundElements":[{"type":"text","id":"9oes2DZoL-mRrT3RGakLq"},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow"}],"updated":1781273248718,"link":null,"locked":false},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow","x":197.047,"y":22,"width":44.70000000000002,"height":0,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":2},"seed":1530192920,"version":4,"versionNonce":1747670296,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"points":[[0.5,0],[44.20000000000002,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"VL7JRGkMTpqCVBye2mq3X","focus":0,"gap":1},"endBinding":{"elementId":"YX9Ff_UgFhhRa7lGo6xS9","focus":0,"gap":5.299874999999986},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"mQsqVweT6BUmQpwbW6sOU","type":"text","x":33.5576171875,"y":9.5,"width":129.931640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a3","roundness":null,"seed":1219280408,"version":3,"versionNonce":1462825496,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"text":"excalialphanode","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"VL7JRGkMTpqCVBye2mq3X","originalText":"excalialphanode","autoResize":true,"lineHeight":1.25},{"id":"9oes2DZoL-mRrT3RGakLq","type":"text","x":280.2998046875,"y":9.5,"width":119.931640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a4","roundness":null,"seed":1436367640,"version":3,"versionNonce":639687528,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"text":"excalibetanode","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"YX9Ff_UgFhhRa7lGo6xS9","originalText":"excalibetanode","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}
|
||||
```
|
||||
|
||||
## Huge photo (downscale trigger, no diagram hint)
|
||||
|
||||

|
||||
|
||||
Done.
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# Emoji rendering gate 😀
|
||||
|
||||
This fixture exists to prove that emoji code points render as real color
|
||||
glyphs in the output PDF, not as `.notdef` tofu boxes (▯).
|
||||
|
||||
Color emoji on one line: 😀 ❤️ 🚀 ✅ 💡
|
||||
|
||||
A variation-selector sequence (FE0F) renders color: ❤️ — the bare code point
|
||||
❤ is text-style. Both must come from a font in the cascade, never tofu.
|
||||
|
||||
Non-emoji Unicode (unchanged, regression guard): em dash —, times ×, arrow →,
|
||||
bullet •, ellipsis …
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Landscape Gate
|
||||
|
||||
Intro text under the first heading.
|
||||
|
||||
## Negative: screenshot stays portrait
|
||||
|
||||

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

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