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
|
## 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 pages → ZIP 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
|
||||||
|
|
||||||
|
|||||||
180
server.js
180
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) */
|
/* Single-flight per machine (ONLY for /convert) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -286,14 +321,13 @@ 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; }
|
||||||
.drop p { color: #888; font-size: .95rem; }
|
.drop p { color: #888; font-size: .95rem; }
|
||||||
@@ -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 pdf/image here to tap to choose</p>
|
||||||
<p>Drop a photo here or tap to choose</p>
|
<input type="file" id="file" accept="image/*,application/pdf,.pdf">
|
||||||
<input type="file" id="file" accept="image/*">
|
|
||||||
</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 */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
Reference in New Issue
Block a user