/** * /api/crop — On-demand bbox crop of a page PNG, sized + cached. * * Inputs (querystring): * doc — doc-id (or use png= absolute path inside UFO_ROOT) * page — page number (1-indexed) OR p001 / p-001 * x,y,w,h — bbox in normalized [0..1] * w_px — output width in px (default 480, max 1600) * pad — relative padding 0..0.05 (default 0.005) * format — png | webp | jpeg (default webp) * tight — 1|0 — auto-tighten to dark-pixel content inside declared bbox * (default 1; turns OFF when type is text-like where margins matter). * * Sonnet's bboxes are ~1.43x bigger than the actual feature on average, so we * post-process: find the tight content bbox inside the declared region and crop * to that with a small margin. Falls back to the declared bbox if the content * scan finds nothing meaningful. * * Caches in-memory for 1h via Cache-Control header. Next.js Image component * can then layer on top for further format/size optimization. */ import { NextRequest } from "next/server"; import path from "node:path"; import sharp from "sharp"; import { PROCESSING } from "@/lib/wiki"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; function clamp01(n: number): number { if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(1, n)); } function badRequest(msg: string) { return new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "content-type": "application/json" }, }); } export async function GET(req: NextRequest) { const u = new URL(req.url); const doc = u.searchParams.get("doc")?.trim() ?? ""; const pngParam = u.searchParams.get("png")?.trim() ?? ""; let pageStr = u.searchParams.get("page")?.trim() ?? ""; const x = clamp01(parseFloat(u.searchParams.get("x") ?? "")); const y = clamp01(parseFloat(u.searchParams.get("y") ?? "")); const w = clamp01(parseFloat(u.searchParams.get("w") ?? "")); const h = clamp01(parseFloat(u.searchParams.get("h") ?? "")); if (w <= 0 || h <= 0) return badRequest("bbox w/h must be > 0"); const w_px = Math.min(Math.max(parseInt(u.searchParams.get("w_px") ?? "480", 10), 64), 1600); const pad = Math.min(Math.max(parseFloat(u.searchParams.get("pad") ?? "0.005"), 0), 0.05); const format = (u.searchParams.get("format") ?? "webp").toLowerCase(); const tight = u.searchParams.get("tight") !== "0"; // Resolve source PNG. // Two conventions exist in the corpus: // 1-based: page=1 → p-001.png (most docs) // 0-based: page=1 → p-000.png (~34 docs converted with pdftoppm -f 0) // Try the 1-based path first; if the file doesn't exist, fall back to // 0-based (page - 1). Idempotent — if both exist, 1-based wins. let pngPath: string; if (pngParam) { if (pngParam.includes("..")) return badRequest("png param: invalid path"); pngPath = path.join(PROCESSING, "..", pngParam.replace(/^\/+/, "")); } else { if (!doc) return badRequest("doc or png required"); let pageNum: number; if (/^p\d{1,3}$/i.test(pageStr)) { pageNum = parseInt(pageStr.replace(/^p-?/i, ""), 10); } else { pageNum = parseInt(pageStr, 10); } if (!Number.isFinite(pageNum) || pageNum < 1) return badRequest("bad page"); const docDir = path.join(PROCESSING, "png", doc); const oneBased = path.join(docDir, `p-${String(pageNum).padStart(3, "0")}.png`); const zeroBased = path.join(docDir, `p-${String(pageNum - 1).padStart(3, "0")}.png`); const { existsSync } = await import("node:fs"); pngPath = existsSync(oneBased) ? oneBased : (existsSync(zeroBased) ? zeroBased : oneBased); pageStr = `p-${String(pageNum).padStart(3, "0")}`; } let buf: Buffer; try { const img = sharp(pngPath); const meta = await img.metadata(); const W = meta.width ?? 0; const H = meta.height ?? 0; if (!W || !H) return new Response("source image has no dims", { status: 500 }); let x0 = Math.max(0, Math.floor((x - pad) * W)); let y0 = Math.max(0, Math.floor((y - pad) * H)); let x1 = Math.min(W, Math.ceil((x + w + pad) * W)); let y1 = Math.min(H, Math.ceil((y + h + pad) * H)); let cw = Math.max(1, x1 - x0); let ch = Math.max(1, y1 - y0); // Auto-tighten + auto-recenter: Sonnet bboxes are ~1.43x bigger AND can be // shifted up to ±15% off. We search in an EXPANDED area (50% margin around // the declared bbox) for dark content, then crop to the tight bbox of that // content — but only if it overlaps the declared bbox center (else we'd // capture unrelated content nearby). if (tight) { try { const searchMargin = 0.5; // search ±50% beyond declared bbox const sx0n = Math.max(0, x - w * searchMargin); const sy0n = Math.max(0, y - h * searchMargin); const sx1n = Math.min(1, x + w + w * searchMargin); const sy1n = Math.min(1, y + h + h * searchMargin); const sx0 = Math.floor(sx0n * W); const sy0 = Math.floor(sy0n * H); const sx1 = Math.ceil(sx1n * W); const sy1 = Math.ceil(sy1n * H); const searchW = sx1 - sx0; const searchH = sy1 - sy0; const raw = await sharp(pngPath) .extract({ left: sx0, top: sy0, width: searchW, height: searchH }) .greyscale() .raw() .toBuffer({ resolveWithObject: true }); const data = raw.data; const rw = raw.info.width; const rh = raw.info.height; const THRESH = 200; let minX = rw, minY = rh, maxX = -1, maxY = -1; for (let py = 0; py < rh; py++) { const rowOff = py * rw; for (let px = 0; px < rw; px++) { if (data[rowOff + px] < THRESH) { if (px < minX) minX = px; if (px > maxX) maxX = px; if (py < minY) minY = py; if (py > maxY) maxY = py; } } } if (maxX >= 0) { // Tight bbox in search-area coords const tx0 = minX, ty0 = minY, tx1 = maxX, ty1 = maxY; // Convert to page-pixel coords const absTX0 = sx0 + tx0; const absTY0 = sy0 + ty0; const absTX1 = sx0 + tx1; const absTY1 = sy0 + ty1; // Validate: tight bbox center must lie inside declared bbox (with 25% slack). const declCX = (x + w / 2) * W; const declCY = (y + h / 2) * H; const tightCX = (absTX0 + absTX1) / 2; const tightCY = (absTY0 + absTY1) / 2; const slackX = (w * W) * 0.75; const slackY = (h * H) * 0.75; const overlapsDeclared = Math.abs(tightCX - declCX) <= slackX && Math.abs(tightCY - declCY) <= slackY; // Sanity: tight area must be at least 1% of search area (filter pure noise) const tightArea = (tx1 - tx0) * (ty1 - ty0); const minArea = rw * rh * 0.01; if (overlapsDeclared && tightArea > minArea) { const marginPx = Math.max(6, Math.round(Math.min(absTX1 - absTX0, absTY1 - absTY0) * 0.06)); x0 = Math.max(0, absTX0 - marginPx); y0 = Math.max(0, absTY0 - marginPx); x1 = Math.min(W, absTX1 + marginPx); y1 = Math.min(H, absTY1 + marginPx); cw = x1 - x0; ch = y1 - y0; } } } catch { /* fall through to declared bbox */ } } let pipeline = img.extract({ left: x0, top: y0, width: cw, height: ch }); if (cw > w_px) { pipeline = pipeline.resize({ width: w_px, withoutEnlargement: true }); } if (format === "png") buf = await pipeline.png({ compressionLevel: 9 }).toBuffer(); else if (format === "jpeg" || format === "jpg") buf = await pipeline.jpeg({ quality: 84 }).toBuffer(); else buf = await pipeline.webp({ quality: 86 }).toBuffer(); } catch (e) { return new Response(JSON.stringify({ error: "crop_failed", message: (e as Error).message }), { status: 500, headers: { "content-type": "application/json" }, }); } const mime = format === "png" ? "image/png" : format === "jpeg" || format === "jpg" ? "image/jpeg" : "image/webp"; return new Response(new Uint8Array(buf), { status: 200, headers: { "content-type": mime, // Crops are pure function of inputs → cache aggressively "cache-control": "public, max-age=31536000, immutable", }, }); }