f5deccd8ea
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>
294 lines
8.2 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|