Added headers for resizing

This commit is contained in:
2026-01-21 11:32:40 -08:00
committed by GitHub
parent fd9d5da896
commit 89076e590f

204
server.js
View File

@@ -17,6 +17,8 @@ app.use(express.raw({ type: "*/*", limit: "30mb" }));
app.get("/", (_req, res) => res.status(200).send("postconvert: ok")); app.get("/", (_req, res) => res.status(200).send("postconvert: ok"));
app.get("/health", (_req, res) => res.status(200).send("ok")); app.get("/health", (_req, res) => res.status(200).send("ok"));
// ---------------- Auth ----------------
function requireAuth(req, res) { function requireAuth(req, res) {
const token = process.env.CONVERTER_TOKEN; const token = process.env.CONVERTER_TOKEN;
const auth = req.headers.authorization || ""; const auth = req.headers.authorization || "";
@@ -27,35 +29,107 @@ function requireAuth(req, res) {
return true; return true;
} }
// ---------------- Type checks ----------------
function isPdfRequest(req) { function isPdfRequest(req) {
const contentType = String(req.headers["content-type"] || "").toLowerCase(); const contentType = String(req.headers["content-type"] || "").toLowerCase();
const filename = String(req.headers["x-filename"] || "").toLowerCase(); const filename = String(req.headers["x-filename"] || "").toLowerCase();
return contentType === "application/pdf" || filename.endsWith(".pdf"); return contentType.startsWith("application/pdf") || filename.endsWith(".pdf");
} }
function looksLikeHeic(buf) { function looksLikeHeic(buf) {
// ISO-BMFF container: "ftyp" at offset 4, brand includes heic/heif/mif1/msf1 // ISO-BMFF container: "ftyp" at offset 4. Scan brands for HEIF-family.
if (!buf || buf.length < 32) return false; if (!buf || buf.length < 16) return false;
if (buf.toString("ascii", 4, 8) !== "ftyp") return false; if (buf.toString("ascii", 4, 8) !== "ftyp") return false;
const brands = buf.toString("ascii", 8, 32);
// Scan more than first 32 bytes; compatible brands can appear later.
const scanEnd = Math.min(buf.length, 256);
const brands = buf.toString("ascii", 8, scanEnd);
return ( return (
brands.includes("heic") || brands.includes("heic") ||
brands.includes("heif") || brands.includes("heif") ||
brands.includes("heix") ||
brands.includes("hevc") ||
brands.includes("hevx") ||
brands.includes("mif1") || brands.includes("mif1") ||
brands.includes("msf1") brands.includes("msf1")
// If you want to treat AVIF similarly, add: || brands.includes("avif")
); );
} }
// ---------- Core converters ---------- // ---------------- Resize / Quality options ----------------
// Headers:
// - x-jpeg-quality: 0..100 (default 100)
// - x-max-dimension: px (max width/height), preserves aspect (default none)
// - x-width: px (optional)
// - x-height: px (optional)
// - x-fit: inside|cover|contain|fill|outside (default inside)
// - x-without-enlargement: true|false (default true)
async function toJpegWithSharp(inputBuffer) { function parseBool(v, fallback = false) {
return sharp(inputBuffer, { if (v == null) return fallback;
const s = String(v).toLowerCase().trim();
if (["1", "true", "yes", "y", "on"].includes(s)) return true;
if (["0", "false", "no", "n", "off"].includes(s)) return false;
return fallback;
}
function parseResizeOptions(req) {
const quality = clampInt(req.headers["x-jpeg-quality"], 0, 100, 100);
const width = clampInt(req.headers["x-width"], 1, 20000, 0) || null;
const height = clampInt(req.headers["x-height"], 1, 20000, 0) || null;
const maxDim = clampInt(req.headers["x-max-dimension"], 1, 20000, 0) || null;
const fitRaw = String(req.headers["x-fit"] || "inside").toLowerCase();
const fit = ["inside", "cover", "contain", "fill", "outside"].includes(fitRaw)
? fitRaw
: "inside";
const withoutEnlargement = parseBool(req.headers["x-without-enlargement"], true);
return { quality, width, height, maxDim, fit, withoutEnlargement };
}
function applyResizeAndJpeg(pipeline, opts) {
const { width, height, maxDim, fit, withoutEnlargement, quality } = opts;
// Resize: explicit width/height wins; else max dimension inside box.
if (width || height) {
pipeline = pipeline.resize({
width: width ?? undefined,
height: height ?? undefined,
fit,
withoutEnlargement,
});
} else if (maxDim) {
pipeline = pipeline.resize({
width: maxDim,
height: maxDim,
fit: "inside",
withoutEnlargement,
});
}
// JPEG encode. mozjpeg requires sharp build support; if unavailable, sharp ignores it.
return pipeline.jpeg({
quality,
chromaSubsampling: "4:4:4",
mozjpeg: true,
});
}
// ---------------- Core converters ----------------
async function toJpegWithSharp(inputBuffer, opts) {
const pipeline = sharp(inputBuffer, {
failOnError: false, failOnError: false,
limitInputPixels: 200e6, // safety limitInputPixels: 200e6, // safety
}) }).rotate();
.rotate()
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) return applyResizeAndJpeg(pipeline, opts).toBuffer();
.toBuffer();
} }
function heifDisplayToRGBA(img) { function heifDisplayToRGBA(img) {
@@ -69,7 +143,6 @@ function heifDisplayToRGBA(img) {
const bufObj = { data: rgba, width, height, channels: 4 }; const bufObj = { data: rgba, width, height, channels: 4 };
img.display(bufObj, (out) => { img.display(bufObj, (out) => {
// out can be null on failure
if (!out || !out.data) { if (!out || !out.data) {
return reject(new Error("libheif-js display() failed (returned null)")); return reject(new Error("libheif-js display() failed (returned null)"));
} }
@@ -85,7 +158,7 @@ function heifDisplayToRGBA(img) {
}); });
} }
async function heicToJpegWithWasm(inputBuffer) { async function heicToJpegWithWasm(inputBuffer, opts) {
if (!libheif?.HeifDecoder) { if (!libheif?.HeifDecoder) {
throw new Error("libheif-js not available (HeifDecoder missing)"); throw new Error("libheif-js not available (HeifDecoder missing)");
} }
@@ -101,32 +174,37 @@ async function heicToJpegWithWasm(inputBuffer) {
const { width, height, rgba } = await heifDisplayToRGBA(img); const { width, height, rgba } = await heifDisplayToRGBA(img);
// Encode to JPEG with sharp (consistent output settings) // Encode to JPEG with sharp (consistent output settings)
return sharp(Buffer.from(rgba), { raw: { width, height, channels: 4 } }) const pipeline = sharp(Buffer.from(rgba), { raw: { width, height, channels: 4 } });
.jpeg({ quality: 100, chromaSubsampling: "4:4:4" }) return applyResizeAndJpeg(pipeline, opts).toBuffer();
.toBuffer();
} }
async function pdfFirstPageToJpeg(inputBuffer, dpi = 300) { async function pdfFirstPageToJpeg(inputBuffer, opts, dpi = 300) {
const id = randomUUID(); const id = randomUUID();
const pdfPath = `/tmp/${id}.pdf`; const pdfPath = `/tmp/${id}.pdf`;
const outPrefix = `/tmp/${id}`; const outPrefix = `/tmp/${id}`;
await fs.writeFile(pdfPath, inputBuffer); try {
await fs.writeFile(pdfPath, inputBuffer);
await execFilePromise("pdftoppm", [ await execFilePromise("pdftoppm", [
"-jpeg", "-jpeg",
"-r", "-r",
String(dpi), String(dpi),
"-singlefile", "-singlefile",
pdfPath, pdfPath,
outPrefix, outPrefix,
]); ]);
const pageJpg = await fs.readFile(`${outPrefix}.jpg`); const pageJpg = await fs.readFile(`${outPrefix}.jpg`);
return toJpegWithSharp(pageJpg); return toJpegWithSharp(pageJpg, opts);
} finally {
// best-effort cleanup
await safeUnlink(pdfPath);
await safeUnlink(`${outPrefix}.jpg`);
}
} }
// ---------- Endpoints ---------- // ---------------- Endpoints ----------------
// Single JPEG output (images + PDF first page) // Single JPEG output (images + PDF first page)
app.post("/convert", async (req, res) => { app.post("/convert", async (req, res) => {
@@ -136,22 +214,24 @@ app.post("/convert", async (req, res) => {
const input = req.body; const input = req.body;
if (!input || input.length === 0) return res.status(400).send("Empty body"); if (!input || input.length === 0) return res.status(400).send("Empty body");
const opts = parseResizeOptions(req);
// PDF: always handle via poppler // PDF: always handle via poppler
if (isPdfRequest(req)) { if (isPdfRequest(req)) {
const jpeg = await pdfFirstPageToJpeg(input, 300); const jpeg = await pdfFirstPageToJpeg(input, opts, 300);
res.setHeader("Content-Type", "image/jpeg"); res.setHeader("Content-Type", "image/jpeg");
return res.status(200).send(jpeg); return res.status(200).send(jpeg);
} }
// Try sharp first (fast path) // Try sharp first (fast path)
try { try {
const jpeg = await toJpegWithSharp(input); const jpeg = await toJpegWithSharp(input, opts);
res.setHeader("Content-Type", "image/jpeg"); res.setHeader("Content-Type", "image/jpeg");
return res.status(200).send(jpeg); return res.status(200).send(jpeg);
} catch (sharpErr) { } catch (sharpErr) {
// If it looks like HEIC/HEIF, decode via WASM and encode to JPEG // If it looks like HEIC/HEIF, decode via WASM and encode to JPEG
if (looksLikeHeic(input)) { if (looksLikeHeic(input)) {
const jpeg = await heicToJpegWithWasm(input); const jpeg = await heicToJpegWithWasm(input, opts);
res.setHeader("Content-Type", "image/jpeg"); res.setHeader("Content-Type", "image/jpeg");
return res.status(200).send(jpeg); return res.status(200).send(jpeg);
} }
@@ -165,8 +245,15 @@ app.post("/convert", async (req, res) => {
} }
}); });
// PDF all pages -> ZIP of JPEG pages // PDF all pages -> ZIP of JPEG pages (supports resize/quality by re-encoding each page)
app.post("/convert/pdf", async (req, res) => { app.post("/convert/pdf", async (req, res) => {
let archive = null;
const id = randomUUID();
const pdfPath = `/tmp/${id}.pdf`;
const outDir = `/tmp/${id}-pages`;
const outPrefix = path.join(outDir, "page");
try { try {
if (!requireAuth(req, res)) return; if (!requireAuth(req, res)) return;
@@ -177,17 +264,14 @@ app.post("/convert/pdf", async (req, res) => {
return res.status(415).send("This endpoint only accepts PDFs"); return res.status(415).send("This endpoint only accepts PDFs");
} }
const opts = parseResizeOptions(req);
const dpi = clampInt(req.headers["x-pdf-dpi"], 72, 600, 300); const dpi = clampInt(req.headers["x-pdf-dpi"], 72, 600, 300);
const maxPages = clampInt(req.headers["x-pdf-max-pages"], 1, 200, 50); const maxPages = clampInt(req.headers["x-pdf-max-pages"], 1, 200, 50);
const id = randomUUID();
const pdfPath = `/tmp/${id}.pdf`;
const outDir = `/tmp/${id}-pages`;
const outPrefix = path.join(outDir, "page");
await fs.mkdir(outDir, { recursive: true }); await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(pdfPath, input); await fs.writeFile(pdfPath, input);
// Render all pages to JPG via poppler
await execFilePromise("pdftoppm", ["-jpeg", "-r", String(dpi), pdfPath, outPrefix]); await execFilePromise("pdftoppm", ["-jpeg", "-r", String(dpi), pdfPath, outPrefix]);
const files = (await fs.readdir(outDir)) const files = (await fs.readdir(outDir))
@@ -203,7 +287,20 @@ app.post("/convert/pdf", async (req, res) => {
res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="pdf-pages-${id}.zip"`); res.setHeader("Content-Disposition", `attachment; filename="pdf-pages-${id}.zip"`);
const archive = archiver("zip", { zlib: { level: 6 } }); archive = archiver("zip", { zlib: { level: 6 } });
// Abort work if client disconnects
res.on("close", () => {
try {
archive?.abort();
} catch {}
});
res.on("aborted", () => {
try {
archive?.abort();
} catch {}
});
archive.on("error", (err) => { archive.on("error", (err) => {
console.error(err); console.error(err);
if (!res.headersSent) res.status(500); if (!res.headersSent) res.status(500);
@@ -212,16 +309,26 @@ app.post("/convert/pdf", async (req, res) => {
archive.pipe(res); archive.pipe(res);
// Re-encode each rendered page with resize + quality controls, then append as buffers
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const f = files[i]; const f = files[i];
const n = String(i + 1).padStart(3, "0"); const n = String(i + 1).padStart(3, "0");
archive.append(createReadStream(path.join(outDir, f)), { name: `${n}.jpg` }); const pagePath = path.join(outDir, f);
const pageBuf = await fs.readFile(pagePath);
const jpegBuf = await toJpegWithSharp(pageBuf, opts);
archive.append(jpegBuf, { name: `${n}.jpg` });
} }
await archive.finalize(); await archive.finalize();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return res.status(500).send(String(e?.stack || e)); return res.status(500).send(String(e?.stack || e));
} finally {
// Best-effort cleanup for PDF zip path
await safeUnlink(pdfPath);
await safeRmrf(outDir);
} }
}); });
@@ -238,7 +345,8 @@ app.listen(port, "0.0.0.0", () => {
console.log(`converter listening on 0.0.0.0:${port}`); console.log(`converter listening on 0.0.0.0:${port}`);
}); });
// ---------- Helpers ---------- // ---------------- Helpers ----------------
function execFilePromise(cmd, args) { function execFilePromise(cmd, args) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(cmd, args, (err, _stdout, stderr) => { execFile(cmd, args, (err, _stdout, stderr) => {
@@ -258,3 +366,17 @@ function clampInt(value, min, max, fallback) {
if (!Number.isFinite(n)) return fallback; if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, Math.floor(n))); return Math.max(min, Math.min(max, Math.floor(n)));
} }
async function safeUnlink(p) {
if (!p) return;
try {
await fs.unlink(p);
} catch {}
}
async function safeRmrf(p) {
if (!p) return;
try {
await fs.rm(p, { recursive: true, force: true });
} catch {}
}