Files
learnharness/src/agent/subagents/pdf-ops.ts
T
amosblomqvist 2780166904 fix(pdf): make pdfjs survive bundling + real-PDF font fidelity (live smoke)
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.
2026-09-19 16:23:20 +03:00

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")}`);
}