mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(compress-pdf): land close to the target size, honestly (#522)
Target-size compression had only a coarse DPI lever, so it undershot badly (a 350KB target could land at 216KB) and silently missed unreachable targets. Adds JPEG quality as a second lever (forced re-encode so it bites on JPEG scans), folds both into one monotonic quality axis that target-size binary-searches, reports targetMet honestly in the panel across 21 locales, and flips the tool to async for the extra passes. Quality-mode output sizes shift intentionally (slider now drives JPEG quality at full resolution in its top half).
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
import { copyFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { gsCompressPdfQuality } from "@snapotter/doc-engine";
|
||||
import { gsCompressPdfTuned } from "@snapotter/doc-engine";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
// Mirrors the image "compress" tool: compress by a quality slider or to a
|
||||
// target file size. For PDFs the size lever is image downsampling resolution
|
||||
// (DPI), so quality 1..100 maps onto a DPI range and target-size binary-
|
||||
// searches that DPI.
|
||||
// target file size. Both size levers (image DPI and JPEG quality) are folded
|
||||
// into one monotonic quality axis (paramsForQuality). Quality mode runs one
|
||||
// pass at the slider value; target-size binary-searches the axis for the
|
||||
// largest output that still fits the target.
|
||||
const settingsSchema = z.object({
|
||||
mode: z.enum(["quality", "targetSize"]).default("quality"),
|
||||
quality: z.number().int().min(1).max(100).optional(),
|
||||
@@ -17,7 +18,19 @@ const settingsSchema = z.object({
|
||||
|
||||
const MIN_DPI = 20;
|
||||
const MAX_DPI = 300;
|
||||
const qualityToDpi = (q: number) => Math.round(MIN_DPI + ((q - 1) / 99) * (MAX_DPI - MIN_DPI));
|
||||
|
||||
/**
|
||||
* Single monotonic quality axis shared by both modes. Quality 1..100 maps to a
|
||||
* (dpi, qFactor) pair whose output size increases with q. The top half (q>=50)
|
||||
* preserves resolution and trades JPEG quality; the bottom half drops resolution
|
||||
* for aggressive targets. Verified monotonic on real scans (see spec).
|
||||
*/
|
||||
export function paramsForQuality(q: number): { dpi: number; qFactor: number } {
|
||||
const clamped = Math.max(1, Math.min(100, Math.round(q)));
|
||||
const dpi = clamped >= 50 ? MAX_DPI : Math.round(MIN_DPI + ((MAX_DPI - MIN_DPI) * clamped) / 50);
|
||||
const qFactor = 0.1 + 2.4 * ((100 - clamped) / 99) ** 1.5;
|
||||
return { dpi, qFactor };
|
||||
}
|
||||
|
||||
export function registerCompressPdf(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
@@ -34,43 +47,61 @@ export function registerCompressPdf(app: FastifyInstance) {
|
||||
await writeFile(inPath, input.buffer);
|
||||
const outPath = join(ctx.scratchDir, `${base}_compressed.pdf`);
|
||||
|
||||
let resultPayload: Record<string, unknown> | undefined;
|
||||
|
||||
if (settings.mode === "targetSize" && settings.targetSizeKb) {
|
||||
// Binary-search the DPI for the highest quality that still fits the
|
||||
// target. Output size is monotonic in DPI, so the search converges.
|
||||
const targetBytes = settings.targetSizeKb * 1024;
|
||||
let lo = MIN_DPI;
|
||||
let hi = MAX_DPI;
|
||||
let bestPath: string | null = null;
|
||||
for (let i = 0; i < 6 && lo <= hi; i++) {
|
||||
const dpi = Math.round((lo + hi) / 2);
|
||||
const candidate = join(ctx.scratchDir, `cand-${dpi}.pdf`);
|
||||
ctx.report(10 + i * 13, "Compressing");
|
||||
await gsCompressPdfQuality(inPath, candidate, dpi);
|
||||
const size = (await stat(candidate)).size;
|
||||
if (size <= targetBytes) {
|
||||
bestPath = candidate;
|
||||
lo = dpi + 1; // fits: try higher quality
|
||||
// Binary-search the quality axis for the largest output that still fits.
|
||||
// Memoize by q so repeated probes never re-run ghostscript.
|
||||
const cache = new Map<number, { path: string; size: number }>();
|
||||
const runQ = async (q: number) => {
|
||||
const key = Math.max(1, Math.min(100, Math.round(q)));
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
const { dpi, qFactor } = paramsForQuality(key);
|
||||
const path = join(ctx.scratchDir, `cand-q${key}.pdf`);
|
||||
await gsCompressPdfTuned(inPath, path, dpi, qFactor);
|
||||
const entry = { path, size: (await stat(path)).size };
|
||||
cache.set(key, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
let lo = 1;
|
||||
let hi = 100;
|
||||
let best: { path: string; size: number } | null = null;
|
||||
let smallest: { path: string; size: number } | null = null;
|
||||
const MAX_ITERS = 8;
|
||||
for (let iter = 0; iter < MAX_ITERS && lo <= hi; iter++) {
|
||||
const q = Math.round((lo + hi) / 2);
|
||||
ctx.report(10 + iter * 10, "Searching");
|
||||
const cand = await runQ(q);
|
||||
if (!smallest || cand.size < smallest.size) smallest = cand;
|
||||
if (cand.size <= targetBytes) {
|
||||
if (!best || cand.size > best.size) best = cand;
|
||||
if (cand.size >= targetBytes * 0.9) break; // close enough
|
||||
lo = q + 1; // room to grow: raise quality
|
||||
} else {
|
||||
hi = dpi - 1; // too big: compress harder
|
||||
hi = q - 1; // too big: compress harder
|
||||
}
|
||||
}
|
||||
if (!bestPath) {
|
||||
// Target unreachable (e.g. a text-only PDF below the floor); fall
|
||||
// back to the most aggressive compression we can do.
|
||||
bestPath = join(ctx.scratchDir, "cand-min.pdf");
|
||||
await gsCompressPdfQuality(inPath, bestPath, MIN_DPI);
|
||||
|
||||
// smallest is always set after >=1 iteration.
|
||||
const chosen = best ?? (smallest as { path: string; size: number });
|
||||
if (chosen.size >= input.buffer.length) {
|
||||
await writeFile(outPath, input.buffer); // never enlarge
|
||||
} else {
|
||||
await copyFile(chosen.path, outPath);
|
||||
}
|
||||
await copyFile(bestPath, outPath);
|
||||
const finalSize = (await stat(outPath)).size;
|
||||
resultPayload = { targetKb: settings.targetSizeKb, targetMet: finalSize <= targetBytes };
|
||||
} else {
|
||||
ctx.report(10, "Compressing");
|
||||
await gsCompressPdfQuality(inPath, outPath, qualityToDpi(settings.quality ?? 75));
|
||||
}
|
||||
|
||||
// A "Compress" tool must never enlarge the file. If re-encoding produced
|
||||
// something at least as large as the original (common for already
|
||||
// compressed or low-DPI scanned PDFs), keep the original bytes instead.
|
||||
if ((await stat(outPath)).size >= input.buffer.length) {
|
||||
await writeFile(outPath, input.buffer);
|
||||
const { dpi, qFactor } = paramsForQuality(settings.quality ?? 75);
|
||||
await gsCompressPdfTuned(inPath, outPath, dpi, qFactor);
|
||||
// A "Compress" tool must never enlarge the file.
|
||||
if ((await stat(outPath)).size >= input.buffer.length) {
|
||||
await writeFile(outPath, input.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.report(95, "Done");
|
||||
@@ -78,6 +109,7 @@ export function registerCompressPdf(app: FastifyInstance) {
|
||||
scratchPath: outPath,
|
||||
filename: `${base}_compressed.pdf`,
|
||||
contentType: "application/pdf",
|
||||
resultPayload,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,8 +10,15 @@ export function CompressPdfSettings() {
|
||||
const { t } = useTranslation();
|
||||
const s = t.toolSettings["compress-pdf"];
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("compress-pdf");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
progress,
|
||||
resultPayload,
|
||||
processedSize,
|
||||
} = useToolProcessor("compress-pdf");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -21,6 +28,12 @@ export function CompressPdfSettings() {
|
||||
settings.mode === "quality" ||
|
||||
(settings.mode === "targetSize" && Number(settings.targetSizeKb) > 0);
|
||||
|
||||
// Honest reporting for target-size mode: whether we actually hit the ceiling.
|
||||
const targetMet = resultPayload?.targetMet as boolean | undefined;
|
||||
const targetKb = resultPayload?.targetKb as number | undefined;
|
||||
const targetLabel = targetKb != null ? `${Math.round(targetKb)} KB` : "";
|
||||
const achievedLabel = processedSize != null ? `${Math.round(processedSize / 1024)} KB` : "";
|
||||
|
||||
const handleProcess = () => {
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
@@ -34,8 +47,23 @@ export function CompressPdfSettings() {
|
||||
{/* Same quality / target-size controls as the image compress tool */}
|
||||
<CompressControls onChange={setSettings} />
|
||||
|
||||
{settings.mode === "targetSize" && (
|
||||
<p className="text-[11px] text-muted-foreground">{s.bestEffortHint}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{targetMet === false && (
|
||||
<p className="text-xs text-amber-600">
|
||||
{format(s.targetMissed, { target: targetLabel, size: achievedLabel })}
|
||||
</p>
|
||||
)}
|
||||
{targetMet === true && (
|
||||
<p className="text-xs text-green-600">
|
||||
{format(s.targetReached, { target: targetLabel, size: achievedLabel })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
|
||||
@@ -56,18 +56,26 @@ export async function gsCompressPdf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-downsampling compression at a target resolution (DPI). Lower DPI
|
||||
* yields a smaller file; image resolution is the dominant size lever for
|
||||
* PDFs. Uses /ebook as a base for sensible JPEG defaults, then overrides the
|
||||
* image resolutions. The compress-pdf tool maps a quality slider (and a
|
||||
* target-size binary search) onto this DPI.
|
||||
* Image compression at a target resolution (DPI) and JPEG quality (QFactor).
|
||||
* Both are size levers for PDFs: lower DPI and higher QFactor each yield a
|
||||
* smaller file, and output size is monotonic in both. Forces re-encode of image
|
||||
* streams so QFactor actually applies: Ghostscript otherwise passes already-JPEG
|
||||
* images through untouched (PassThroughJPEGImages defaults true), which makes the
|
||||
* quality lever a no-op on scans. The compress-pdf tool maps a single quality
|
||||
* axis (and a target-size binary search) onto this (dpi, qFactor) pair.
|
||||
*/
|
||||
export async function gsCompressPdfQuality(
|
||||
export async function gsCompressPdfTuned(
|
||||
inputPath: string,
|
||||
outPath: string,
|
||||
dpi: number,
|
||||
qFactor: number,
|
||||
): Promise<void> {
|
||||
const res = Math.max(9, Math.min(600, Math.round(dpi)));
|
||||
const qf = Math.max(0.05, Math.min(4, qFactor));
|
||||
// 2x2 chroma subsampling ([2 1 1 2]) keeps photo output small and predictable.
|
||||
const dict =
|
||||
`<< /ColorImageDict << /QFactor ${qf} /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >>` +
|
||||
` /GrayImageDict << /QFactor ${qf} /Blend 1 >> >> setdistillerparams`;
|
||||
await runGs([
|
||||
"-dSAFER",
|
||||
"-dBATCH",
|
||||
@@ -75,7 +83,6 @@ export async function gsCompressPdfQuality(
|
||||
"-dQUIET",
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-dCompatibilityLevel=1.6",
|
||||
"-dPDFSETTINGS=/ebook",
|
||||
"-dDownsampleColorImages=true",
|
||||
"-dColorImageDownsampleType=/Average",
|
||||
`-dColorImageResolution=${res}`,
|
||||
@@ -85,7 +92,15 @@ export async function gsCompressPdfQuality(
|
||||
"-dDownsampleMonoImages=true",
|
||||
"-dMonoImageDownsampleType=/Subsample",
|
||||
`-dMonoImageResolution=${Math.min(600, res * 4)}`,
|
||||
"-dAutoFilterColorImages=false",
|
||||
"-dColorImageFilter=/DCTEncode",
|
||||
"-dAutoFilterGrayImages=false",
|
||||
"-dGrayImageFilter=/DCTEncode",
|
||||
"-dPassThroughJPEGImages=false",
|
||||
`-sOutputFile=${outPath}`,
|
||||
"-c",
|
||||
dict,
|
||||
"-f",
|
||||
inputPath,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
} from "./binaries.js";
|
||||
export {
|
||||
gsCompressPdf,
|
||||
gsCompressPdfQuality,
|
||||
gsCompressPdfTuned,
|
||||
gsGrayscalePdf,
|
||||
gsPdfaConvert,
|
||||
type PdfCompressionPreset,
|
||||
|
||||
@@ -1360,7 +1360,9 @@ const BASE_TOOLS: Tool[] = [
|
||||
route: "/compress-pdf",
|
||||
modality: "document",
|
||||
acceptedInputs: [".pdf"],
|
||||
executionHint: "fast",
|
||||
// "long": the target-size mode runs several ghostscript passes; async keeps
|
||||
// the request off the sync window and surfaces a real progress bar.
|
||||
executionHint: "long",
|
||||
},
|
||||
{
|
||||
id: "rotate-pdf",
|
||||
|
||||
@@ -2639,6 +2639,11 @@ export const ar: TranslationKeys = {
|
||||
submit: "ضغط",
|
||||
submitBatch: "ضغط ({count} ملفات)",
|
||||
progressLabel: "جارٍ الضغط",
|
||||
bestEffortHint:
|
||||
"الحد الأقصى قدر الإمكان. ملفات PDF المستندة إلى الصور تقترب من هذا الحجم؛ أما ملفات PDF النصية فقد لا تنكمش إلى هذا الحجم.",
|
||||
targetReached: "تم الوصول إلى الحجم المستهدف {target} (النتيجة: {size}).",
|
||||
targetMissed:
|
||||
"تعذّر الوصول إلى {target}. أصغر حجم يمكن تحقيقه هو {size} لأن ملف PDF هذا يتكوّن في الغالب من نصوص أو رسومات متجهة لا يمكن لضغط الصور تصغيرها.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "زاوية التدوير",
|
||||
|
||||
@@ -2662,6 +2662,11 @@ export const de: TranslationKeys = {
|
||||
submit: "Komprimieren",
|
||||
submitBatch: "Komprimieren ({count} Dateien)",
|
||||
progressLabel: "Wird komprimiert",
|
||||
bestEffortHint:
|
||||
"Bestmögliches Maximum. Bildbasierte PDFs kommen nah heran; reine Text-PDFs schrumpfen womöglich nicht auf diese Größe.",
|
||||
targetReached: "Zielgröße {target} erreicht (Ergebnis: {size}).",
|
||||
targetMissed:
|
||||
"{target} konnte nicht erreicht werden. Die kleinstmögliche Größe war {size}, weil dieses PDF überwiegend aus Text oder Vektoren besteht, die die Bildkomprimierung nicht verkleinern kann.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Drehwinkel",
|
||||
|
||||
@@ -2603,6 +2603,11 @@ export const en = {
|
||||
submit: "Compress",
|
||||
submitBatch: "Compress ({count} files)",
|
||||
progressLabel: "Compressing",
|
||||
bestEffortHint:
|
||||
"Best-effort maximum. Image-based PDFs land close; text-only PDFs may not shrink to this size.",
|
||||
targetReached: "Reached your {target} target (result: {size}).",
|
||||
targetMissed:
|
||||
"Couldn't reach {target}. Smallest achievable was {size} because this PDF is mostly text or vectors, which image compression can't shrink.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Rotation angle",
|
||||
|
||||
@@ -2643,6 +2643,11 @@ export const es: TranslationKeys = {
|
||||
submit: "Comprimir",
|
||||
submitBatch: "Comprimir ({count} archivos)",
|
||||
progressLabel: "Comprimiendo",
|
||||
bestEffortHint:
|
||||
"Máximo posible. Los PDF basados en imágenes se acercan; los PDF de solo texto quizá no se reduzcan a este tamaño.",
|
||||
targetReached: "Se alcanzó el objetivo de {target} (resultado: {size}).",
|
||||
targetMissed:
|
||||
"No se pudo alcanzar {target}. El tamaño más pequeño posible fue {size} porque este PDF es principalmente texto o vectores, que la compresión de imágenes no puede reducir.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Ángulo de rotación",
|
||||
|
||||
@@ -2669,6 +2669,11 @@ export const fr: TranslationKeys = {
|
||||
submit: "Compresser",
|
||||
submitBatch: "Compresser ({count} fichiers)",
|
||||
progressLabel: "Compression",
|
||||
bestEffortHint:
|
||||
"Maximum au mieux. Les PDF composés d'images s'en approchent ; les PDF uniquement texte peuvent ne pas atteindre cette taille.",
|
||||
targetReached: "Objectif de {target} atteint (résultat : {size}).",
|
||||
targetMissed:
|
||||
"Impossible d'atteindre {target}. La plus petite taille possible était {size}, car ce PDF est principalement composé de texte ou de vecteurs, que la compression d'images ne peut pas réduire.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Angle de rotation",
|
||||
|
||||
@@ -2469,6 +2469,11 @@ export const hi: TranslationKeys = {
|
||||
submit: "संपीड़ित करें",
|
||||
submitBatch: "संपीड़ित करें ({count} फ़ाइलें)",
|
||||
progressLabel: "संपीड़ित हो रहा है",
|
||||
bestEffortHint:
|
||||
"अधिकतम संभव प्रयास. छवि-आधारित PDF इसके करीब पहुंचती हैं; केवल-टेक्स्ट वाली PDF शायद इस आकार तक न सिकुड़ें.",
|
||||
targetReached: "आपका {target} लक्ष्य पूरा हुआ (परिणाम: {size}).",
|
||||
targetMissed:
|
||||
"{target} तक नहीं पहुंच सके. सबसे छोटा संभव आकार {size} था क्योंकि यह PDF मुख्य रूप से टेक्स्ट या वेक्टर से बनी है, जिन्हें छवि संपीड़न छोटा नहीं कर सकता.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "घुमाव कोण",
|
||||
|
||||
@@ -2652,6 +2652,11 @@ export const id: TranslationKeys = {
|
||||
submit: "Kompres",
|
||||
submitBatch: "Kompres ({count} file)",
|
||||
progressLabel: "Mengompres",
|
||||
bestEffortHint:
|
||||
"Maksimum sebisa mungkin. PDF berbasis gambar mendekati ukuran ini; PDF berisi teks saja mungkin tidak menyusut ke ukuran ini.",
|
||||
targetReached: "Target {target} tercapai (hasil: {size}).",
|
||||
targetMissed:
|
||||
"Tidak dapat mencapai {target}. Ukuran terkecil yang bisa dicapai adalah {size} karena PDF ini sebagian besar berupa teks atau vektor, yang tidak dapat diperkecil oleh kompresi gambar.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Sudut rotasi",
|
||||
|
||||
@@ -2657,6 +2657,11 @@ export const it: TranslationKeys = {
|
||||
submit: "Comprimi",
|
||||
submitBatch: "Comprimi ({count} file)",
|
||||
progressLabel: "Compressione",
|
||||
bestEffortHint:
|
||||
"Massimo possibile. I PDF basati su immagini si avvicinano; i PDF di solo testo potrebbero non ridursi a questa dimensione.",
|
||||
targetReached: "Obiettivo di {target} raggiunto (risultato: {size}).",
|
||||
targetMissed:
|
||||
"Impossibile raggiungere {target}. La dimensione minima ottenibile era {size} perché questo PDF è composto principalmente da testo o vettori, che la compressione delle immagini non può ridurre.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Angolo di rotazione",
|
||||
|
||||
@@ -2612,6 +2612,11 @@ export const ja: TranslationKeys = {
|
||||
submit: "圧縮",
|
||||
submitBatch: "圧縮 ({count}ファイル)",
|
||||
progressLabel: "圧縮中",
|
||||
bestEffortHint:
|
||||
"可能な限りの上限です。画像ベースのPDFはこのサイズに近づきますが、テキストのみのPDFはこのサイズまで縮小できない場合があります。",
|
||||
targetReached: "目標の{target}に到達しました(結果: {size})。",
|
||||
targetMissed:
|
||||
"{target}に到達できませんでした。このPDFは主にテキストまたはベクターで構成されており、画像圧縮では縮小できないため、達成できた最小サイズは{size}でした。",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "回転角度",
|
||||
|
||||
@@ -2592,6 +2592,11 @@ export const ko: TranslationKeys = {
|
||||
submit: "압축",
|
||||
submitBatch: "압축 ({count}개 파일)",
|
||||
progressLabel: "압축 중",
|
||||
bestEffortHint:
|
||||
"최대한 노력한 최대치입니다. 이미지 기반 PDF는 이 크기에 가깝게 도달하지만, 텍스트 전용 PDF는 이 크기까지 줄어들지 않을 수 있습니다.",
|
||||
targetReached: "목표 {target}에 도달했습니다(결과: {size}).",
|
||||
targetMissed:
|
||||
"{target}에 도달할 수 없습니다. 이 PDF는 대부분 텍스트나 벡터로 이루어져 있어 이미지 압축으로 줄일 수 없으므로, 달성 가능한 최소 크기는 {size}였습니다.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "회전 각도",
|
||||
|
||||
@@ -2658,6 +2658,11 @@ export const nl: TranslationKeys = {
|
||||
submit: "Comprimeren",
|
||||
submitBatch: "Comprimeren ({count} bestanden)",
|
||||
progressLabel: "Comprimeren",
|
||||
bestEffortHint:
|
||||
"Maximaal haalbaar. Op afbeeldingen gebaseerde PDF's komen dichtbij; PDF's met alleen tekst worden mogelijk niet tot deze grootte verkleind.",
|
||||
targetReached: "Doel van {target} bereikt (resultaat: {size}).",
|
||||
targetMissed:
|
||||
"Kon {target} niet bereiken. De kleinst haalbare grootte was {size}, omdat deze PDF voornamelijk uit tekst of vectoren bestaat, die beeldcompressie niet kan verkleinen.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Rotatiehoek",
|
||||
|
||||
@@ -2656,6 +2656,11 @@ export const pl: TranslationKeys = {
|
||||
submit: "Kompresuj",
|
||||
submitBatch: "Kompresuj ({count} plików)",
|
||||
progressLabel: "Kompresja",
|
||||
bestEffortHint:
|
||||
"Maksimum w miarę możliwości. Pliki PDF oparte na obrazach zbliżają się do tego rozmiaru; pliki PDF zawierające tylko tekst mogą się do niego nie zmniejszyć.",
|
||||
targetReached: "Osiągnięto docelowy rozmiar {target} (wynik: {size}).",
|
||||
targetMissed:
|
||||
"Nie udało się osiągnąć {target}. Najmniejszy możliwy rozmiar to {size}, ponieważ ten plik PDF składa się głównie z tekstu lub wektorów, których kompresja obrazu nie może zmniejszyć.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Kąt obrotu",
|
||||
|
||||
@@ -2654,6 +2654,11 @@ export const ptBR: TranslationKeys = {
|
||||
submit: "Comprimir",
|
||||
submitBatch: "Comprimir ({count} arquivos)",
|
||||
progressLabel: "Comprimindo",
|
||||
bestEffortHint:
|
||||
"Máximo possível. PDFs baseados em imagens chegam perto; PDFs somente com texto podem não reduzir para este tamanho.",
|
||||
targetReached: "Alvo de {target} atingido (resultado: {size}).",
|
||||
targetMissed:
|
||||
"Não foi possível atingir {target}. O menor tamanho possível foi {size} porque este PDF é composto principalmente de texto ou vetores, que a compressão de imagem não consegue reduzir.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Ângulo de rotação",
|
||||
|
||||
@@ -2654,6 +2654,11 @@ export const ru: TranslationKeys = {
|
||||
submit: "Сжать",
|
||||
submitBatch: "Сжать ({count} файлов)",
|
||||
progressLabel: "Сжатие",
|
||||
bestEffortHint:
|
||||
"Максимум по возможности. PDF на основе изображений приближаются к этому размеру; PDF только с текстом могут не уменьшиться до него.",
|
||||
targetReached: "Целевой размер {target} достигнут (результат: {size}).",
|
||||
targetMissed:
|
||||
"Не удалось достичь {target}. Минимально возможный размер составил {size}, поскольку этот PDF состоит в основном из текста или векторной графики, которые сжатие изображений не может уменьшить.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Угол поворота",
|
||||
|
||||
@@ -2651,6 +2651,11 @@ export const sv: TranslationKeys = {
|
||||
submit: "Komprimera",
|
||||
submitBatch: "Komprimera ({count} filer)",
|
||||
progressLabel: "Komprimerar",
|
||||
bestEffortHint:
|
||||
"Bästa möjliga maximum. Bildbaserade PDF:er kommer nära; PDF:er med enbart text krymper kanske inte till denna storlek.",
|
||||
targetReached: "Målet på {target} uppnåddes (resultat: {size}).",
|
||||
targetMissed:
|
||||
"Kunde inte nå {target}. Minsta möjliga storlek var {size} eftersom denna PDF mestadels består av text eller vektorer, som bildkomprimering inte kan krympa.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Rotationsvinkel",
|
||||
|
||||
@@ -2623,6 +2623,11 @@ export const th: TranslationKeys = {
|
||||
submit: "บีบอัด",
|
||||
submitBatch: "บีบอัด ({count} ไฟล์)",
|
||||
progressLabel: "กำลังบีบอัด",
|
||||
bestEffortHint:
|
||||
"ค่าสูงสุดเท่าที่ทำได้ PDF ที่เป็นรูปภาพจะได้ขนาดใกล้เคียง ส่วน PDF ที่มีแต่ข้อความอาจย่อไม่ถึงขนาดนี้",
|
||||
targetReached: "ถึงเป้าหมาย {target} ที่คุณตั้งไว้แล้ว (ผลลัพธ์: {size})",
|
||||
targetMissed:
|
||||
"ไม่สามารถทำได้ถึง {target} ขนาดเล็กที่สุดที่ทำได้คือ {size} เนื่องจาก PDF นี้ส่วนใหญ่เป็นข้อความหรือเวกเตอร์ ซึ่งการบีบอัดรูปภาพไม่สามารถย่อได้",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "มุมหมุน",
|
||||
|
||||
@@ -2656,6 +2656,11 @@ export const tr: TranslationKeys = {
|
||||
submit: "Sıkıştır",
|
||||
submitBatch: "Sıkıştır ({count} dosya)",
|
||||
progressLabel: "Sıkıştırılıyor",
|
||||
bestEffortHint:
|
||||
"Elden gelenin en fazlası. Görüntü tabanlı PDF'ler bu boyuta yaklaşır; yalnızca metin içeren PDF'ler bu boyuta küçülmeyebilir.",
|
||||
targetReached: "{target} hedefinize ulaşıldı (sonuç: {size}).",
|
||||
targetMissed:
|
||||
"{target} boyutuna ulaşılamadı. Ulaşılabilen en küçük boyut {size} oldu; çünkü bu PDF çoğunlukla metin veya vektörlerden oluşuyor ve görüntü sıkıştırma bunları küçültemez.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Döndürme açısı",
|
||||
|
||||
@@ -2654,6 +2654,11 @@ export const uk: TranslationKeys = {
|
||||
submit: "Стиснути",
|
||||
submitBatch: "Стиснути ({count} файлів)",
|
||||
progressLabel: "Стиснення",
|
||||
bestEffortHint:
|
||||
"Максимум за можливості. PDF на основі зображень наближаються до цього розміру; PDF лише з текстом можуть не зменшитися до нього.",
|
||||
targetReached: "Досягнуто цільового розміру {target} (результат: {size}).",
|
||||
targetMissed:
|
||||
"Не вдалося досягти {target}. Найменший можливий розмір становив {size}, оскільки цей PDF складається переважно з тексту або векторної графіки, які стиснення зображень не може зменшити.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Кут повороту",
|
||||
|
||||
@@ -2653,6 +2653,11 @@ export const vi: TranslationKeys = {
|
||||
submit: "Nén",
|
||||
submitBatch: "Nén ({count} tệp)",
|
||||
progressLabel: "Đang nén",
|
||||
bestEffortHint:
|
||||
"Mức tối đa trong khả năng. PDF chứa hình ảnh sẽ đạt gần mức này; PDF chỉ có văn bản có thể không thu nhỏ được xuống kích thước này.",
|
||||
targetReached: "Đã đạt mục tiêu {target} (kết quả: {size}).",
|
||||
targetMissed:
|
||||
"Không thể đạt {target}. Kích thước nhỏ nhất có thể đạt là {size} vì PDF này chủ yếu là văn bản hoặc vector, mà nén ảnh không thể thu nhỏ.",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "Góc xoay",
|
||||
|
||||
@@ -2411,6 +2411,11 @@ export const zhCN: TranslationKeys = {
|
||||
submit: "压缩",
|
||||
submitBatch: "压缩({count} 个文件)",
|
||||
progressLabel: "正在压缩",
|
||||
bestEffortHint:
|
||||
"尽力压缩的上限。基于图像的 PDF 会接近该大小;纯文本 PDF 可能无法缩小到此大小。",
|
||||
targetReached: "已达到你设定的 {target} 目标(结果:{size})。",
|
||||
targetMissed:
|
||||
"无法达到 {target}。可实现的最小体积为 {size},因为此 PDF 主要由文本或矢量图形组成,图像压缩无法将其缩小。",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "旋转角度",
|
||||
|
||||
@@ -2411,6 +2411,11 @@ export const zhTW: TranslationKeys = {
|
||||
submit: "壓縮",
|
||||
submitBatch: "壓縮({count} 個檔案)",
|
||||
progressLabel: "正在壓縮",
|
||||
bestEffortHint:
|
||||
"盡力壓縮的上限。以影像為主的 PDF 會接近此大小;純文字 PDF 可能無法縮小到此大小。",
|
||||
targetReached: "已達到你設定的 {target} 目標(結果:{size})。",
|
||||
targetMissed:
|
||||
"無法達到 {target}。可達成的最小大小為 {size},因為此 PDF 主要由文字或向量圖形組成,影像壓縮無法將其縮小。",
|
||||
},
|
||||
"rotate-pdf": {
|
||||
angle: "旋轉角度",
|
||||
|
||||
@@ -161,11 +161,22 @@ describe.skipIf(!gsAvailable())("Document depth: compress-pdf multipage hero", (
|
||||
},
|
||||
body,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
// compress-pdf has executionHint "long": 202 + poll the durable job row.
|
||||
expect(res.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(res.body);
|
||||
const { db, schema } = await import("../../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
let row: { status: string; outputRefs: unknown } | undefined;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
expect(row?.status).toBe("completed");
|
||||
const completed = row as { status: string; outputRefs: string[] };
|
||||
const outName = completed.outputRefs[0].split("/").pop() as string;
|
||||
|
||||
const payload = await download(envelope.downloadUrl);
|
||||
const payload = await download(`/api/v1/download/${jobId}/${encodeURIComponent(outName)}`);
|
||||
// Valid PDF header
|
||||
expect(payload.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
expect(payload.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import {
|
||||
gsAvailable,
|
||||
gsCompressPdf,
|
||||
gsCompressPdfTuned,
|
||||
gsGrayscalePdf,
|
||||
gsPdfaConvert,
|
||||
qpdfAvailable,
|
||||
@@ -168,6 +169,23 @@ describe.skipIf(!gsAvailable())("doc-engine ghostscript compress (requires gs)",
|
||||
}
|
||||
});
|
||||
|
||||
it("gsCompressPdfTuned: lower quality (higher QFactor) yields a smaller file at fixed DPI", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pdf-ops-"));
|
||||
try {
|
||||
const best = join(dir, "tuned-best.pdf");
|
||||
const worst = join(dir, "tuned-worst.pdf");
|
||||
// Image-heavy scan: QFactor only bites because the primitive forces re-encode.
|
||||
await gsCompressPdfTuned(fixtures.document.pdfScanned, best, 150, 0.1);
|
||||
await gsCompressPdfTuned(fixtures.document.pdfScanned, worst, 150, 2.0);
|
||||
const bestBytes = await readFile(best);
|
||||
const worstBytes = await readFile(worst);
|
||||
expect(worstBytes.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
expect(worstBytes.length).toBeLessThan(bestBytes.length);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("converts to grayscale (valid pdf out)", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pdf-ops-"));
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
type TestApp,
|
||||
} from "../../test-server.js";
|
||||
|
||||
const PDF = readFixture(fixtures.document.pdf3);
|
||||
const PDF = readFixture(fixtures.document.pdf3); // text (test-3page.pdf)
|
||||
const SCAN = readFixture(fixtures.document.pdfScanned); // image-heavy ~1MB scan
|
||||
|
||||
let testApp: TestApp;
|
||||
let adminToken: string;
|
||||
@@ -23,9 +24,9 @@ afterAll(async () => {
|
||||
}, 10_000);
|
||||
|
||||
describe.skipIf(!gsAvailable())("compress-pdf (requires gs)", () => {
|
||||
async function run(settings: Record<string, unknown>) {
|
||||
function post(pdf: Buffer, filename: string, settings: Record<string, unknown>) {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "test-3page.pdf", contentType: "application/pdf", content: PDF },
|
||||
{ name: "file", filename, contentType: "application/pdf", content: pdf },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
return testApp.app.inject({
|
||||
@@ -36,23 +37,60 @@ describe.skipIf(!gsAvailable())("compress-pdf (requires gs)", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectValidPdf(res: Awaited<ReturnType<typeof run>>): Promise<number> {
|
||||
expect(res.statusCode).toBe(200);
|
||||
const envelope = JSON.parse(res.body);
|
||||
expect(envelope.downloadUrl).toBeDefined();
|
||||
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
|
||||
// compress-pdf has executionHint "long": 202 + poll the durable job row. The
|
||||
// completion payload (including targetMet) lands in jobs.progress.result.
|
||||
async function runToCompletion(
|
||||
pdf: Buffer,
|
||||
filename: string,
|
||||
settings: Record<string, unknown>,
|
||||
): Promise<{ size: number; result: Record<string, unknown> }> {
|
||||
const res = await post(pdf, filename, settings);
|
||||
expect(res.statusCode).toBe(202);
|
||||
const { jobId } = JSON.parse(res.body);
|
||||
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
let row: { status: string; outputRefs: unknown; progress: unknown } | undefined;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
expect(row?.status).toBe("completed");
|
||||
const completed = row as { status: string; outputRefs: string[]; progress: unknown };
|
||||
const result = (completed.progress as { result?: Record<string, unknown> }).result ?? {};
|
||||
const outName = completed.outputRefs[0].split("/").pop() as string;
|
||||
const dl = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
|
||||
});
|
||||
expect(dl.statusCode).toBe(200);
|
||||
expect(dl.rawPayload.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
return dl.rawPayload.length;
|
||||
return { size: dl.rawPayload.length, result };
|
||||
}
|
||||
|
||||
it("compresses by quality and returns a valid PDF", async () => {
|
||||
await expectValidPdf(await run({ mode: "quality", quality: 60 }));
|
||||
}, 60_000);
|
||||
it("quality mode: higher quality yields larger-or-equal output", async () => {
|
||||
const lo = await runToCompletion(SCAN, "ocr-scanned.pdf", { mode: "quality", quality: 30 });
|
||||
const hi = await runToCompletion(SCAN, "ocr-scanned.pdf", { mode: "quality", quality: 90 });
|
||||
expect(hi.size).toBeGreaterThanOrEqual(lo.size);
|
||||
}, 120_000);
|
||||
|
||||
it("compresses to a target size (DPI binary search) and returns a valid PDF", async () => {
|
||||
// Output is content-dependent (text PDFs barely shrink), so assert a valid
|
||||
// PDF rather than an exact size; this exercises the binary-search path.
|
||||
await expectValidPdf(await run({ mode: "targetSize", targetSizeKb: 50 }));
|
||||
it("target-size: image PDF lands within [0.80x, 1.0x] of target", async () => {
|
||||
const targetKb = 300;
|
||||
const { size, result } = await runToCompletion(SCAN, "ocr-scanned.pdf", {
|
||||
mode: "targetSize",
|
||||
targetSizeKb: targetKb,
|
||||
});
|
||||
expect(size).toBeLessThanOrEqual(targetKb * 1024);
|
||||
expect(size).toBeGreaterThanOrEqual(targetKb * 1024 * 0.8);
|
||||
expect(result.targetMet).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("target-size: text PDF with tiny target reports targetMet=false and never enlarges", async () => {
|
||||
const { size, result } = await runToCompletion(PDF, "test-3page.pdf", {
|
||||
mode: "targetSize",
|
||||
targetSizeKb: 1,
|
||||
});
|
||||
expect(result.targetMet).toBe(false);
|
||||
expect(size).toBeLessThanOrEqual(PDF.length);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { paramsForQuality } from "../../../apps/api/src/routes/tools/compress-pdf.js";
|
||||
|
||||
describe("paramsForQuality", () => {
|
||||
it("endpoints: q=100 preserves resolution at best quality; q=1 is smallest", () => {
|
||||
const hi = paramsForQuality(100);
|
||||
expect(hi.dpi).toBe(300);
|
||||
expect(hi.qFactor).toBeCloseTo(0.1, 2);
|
||||
const lo = paramsForQuality(1);
|
||||
expect(lo.dpi).toBeLessThanOrEqual(30);
|
||||
expect(lo.qFactor).toBeGreaterThan(2.0);
|
||||
});
|
||||
|
||||
it("is monotonic in size: dpi non-decreasing and qFactor non-increasing as q rises", () => {
|
||||
let prevDpi = 0;
|
||||
let prevQf = Number.POSITIVE_INFINITY;
|
||||
for (let q = 1; q <= 100; q++) {
|
||||
const { dpi, qFactor } = paramsForQuality(q);
|
||||
expect(dpi).toBeGreaterThanOrEqual(prevDpi);
|
||||
expect(qFactor).toBeLessThanOrEqual(prevQf + 1e-9);
|
||||
prevDpi = dpi;
|
||||
prevQf = qFactor;
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps out-of-range input", () => {
|
||||
expect(paramsForQuality(0)).toEqual(paramsForQuality(1));
|
||||
expect(paramsForQuality(200)).toEqual(paramsForQuality(100));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user