Live dispatch smoke against the Turbopack dev server (master's ask, 2026-09-19) caught two things unit tests cannot - they run unbundled: - Bundling pdfjs-dist broke its node fake-worker: the chunk-relative pdf.worker.mjs import 404s inside .next/dev/server/chunks. Fix: serverExternalPackages + tracing includes for legacy/ + standard_fonts/ (next.config.ts - a clearly-marked seam, same precedent as bcdcd51; the standalone output now ships the worker and font files, verified). - Real-world PDFs warned about missing standardFontDataUrl (non-embedded standard fonts silently fall back) and flooded the log with JBIG2 warnings on scanned files. Fix: standardFontsDir() + verbosity 0 in both the node ops and the headless canvas shim. Verified live end-to-end on GEB (802pp, text layer): teacher dispatch, 12-step read with vision renders, brief with accurate page citations, wake turn folding the resolution (Chapter IX, pp. 268-270) into the lesson. Scanned/DRM Polya correctly refuses to fabricate citations.
238 lines
8.7 KiB
TypeScript
238 lines
8.7 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
/**
|
|
* PDF plumbing for the pdf-reader sub-agent — sources v0 (2026-09): Node
|
|
* ports of the four pdf-reader skill scripts (pdf_info / pdf_extract /
|
|
* pdf_search / pdf_render), on the user-chosen hybrid stack:
|
|
*
|
|
* - TEXT (info / extract / search): pdfjs-dist — pure JS, no native deps.
|
|
* - RENDER: the shared headless-Chrome infra (subagents/headless.ts,
|
|
* puppeteer + system Chrome) screenshots pdfium-rendered pages.
|
|
*
|
|
* Doctrine (the skill's reading strategy, mirrored in the sub-agent's system
|
|
* prompt): triage with pdf_info FIRST, then size-based strategy — short PDFs
|
|
* render all pages, medium render flagged (math/image-heavy) pages, long
|
|
* ones search for targeted lookups before reading anything at length.
|
|
*
|
|
* These are pure library functions — no model in sight. The pdf-reader
|
|
* agent (pdf-reader.ts) drives them through its own tool loop.
|
|
*/
|
|
|
|
export interface PdfPageInfo {
|
|
page: number;
|
|
text_length: number;
|
|
image_count: number;
|
|
/** Fraction of math-flavored chars — the render-this-page signal (port
|
|
* of the skill's unicode-range density heuristic). */
|
|
math_density: number;
|
|
}
|
|
|
|
export interface PdfInfo {
|
|
path: string;
|
|
page_count: number;
|
|
/** Mean chars/page — the size class the strategy branches on. */
|
|
mean_text_length: number;
|
|
pages: PdfPageInfo[];
|
|
}
|
|
|
|
/** Load the legacy pdfjs build (runs headless in plain node, no worker). */
|
|
async function loadPdfjs() {
|
|
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
return pdfjs;
|
|
}
|
|
|
|
/** Standard-font data dir (live-smoke finding 2026-09-19: without it pdfjs
|
|
* warns and falls back on non-embedded standard fonts — Helvetica, Times…).
|
|
* The node side takes a plain filesystem path. Resolved from this module's
|
|
* location, the headless.ts PATHS_nodeModules idiom. */
|
|
export function standardFontsDir(): string {
|
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "node_modules", "pdfjs-dist", "standard_fonts") + path.sep;
|
|
}
|
|
|
|
async function openDocument(filePath: string) {
|
|
const pdfjs = await loadPdfjs();
|
|
const data = new Uint8Array(await fs.readFile(filePath));
|
|
// The legacy build in plain node runs on a main-thread fake worker (no
|
|
// workerSrc needed); teardown is the loading task's destroy().
|
|
// verbosity 0 = errors only — scanned PDFs (JBIG2 XObjects) otherwise
|
|
// flood the log with per-page warnings (live-smoke finding 2026-09-19).
|
|
const task = pdfjs.getDocument({ data, standardFontDataUrl: standardFontsDir(), verbosity: 0 });
|
|
return { doc: await task.promise, task };
|
|
}
|
|
|
|
/** Concatenate a page's text items into a body string. */
|
|
function itemsToText(items: unknown[]): string {
|
|
let out = "";
|
|
for (const it of items) {
|
|
if (it && typeof it === "object" && "str" in (it as Record<string, unknown>)) {
|
|
const str = (it as { str?: unknown }).str;
|
|
if (typeof str === "string") out += str;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Math-flavored char fraction — the skill's heuristic, verbatim ranges. */
|
|
function mathDensity(text: string): number {
|
|
let mathChars = 0;
|
|
for (const c of text) {
|
|
const code = c.codePointAt(0)!;
|
|
if (
|
|
(code >= 0x2200 && code <= 0x22ff) || // Mathematical Operators
|
|
(code >= 0x2100 && code <= 0x214f) || // Letterlike Symbols
|
|
(code >= 0x2190 && code <= 0x21ff) || // Arrows
|
|
(code >= 0x27c0 && code <= 0x27ef) || // Misc Mathematical Symbols-A
|
|
(code >= 0x2980 && code <= 0x29ff) || // Misc Mathematical Symbols-B
|
|
(code >= 0x2a00 && code <= 0x2aff) || // Supplemental Mathematical Operators
|
|
(code >= 0x0370 && code <= 0x03ff) || // Greek
|
|
(code >= 0x1d400 && code <= 0x1d7ff) || // Mathematical Alphanumeric Symbols
|
|
"∫∑∏√∂∇∞≈≠≤≥±×÷∈∉⊂⊃∪∩∧∨¬∀∃∅".includes(c)
|
|
) {
|
|
mathChars++;
|
|
}
|
|
}
|
|
return mathChars / Math.max(text.length, 1);
|
|
}
|
|
|
|
/** Cheap validity probe: getDocument resolves only for parseable PDFs —
|
|
* no page walk, no text extraction (unlike pdfInfo). The reader's upfront
|
|
* unreadable-source gate; garbage files throw pdfjs's precise error. */
|
|
export async function pdfProbe(filePath: string): Promise<void> {
|
|
const { task } = await openDocument(filePath);
|
|
await task.destroy();
|
|
}
|
|
|
|
/** pdf_info: page count + per-page triage signals. ALWAYS run first. */
|
|
export async function pdfInfo(filePath: string): Promise<PdfInfo> {
|
|
const pdfjs = await loadPdfjs(); // OPS constant for the operator-list scan
|
|
const { doc } = await openDocument(filePath);
|
|
const pages: PdfPageInfo[] = [];
|
|
let totalText = 0;
|
|
for (let i = 1; i <= doc.numPages; i++) {
|
|
const page = await doc.getPage(i);
|
|
const content = await page.getTextContent();
|
|
const text = itemsToText(content.items as unknown[]);
|
|
// count page images: paintImageXObject ops in the operator list
|
|
const ops = await page.getOperatorList();
|
|
let imageCount = 0;
|
|
for (const fn of ops.fnArray) {
|
|
if (fn === pdfjs.OPS.paintImageXObject) imageCount++;
|
|
}
|
|
totalText += text.length;
|
|
pages.push({
|
|
page: i,
|
|
text_length: text.length,
|
|
image_count: imageCount,
|
|
math_density: Math.round(mathDensity(text) * 10000) / 10000,
|
|
});
|
|
}
|
|
return {
|
|
path: filePath,
|
|
page_count: doc.numPages,
|
|
mean_text_length: Math.round(totalText / Math.max(doc.numPages, 1)),
|
|
pages,
|
|
};
|
|
}
|
|
|
|
/** Parse 'all' | '1-5' | '1,3,7' | '3' into a sorted 1-indexed page list. */
|
|
export function parsePageSpec(spec: string, total: number): number[] {
|
|
const trimmed = spec.trim().toLowerCase();
|
|
if (trimmed === "all") return Array.from({ length: total }, (_, i) => i + 1);
|
|
const pages = new Set<number>();
|
|
for (const part of spec.split(",")) {
|
|
const p = part.trim();
|
|
if (!p) continue;
|
|
if (p.includes("-")) {
|
|
const [startRaw, endRaw] = p.split("-", 2) as [string, string];
|
|
const start = Math.max(1, parseInt(startRaw, 10));
|
|
const end = Math.min(total, parseInt(endRaw, 10));
|
|
for (let pg = start; pg <= end; pg++) pages.add(pg);
|
|
} else {
|
|
const pg = parseInt(p, 10);
|
|
if (pg >= 1 && pg <= total) pages.add(pg);
|
|
}
|
|
}
|
|
return [...pages].sort((a, b) => a - b);
|
|
}
|
|
|
|
/** pdf_extract: text of the requested pages, `--- Page N ---` headed. */
|
|
export async function pdfExtract(filePath: string, pageSpec: string): Promise<string> {
|
|
const { doc } = await openDocument(filePath);
|
|
const pages = parsePageSpec(pageSpec, doc.numPages);
|
|
if (pages.length === 0) return "No pages selected — page spec matched nothing.";
|
|
const out: string[] = [];
|
|
for (const i of pages) {
|
|
const page = await doc.getPage(i);
|
|
const content = await page.getTextContent();
|
|
out.push(`--- Page ${i} ---`);
|
|
out.push(itemsToText(content.items as unknown[]));
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
export interface PdfSearchHit {
|
|
page: number;
|
|
line: number;
|
|
/** contextLines around the hit, `>>>` marking the hit line. */
|
|
context: string;
|
|
}
|
|
|
|
/** pdf_search: keyword/regex across all pages with context lines.
|
|
* pageTexts rides along so the agent can extract a hit's full page without
|
|
* a second pass (the skill pipelines `search → extract` the same way). */
|
|
export async function pdfSearch(
|
|
filePath: string,
|
|
query: string,
|
|
opts?: { contextLines?: number; literal?: boolean },
|
|
): Promise<{ hits: PdfSearchHit[]; pageTexts: Map<number, string> }> {
|
|
const contextLines = opts?.contextLines ?? 3;
|
|
const { doc } = await openDocument(filePath);
|
|
let pattern: RegExp;
|
|
if (opts?.literal) {
|
|
pattern = new RegExp(escapeRegex(query), "i");
|
|
} else {
|
|
try {
|
|
pattern = new RegExp(query, "i");
|
|
} catch (err) {
|
|
throw new Error(`Invalid regex: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
const hits: PdfSearchHit[] = [];
|
|
const pageTexts = new Map<number, string>();
|
|
for (let i = 1; i <= doc.numPages; i++) {
|
|
const page = await doc.getPage(i);
|
|
const content = await page.getTextContent();
|
|
const text = itemsToText(content.items as unknown[]);
|
|
pageTexts.set(i, text);
|
|
const lines = text.split("\n");
|
|
for (let ln = 0; ln < lines.length; ln++) {
|
|
if (!pattern.test(lines[ln]!)) continue;
|
|
const start = Math.max(0, ln - contextLines);
|
|
const end = Math.min(lines.length, ln + contextLines + 1);
|
|
const context = lines
|
|
.slice(start, end)
|
|
.map((l, j) => `${start + j === ln ? ">>>" : " "} ${l}`)
|
|
.join("\n");
|
|
hits.push({ page: i, line: ln + 1, context });
|
|
}
|
|
}
|
|
return { hits, pageTexts };
|
|
}
|
|
|
|
function escapeRegex(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/** Where rendered pages land (mirrors the skill's /tmp convention). */
|
|
export function renderDir(filePath: string): string {
|
|
// FNV-1a short hash of the absolute path — stable per file, no deps.
|
|
const abs = path.resolve(filePath);
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < abs.length; i++) {
|
|
h ^= abs.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193) >>> 0;
|
|
}
|
|
return path.join("/tmp", `lh-pdf-${h.toString(16).padStart(8, "0")}`);
|
|
} |