Files
ArchiTools/src/app/api/compress-pdf/cloud/route.ts
T
AI Assistant f5deccd8ea refactor(pdf-compress): replace Ghostscript with qpdf + iLovePDF API
Ghostscript -sDEVICE=pdfwrite fundamentally re-encodes fonts, causing
garbled text regardless of parameters. This cannot be fixed.

New approach:
- Local: qpdf-only lossless structural optimization (5-30% savings,
  zero corruption risk — fonts and images completely untouched)
- Cloud: iLovePDF API integration (auth → start → upload → process →
  download) with 3 levels (recommended/extreme/low), proper image
  recompression without font corruption

Frontend: 3 modes (cloud recommended, cloud extreme, local lossless).
Docker: ILOVEPDF_PUBLIC_KEY env var added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:50:46 +02:00

294 lines
8.2 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
/**
* iLovePDF API integration for PDF compression.
*
* Workflow: auth → start → upload → process → download
* Docs: https://www.iloveapi.com/docs/api-reference
*
* Env vars: ILOVEPDF_PUBLIC_KEY, ILOVEPDF_SECRET_KEY
* Free tier: 250 files/month
*/
const ILOVEPDF_PUBLIC_KEY = process.env.ILOVEPDF_PUBLIC_KEY ?? "";
const API_BASE = "https://api.ilovepdf.com/v1";
/**
* Extract the file binary from a raw multipart/form-data buffer.
*/
function extractFileFromMultipart(
raw: Buffer,
boundary: string,
): { buffer: Buffer; filename: string } | null {
const boundaryBuf = Buffer.from(`--${boundary}`);
const headerSep = Buffer.from("\r\n\r\n");
const crlf = Buffer.from("\r\n");
let searchFrom = 0;
while (searchFrom < raw.length) {
const partStart = raw.indexOf(boundaryBuf, searchFrom);
if (partStart === -1) break;
const lineEnd = raw.indexOf(crlf, partStart);
if (lineEnd === -1) break;
const headerEnd = raw.indexOf(headerSep, lineEnd);
if (headerEnd === -1) break;
const headers = raw.subarray(lineEnd + 2, headerEnd).toString("utf8");
if (headers.includes("filename=")) {
const fileStart = headerEnd + 4;
// Extract original filename
const fnMatch = headers.match(/filename="([^"]+)"/);
const filename = fnMatch?.[1] ?? "input.pdf";
const closingMarker = Buffer.from(`\r\n--${boundary}`);
const fileEnd = raw.lastIndexOf(closingMarker);
const buffer =
fileEnd > fileStart
? raw.subarray(fileStart, fileEnd)
: raw.subarray(fileStart);
return { buffer, filename };
}
searchFrom = headerEnd + 4;
}
return null;
}
/**
* Extract a text field value from multipart body.
*/
function extractFieldFromMultipart(
raw: Buffer,
boundary: string,
fieldName: string,
): string | null {
const boundaryBuf = Buffer.from(`--${boundary}`);
const headerSep = Buffer.from("\r\n\r\n");
const crlf = Buffer.from("\r\n");
const namePattern = `name="${fieldName}"`;
let searchFrom = 0;
while (searchFrom < raw.length) {
const partStart = raw.indexOf(boundaryBuf, searchFrom);
if (partStart === -1) break;
const lineEnd = raw.indexOf(crlf, partStart);
if (lineEnd === -1) break;
const headerEnd = raw.indexOf(headerSep, lineEnd);
if (headerEnd === -1) break;
const headers = raw.subarray(lineEnd + 2, headerEnd).toString("utf8");
if (headers.includes(namePattern) && !headers.includes("filename=")) {
const valueStart = headerEnd + 4;
const nextBoundary = raw.indexOf(
Buffer.from(`\r\n--${boundary}`),
valueStart,
);
if (nextBoundary > valueStart) {
return raw.subarray(valueStart, nextBoundary).toString("utf8").trim();
}
}
searchFrom = headerEnd + 4;
}
return null;
}
export async function POST(req: NextRequest) {
if (!ILOVEPDF_PUBLIC_KEY) {
return NextResponse.json(
{
error:
"iLovePDF nu este configurat. Setează ILOVEPDF_PUBLIC_KEY în variabilele de mediu.",
},
{ status: 501 },
);
}
try {
// Parse multipart body
if (!req.body) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ status: 400 },
);
}
const rawBuf = Buffer.from(await req.arrayBuffer());
const contentType = req.headers.get("content-type") || "";
const boundaryMatch = contentType.match(
/boundary=(?:"([^"]+)"|([^\s;]+))/,
);
const boundary = boundaryMatch?.[1] ?? boundaryMatch?.[2] ?? "";
if (!boundary) {
return NextResponse.json(
{ error: "Invalid request." },
{ status: 400 },
);
}
const fileData = extractFileFromMultipart(rawBuf, boundary);
if (!fileData || fileData.buffer.length < 100) {
return NextResponse.json(
{ error: "Fișierul PDF nu a putut fi extras." },
{ status: 400 },
);
}
// Extract compression level (extreme / recommended / low)
const levelParam = extractFieldFromMultipart(rawBuf, boundary, "level");
const compressionLevel =
levelParam === "extreme"
? "extreme"
: levelParam === "low"
? "low"
: "recommended";
const originalSize = fileData.buffer.length;
// Step 1: Authenticate
const authRes = await fetch(`${API_BASE}/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ public_key: ILOVEPDF_PUBLIC_KEY }),
});
if (!authRes.ok) {
const text = await authRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF auth failed: ${authRes.status}${text}` },
{ status: 502 },
);
}
const { token } = (await authRes.json()) as { token: string };
// Step 2: Start compress task
const startRes = await fetch(`${API_BASE}/start/compress`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
if (!startRes.ok) {
const text = await startRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF start failed: ${startRes.status}${text}` },
{ status: 502 },
);
}
const { server, task } = (await startRes.json()) as {
server: string;
task: string;
};
// Step 3: Upload file
const uploadForm = new FormData();
uploadForm.append("task", task);
uploadForm.append(
"file",
new Blob([new Uint8Array(fileData.buffer)], { type: "application/pdf" }),
fileData.filename,
);
const uploadRes = await fetch(`https://${server}/v1/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: uploadForm,
signal: AbortSignal.timeout(300_000), // 5 min for large files
});
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF upload failed: ${uploadRes.status}${text}` },
{ status: 502 },
);
}
const { server_filename } = (await uploadRes.json()) as {
server_filename: string;
};
// Step 4: Process
const processRes = await fetch(`https://${server}/v1/process`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
task,
tool: "compress",
compression_level: compressionLevel,
files: [
{
server_filename,
filename: fileData.filename,
},
],
}),
signal: AbortSignal.timeout(300_000),
});
if (!processRes.ok) {
const text = await processRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF process failed: ${processRes.status}${text}` },
{ status: 502 },
);
}
// Step 5: Download result
const downloadRes = await fetch(
`https://${server}/v1/download/${task}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(300_000),
},
);
if (!downloadRes.ok) {
const text = await downloadRes.text().catch(() => "");
return NextResponse.json(
{
error: `iLovePDF download failed: ${downloadRes.status}${text}`,
},
{ status: 502 },
);
}
const resultBlob = await downloadRes.blob();
const resultBuffer = Buffer.from(await resultBlob.arrayBuffer());
const compressedSize = resultBuffer.length;
// Clean up task on iLovePDF
fetch(`https://${server}/v1/task/${task}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
return new NextResponse(new Uint8Array(resultBuffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${fileData.filename.replace(/\.pdf$/i, "-comprimat.pdf")}"`,
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(compressedSize),
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Eroare iLovePDF: ${message}` },
{ status: 500 },
);
}
}