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:
@@ -11,9 +11,11 @@ Image/PDF-to-JPEG conversion microservice. Single-file Node.js/Express app (`ser
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /` — web UI for stripping photo metadata (drag-and-drop, uses `/strip`)
|
||||
- `GET /health` — health check
|
||||
- `POST /convert` — image or first PDF page → JPEG
|
||||
- `POST /convert/pdf` — all PDF pages → ZIP of JPEGs
|
||||
- `POST /strip` — strip metadata from image, return clean JPEG + removed metadata (JSON or raw)
|
||||
- `POST /convert` — image or first PDF page → JPEG (resize/quality via headers)
|
||||
- `POST /convert/pdf` — all PDF pages → JSON array of base64 JPEGs
|
||||
|
||||
## Auth
|
||||
|
||||
|
||||
176
server.js
176
server.js
@@ -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) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -286,13 +321,12 @@ app.get("/", (_req, res) => {
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PostConvert – Strip Metadata</title>
|
||||
<title>PostConvert</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
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; }
|
||||
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;
|
||||
cursor: pointer; transition: border-color .2s; }
|
||||
.drop.over { border-color: #4a9eff; }
|
||||
@@ -334,10 +368,9 @@ app.get("/", (_req, res) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap" id="main" style="display:none">
|
||||
<h1>Strip Photo Metadata</h1>
|
||||
<div class="drop" id="drop">
|
||||
<p>Drop a photo here or tap to choose</p>
|
||||
<input type="file" id="file" accept="image/*">
|
||||
<p>Drop a pdf/image here to tap to choose</p>
|
||||
<input type="file" id="file" accept="image/*,application/pdf,.pdf">
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="removed" id="removed" style="display:none">
|
||||
@@ -381,15 +414,82 @@ app.get("/", (_req, res) => {
|
||||
|
||||
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();
|
||||
if (!token) { showAuth(); return; }
|
||||
|
||||
status.className = "status";
|
||||
status.textContent = "Stripping metadata…";
|
||||
removedDiv.style.display = "none";
|
||||
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 {
|
||||
const res = await fetch("/strip", {
|
||||
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";
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBase64Jpeg(data.image, name);
|
||||
|
||||
status.className = "status";
|
||||
status.textContent = "Done — saved " + name;
|
||||
@@ -466,14 +556,14 @@ app.get("/", (_req, res) => {
|
||||
}
|
||||
|
||||
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("dragleave", () => drop.classList.remove("over"));
|
||||
drop.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
@@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user