src / pdfRender.ts
// Renders PDF pages to PNG buffers using PDFium-WASM (no native binaries) and a
// pure-JS PNG encoder. Also parses page-range specs.
import { PDFiumLibrary, type PDFiumDocument } from "@hyzyla/pdfium";
import { PNG } from "pngjs";
export type RenderedPage = {
pageNumber: number;
pngBase64: string;
width: number;
height: number;
};
export type PdfDocument = {
numPages: number;
isEncrypted: boolean;
destroy: () => Promise<void>;
renderPage: (pageNumber: number, scale: number) => Promise<RenderedPage>;
};
let libraryCache: Promise<PDFiumLibrary> | null = null;
function getLibrary(): Promise<PDFiumLibrary> {
if (!libraryCache) libraryCache = PDFiumLibrary.init();
return libraryCache;
}
export async function openPdf(buffer: Buffer): Promise<PdfDocument> {
const library = await getLibrary();
let doc: PDFiumDocument;
try {
doc = await library.loadDocument(new Uint8Array(buffer));
} catch (e: unknown) {
const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
if (msg.includes("password") || msg.includes("encrypted") || msg.includes("auth")) {
return {
numPages: 0,
isEncrypted: true,
destroy: async () => {},
renderPage: async () => {
throw new Error("Encrypted PDF");
},
};
}
throw e;
}
return {
numPages: doc.getPageCount(),
isEncrypted: false,
destroy: async () => {
doc.destroy();
},
renderPage: async (pageNumber, scale) => {
const page = doc.getPage(pageNumber - 1);
const rendered = await page.render({
scale,
render: async ({ width, height, data }) => bgraToPng(data, width, height),
});
return {
pageNumber,
pngBase64: Buffer.from(rendered.data).toString("base64"),
width: rendered.width,
height: rendered.height,
};
},
};
}
// PDFium gives raw BGRA pixels; PNG wants RGBA. Swap channels in place into a
// new buffer, then encode synchronously via pngjs.
function bgraToPng(bgra: Uint8Array, width: number, height: number): Uint8Array {
const rgba = Buffer.alloc(bgra.length);
for (let i = 0; i < bgra.length; i += 4) {
rgba[i] = bgra[i + 2];
rgba[i + 1] = bgra[i + 1];
rgba[i + 2] = bgra[i];
rgba[i + 3] = bgra[i + 3];
}
const png = new PNG({ width, height, colorType: 6, inputColorType: 6 });
png.data = rgba;
return PNG.sync.write(png);
}
// Parses "1", "1-3", "1,3,5-7", "5-", "-3" → 1-indexed page numbers,
// deduped, sorted, clamped to [1, numPages]. Returns null if unparseable.
export function parsePageRange(spec: string, numPages: number): number[] | null {
const out = new Set<number>();
const trimmed = spec.trim();
if (!trimmed) return null;
const parts = trimmed.split(",").map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return null;
for (const part of parts) {
const m = part.match(/^(\d*)\s*-\s*(\d*)$/);
if (m) {
const lo = m[1] === "" ? 1 : parseInt(m[1], 10);
const hi = m[2] === "" ? numPages : parseInt(m[2], 10);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
const a = Math.max(1, Math.min(numPages, lo));
const b = Math.max(1, Math.min(numPages, hi));
const [start, end] = a <= b ? [a, b] : [b, a];
for (let i = start; i <= end; i++) out.add(i);
continue;
}
if (/^\d+$/.test(part)) {
const n = parseInt(part, 10);
if (n >= 1 && n <= numPages) out.add(n);
continue;
}
return null;
}
if (out.size === 0) return null;
return [...out].sort((a, b) => a - b);
}