166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
import path from 'path';
|
|
import type { Canvas } from '@napi-rs/canvas';
|
|
|
|
type CanvasRuntime = {
|
|
DOMMatrixCtor: unknown;
|
|
Path2DCtor: unknown;
|
|
createCanvasFn: (width: number, height: number) => Canvas;
|
|
};
|
|
|
|
let canvasRuntimePromise: Promise<CanvasRuntime> | null = null;
|
|
|
|
function resolvePdfjsStandardFontDataUrl(): string {
|
|
const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts');
|
|
return `${standardFontDir.replace(/\/?$/, '/')}`;
|
|
}
|
|
|
|
async function loadCanvasRuntime(): Promise<CanvasRuntime> {
|
|
if (!canvasRuntimePromise) {
|
|
canvasRuntimePromise = (async () => {
|
|
const mod = await import('@napi-rs/canvas');
|
|
const namespace = mod as Record<string, unknown>;
|
|
const fallback = (namespace.default ?? {}) as Record<string, unknown>;
|
|
|
|
const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as
|
|
| ((width: number, height: number) => Canvas)
|
|
| undefined;
|
|
const DOMMatrixCtor = namespace.DOMMatrix ?? fallback.DOMMatrix;
|
|
const Path2DCtor = namespace.Path2D ?? fallback.Path2D;
|
|
|
|
if (typeof createCanvasFn !== 'function') {
|
|
throw new Error(
|
|
`Canvas runtime missing createCanvas export (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`,
|
|
);
|
|
}
|
|
if (!DOMMatrixCtor || !Path2DCtor) {
|
|
throw new Error(
|
|
`Canvas runtime missing DOMMatrix/Path2D exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
DOMMatrixCtor,
|
|
Path2DCtor,
|
|
createCanvasFn,
|
|
};
|
|
})();
|
|
}
|
|
return canvasRuntimePromise;
|
|
}
|
|
|
|
function ensureNodeCanvasGlobals(runtime: CanvasRuntime): void {
|
|
const g = globalThis as Record<string, unknown>;
|
|
if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = runtime.DOMMatrixCtor;
|
|
if (typeof g.Path2D === 'undefined') g.Path2D = runtime.Path2DCtor;
|
|
}
|
|
|
|
interface RenderInput {
|
|
pdfBytes: ArrayBuffer;
|
|
pageNumber: number;
|
|
scale?: number;
|
|
targetWidth?: number;
|
|
format?: 'png' | 'jpeg';
|
|
jpegQuality?: number;
|
|
}
|
|
|
|
function createPdfjsCanvasFactory(runtime: CanvasRuntime) {
|
|
return class OpenReaderCanvasFactory {
|
|
create(width: number, height: number) {
|
|
const canvas = runtime.createCanvasFn(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height)));
|
|
return {
|
|
canvas,
|
|
context: canvas.getContext('2d') as unknown as CanvasRenderingContext2D,
|
|
};
|
|
}
|
|
|
|
reset(target: { canvas: Canvas; context: CanvasRenderingContext2D }, width: number, height: number): void {
|
|
target.canvas.width = Math.max(1, Math.floor(width));
|
|
target.canvas.height = Math.max(1, Math.floor(height));
|
|
}
|
|
|
|
destroy(target: { canvas: Canvas; context: CanvasRenderingContext2D }): void {
|
|
target.canvas.width = 0;
|
|
target.canvas.height = 0;
|
|
// @ts-expect-error pdf.js expects these nulled on destroy
|
|
target.canvas = null;
|
|
// @ts-expect-error pdf.js expects these nulled on destroy
|
|
target.context = null;
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function renderPage({
|
|
pdfBytes,
|
|
pageNumber,
|
|
scale = 1.5,
|
|
targetWidth,
|
|
format = 'png',
|
|
jpegQuality = 82,
|
|
}: RenderInput): Promise<{
|
|
width: number;
|
|
height: number;
|
|
image: Buffer;
|
|
contentType: 'image/png' | 'image/jpeg';
|
|
}> {
|
|
// pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so
|
|
// callers can safely reuse their original bytes across pages/calls.
|
|
const isolatedBytes = new Uint8Array(pdfBytes).slice();
|
|
|
|
const canvasRuntime = await loadCanvasRuntime();
|
|
ensureNodeCanvasGlobals(canvasRuntime);
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
|
|
if (pdfjs.GlobalWorkerOptions) {
|
|
pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
|
pdfjs.GlobalWorkerOptions.workerPort = null;
|
|
}
|
|
|
|
const standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
|
|
|
|
const loadingTask = pdfjs.getDocument({
|
|
data: isolatedBytes,
|
|
useWorkerFetch: false,
|
|
standardFontDataUrl,
|
|
isEvalSupported: false,
|
|
// Ensure pdf.js transport uses our canvas backend in Node/Next runtime.
|
|
CanvasFactory: createPdfjsCanvasFactory(canvasRuntime),
|
|
});
|
|
|
|
const pdf = await loadingTask.promise;
|
|
try {
|
|
const page = await pdf.getPage(pageNumber);
|
|
const baseViewport = page.getViewport({ scale: 1.0 });
|
|
const effectiveScale = typeof targetWidth === 'number' && Number.isFinite(targetWidth) && targetWidth > 0
|
|
? (Math.max(1, Math.round(targetWidth)) / Math.max(1, baseViewport.width))
|
|
: scale;
|
|
const viewport = page.getViewport({ scale: effectiveScale });
|
|
const width = Math.max(1, Math.floor(viewport.width));
|
|
const height = Math.max(1, Math.floor(viewport.height));
|
|
const canvas = canvasRuntime.createCanvasFn(width, height);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
const renderTask = page.render({
|
|
canvasContext: ctx as unknown as CanvasRenderingContext2D,
|
|
viewport,
|
|
intent: 'display',
|
|
});
|
|
await renderTask.promise;
|
|
const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
const image = format === 'jpeg'
|
|
? canvas.toBuffer('image/jpeg', jpegQuality)
|
|
: canvas.toBuffer('image/png');
|
|
return {
|
|
width,
|
|
height,
|
|
image,
|
|
contentType,
|
|
};
|
|
} finally {
|
|
await pdf.destroy().catch(() => undefined);
|
|
await loadingTask.destroy().catch(() => undefined);
|
|
}
|
|
}
|