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:
SnapOtter
2026-07-16 15:08:21 +08:00
committed by GitHub
parent f858c4cea0
commit 7d938af1f9
30 changed files with 344 additions and 65 deletions
+66 -34
View File
@@ -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}
+22 -7
View File
@@ -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,
]);
}
+1 -1
View File
@@ -10,7 +10,7 @@ export {
} from "./binaries.js";
export {
gsCompressPdf,
gsCompressPdfQuality,
gsCompressPdfTuned,
gsGrayscalePdf,
gsPdfaConvert,
type PdfCompressionPreset,
+3 -1
View File
@@ -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",
+5
View File
@@ -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: "زاوية التدوير",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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: "घुमाव कोण",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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: "回転角度",
+5
View File
@@ -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: "회전 각도",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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: "Угол поворота",
+5
View File
@@ -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",
+5
View File
@@ -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: "มุมหมุน",
+5
View File
@@ -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ı",
+5
View File
@@ -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: "Кут повороту",
+5
View File
@@ -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",
+5
View File
@@ -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: "旋转角度",
+5
View File
@@ -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));
});
});