Mini Utilities v0.2.0: extreme PDF compression (GS+qpdf), DWG→DXF, paste support, drag-drop layers

- Extreme PDF compression via direct Ghostscript + qpdf pipeline
  (PassThroughJPEGImages=false, QFactor 1.5, 72 DPI downsample)
- DWG→DXF converter via libredwg (Docker only)
- PDF unlock in-app via Stirling PDF proxy
- Removed PDF/A tab (unused)
- Paste (Ctrl+V) on all file drop zones
- Mouse drag-drop reordering on thermal layers
- Tabs reorganized into 2 visual rows
- Dockerfile: added ghostscript, qpdf, libredwg
This commit is contained in:
AI Assistant
2026-03-08 21:44:43 +02:00
parent 94b342e5ce
commit 12b7bca990
7 changed files with 1670 additions and 99 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ legacy/ # Original HTML tools for reference
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
| 7 | **Address Book** | `/address-book` | 0.1.1 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **alphabetically sorted type dropdown** |
| 8 | **Password Vault** | `/password-vault` | 0.3.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption** |
| 9 | **Mini Utilities** | `/mini-utilities` | 0.1.1 | Text case, char counter, percentage, **TVA calculator (19%)**, area converter, U→R, num→text, artifact cleaner, MDLPA, PDF reducer, OCR, color palette |
| 9 | **Mini Utilities** | `/mini-utilities` | 0.2.0 | Text case, char counter, percentage, **TVA calculator (19%)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **extreme PDF compression (GS+qpdf)**, PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder** |
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
+3 -2
View File
@@ -29,8 +29,9 @@ WORKDIR /app
ENV NODE_ENV=production
# GDAL/ogr2ogr for real GeoPackage export (parcel-sync module)
RUN apk add --no-cache gdal gdal-tools
# GDAL/ogr2ogr for GeoPackage export, libredwg for DWG→DXF,
# ghostscript+qpdf for extreme PDF compression (direct, not via Stirling)
RUN apk add --no-cache gdal gdal-tools libredwg ghostscript qpdf
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
+14 -4
View File
@@ -42,7 +42,7 @@
| 8 | Prompt Generator | 0.2.0 | HARDENING | Bug fixes, new idea TBD | Prompt scoring, more image templates |
| 9 | Word Templates | 0.1.0 | COMPLETE | No clause library; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | 0.2.0 | HARDENING | Logic/workflow fix, ERP API exposure needed | Smart suggestions |
| 11 | Mini Utilities | 0.1.1 | COMPLETE | — | More converters, DWG→DXF, more tools TBD |
| 11 | Mini Utilities | 0.2.0 | COMPLETE | — | More converters, more tools TBD |
| 12 | Dashboard | 0.1.0 | COMPLETE | — | Custom dashboards per role |
| 13 | AI Chat | 0.2.0 | COMPLETE | Needs API key env vars for real AI | Streaming, model selector, conversation templates |
| 14 | Hot Desk | 0.1.1 | COMPLETE | — | — |
@@ -785,10 +785,20 @@ Env vars (hardcoded in docker-compose.yml for Portainer CE):
---
### 4B.09 `[STANDARD]` Mini Utilities — Additional Tools (TBD)
### 4B.09 `[STANDARD]` Mini Utilities v0.2.0 — Extreme Compression, DWG, UX (2025-07-21)
**What:** User will provide a list of additional quick tools to add to the Mini Utilities module.
**Status:** TODO — awaiting user list
**What:** Major Mini Utilities upgrade:
- **Extreme PDF compression** via direct Ghostscript + qpdf pipeline (rivaling iLovePDF — `PassThroughJPEGImages=false`, QFactor 1.5, 72 DPI downsample)
- **DWG→DXF converter** via libredwg (Docker only)
- **PDF Unlock** in-app via Stirling PDF proxy
- **Removed PDF/A** tab (unused)
- **Paste support** (Ctrl+V) on all file drop zones
- **Mouse drag-drop reordering** on thermal comparison layers
- **Tabs reorganized** into 2 visual rows
- Dockerfile updated: `apk add ghostscript qpdf libredwg`
**Files:** `Dockerfile`, `src/modules/mini-utilities/components/mini-utilities-module.tsx`, `src/app/api/compress-pdf/extreme/route.ts` (rewritten), `src/app/api/compress-pdf/unlock/route.ts` (new), `src/app/api/dwg-convert/route.ts` (new)
**Status:** ✅ DONE
---
+187
View File
@@ -0,0 +1,187 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
import { execFile } from "child_process";
import { promisify } from "util";
import { randomUUID } from "crypto";
import { join } from "path";
import { tmpdir } from "os";
const execFileAsync = promisify(execFile);
// Ghostscript args for extreme compression
// Key: -dPassThroughJPEGImages=false forces recompression of existing JPEGs
// QFactor 1.5 ≈ JPEG quality 25-30, matching iLovePDF extreme
function gsArgs(input: string, output: string): string[] {
return [
"-sDEVICE=pdfwrite",
"-dCompatibilityLevel=1.5",
"-dNOPAUSE",
"-dBATCH",
"-dQUIET",
`-sOutputFile=${output}`,
"-dPDFSETTINGS=/screen",
// Force recompression of ALL images (the #1 key to matching iLovePDF)
"-dPassThroughJPEGImages=false",
"-dPassThroughJPXImages=false",
"-dAutoFilterColorImages=false",
"-dAutoFilterGrayImages=false",
"-dColorImageFilter=/DCTEncode",
"-dGrayImageFilter=/DCTEncode",
// Aggressive downsampling
"-dDownsampleColorImages=true",
"-dDownsampleGrayImages=true",
"-dDownsampleMonoImages=true",
"-dColorImageResolution=72",
"-dGrayImageResolution=72",
"-dMonoImageResolution=150",
"-dColorImageDownsampleType=/Bicubic",
"-dGrayImageDownsampleType=/Bicubic",
"-dColorImageDownsampleThreshold=1.0",
"-dGrayImageDownsampleThreshold=1.0",
"-dMonoImageDownsampleThreshold=1.0",
// Encoding
"-dEncodeColorImages=true",
"-dEncodeGrayImages=true",
// Font & structure
"-dSubsetFonts=true",
"-dEmbedAllFonts=true",
"-dCompressFonts=true",
"-dCompressStreams=true",
// CMYK→RGB (saves ~25% on CMYK images)
"-sColorConversionStrategy=RGB",
// Structure optimization
"-dDetectDuplicateImages=true",
"-dWriteXRefStm=true",
"-dWriteObjStms=true",
"-dPreserveMarkedContent=false",
"-dOmitXMP=true",
// JPEG quality dictionaries (QFactor 1.5 ≈ quality 25-30)
"-c",
"<< /ColorACSImageDict << /QFactor 1.5 /Blend 1 /ColorTransform 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"<< /GrayACSImageDict << /QFactor 1.5 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"<< /ColorImageDict << /QFactor 1.5 /Blend 1 /ColorTransform 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"<< /GrayImageDict << /QFactor 1.5 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"-f",
input,
];
}
// qpdf args for structure polish (5-15% additional saving)
function qpdfArgs(input: string, output: string): string[] {
return [
input,
output,
"--object-streams=generate",
"--compress-streams=y",
"--recompress-flate",
"--compression-level=9",
"--remove-unreferenced-resources=yes",
"--linearize",
];
}
async function cleanup(dir: string) {
try {
const { readdir } = await import("fs/promises");
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
const { rmdir } = await import("fs/promises");
await rmdir(dir).catch(() => {});
} catch {
// cleanup failure is non-critical
}
}
export async function POST(req: NextRequest) {
const tmpDir = join(tmpdir(), `pdf-extreme-${randomUUID()}`);
try {
const formData = await req.formData();
const fileBlob = formData.get("fileInput") as Blob | null;
if (!fileBlob) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ status: 400 },
);
}
const originalSize = fileBlob.size;
await mkdir(tmpDir, { recursive: true });
const inputPath = join(tmpDir, "input.pdf");
const gsOutputPath = join(tmpDir, "gs-output.pdf");
const finalOutputPath = join(tmpDir, "final.pdf");
await writeFile(inputPath, Buffer.from(await fileBlob.arrayBuffer()));
// Step 1: Ghostscript — aggressive image recompression + downsampling
try {
await execFileAsync("gs", gsArgs(inputPath, gsOutputPath), {
timeout: 120_000,
});
} catch (gsErr) {
const msg = gsErr instanceof Error ? gsErr.message : "Ghostscript failed";
if (msg.includes("ENOENT") || msg.includes("not found")) {
return NextResponse.json(
{
error:
"Ghostscript nu este instalat pe server. Trebuie adăugat `ghostscript` în Dockerfile.",
},
{ status: 501 },
);
}
return NextResponse.json(
{ error: `Ghostscript error: ${msg}` },
{ status: 500 },
);
}
// Step 2: qpdf — structure optimization + linearization
let finalPath = gsOutputPath;
try {
await execFileAsync("qpdf", qpdfArgs(gsOutputPath, finalOutputPath), {
timeout: 30_000,
});
finalPath = finalOutputPath;
} catch {
// qpdf failed or not installed — GS output is still good
}
const resultBuffer = await readFile(finalPath);
const compressedSize = resultBuffer.length;
// If compression made it bigger, return original
if (compressedSize >= originalSize) {
const originalBuffer = await readFile(inputPath);
return new NextResponse(originalBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition":
'attachment; filename="compressed-extreme.pdf"',
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(originalSize),
},
});
}
return new NextResponse(resultBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="compressed-extreme.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 la compresia extremă: ${message}` },
{ status: 500 },
);
} finally {
await cleanup(tmpDir);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
const STIRLING_PDF_URL =
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
const STIRLING_PDF_API_KEY =
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const res = await fetch(
`${STIRLING_PDF_URL}/api/v1/security/remove-password`,
{
method: "POST",
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
body: formData,
},
);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
return NextResponse.json(
{ error: `Stirling PDF error: ${res.status}${text}` },
{ status: res.status },
);
}
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="unlocked.pdf"',
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Nu s-a putut contacta Stirling PDF: ${message}` },
{ status: 502 },
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
import { exec } from "child_process";
import { promisify } from "util";
import { randomUUID } from "crypto";
import { join } from "path";
import { tmpdir } from "os";
const execAsync = promisify(exec);
export async function POST(req: NextRequest) {
const tmpDir = join(tmpdir(), `dwg-${randomUUID()}`);
try {
const formData = await req.formData();
const file = formData.get("fileInput") as File | null;
if (!file) {
return NextResponse.json(
{ error: "Lipsește fișierul DWG." },
{ status: 400 },
);
}
const name = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
if (!name.toLowerCase().endsWith(".dwg")) {
return NextResponse.json(
{ error: "Fișierul trebuie să fie .dwg" },
{ status: 400 },
);
}
await mkdir(tmpDir, { recursive: true });
const inputPath = join(tmpDir, name);
const outputPath = inputPath.replace(/\.dwg$/i, ".dxf");
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(inputPath, buffer);
await execAsync(`dwg2dxf "${inputPath}"`, { timeout: 60_000 });
const dxfBuffer = await readFile(outputPath);
const dxfName = name.replace(/\.dwg$/i, ".dxf");
return new NextResponse(dxfBuffer, {
status: 200,
headers: {
"Content-Type": "application/dxf",
"Content-Disposition": `attachment; filename="${dxfName}"`,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (
message.includes("ENOENT") ||
message.includes("not found") ||
message.includes("not recognized")
) {
return NextResponse.json(
{
error:
"Conversia DWG→DXF este disponibilă doar pe server (Docker). Local nu este instalat dwg2dxf (libredwg).",
},
{ status: 501 },
);
}
return NextResponse.json(
{ error: `Eroare la conversie DWG→DXF: ${message}` },
{ status: 500 },
);
} finally {
// Clean up temp files
try {
const { readdir } = await import("fs/promises");
const files = await readdir(tmpDir);
for (const f of files) {
await unlink(join(tmpDir, f)).catch(() => {});
}
const { rmdir } = await import("fs/promises");
await rmdir(tmpDir).catch(() => {});
} catch {
// temp cleanup failure is non-critical
}
}
}
File diff suppressed because it is too large Load Diff