Add PDF-to-JPEG support in web UI and /convert/pdf endpoint

Web UI now accepts both images and PDFs. Images go through /strip
as before; PDFs go through new /convert/pdf endpoint which renders
all pages via pdftoppm and returns base64 JPEGs. Also updates
page title and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 10:01:42 -07:00
parent 5227de3779
commit baa17b0fc7
2 changed files with 161 additions and 25 deletions

View File

@@ -11,9 +11,11 @@ Image/PDF-to-JPEG conversion microservice. Single-file Node.js/Express app (`ser
## Endpoints ## Endpoints
- `GET /` — web UI for stripping photo metadata (drag-and-drop, uses `/strip`)
- `GET /health` — health check - `GET /health` — health check
- `POST /convert` — image or first PDF page → JPEG - `POST /strip` — strip metadata from image, return clean JPEG + removed metadata (JSON or raw)
- `POST /convert/pdf`all PDF pagesZIP of JPEGs - `POST /convert`image or first PDF page → JPEG (resize/quality via headers)
- `POST /convert/pdf` — all PDF pages → JSON array of base64 JPEGs
## Auth ## Auth

176
server.js
View File

@@ -251,6 +251,41 @@ async function pdfFirstPageToJpeg(input, opts) {
} }
} }
async function pdfAllPagesToJpegs(input, opts) {
const id = randomUUID();
const pdf = `/tmp/${id}.pdf`;
const prefix = `/tmp/${id}`;
try {
await fs.writeFile(pdf, input);
await execFilePromise(
"pdftoppm",
["-jpeg", "-r", String(opts.pdfDpi), pdf, prefix],
DEFAULT_REQ_TIMEOUT_PDF_MS
);
// pdftoppm writes <prefix>-01.jpg, <prefix>-02.jpg, etc.
const dir = await fs.readdir("/tmp");
const pages = dir
.filter((f) => f.startsWith(`${id}-`) && f.endsWith(".jpg"))
.sort();
const results = [];
for (const page of pages) {
const pagePath = `/tmp/${page}`;
const buf = await fs.readFile(pagePath);
const jpeg = await normalizeForVision(buf, opts);
results.push(jpeg);
await safeUnlink(pagePath);
}
return results;
} finally {
await safeUnlink(pdf);
}
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Single-flight per machine (ONLY for /convert) */ /* Single-flight per machine (ONLY for /convert) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -286,13 +321,12 @@ app.get("/", (_req, res) => {
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>PostConvert Strip Metadata</title> <title>PostConvert</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, system-ui, sans-serif; background: #111; color: #eee; body { font-family: -apple-system, system-ui, sans-serif; background: #111; color: #eee;
display: flex; align-items: center; justify-content: center; min-height: 100vh; } display: flex; align-items: flex-start; justify-content: center; min-height: 100vh; padding-top: 4rem; }
.wrap { max-width: 420px; width: 100%; padding: 2rem; } .wrap { max-width: 420px; width: 100%; padding: 2rem; }
h1 { font-size: 1.3rem; margin-bottom: 1.5rem; text-align: center; }
.drop { border: 2px dashed #444; border-radius: 12px; padding: 3rem 1.5rem; text-align: center; .drop { border: 2px dashed #444; border-radius: 12px; padding: 3rem 1.5rem; text-align: center;
cursor: pointer; transition: border-color .2s; } cursor: pointer; transition: border-color .2s; }
.drop.over { border-color: #4a9eff; } .drop.over { border-color: #4a9eff; }
@@ -334,10 +368,9 @@ app.get("/", (_req, res) => {
</div> </div>
</div> </div>
<div class="wrap" id="main" style="display:none"> <div class="wrap" id="main" style="display:none">
<h1>Strip Photo Metadata</h1>
<div class="drop" id="drop"> <div class="drop" id="drop">
<p>Drop a photo here or tap to choose</p> <p>Drop a pdf/image here to tap to choose</p>
<input type="file" id="file" accept="image/*"> <input type="file" id="file" accept="image/*,application/pdf,.pdf">
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
<div class="removed" id="removed" style="display:none"> <div class="removed" id="removed" style="display:none">
@@ -381,15 +414,82 @@ app.get("/", (_req, res) => {
function getToken() { return localStorage.getItem(KEY); } function getToken() { return localStorage.getItem(KEY); }
async function strip(file) { function isPdf(file) {
return file.type === "application/pdf" || (file.name || "").toLowerCase().endsWith(".pdf");
}
function downloadBase64Jpeg(b64, name) {
const byteString = atob(b64);
const bytes = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) bytes[i] = byteString.charCodeAt(i);
const blob = new Blob([bytes], { type: "image/jpeg" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
async function handleFile(file) {
const token = getToken(); const token = getToken();
if (!token) { showAuth(); return; } if (!token) { showAuth(); return; }
status.className = "status"; status.className = "status";
status.textContent = "Stripping metadata…";
removedDiv.style.display = "none"; removedDiv.style.display = "none";
removedBody.innerHTML = ""; removedBody.innerHTML = "";
if (isPdf(file)) {
await convertPdf(file, token);
} else {
await stripImage(file, token);
}
}
async function convertPdf(file, token) {
status.textContent = "Converting PDF…";
try {
const res = await fetch("/convert/pdf", {
method: "POST",
headers: {
"Authorization": "Bearer " + token,
"Content-Type": "application/pdf",
},
body: file,
});
if (res.status === 401) {
localStorage.removeItem(KEY);
showAuth();
tokenErr.textContent = "Invalid token. Please try again.";
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || "PDF conversion failed");
}
const data = await res.json();
const baseName = (file.name || "document").replace(/\\.[^.]+$/, "");
for (let i = 0; i < data.pages.length; i++) {
const name = data.pages.length === 1
? baseName + ".jpg"
: baseName + "_page" + (i + 1) + ".jpg";
downloadBase64Jpeg(data.pages[i], name);
}
status.className = "status";
status.textContent = "Done — saved " + data.pages.length + " page" + (data.pages.length === 1 ? "" : "s");
} catch (e) {
status.className = "status error";
status.textContent = e.message;
}
}
async function stripImage(file, token) {
status.textContent = "Stripping metadata…";
try { try {
const res = await fetch("/strip", { const res = await fetch("/strip", {
method: "POST", method: "POST",
@@ -444,18 +544,8 @@ app.get("/", (_req, res) => {
} }
} }
// Download clean image
const byteString = atob(data.image);
const bytes = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) bytes[i] = byteString.charCodeAt(i);
const blob = new Blob([bytes], { type: "image/jpeg" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const name = (file.name || "photo").replace(/\\.[^.]+$/, "") + "_clean.jpg"; const name = (file.name || "photo").replace(/\\.[^.]+$/, "") + "_clean.jpg";
a.href = url; downloadBase64Jpeg(data.image, name);
a.download = name;
a.click();
URL.revokeObjectURL(url);
status.className = "status"; status.className = "status";
status.textContent = "Done — saved " + name; status.textContent = "Done — saved " + name;
@@ -466,14 +556,14 @@ app.get("/", (_req, res) => {
} }
drop.addEventListener("click", () => fileInput.click()); drop.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => { if (fileInput.files[0]) strip(fileInput.files[0]); }); fileInput.addEventListener("change", () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); });
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); }); drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
drop.addEventListener("dragleave", () => drop.classList.remove("over")); drop.addEventListener("dragleave", () => drop.classList.remove("over"));
drop.addEventListener("drop", (e) => { drop.addEventListener("drop", (e) => {
e.preventDefault(); e.preventDefault();
drop.classList.remove("over"); drop.classList.remove("over");
if (e.dataTransfer.files[0]) strip(e.dataTransfer.files[0]); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
}); });
})(); })();
</script> </script>
@@ -672,6 +762,50 @@ app.post("/convert", async (req, res) => {
}); });
}); });
app.post("/convert/pdf", async (req, res) => {
res.setHeader("Connection", "close");
req.setTimeout(DEFAULT_REQ_TIMEOUT_PDF_MS);
res.setTimeout(DEFAULT_REQ_TIMEOUT_PDF_MS);
return withConvertSingleFlight(req, res, async () => {
try {
if (!requireAuth(req, res)) return;
if (!req.body?.length) {
return sendError(res, 400, "empty_body", "Empty body", req.requestId);
}
if (!isPdfRequest(req)) {
return sendError(res, 415, "unsupported_media_type", "Expected a PDF", req.requestId);
}
const opts = parseOptions(req);
if (isAborted(req, res)) return;
const jpegs = await pdfAllPagesToJpegs(req.body, opts);
if (isAborted(req, res)) return;
res.setHeader("Content-Type", "application/json");
return res.json({
pages: jpegs.map((buf) => buf.toString("base64")),
});
} catch (e) {
const status = e?.statusCode || 500;
const code = e?.code || "conversion_failed";
console.error(JSON.stringify({ requestId: req.requestId, err: String(e?.stack || e) }));
return sendError(
res,
status,
code,
status === 415 ? "Unsupported media type" : "PDF conversion failed",
req.requestId
);
}
});
});
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */