2026-05-18 01:44:36 +00:00
|
|
|
/**
|
|
|
|
|
* /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";
|
|
|
|
|
|
2026-05-18 14:45:40 +00:00
|
|
|
// 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.
|
2026-05-18 01:44:36 +00:00
|
|
|
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");
|
2026-05-18 14:45:40 +00:00
|
|
|
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);
|
2026-05-18 01:44:36 +00:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|