echos-ocr/src/lib/pdf.ts
Randall Stillwell 21c863da01 Migrate to Vercel + Supabase + Upstash production stack
- Replace pdf2pic/GraphicsMagick with pdfjs-dist + @napi-rs/canvas for
  Vercel-compatible PDF rasterization
- Replace MinIO with Supabase Storage (S3-compatible); rename minio.ts
  to storage.ts and update all imports
- Replace in-memory job queue with Upstash QStash; upload route now
  persists files to storage before enqueuing, /api/jobs/process handles
  the QStash callback
- Convert email watcher from persistent IMAP connection to stateless
  scanInbox() polled by Vercel Cron every 2 minutes
- Add FTP watcher (basic-ftp) with cron polling for scanner integration
  via Dreamhost FTP drop directory
- Add FTP config fields to AppSettings schema
- Remove folder watcher (chokidar), standalone output, Docker-only code
- Update next.config.ts, middleware, instrumentation for serverless
- Add vercel.json with cron schedules for email and FTP polling
- Add migration scripts for database (pg_dump/restore) and storage
  (S3-to-S3 copy) with verification

Made-with: Cursor
2026-04-15 23:51:44 -05:00

97 lines
2.9 KiB
TypeScript

import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import sharp from "sharp";
const TARGET_DPI = 200;
const PDF_DEFAULT_DPI = 72;
const SCALE = TARGET_DPI / PDF_DEFAULT_DPI;
export interface PageImage {
page: number;
buffer: Buffer;
width: number;
height: number;
}
interface CanvasAndContext {
canvas: { toBuffer(mime: string): Buffer };
context: unknown;
}
interface CanvasFactory {
create(width: number, height: number): CanvasAndContext;
}
export async function pdfToImages(pdfBuffer: Buffer): Promise<PageImage[]> {
const data = new Uint8Array(pdfBuffer);
const loadingTask = getDocument({ data, useSystemFonts: true });
const pdfDocument = await loadingTask.promise;
const pageCount = pdfDocument.numPages;
const images: PageImage[] = [];
const canvasFactory = pdfDocument.canvasFactory as unknown as CanvasFactory;
console.log(`[pdf] Rendering ${pageCount} page(s) at ${TARGET_DPI} DPI (scale ${SCALE.toFixed(2)})`);
for (let i = 1; i <= pageCount; i++) {
const page = await pdfDocument.getPage(i);
const viewport = page.getViewport({ scale: SCALE });
const { canvas, context } = canvasFactory.create(
Math.floor(viewport.width),
Math.floor(viewport.height)
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await page.render({ canvas: canvas as any, viewport } as any).promise;
const pngBuffer = canvas.toBuffer("image/png");
page.cleanup();
let buf = await sharp(pngBuffer).jpeg({ quality: 90 }).toBuffer();
let meta = await sharp(buf).metadata();
const w = meta.width || 0;
const h = meta.height || 0;
// Duplex scanners flip the sheet between sides, so odd pages
// (back/response card) are 180-deg rotated relative to even pages
// (front/survey). Landscape pages need rotation to portrait;
// the direction depends on which side of the sheet was scanned.
const isBackSide = i % 2 === 1;
if (w > h) {
const angle = isBackSide ? -90 : 90;
buf = await sharp(buf).rotate(angle).jpeg({ quality: 90 }).toBuffer();
meta = await sharp(buf).metadata();
} else if (isBackSide) {
buf = await sharp(buf).rotate(180).jpeg({ quality: 90 }).toBuffer();
meta = await sharp(buf).metadata();
}
images.push({
page: i,
buffer: buf,
width: meta.width || Math.floor(viewport.width),
height: meta.height || Math.floor(viewport.height),
});
}
await pdfDocument.destroy();
return images;
}
export async function imageToBase64(buffer: Buffer): Promise<string> {
const processed = await sharp(buffer)
.resize(1200, undefined, { withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer();
return processed.toString("base64");
}
export async function processUploadedImage(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.resize(1600, undefined, { withoutEnlargement: true })
.normalize()
.sharpen()
.jpeg({ quality: 90 })
.toBuffer();
}